@@ -30,9 +30,26 @@ from app.modules.zootech.catalog_models import (
|
||||
ZootechFeedingPoint,
|
||||
ZootechFeedMixer,
|
||||
ZootechPeriodRecipe,
|
||||
ZootechSkladStock,
|
||||
ZootechTrip,
|
||||
ZootechUnloadingGroup,
|
||||
)
|
||||
from app.modules.zootech.models import ZootechComponent, ZootechIngredient, ZootechRecipe
|
||||
from app.modules.zootech.lab_models import (
|
||||
ZootechLabAnimalProfile,
|
||||
ZootechLabComponentNutrientValue,
|
||||
ZootechLabProfileNorm,
|
||||
ZootechLabRacionNormyInfo,
|
||||
ZootechLabRacionNormyMoskwa,
|
||||
ZootechLabRacionNormyMoskwaMeta,
|
||||
ZootechLabRacionNormyPiter,
|
||||
ZootechLabRacionNormyPiterMeta,
|
||||
ZootechLabRationCalcIndicator,
|
||||
ZootechLabRationCalcTotal,
|
||||
ZootechLabRationCompoundLine,
|
||||
ZootechLabRationLine,
|
||||
ZootechLabRecipeRation,
|
||||
)
|
||||
from app.modules.zootech.report_models import ZootechFeedAlert, ZootechLoadingReport, ZootechUnloadingReport
|
||||
|
||||
__all__ = [
|
||||
@@ -67,6 +84,7 @@ __all__ = [
|
||||
"ZootechFeedingPoint",
|
||||
"ZootechPeriodRecipe",
|
||||
"ZootechTrip",
|
||||
"ZootechSkladStock",
|
||||
"ZootechDailyTripSkip",
|
||||
"ZootechDailyIngredientSkip",
|
||||
"ZootechDailyUnloadingGroupSkip",
|
||||
@@ -77,4 +95,17 @@ __all__ = [
|
||||
"ZootechFeedAlert",
|
||||
"ZootechOrgSettings",
|
||||
"ZootechFeedQualitySettings",
|
||||
"ZootechLabAnimalProfile",
|
||||
"ZootechLabProfileNorm",
|
||||
"ZootechLabComponentNutrientValue",
|
||||
"ZootechLabRecipeRation",
|
||||
"ZootechLabRationLine",
|
||||
"ZootechLabRationCalcIndicator",
|
||||
"ZootechLabRationCalcTotal",
|
||||
"ZootechLabRationCompoundLine",
|
||||
"ZootechLabRacionNormyMoskwa",
|
||||
"ZootechLabRacionNormyMoskwaMeta",
|
||||
"ZootechLabRacionNormyPiter",
|
||||
"ZootechLabRacionNormyPiterMeta",
|
||||
"ZootechLabRacionNormyInfo",
|
||||
]
|
||||
|
||||
@@ -5,8 +5,13 @@ from datetime import UTC, datetime
|
||||
from app.core.config import settings
|
||||
from app.core.security import hash_password
|
||||
from app.modules.content.repository import create_page, get_page_by_slug
|
||||
from app.modules.sync import repository as sync_repo
|
||||
from app.modules.sync.repository import get_enterprise_by_slug
|
||||
from app.modules.users.models import User
|
||||
from app.modules.users.repository import create_user, get_user_by_email
|
||||
|
||||
_DEMO_ENTERPRISE_SLUG = "demo-farm"
|
||||
|
||||
|
||||
def _ensure_user(email: str, password: str, role: str, is_superuser: bool, status: str):
|
||||
from app.modules.users import repository
|
||||
@@ -42,6 +47,17 @@ def _ensure_user(email: str, password: str, role: str, is_superuser: bool, statu
|
||||
return user
|
||||
|
||||
|
||||
def _ensure_demo_enterprise(admin: User) -> None:
|
||||
ent = get_enterprise_by_slug(_DEMO_ENTERPRISE_SLUG)
|
||||
if not ent:
|
||||
ent = sync_repo.create_enterprise("Demo Farm", _DEMO_ENTERPRISE_SLUG)
|
||||
if not sync_repo.get_member(admin.id, ent.id):
|
||||
sync_repo.add_member(admin.id, ent.id, "admin")
|
||||
ops = get_user_by_email("ops@compton.example")
|
||||
if ops and not sync_repo.get_member(ops.id, ent.id):
|
||||
sync_repo.add_member(ops.id, ent.id, "zootech")
|
||||
|
||||
|
||||
def run_seed(include_demo_pages: bool = True) -> None:
|
||||
admin = _ensure_user(
|
||||
email="admin@compton.example",
|
||||
@@ -65,6 +81,7 @@ def run_seed(include_demo_pages: bool = True) -> None:
|
||||
is_superuser=False,
|
||||
status="active",
|
||||
)
|
||||
_ensure_demo_enterprise(admin)
|
||||
|
||||
if not include_demo_pages:
|
||||
return
|
||||
|
||||
+14
-4
@@ -1,5 +1,5 @@
|
||||
from contextlib import asynccontextmanager
|
||||
from urllib.parse import urlparse, parse_qs
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
|
||||
from fastapi import FastAPI, HTTPException, Request
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
@@ -31,6 +31,10 @@ from app.modules.zootech.wesp_compat_admin import router as wesp_compat_admin_ro
|
||||
from app.modules.zootech.wesp_compat_misc import router as wesp_compat_misc_router
|
||||
from app.modules.zootech.wesp_compat_reports import router as wesp_compat_reports_router
|
||||
from app.modules.zootech.wesp_compat_analytics import router as wesp_compat_analytics_router
|
||||
from app.modules.zootech.wesp_compat_sklad import router as wesp_compat_sklad_router
|
||||
from app.modules.zootech.wesp_compat_feed_accounting import router as wesp_compat_feed_accounting_router
|
||||
from app.modules.zootech.wesp_compat_lab import router as wesp_compat_lab_router
|
||||
from app.modules.zootech.wesp_compat_daily_plan import router as wesp_compat_daily_plan_router
|
||||
from app.modules.enterprise.router import router as enterprise_router
|
||||
|
||||
|
||||
@@ -83,13 +87,15 @@ def create_app() -> FastAPI:
|
||||
CORSMiddleware,
|
||||
allow_origins=settings.cors_origins,
|
||||
allow_credentials=True,
|
||||
allow_methods=["GET", "POST", "PATCH", "DELETE", "OPTIONS"],
|
||||
allow_headers=["Authorization", "Content-Type", "X-Request-ID"],
|
||||
allow_methods=["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
|
||||
allow_headers=["Authorization", "Content-Type", "X-Request-ID", "X-Enterprise-Id"],
|
||||
)
|
||||
|
||||
@app.exception_handler(HTTPException)
|
||||
async def wesp_compat_http_exception_handler(request: Request, exc: HTTPException):
|
||||
if isinstance(exc.detail, dict) and exc.detail.get("status") == "error":
|
||||
if isinstance(exc.detail, dict) and (
|
||||
exc.detail.get("status") == "error" or exc.detail.get("error") is True
|
||||
):
|
||||
return JSONResponse(status_code=exc.status_code, content=exc.detail)
|
||||
return await http_exception_handler(request, exc)
|
||||
|
||||
@@ -113,6 +119,10 @@ def create_app() -> FastAPI:
|
||||
app.include_router(wesp_compat_misc_router, prefix="/api", tags=["zootech-wesp-compat"])
|
||||
app.include_router(wesp_compat_reports_router, prefix="/api", tags=["zootech-wesp-compat"])
|
||||
app.include_router(wesp_compat_analytics_router, prefix="/api", tags=["zootech-wesp-compat"])
|
||||
app.include_router(wesp_compat_sklad_router, prefix="/api", tags=["zootech-wesp-compat"])
|
||||
app.include_router(wesp_compat_feed_accounting_router, prefix="/api", tags=["zootech-wesp-compat"])
|
||||
app.include_router(wesp_compat_lab_router, prefix="/api", tags=["zootech-wesp-compat"])
|
||||
app.include_router(wesp_compat_daily_plan_router, prefix="/api", tags=["zootech-wesp-compat"])
|
||||
if settings.enable_test_routes:
|
||||
app.include_router(test_router, prefix="/api/v1/test", tags=["test"])
|
||||
return app
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
{"sessionId": "e7c486", "runId": "post-fix", "hypothesisId": "H1", "location": "wesp_compat_analytics.py:feed_quality_settings_get", "message": "settings route hit", "data": {"enterprise_id": "9e99368b-e72b-49d5-b83f-9717df999235"}, "timestamp": 1784206923873}
|
||||
{"sessionId": "e7c486", "runId": "post-fix", "hypothesisId": "H1", "location": "wesp_compat_analytics.py:feed_quality_settings_get", "message": "settings route hit", "data": {"enterprise_id": "d6e18152-0ec7-4b77-844b-7985dc991278"}, "timestamp": 1784208577037}
|
||||
{"sessionId": "e7c486", "runId": "post-fix", "hypothesisId": "H1", "location": "wesp_compat_analytics.py:feed_quality_settings_put", "message": "settings saved", "data": {"enterprise_id": "d6e18152-0ec7-4b77-844b-7985dc991278"}, "timestamp": 1784208585163}
|
||||
{"sessionId": "e7c486", "runId": "post-fix", "hypothesisId": "H1", "location": "wesp_compat_analytics.py:feed_quality_settings_get", "message": "settings route hit", "data": {"enterprise_id": "c5ad2c76-1a24-4e72-9f18-f5e0f77cdc68"}, "timestamp": 1784209007588}
|
||||
{"sessionId": "e7c486", "runId": "post-fix", "hypothesisId": "H1", "location": "wesp_compat_analytics.py:feed_quality_settings_put", "message": "settings saved", "data": {"enterprise_id": "c5ad2c76-1a24-4e72-9f18-f5e0f77cdc68"}, "timestamp": 1784209015641}
|
||||
{"sessionId": "e7c486", "runId": "post-fix", "hypothesisId": "H1", "location": "wesp_compat_analytics.py:feed_quality_settings_get", "message": "settings route hit", "data": {"enterprise_id": "26d1bb13-7dac-4982-85f4-18a6735ca570"}, "timestamp": 1784209620693}
|
||||
{"sessionId": "e7c486", "runId": "post-fix", "hypothesisId": "H1", "location": "wesp_compat_analytics.py:feed_quality_settings_put", "message": "settings saved", "data": {"enterprise_id": "26d1bb13-7dac-4982-85f4-18a6735ca570"}, "timestamp": 1784209628802}
|
||||
{"sessionId": "e7c486", "runId": "post-fix", "hypothesisId": "H1", "location": "wesp_compat_analytics.py:feed_quality_settings_get", "message": "settings route hit", "data": {"enterprise_id": "b743ca10-61ff-47f8-9ea6-4b9c96cd9b22"}, "timestamp": 1784210286799}
|
||||
{"sessionId": "e7c486", "runId": "post-fix", "hypothesisId": "H1", "location": "wesp_compat_analytics.py:feed_quality_settings_put", "message": "settings saved", "data": {"enterprise_id": "b743ca10-61ff-47f8-9ea6-4b9c96cd9b22"}, "timestamp": 1784210294921}
|
||||
{"sessionId": "e7c486", "runId": "post-fix", "hypothesisId": "H1", "location": "wesp_compat_analytics.py:feed_quality_settings_get", "message": "settings route hit", "data": {"enterprise_id": "9e99368b-e72b-49d5-b83f-9717df999235"}, "timestamp": 1784272007979}
|
||||
@@ -0,0 +1,3 @@
|
||||
"""Analytics helpers ported from WESP (import submodules directly)."""
|
||||
|
||||
__all__: list[str] = []
|
||||
@@ -0,0 +1,37 @@
|
||||
"""Разбор периода для analytics."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date, datetime, timedelta
|
||||
from typing import Optional, Tuple
|
||||
|
||||
|
||||
def parse_iso_date(value: Optional[str]) -> Optional[date]:
|
||||
if not value:
|
||||
return None
|
||||
try:
|
||||
return date.fromisoformat(str(value).strip()[:10])
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def resolve_date_bounds(
|
||||
date_from: Optional[str],
|
||||
date_to: Optional[str],
|
||||
) -> Tuple[datetime, datetime]:
|
||||
"""Включительно date_from … date_to (конец дня date_to)."""
|
||||
today = date.today()
|
||||
start_d = parse_iso_date(date_from) or today
|
||||
end_d = parse_iso_date(date_to) or start_d
|
||||
if end_d < start_d:
|
||||
start_d, end_d = end_d, start_d
|
||||
start_dt = datetime.combine(start_d, datetime.min.time())
|
||||
end_dt = datetime.combine(end_d, datetime.max.time())
|
||||
return start_dt, end_dt
|
||||
|
||||
|
||||
def iter_dates_in_range(start_d: date, end_d: date):
|
||||
cur = start_d
|
||||
while cur <= end_d:
|
||||
yield cur
|
||||
cur += timedelta(days=1)
|
||||
@@ -0,0 +1,105 @@
|
||||
"""Итоги по кормораздатчикам для PDF/Excel."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import defaultdict
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from app import db
|
||||
from app.models import LoadingReportComponent
|
||||
from app.services.analytics.date_range import resolve_date_bounds
|
||||
from app.services.analytics.finance_summary import _report_filters
|
||||
from app.services.analytics.prices import component_prices, price_for
|
||||
from app.services.analytics.recipe_filter import parse_recipe_ids
|
||||
from app.services.analytics.recipe_location import build_recipe_location_maps
|
||||
|
||||
|
||||
def build_dispenser_summary_rows(
|
||||
*,
|
||||
date_from: Optional[str] = None,
|
||||
date_to: Optional[str] = None,
|
||||
recipe_id: Optional[str] = None,
|
||||
recipe_ids: Optional[str] = None,
|
||||
) -> List[Dict[str, Any]]:
|
||||
start_dt, end_dt = resolve_date_bounds(date_from, date_to)
|
||||
ids = parse_recipe_ids(recipe_id=recipe_id, recipe_ids=recipe_ids)
|
||||
reports = db.session.execute(
|
||||
_report_filters(start_dt=start_dt, end_dt=end_dt, recipe_ids=ids)
|
||||
).scalars().all()
|
||||
if not reports:
|
||||
return []
|
||||
|
||||
report_by_id = {r.id: r for r in reports}
|
||||
recipe_ids_set = {r.recipe_id for r in reports if r.recipe_id}
|
||||
farm_by_recipe, dispenser_by_recipe = build_recipe_location_maps(recipe_ids_set)
|
||||
|
||||
report_ids = [r.id for r in reports]
|
||||
components = db.session.execute(
|
||||
select(LoadingReportComponent).where(
|
||||
LoadingReportComponent.report_id.in_(report_ids),
|
||||
LoadingReportComponent.is_deleted.is_(False),
|
||||
)
|
||||
).scalars().all()
|
||||
|
||||
prices = component_prices(
|
||||
component_ids=[c.component_id for c in components if c.component_id],
|
||||
names=[c.component_name for c in components],
|
||||
)
|
||||
|
||||
agg: Dict[str, Dict[str, Any]] = defaultdict(
|
||||
lambda: {
|
||||
"dispenser": "",
|
||||
"farm": "",
|
||||
"overloadRub": 0.0,
|
||||
"underloadRub": 0.0,
|
||||
"netRub": 0.0,
|
||||
}
|
||||
)
|
||||
|
||||
for comp in components:
|
||||
report = report_by_id.get(comp.report_id)
|
||||
if not report:
|
||||
continue
|
||||
dispenser = dispenser_by_recipe.get(report.recipe_id, "—")
|
||||
farm = farm_by_recipe.get(report.recipe_id, "—")
|
||||
target = float(comp.target_weight or 0)
|
||||
actual = float(comp.actual_weight or 0)
|
||||
if target <= 0 and actual <= 0:
|
||||
continue
|
||||
|
||||
price = price_for(
|
||||
prices=prices,
|
||||
component_id=comp.component_id,
|
||||
name=str(comp.component_name or ""),
|
||||
)
|
||||
dev_rub = (actual - target) * price
|
||||
row = agg[dispenser]
|
||||
row["dispenser"] = dispenser
|
||||
row["farm"] = farm
|
||||
if dev_rub > 0:
|
||||
row["overloadRub"] += dev_rub
|
||||
elif dev_rub < 0:
|
||||
row["underloadRub"] += abs(dev_rub)
|
||||
row["netRub"] += dev_rub
|
||||
|
||||
out: List[Dict[str, Any]] = []
|
||||
for row in agg.values():
|
||||
if max(row["overloadRub"], row["underloadRub"]) <= 0:
|
||||
continue
|
||||
out.append(
|
||||
{
|
||||
"dispenser": row["dispenser"],
|
||||
"farm": row["farm"],
|
||||
"overloadRub": round(row["overloadRub"], 2),
|
||||
"underloadRub": round(row["underloadRub"], 2),
|
||||
"netRub": round(row["netRub"], 2),
|
||||
}
|
||||
)
|
||||
|
||||
out.sort(
|
||||
key=lambda r: max(r["overloadRub"], r["underloadRub"]),
|
||||
reverse=True,
|
||||
)
|
||||
return out
|
||||
@@ -0,0 +1,51 @@
|
||||
"""Текст шапки экспорта с применёнными фильтрами."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Optional
|
||||
|
||||
_NET_DOMINANT_HINTS = {
|
||||
"overload": "Преобладает перерасход — прямой убыток бюджета",
|
||||
"underload": "Преобладает недогруз — экономия сейчас, риск падения надоев",
|
||||
"balanced": "Перерасход и недогруз за период уравновешены",
|
||||
}
|
||||
|
||||
|
||||
def net_rub_display(summary: dict[str, Any]) -> float:
|
||||
"""Итог без знака минус — как на вкладке «Итоги» в интерфейсе."""
|
||||
return round(abs(float(summary.get("netRub") or 0)), 2)
|
||||
|
||||
|
||||
def net_rub_dominant_hint(summary: dict[str, Any]) -> str:
|
||||
return _NET_DOMINANT_HINTS.get(str(summary.get("dominantIssue") or "balanced"), _NET_DOMINANT_HINTS["balanced"])
|
||||
|
||||
|
||||
def _count_label(raw: Optional[str], *, all_label: str) -> str:
|
||||
text = (raw or "").strip()
|
||||
if not text or text.lower() == "all":
|
||||
return all_label
|
||||
parts = [p.strip() for p in text.split(",") if p.strip()]
|
||||
if not parts:
|
||||
return all_label
|
||||
if len(parts) == 1:
|
||||
return parts[0]
|
||||
return str(len(parts))
|
||||
|
||||
|
||||
def build_export_header_lines(
|
||||
*,
|
||||
date_from: str,
|
||||
date_to: str,
|
||||
filter_farms: Optional[str] = None,
|
||||
filter_dispensers: Optional[str] = None,
|
||||
filter_recipes: Optional[str] = None,
|
||||
) -> list[str]:
|
||||
period = f"{date_from} — {date_to}"
|
||||
farms = _count_label(filter_farms, all_label="все")
|
||||
dispensers = _count_label(filter_dispensers, all_label="все")
|
||||
recipes = _count_label(filter_recipes, all_label="все")
|
||||
|
||||
lines = [f"Отчёт о загрузках кормов за период {period}."]
|
||||
filter_bits = [f"Фермы: {farms}", f"Кормораздатчики: {dispensers}", f"Рейсы: {recipes}"]
|
||||
lines.append("Выбрано: " + ", ".join(filter_bits) + ".")
|
||||
return lines
|
||||
@@ -0,0 +1,29 @@
|
||||
"""Разделы экспорта analytics: итоги, сравнение, отчёты."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
VALID_EXPORT_SECTIONS = frozenset({"summary", "comparison", "reports"})
|
||||
|
||||
_SECTION_FILENAMES = {
|
||||
"summary": "itogi",
|
||||
"comparison": "sravnenie",
|
||||
"reports": "podrobno",
|
||||
}
|
||||
|
||||
|
||||
def parse_export_sections(section: str | None = None) -> frozenset[str]:
|
||||
if not section or not str(section).strip() or str(section).strip().lower() == "all":
|
||||
return VALID_EXPORT_SECTIONS
|
||||
parts = {p.strip().lower() for p in str(section).split(",") if p.strip()}
|
||||
filtered = parts & VALID_EXPORT_SECTIONS
|
||||
return filtered if filtered else VALID_EXPORT_SECTIONS
|
||||
|
||||
|
||||
def export_filename_base(*, date_from: str, date_to: str, sections: frozenset[str]) -> str:
|
||||
if sections == VALID_EXPORT_SECTIONS:
|
||||
return f"otchety_{date_from}_{date_to}"
|
||||
if len(sections) == 1:
|
||||
key = next(iter(sections))
|
||||
return f"{_SECTION_FILENAMES[key]}_{date_from}_{date_to}"
|
||||
joined = "_".join(sorted(_SECTION_FILENAMES[s] for s in sections))
|
||||
return f"{joined}_{date_from}_{date_to}"
|
||||
@@ -0,0 +1,130 @@
|
||||
"""Итоги в рублях: перерасход, недогруз, топ компонентов."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import defaultdict
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from app import db
|
||||
from app.models import LoadingReport, LoadingReportComponent
|
||||
from app.services.analytics.date_range import resolve_date_bounds
|
||||
from app.services.analytics.prices import component_prices, price_for
|
||||
from app.services.analytics.recipe_filter import parse_recipe_ids
|
||||
|
||||
|
||||
def _report_filters(
|
||||
*,
|
||||
start_dt,
|
||||
end_dt,
|
||||
recipe_id: Optional[str] = None,
|
||||
recipe_ids: Optional[list[str]] = None,
|
||||
):
|
||||
q = select(LoadingReport).where(
|
||||
LoadingReport.is_deleted.is_(False),
|
||||
LoadingReport.start_time >= start_dt,
|
||||
LoadingReport.start_time <= end_dt,
|
||||
)
|
||||
ids = recipe_ids or parse_recipe_ids(recipe_id=recipe_id)
|
||||
if ids:
|
||||
q = q.where(LoadingReport.recipe_id.in_(ids) if len(ids) > 1 else LoadingReport.recipe_id == ids[0])
|
||||
return q.order_by(LoadingReport.start_time.desc())
|
||||
|
||||
|
||||
def build_finance_summary(
|
||||
*,
|
||||
date_from: Optional[str] = None,
|
||||
date_to: Optional[str] = None,
|
||||
recipe_id: Optional[str] = None,
|
||||
recipe_ids: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
start_dt, end_dt = resolve_date_bounds(date_from, date_to)
|
||||
ids = parse_recipe_ids(recipe_id=recipe_id, recipe_ids=recipe_ids)
|
||||
reports = db.session.execute(_report_filters(
|
||||
start_dt=start_dt, end_dt=end_dt, recipe_ids=ids
|
||||
)).scalars().all()
|
||||
if not reports:
|
||||
return {
|
||||
"overloadRub": 0.0,
|
||||
"underloadRub": 0.0,
|
||||
"netRub": 0.0,
|
||||
"dominantIssue": "balanced",
|
||||
"topComponents": [],
|
||||
"reportCount": 0,
|
||||
}
|
||||
|
||||
report_ids = [r.id for r in reports]
|
||||
components = db.session.execute(
|
||||
select(LoadingReportComponent).where(
|
||||
LoadingReportComponent.report_id.in_(report_ids),
|
||||
LoadingReportComponent.is_deleted.is_(False),
|
||||
)
|
||||
).scalars().all()
|
||||
|
||||
comp_ids = [c.component_id for c in components if c.component_id]
|
||||
names = [c.component_name for c in components]
|
||||
prices = component_prices(component_ids=comp_ids, names=names)
|
||||
|
||||
by_component: Dict[str, Dict[str, Any]] = defaultdict(
|
||||
lambda: {"name": "", "overloadRub": 0.0, "underloadRub": 0.0, "netRub": 0.0}
|
||||
)
|
||||
overload_total = 0.0
|
||||
underload_total = 0.0
|
||||
|
||||
for comp in components:
|
||||
target = float(comp.target_weight or 0)
|
||||
actual = float(comp.actual_weight or 0)
|
||||
if target <= 0 and actual <= 0:
|
||||
continue
|
||||
price = price_for(
|
||||
prices=prices,
|
||||
component_id=comp.component_id,
|
||||
name=str(comp.component_name or ""),
|
||||
)
|
||||
dev_kg = actual - target
|
||||
dev_rub = dev_kg * price
|
||||
key = str(comp.component_id or comp.component_name or "unknown")
|
||||
row = by_component[key]
|
||||
row["name"] = str(comp.component_name or "—")
|
||||
row["componentId"] = comp.component_id
|
||||
if dev_rub > 0:
|
||||
row["overloadRub"] += dev_rub
|
||||
overload_total += dev_rub
|
||||
elif dev_rub < 0:
|
||||
row["underloadRub"] += abs(dev_rub)
|
||||
underload_total += abs(dev_rub)
|
||||
row["netRub"] += dev_rub
|
||||
|
||||
top = sorted(
|
||||
by_component.values(),
|
||||
key=lambda x: max(x["overloadRub"], x["underloadRub"]),
|
||||
reverse=True,
|
||||
)[:3]
|
||||
top_out = [
|
||||
{
|
||||
"name": r["name"],
|
||||
"componentId": r.get("componentId"),
|
||||
"overloadRub": round(r["overloadRub"], 2),
|
||||
"underloadRub": round(r["underloadRub"], 2),
|
||||
"netRub": round(r["netRub"], 2),
|
||||
}
|
||||
for r in top
|
||||
if max(r["overloadRub"], r["underloadRub"]) > 0
|
||||
]
|
||||
|
||||
net = overload_total - underload_total
|
||||
if overload_total > underload_total:
|
||||
dominant = "overload"
|
||||
elif underload_total > overload_total:
|
||||
dominant = "underload"
|
||||
else:
|
||||
dominant = "balanced"
|
||||
return {
|
||||
"overloadRub": round(overload_total, 2),
|
||||
"underloadRub": round(underload_total, 2),
|
||||
"netRub": round(net, 2),
|
||||
"dominantIssue": dominant,
|
||||
"topComponents": top_out,
|
||||
"reportCount": len(reports),
|
||||
}
|
||||
@@ -0,0 +1,425 @@
|
||||
"""PDF: итоги + сравнение + подробно (для директора)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
from typing import Any, Iterable, List, Optional
|
||||
from xml.sax.saxutils import escape
|
||||
|
||||
from reportlab.lib import colors
|
||||
from reportlab.lib.pagesizes import A4, landscape
|
||||
from reportlab.lib.styles import ParagraphStyle, getSampleStyleSheet
|
||||
from reportlab.lib.units import mm
|
||||
from reportlab.platypus import PageBreak, Paragraph, SimpleDocTemplate, Spacer, Table, TableStyle
|
||||
|
||||
from app.services.analytics.dispenser_summary_export_rows import build_dispenser_summary_rows
|
||||
from app.services.analytics.export_context import (
|
||||
build_export_header_lines,
|
||||
net_rub_display,
|
||||
net_rub_dominant_hint,
|
||||
)
|
||||
from app.services.analytics.export_sections import parse_export_sections
|
||||
from app.services.analytics.finance_summary import build_finance_summary
|
||||
from app.services.analytics.plan_fact_layers import build_plan_fact_rows
|
||||
from app.services.analytics.reports_detail import build_reports_detail_rows
|
||||
from app.services.feed_accounting.pdf_feed_accounting import (
|
||||
_register_cyrillic_font,
|
||||
_register_cyrillic_font_bold,
|
||||
)
|
||||
|
||||
_PAGE_MARGIN = 5 * mm
|
||||
_LANDSCAPE_WIDTH = landscape(A4)[0]
|
||||
_USABLE_WIDTH = _LANDSCAPE_WIDTH - 2 * _PAGE_MARGIN
|
||||
|
||||
|
||||
def _widths_from_percentages(percentages: list[float]) -> list[float]:
|
||||
total = float(sum(percentages)) or 100.0
|
||||
scale = _USABLE_WIDTH / total
|
||||
return [pct * scale for pct in percentages]
|
||||
|
||||
|
||||
def _format_rub(amount: float) -> str:
|
||||
"""Сумма в рублях без символа ₽ — DejaVu Sans его не всегда отрисовывает."""
|
||||
return f"{abs(float(amount or 0)):,.0f} руб.".replace(",", " ")
|
||||
|
||||
|
||||
def _format_rub_plain(amount: float) -> str:
|
||||
"""Число для ячеек таблицы без суффикса валюты."""
|
||||
return f"{abs(float(amount or 0)):,.0f}".replace(",", " ")
|
||||
|
||||
|
||||
def _para(text: Any, style: ParagraphStyle) -> Paragraph:
|
||||
return Paragraph(escape(str(text if text is not None else "")), style)
|
||||
|
||||
|
||||
def _styles():
|
||||
font = _register_cyrillic_font()
|
||||
bold_font = _register_cyrillic_font_bold()
|
||||
styles = getSampleStyleSheet()
|
||||
for key in ("Title", "Normal", "Heading2", "Heading3"):
|
||||
styles[key].fontName = font
|
||||
styles.add(
|
||||
ParagraphStyle(
|
||||
name="KpiValue",
|
||||
parent=styles["Normal"],
|
||||
fontName=font,
|
||||
fontSize=16,
|
||||
leading=20,
|
||||
spaceAfter=2,
|
||||
)
|
||||
)
|
||||
styles.add(
|
||||
ParagraphStyle(
|
||||
name="KpiLabel",
|
||||
parent=styles["Normal"],
|
||||
fontName=font,
|
||||
fontSize=8,
|
||||
textColor=colors.grey,
|
||||
spaceAfter=8,
|
||||
)
|
||||
)
|
||||
styles.add(
|
||||
ParagraphStyle(
|
||||
name="PdfTableCell",
|
||||
fontName=font,
|
||||
fontSize=6.5,
|
||||
leading=8,
|
||||
wordWrap="LTR",
|
||||
)
|
||||
)
|
||||
styles.add(
|
||||
ParagraphStyle(
|
||||
name="PdfTableHeaderCell",
|
||||
fontName=bold_font,
|
||||
fontSize=7,
|
||||
leading=8.5,
|
||||
wordWrap="LTR",
|
||||
)
|
||||
)
|
||||
return styles, font, bold_font
|
||||
|
||||
|
||||
def _table(
|
||||
data: List[List[Any]],
|
||||
col_widths,
|
||||
font: str,
|
||||
styles,
|
||||
*,
|
||||
header_rows: int = 1,
|
||||
bold_font: str | None = None,
|
||||
wrap_cols: Iterable[int] = (),
|
||||
numeric_cols: Iterable[int] = (),
|
||||
):
|
||||
wrap_set = frozenset(wrap_cols)
|
||||
numeric_set = frozenset(numeric_cols)
|
||||
cell_style: ParagraphStyle = styles["PdfTableCell"]
|
||||
header_style: ParagraphStyle = styles["PdfTableHeaderCell"]
|
||||
|
||||
processed: List[List[Any]] = []
|
||||
for row_idx, row in enumerate(data):
|
||||
is_header = row_idx < header_rows
|
||||
new_row: List[Any] = []
|
||||
for col_idx, val in enumerate(row):
|
||||
if col_idx in wrap_set:
|
||||
new_row.append(_para(val, header_style if is_header else cell_style))
|
||||
elif val is None:
|
||||
new_row.append("")
|
||||
else:
|
||||
new_row.append(str(val))
|
||||
processed.append(new_row)
|
||||
|
||||
tbl = Table(processed, colWidths=col_widths, repeatRows=header_rows, splitByRow=1)
|
||||
header_font = bold_font or font
|
||||
style_rules: List[Any] = [
|
||||
("FONTNAME", (0, 0), (-1, -1), font),
|
||||
("FONTSIZE", (0, header_rows), (-1, -1), 7),
|
||||
("BACKGROUND", (0, 0), (-1, header_rows - 1), colors.Color(0.82, 0.82, 0.82)),
|
||||
("GRID", (0, 0), (-1, -1), 0.25, colors.grey),
|
||||
("VALIGN", (0, 0), (-1, -1), "TOP"),
|
||||
("FONTNAME", (0, 0), (-1, header_rows - 1), header_font),
|
||||
("FONTSIZE", (0, 0), (-1, header_rows - 1), 7.5),
|
||||
("TEXTCOLOR", (0, 0), (-1, header_rows - 1), colors.black),
|
||||
("BOTTOMPADDING", (0, 0), (-1, -1), 3),
|
||||
("TOPPADDING", (0, 0), (-1, -1), 3),
|
||||
("LEFTPADDING", (0, 0), (-1, -1), 3),
|
||||
("RIGHTPADDING", (0, 0), (-1, -1), 3),
|
||||
]
|
||||
for col_idx in numeric_set:
|
||||
style_rules.append(("ALIGN", (col_idx, header_rows), (col_idx, -1), "RIGHT"))
|
||||
tbl.setStyle(TableStyle(style_rules))
|
||||
return tbl
|
||||
|
||||
|
||||
def _kpi_block(
|
||||
story: List[Any],
|
||||
styles,
|
||||
font: str,
|
||||
bold_font: str,
|
||||
summary: dict[str, Any],
|
||||
*,
|
||||
date_from: str,
|
||||
date_to: str,
|
||||
recipe_id: str | None,
|
||||
recipe_ids: str | None,
|
||||
) -> None:
|
||||
story.append(Paragraph("Итоги", styles["Heading2"]))
|
||||
kpis = [
|
||||
("Перерасход", _format_rub(summary.get("overloadRub", 0))),
|
||||
("Недогруз", _format_rub(summary.get("underloadRub", 0))),
|
||||
("Итого", _format_rub(net_rub_display(summary))),
|
||||
]
|
||||
kpi_data = [[label, value] for label, value in kpis]
|
||||
tbl = Table(kpi_data, colWidths=[45 * mm, 55 * mm])
|
||||
tbl.setStyle(
|
||||
TableStyle(
|
||||
[
|
||||
("FONTNAME", (0, 0), (-1, -1), font),
|
||||
("FONTSIZE", (0, 0), (0, -1), 8),
|
||||
("FONTSIZE", (1, 0), (1, -1), 14),
|
||||
("TEXTCOLOR", (0, 0), (0, -1), colors.grey),
|
||||
("BOTTOMPADDING", (0, 0), (-1, -1), 6),
|
||||
]
|
||||
)
|
||||
)
|
||||
story.append(tbl)
|
||||
story.append(Paragraph(net_rub_dominant_hint(summary), styles["KpiLabel"]))
|
||||
story.append(Spacer(1, 6))
|
||||
|
||||
top = summary.get("topComponents") or []
|
||||
if top:
|
||||
story.append(Paragraph("Где больше всего потеряли", styles["Heading3"]))
|
||||
top_data = [["Компонент", "Перерасход", "Недогруз", "Итого"]]
|
||||
for row in top[:8]:
|
||||
top_data.append(
|
||||
[
|
||||
str(row.get("name") or ""),
|
||||
_format_rub_plain(row.get("overloadRub", 0)),
|
||||
_format_rub_plain(row.get("underloadRub", 0)),
|
||||
_format_rub_plain(row.get("netRub", 0)),
|
||||
]
|
||||
)
|
||||
story.append(
|
||||
_table(
|
||||
top_data,
|
||||
[70 * mm, 35 * mm, 35 * mm, 35 * mm],
|
||||
font,
|
||||
styles,
|
||||
bold_font=bold_font,
|
||||
wrap_cols=(0,),
|
||||
numeric_cols=(1, 2, 3),
|
||||
)
|
||||
)
|
||||
|
||||
dispensers = build_dispenser_summary_rows(
|
||||
date_from=date_from,
|
||||
date_to=date_to,
|
||||
recipe_id=recipe_id,
|
||||
recipe_ids=recipe_ids,
|
||||
)
|
||||
if dispensers:
|
||||
story.append(Spacer(1, 8))
|
||||
story.append(Paragraph("Кормораздатчики: итоговые отклонения", styles["Heading3"]))
|
||||
disp_data = [["Кормораздатчик", "Ферма", "Перерасход", "Недогруз", "Итого"]]
|
||||
for row in dispensers:
|
||||
disp_data.append(
|
||||
[
|
||||
str(row.get("dispenser") or "—"),
|
||||
str(row.get("farm") or "—"),
|
||||
_format_rub_plain(row.get("overloadRub", 0)),
|
||||
_format_rub_plain(row.get("underloadRub", 0)),
|
||||
_format_rub_plain(row.get("netRub", 0)),
|
||||
]
|
||||
)
|
||||
story.append(
|
||||
_table(
|
||||
disp_data,
|
||||
[52 * mm, 48 * mm, 32 * mm, 32 * mm, 32 * mm],
|
||||
font,
|
||||
styles,
|
||||
bold_font=bold_font,
|
||||
wrap_cols=(0, 1),
|
||||
numeric_cols=(2, 3, 4),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
# Ширины «Сравнение» — укладываются в usable width, длинный текст переносится.
|
||||
_COMPARISON_COL_WIDTHS = [
|
||||
34 * mm, # Рейс
|
||||
16 * mm, # Дата
|
||||
38 * mm, # Компонент
|
||||
16 * mm, # База
|
||||
16 * mm, # План
|
||||
16 * mm, # Факт
|
||||
18 * mm, # Δ База
|
||||
18 * mm, # Δ План
|
||||
]
|
||||
|
||||
# «Подробно»: проценты от рабочей ширины — текст переносится, числа не сжимаются.
|
||||
_DETAIL_COL_PCT = [5, 15, 16, 14, 10, 11, 11, 9, 9]
|
||||
_DETAIL_COL_WIDTHS = _widths_from_percentages(_DETAIL_COL_PCT)
|
||||
|
||||
|
||||
def build_analytics_pdf(
|
||||
*,
|
||||
date_from: str,
|
||||
date_to: str,
|
||||
recipe_id: str | None = None,
|
||||
recipe_ids: str | None = None,
|
||||
section: str | None = None,
|
||||
filter_farms: Optional[str] = None,
|
||||
filter_dispensers: Optional[str] = None,
|
||||
filter_recipes: Optional[str] = None,
|
||||
) -> bytes:
|
||||
sections = parse_export_sections(section)
|
||||
summary = None
|
||||
plan_fact = None
|
||||
detail_rows = None
|
||||
|
||||
if "summary" in sections:
|
||||
summary = build_finance_summary(
|
||||
date_from=date_from, date_to=date_to, recipe_id=recipe_id, recipe_ids=recipe_ids
|
||||
)
|
||||
if "comparison" in sections:
|
||||
plan_fact = build_plan_fact_rows(
|
||||
date_from=date_from, date_to=date_to, recipe_id=recipe_id, recipe_ids=recipe_ids
|
||||
)
|
||||
if "reports" in sections:
|
||||
detail_rows = build_reports_detail_rows(
|
||||
date_from=date_from, date_to=date_to, recipe_id=recipe_id, recipe_ids=recipe_ids
|
||||
)
|
||||
|
||||
buf = io.BytesIO()
|
||||
doc = SimpleDocTemplate(
|
||||
buf,
|
||||
pagesize=landscape(A4),
|
||||
leftMargin=_PAGE_MARGIN,
|
||||
rightMargin=_PAGE_MARGIN,
|
||||
topMargin=_PAGE_MARGIN,
|
||||
bottomMargin=_PAGE_MARGIN,
|
||||
)
|
||||
styles, font, bold_font = _styles()
|
||||
story: List[Any] = []
|
||||
section_list = [s for s in ("summary", "comparison", "reports") if s in sections]
|
||||
|
||||
story.append(Paragraph("Отчёты о загрузках", styles["Title"]))
|
||||
for line in build_export_header_lines(
|
||||
date_from=date_from,
|
||||
date_to=date_to,
|
||||
filter_farms=filter_farms,
|
||||
filter_dispensers=filter_dispensers,
|
||||
filter_recipes=filter_recipes,
|
||||
):
|
||||
story.append(Paragraph(line, styles["Normal"]))
|
||||
story.append(Spacer(1, 8))
|
||||
|
||||
for idx, key in enumerate(section_list):
|
||||
if idx > 0:
|
||||
story.append(PageBreak())
|
||||
|
||||
if key == "summary":
|
||||
_kpi_block(
|
||||
story,
|
||||
styles,
|
||||
font,
|
||||
bold_font,
|
||||
summary or {},
|
||||
date_from=date_from,
|
||||
date_to=date_to,
|
||||
recipe_id=recipe_id,
|
||||
recipe_ids=recipe_ids,
|
||||
)
|
||||
|
||||
elif key == "comparison":
|
||||
story.append(Paragraph("Сравнение", styles["Heading2"]))
|
||||
pf_data = [
|
||||
[
|
||||
"Рейс",
|
||||
"Дата",
|
||||
"Компонент",
|
||||
"База, кг",
|
||||
"План, кг",
|
||||
"Факт, кг",
|
||||
"Δ База, кг",
|
||||
"Δ План, кг",
|
||||
]
|
||||
]
|
||||
for item in (plan_fact or {}).get("items") or []:
|
||||
for comp in item.get("components") or []:
|
||||
base_kg = float(comp.get("baseKg") or 0)
|
||||
plan_kg = float(comp.get("planTodayKg") or 0)
|
||||
fact_kg = float(comp.get("actualKg") or 0)
|
||||
pf_data.append(
|
||||
[
|
||||
str(item.get("recipeName") or ""),
|
||||
str(item.get("date") or ""),
|
||||
str(comp.get("name") or ""),
|
||||
f"{base_kg:.1f}",
|
||||
f"{plan_kg:.1f}",
|
||||
f"{fact_kg:.1f}",
|
||||
f"{fact_kg - base_kg:.1f}",
|
||||
f"{fact_kg - plan_kg:.1f}",
|
||||
]
|
||||
)
|
||||
if len(pf_data) == 1:
|
||||
pf_data.append(["—", "", "", "", "", "", "", ""])
|
||||
story.append(
|
||||
_table(
|
||||
pf_data,
|
||||
_COMPARISON_COL_WIDTHS,
|
||||
font,
|
||||
styles,
|
||||
bold_font=bold_font,
|
||||
wrap_cols=(0, 2),
|
||||
numeric_cols=(3, 4, 5, 6, 7),
|
||||
)
|
||||
)
|
||||
|
||||
elif key == "reports":
|
||||
story.append(Paragraph("Подробно", styles["Heading2"]))
|
||||
detail_data = [
|
||||
[
|
||||
"Дата",
|
||||
"Ферма",
|
||||
"Рейс",
|
||||
"Компонент",
|
||||
"Группа",
|
||||
"План, кг",
|
||||
"Факт, кг",
|
||||
"Откл., кг",
|
||||
"Перерасх., кг",
|
||||
]
|
||||
]
|
||||
for row in detail_rows or []:
|
||||
target = float(row.get("targetKg") or 0)
|
||||
actual = float(row.get("actualKg") or 0)
|
||||
overload_kg = max(0.0, round(actual - target, 2))
|
||||
detail_data.append(
|
||||
[
|
||||
str(row.get("dateDay") or ""),
|
||||
str(row.get("farm") or ""),
|
||||
str(row.get("recipeName") or ""),
|
||||
str(row.get("feedComponent") or ""),
|
||||
str(row.get("animalGroup") or ""),
|
||||
f"{target:.1f}",
|
||||
f"{actual:.1f}",
|
||||
f"{actual - target:.1f}",
|
||||
f"{overload_kg:.1f}" if overload_kg > 0 else "",
|
||||
]
|
||||
)
|
||||
if len(detail_data) == 1:
|
||||
detail_data.append(["—", "", "", "", "", "", "", "", ""])
|
||||
story.append(
|
||||
_table(
|
||||
detail_data,
|
||||
_DETAIL_COL_WIDTHS,
|
||||
font,
|
||||
styles,
|
||||
bold_font=bold_font,
|
||||
wrap_cols=(1, 2, 3, 4),
|
||||
numeric_cols=(5, 6, 7, 8),
|
||||
)
|
||||
)
|
||||
|
||||
doc.build(story)
|
||||
return buf.getvalue()
|
||||
@@ -0,0 +1,705 @@
|
||||
"""Сравнение: по рецепту / на сегодня / сделали."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from app import db
|
||||
from app.models import (
|
||||
Component,
|
||||
Ingredient,
|
||||
LoadingReport,
|
||||
LoadingReportComponent,
|
||||
Recipe,
|
||||
UnloadingGroup,
|
||||
UnloadingReport,
|
||||
UnloadingReportGroup,
|
||||
)
|
||||
from app.services.analytics.date_range import resolve_date_bounds
|
||||
from app.services.analytics.recipe_filter import parse_recipe_ids
|
||||
from app.services.daily_plan.builder import ALL_DISPENSERS_ID, build_daily_plan
|
||||
from app.services.daily_plan.skips import get_skipped_ingredient_ids
|
||||
from app.services.recipe_calculator import calculate_recipe
|
||||
|
||||
_EXEC_TOLERANCE_KG = 20.0
|
||||
_MIN_PLAN_KG = 0.05
|
||||
|
||||
|
||||
def _base_kg(recipe: Recipe, ing: Ingredient) -> float:
|
||||
heads = int(recipe.heads_per_trip or 0)
|
||||
tp = float(recipe.trip_percent or 100) / 100.0
|
||||
return round(float(ing.weight_per_head or 0) * heads * tp, 2)
|
||||
|
||||
|
||||
def _find_trip_in_plan(plan: Dict[str, Any], recipe_id: str) -> Optional[Dict[str, Any]]:
|
||||
for period in plan.get("periods") or []:
|
||||
for trip in period.get("trips") or []:
|
||||
if trip.get("recipeId") == recipe_id:
|
||||
return trip
|
||||
return None
|
||||
|
||||
|
||||
def _plan_cache() -> Dict[str, Dict[str, Any]]:
|
||||
return {}
|
||||
|
||||
|
||||
def _get_plan_for_date(iso_date: str, cache: Dict[str, Dict[str, Any]]) -> Dict[str, Any]:
|
||||
if iso_date not in cache:
|
||||
cache[iso_date] = build_daily_plan(dispenser_id=ALL_DISPENSERS_ID, plan_date=iso_date)
|
||||
return cache[iso_date]
|
||||
|
||||
|
||||
def _match_plan_ingredient(
|
||||
trip: Optional[Dict[str, Any]],
|
||||
*,
|
||||
component_id: Optional[str],
|
||||
component_name: str,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
if not trip:
|
||||
return None
|
||||
name_lo = (component_name or "").strip().lower()
|
||||
for ing in trip.get("ingredients") or []:
|
||||
if component_id and ing.get("componentId") == component_id:
|
||||
return ing
|
||||
if ing.get("replacementComponentId") == component_id:
|
||||
return ing
|
||||
orig = str(ing.get("originalName") or ing.get("name") or "").lower()
|
||||
if name_lo and orig == name_lo:
|
||||
return ing
|
||||
return None
|
||||
|
||||
|
||||
def _build_deviation_notes(
|
||||
*,
|
||||
base_kg: float,
|
||||
plan_today_kg: float,
|
||||
actual_kg: float,
|
||||
report_date: date,
|
||||
skipped_today: bool = False,
|
||||
phase: str = "loading",
|
||||
) -> Tuple[List[Dict[str, str]], str]:
|
||||
"""notes[].kind: zootech | execution.
|
||||
|
||||
fault:
|
||||
execution — механизатор отклонился от плана на сегодня;
|
||||
excluded — зоотехник исключил компонент, загрузка совпала с планом (0);
|
||||
adjusted — зоотехник скорректировал план, загрузка совпала с новым планом;
|
||||
none — без отклонений.
|
||||
"""
|
||||
notes: List[Dict[str, str]] = []
|
||||
ds = report_date.strftime("%d.%m")
|
||||
is_unloading = phase == "unloading"
|
||||
overload_label = "перегруз при выгрузке" if is_unloading else "перегруз при загрузке"
|
||||
underload_label = "недогруз при выгрузке" if is_unloading else "недогруз при загрузке"
|
||||
loaded_despite_label = (
|
||||
"выгрузили несмотря на исключение" if is_unloading else "загрузили несмотря на исключение"
|
||||
)
|
||||
plan_ok_label = (
|
||||
"выгрузка по новому плану" if is_unloading else "загрузка по новому плану"
|
||||
)
|
||||
zootech_changed = base_kg > plan_today_kg + _EXEC_TOLERANCE_KG
|
||||
execution_ok = abs(actual_kg - plan_today_kg) <= _EXEC_TOLERANCE_KG
|
||||
fully_excluded = skipped_today and plan_today_kg <= _MIN_PLAN_KG
|
||||
|
||||
if fully_excluded:
|
||||
notes.append(
|
||||
{
|
||||
"text": f"исключено из плана на сегодня ({ds})",
|
||||
"kind": "zootech",
|
||||
}
|
||||
)
|
||||
elif zootech_changed:
|
||||
diff = round(base_kg - plan_today_kg, 1)
|
||||
if execution_ok:
|
||||
text = f"план скорректирован −{diff} кг ({ds}) — {plan_ok_label}"
|
||||
else:
|
||||
text = f"план скорректирован −{diff} кг ({ds})"
|
||||
notes.append({"text": text, "kind": "zootech"})
|
||||
|
||||
if plan_today_kg > _MIN_PLAN_KG and actual_kg > plan_today_kg + _EXEC_TOLERANCE_KG:
|
||||
diff = round(actual_kg - plan_today_kg, 1)
|
||||
notes.append(
|
||||
{
|
||||
"text": f"{overload_label} +{diff} кг ({ds})",
|
||||
"kind": "execution",
|
||||
}
|
||||
)
|
||||
elif plan_today_kg > _MIN_PLAN_KG and actual_kg < plan_today_kg - _EXEC_TOLERANCE_KG:
|
||||
diff = round(plan_today_kg - actual_kg, 1)
|
||||
notes.append(
|
||||
{
|
||||
"text": f"{underload_label} −{diff} кг ({ds})",
|
||||
"kind": "execution",
|
||||
}
|
||||
)
|
||||
elif fully_excluded and actual_kg > _MIN_PLAN_KG:
|
||||
diff = round(actual_kg, 1)
|
||||
notes.append(
|
||||
{
|
||||
"text": f"{loaded_despite_label} +{diff} кг ({ds})",
|
||||
"kind": "execution",
|
||||
}
|
||||
)
|
||||
|
||||
has_execution = any(n["kind"] == "execution" for n in notes)
|
||||
if has_execution:
|
||||
fault = "execution"
|
||||
elif fully_excluded and execution_ok:
|
||||
fault = "excluded"
|
||||
elif zootech_changed and execution_ok:
|
||||
fault = "adjusted"
|
||||
elif zootech_changed:
|
||||
fault = "adjusted"
|
||||
else:
|
||||
fault = "none"
|
||||
|
||||
return notes, fault
|
||||
|
||||
|
||||
def _plan_ing_matches_report_comp(
|
||||
plan_ing: Dict[str, Any],
|
||||
comp: LoadingReportComponent,
|
||||
) -> bool:
|
||||
cid = comp.component_id
|
||||
cname = (comp.component_name or "").strip().lower()
|
||||
pcid = plan_ing.get("componentId")
|
||||
repl = plan_ing.get("replacementComponentId")
|
||||
if cid:
|
||||
sc = str(cid)
|
||||
if pcid and sc == str(pcid):
|
||||
return True
|
||||
if repl and sc == str(repl):
|
||||
return True
|
||||
for key in ("originalName", "name"):
|
||||
plan_name = str(plan_ing.get(key) or "").strip().lower()
|
||||
if cname and plan_name and cname == plan_name:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _component_row(
|
||||
*,
|
||||
recipe: Recipe,
|
||||
ing: Optional[Ingredient],
|
||||
plan_ing: Optional[Dict[str, Any]],
|
||||
comp: Optional[LoadingReportComponent],
|
||||
report_date: date,
|
||||
skipped_ingredients: set,
|
||||
) -> Dict[str, Any]:
|
||||
cid = comp.component_id if comp else None
|
||||
if plan_ing and not cid:
|
||||
cid = plan_ing.get("replacementComponentId") or plan_ing.get("componentId")
|
||||
cname = str(
|
||||
(comp.component_name if comp else None)
|
||||
or (plan_ing or {}).get("name")
|
||||
or (plan_ing or {}).get("originalName")
|
||||
or (ing.name if ing else None)
|
||||
or "—"
|
||||
)
|
||||
|
||||
base_kg = _base_kg(recipe, ing) if ing else float((comp.target_weight if comp else 0) or 0)
|
||||
skipped_today = False
|
||||
if plan_ing:
|
||||
skipped_today = bool(plan_ing.get("skippedToday"))
|
||||
if skipped_today:
|
||||
plan_today_kg = 0.0
|
||||
base_kg = float(plan_ing.get("baselineTotalKg") or base_kg)
|
||||
else:
|
||||
plan_today_kg = float(plan_ing.get("totalKg") or 0)
|
||||
elif ing and ing.id in skipped_ingredients:
|
||||
skipped_today = True
|
||||
plan_today_kg = 0.0
|
||||
elif comp is not None:
|
||||
plan_today_kg = float(comp.target_weight or base_kg)
|
||||
else:
|
||||
plan_today_kg = 0.0
|
||||
|
||||
actual_kg = float(comp.actual_weight or 0) if comp is not None else 0.0
|
||||
notes, fault = _build_deviation_notes(
|
||||
base_kg=base_kg,
|
||||
plan_today_kg=plan_today_kg,
|
||||
actual_kg=actual_kg,
|
||||
report_date=report_date,
|
||||
skipped_today=skipped_today,
|
||||
)
|
||||
ingredient_id = None
|
||||
if plan_ing:
|
||||
ingredient_id = plan_ing.get("id")
|
||||
elif ing:
|
||||
ingredient_id = ing.id
|
||||
|
||||
return {
|
||||
"componentId": cid,
|
||||
"ingredientId": ingredient_id,
|
||||
"name": cname,
|
||||
"baseKg": round(base_kg, 2),
|
||||
"planTodayKg": round(plan_today_kg, 2),
|
||||
"actualKg": round(actual_kg, 2),
|
||||
"skippedToday": skipped_today,
|
||||
"loadingOrder": int(comp.loading_order or 0) if comp is not None else None,
|
||||
"notes": notes,
|
||||
"fault": fault,
|
||||
}
|
||||
|
||||
|
||||
def _match_plan_unloading_group(
|
||||
trip: Optional[Dict[str, Any]],
|
||||
*,
|
||||
group_id: Optional[str],
|
||||
group_name: str,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
if not trip:
|
||||
return None
|
||||
name_lo = (group_name or "").strip().lower()
|
||||
for grp in trip.get("unloadingGroups") or []:
|
||||
if group_id and grp.get("id") == group_id:
|
||||
return grp
|
||||
grp_name = str(grp.get("name") or "").strip().lower()
|
||||
if name_lo and grp_name == name_lo:
|
||||
return grp
|
||||
return None
|
||||
|
||||
|
||||
def _baseline_unloading_weights(
|
||||
recipe: Recipe,
|
||||
*,
|
||||
cache: Dict[str, Dict[str, float]],
|
||||
) -> Dict[str, float]:
|
||||
rid = recipe.id
|
||||
if rid in cache:
|
||||
return cache[rid]
|
||||
|
||||
groups = list(
|
||||
db.session.execute(
|
||||
select(UnloadingGroup)
|
||||
.where(UnloadingGroup.recipe_id == rid, UnloadingGroup.is_deleted.is_(False))
|
||||
.order_by(UnloadingGroup.order.asc())
|
||||
).scalars().all()
|
||||
)
|
||||
if not groups:
|
||||
cache[rid] = {}
|
||||
return cache[rid]
|
||||
|
||||
ingredients = list(
|
||||
db.session.execute(
|
||||
select(Ingredient)
|
||||
.where(Ingredient.recipe_id == rid, Ingredient.is_deleted.is_(False))
|
||||
.order_by(Ingredient.order.asc())
|
||||
).scalars().all()
|
||||
)
|
||||
comp_ids = [str(i.component_id) for i in ingredients if i.component_id]
|
||||
components = (
|
||||
db.session.execute(
|
||||
select(Component).where(
|
||||
Component.id.in_(set(comp_ids)),
|
||||
Component.is_deleted.is_(False),
|
||||
)
|
||||
).scalars().all()
|
||||
if comp_ids
|
||||
else []
|
||||
)
|
||||
dm_map = {str(c.id): float(c.dry_matter or 0) for c in components}
|
||||
calc_inputs = [
|
||||
{
|
||||
"weightPerHead": float(ing.weight_per_head or 0),
|
||||
"dryMatter": float(ing.dry_matter or dm_map.get(str(ing.component_id or ""), 0) or 0),
|
||||
"component_id": ing.component_id,
|
||||
}
|
||||
for ing in ingredients
|
||||
]
|
||||
calc_groups = [
|
||||
{
|
||||
"distributionType": g.distribution_type or "percent",
|
||||
"value": float(g.value or 0),
|
||||
}
|
||||
for g in groups
|
||||
]
|
||||
result = calculate_recipe(
|
||||
calc_inputs,
|
||||
heads_count=int(recipe.heads_per_trip or 0),
|
||||
trip_percent=float(recipe.trip_percent or 100),
|
||||
unloading_groups=calc_groups,
|
||||
component_dry_matter_map=dm_map,
|
||||
)
|
||||
calc_unloading = result.get("unloadingGroups") or []
|
||||
out: Dict[str, float] = {}
|
||||
for idx, grp in enumerate(groups):
|
||||
if idx < len(calc_unloading):
|
||||
out[grp.id] = float(calc_unloading[idx].get("calculatedWeight") or 0)
|
||||
cache[rid] = out
|
||||
return out
|
||||
|
||||
|
||||
def _unloading_group_row(
|
||||
*,
|
||||
plan_grp: Optional[Dict[str, Any]],
|
||||
report_grp: Optional[UnloadingReportGroup],
|
||||
base_kg: float,
|
||||
report_date: date,
|
||||
) -> Dict[str, Any]:
|
||||
gid = plan_grp.get("id") if plan_grp else None
|
||||
|
||||
name = str(
|
||||
(report_grp.name if report_grp is not None else None)
|
||||
or (plan_grp or {}).get("name")
|
||||
or "—"
|
||||
)
|
||||
skipped_today = bool((plan_grp or {}).get("skippedToday"))
|
||||
if plan_grp is not None:
|
||||
plan_today_kg = 0.0 if skipped_today else float(plan_grp.get("weightKg") or 0)
|
||||
elif report_grp is not None:
|
||||
plan_today_kg = float(report_grp.target_weight or base_kg)
|
||||
else:
|
||||
plan_today_kg = 0.0
|
||||
|
||||
actual_kg = float(report_grp.unloaded_weight or 0) if report_grp is not None else 0.0
|
||||
notes, fault = _build_deviation_notes(
|
||||
base_kg=base_kg,
|
||||
plan_today_kg=plan_today_kg,
|
||||
actual_kg=actual_kg,
|
||||
report_date=report_date,
|
||||
skipped_today=skipped_today,
|
||||
phase="unloading",
|
||||
)
|
||||
return {
|
||||
"groupId": gid,
|
||||
"name": name,
|
||||
"baseKg": round(base_kg, 2),
|
||||
"planTodayKg": round(plan_today_kg, 2),
|
||||
"actualKg": round(actual_kg, 2),
|
||||
"skippedToday": skipped_today,
|
||||
"unloadingOrder": int(report_grp.order or 0) if report_grp is not None else None,
|
||||
"notes": notes,
|
||||
"fault": fault,
|
||||
}
|
||||
|
||||
|
||||
def _mixer_remainder_row(
|
||||
*,
|
||||
remaining_kg: float,
|
||||
report_date: date,
|
||||
) -> Dict[str, Any]:
|
||||
base_kg = 0.0
|
||||
plan_today_kg = 0.0
|
||||
actual_kg = float(remaining_kg or 0)
|
||||
notes: List[Dict[str, str]] = []
|
||||
fault = "none"
|
||||
if abs(actual_kg) > _EXEC_TOLERANCE_KG:
|
||||
ds = report_date.strftime("%d.%m")
|
||||
notes.append(
|
||||
{
|
||||
"text": f"остаток в миксере {round(actual_kg, 1):+g} кг ({ds})",
|
||||
"kind": "execution",
|
||||
}
|
||||
)
|
||||
fault = "execution"
|
||||
return {
|
||||
"name": "Остаток в миксере",
|
||||
"baseKg": round(base_kg, 2),
|
||||
"planTodayKg": round(plan_today_kg, 2),
|
||||
"actualKg": round(actual_kg, 2),
|
||||
"notes": notes,
|
||||
"fault": fault,
|
||||
}
|
||||
|
||||
|
||||
def _build_unloading_section(
|
||||
*,
|
||||
trip: Optional[Dict[str, Any]],
|
||||
unloading_report: Optional[UnloadingReport],
|
||||
report_groups: List[UnloadingReportGroup],
|
||||
baseline_by_group: Dict[str, float],
|
||||
report_date: date,
|
||||
) -> Tuple[List[Dict[str, Any]], Optional[Dict[str, Any]]]:
|
||||
matched_plan_ids: set = set()
|
||||
groups_out: List[Dict[str, Any]] = []
|
||||
|
||||
for report_grp in report_groups:
|
||||
plan_grp = _match_plan_unloading_group(
|
||||
trip,
|
||||
group_id=None,
|
||||
group_name=str(report_grp.name or ""),
|
||||
)
|
||||
if plan_grp and plan_grp.get("id"):
|
||||
matched_plan_ids.add(plan_grp["id"])
|
||||
base_kg = float(baseline_by_group.get(plan_grp.get("id") if plan_grp else "", 0) or 0)
|
||||
if base_kg <= 0 and report_grp is not None:
|
||||
base_kg = float(report_grp.target_weight or 0)
|
||||
groups_out.append(
|
||||
_unloading_group_row(
|
||||
plan_grp=plan_grp,
|
||||
report_grp=report_grp,
|
||||
base_kg=base_kg,
|
||||
report_date=report_date,
|
||||
)
|
||||
)
|
||||
|
||||
if trip:
|
||||
report_names_lo = {str(g.name or "").strip().lower() for g in report_groups}
|
||||
for plan_grp in trip.get("unloadingGroups") or []:
|
||||
if not plan_grp.get("skippedToday"):
|
||||
continue
|
||||
pid = plan_grp.get("id")
|
||||
if pid and pid in matched_plan_ids:
|
||||
continue
|
||||
pname = str(plan_grp.get("name") or "").strip().lower()
|
||||
if pname and pname in report_names_lo:
|
||||
continue
|
||||
if pid:
|
||||
matched_plan_ids.add(pid)
|
||||
base_kg = float(baseline_by_group.get(pid or "", 0) or 0)
|
||||
groups_out.append(
|
||||
_unloading_group_row(
|
||||
plan_grp=plan_grp,
|
||||
report_grp=None,
|
||||
base_kg=base_kg,
|
||||
report_date=report_date,
|
||||
)
|
||||
)
|
||||
if not report_groups:
|
||||
for plan_grp in trip.get("unloadingGroups") or []:
|
||||
pid = plan_grp.get("id")
|
||||
if pid and pid in matched_plan_ids:
|
||||
continue
|
||||
if pid:
|
||||
matched_plan_ids.add(pid)
|
||||
base_kg = float(baseline_by_group.get(pid or "", 0) or 0)
|
||||
groups_out.append(
|
||||
_unloading_group_row(
|
||||
plan_grp=plan_grp,
|
||||
report_grp=None,
|
||||
base_kg=base_kg,
|
||||
report_date=report_date,
|
||||
)
|
||||
)
|
||||
|
||||
def _sort_key(row: Dict[str, Any]) -> tuple:
|
||||
order = row.get("unloadingOrder")
|
||||
if order is not None:
|
||||
return (order, row.get("name") or "")
|
||||
return (10_000, row.get("name") or "")
|
||||
|
||||
groups_out.sort(key=_sort_key)
|
||||
|
||||
mixer = None
|
||||
if unloading_report is not None:
|
||||
mixer = _mixer_remainder_row(
|
||||
remaining_kg=float(unloading_report.remaining_weight or 0),
|
||||
report_date=report_date,
|
||||
)
|
||||
return groups_out, mixer
|
||||
|
||||
|
||||
def build_plan_fact_rows(
|
||||
*,
|
||||
date_from: Optional[str] = None,
|
||||
date_to: Optional[str] = None,
|
||||
recipe_id: Optional[str] = None,
|
||||
recipe_ids: Optional[str] = None,
|
||||
client_id: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
start_dt, end_dt = resolve_date_bounds(date_from, date_to)
|
||||
q = select(LoadingReport).where(
|
||||
LoadingReport.is_deleted.is_(False),
|
||||
LoadingReport.start_time >= start_dt,
|
||||
LoadingReport.start_time <= end_dt,
|
||||
)
|
||||
ids = parse_recipe_ids(recipe_id=recipe_id, recipe_ids=recipe_ids)
|
||||
if ids:
|
||||
q = q.where(LoadingReport.recipe_id.in_(ids) if len(ids) > 1 else LoadingReport.recipe_id == ids[0])
|
||||
if client_id:
|
||||
q = q.where(LoadingReport.client_id == client_id)
|
||||
reports = db.session.execute(q.order_by(LoadingReport.start_time.desc())).scalars().all()
|
||||
if not reports:
|
||||
return {"items": [], "reportCount": 0}
|
||||
|
||||
report_ids = [r.id for r in reports]
|
||||
comp_rows = db.session.execute(
|
||||
select(LoadingReportComponent).where(
|
||||
LoadingReportComponent.report_id.in_(report_ids),
|
||||
LoadingReportComponent.is_deleted.is_(False),
|
||||
).order_by(LoadingReportComponent.loading_order.asc())
|
||||
).scalars().all()
|
||||
comps_by_report: Dict[str, List[LoadingReportComponent]] = {}
|
||||
for c in comp_rows:
|
||||
comps_by_report.setdefault(c.report_id, []).append(c)
|
||||
|
||||
recipe_ids = {r.recipe_id for r in reports}
|
||||
recipes = {
|
||||
r.id: r
|
||||
for r in db.session.execute(
|
||||
select(Recipe).where(Recipe.id.in_(recipe_ids), Recipe.is_deleted.is_(False))
|
||||
).scalars().all()
|
||||
}
|
||||
ingredients_by_recipe: Dict[str, List[Ingredient]] = {}
|
||||
for rid in recipe_ids:
|
||||
ingredients_by_recipe[rid] = list(
|
||||
db.session.execute(
|
||||
select(Ingredient)
|
||||
.where(Ingredient.recipe_id == rid, Ingredient.is_deleted.is_(False))
|
||||
.order_by(Ingredient.order.asc())
|
||||
).scalars().all()
|
||||
)
|
||||
|
||||
unloading_reports = db.session.execute(
|
||||
select(UnloadingReport).where(
|
||||
UnloadingReport.loading_report_id.in_(report_ids),
|
||||
UnloadingReport.is_deleted.is_(False),
|
||||
)
|
||||
).scalars().all()
|
||||
unloading_by_loading: Dict[str, UnloadingReport] = {
|
||||
ur.loading_report_id: ur for ur in unloading_reports
|
||||
}
|
||||
unloading_report_ids = [ur.id for ur in unloading_reports]
|
||||
unloading_groups_rows: List[UnloadingReportGroup] = []
|
||||
if unloading_report_ids:
|
||||
unloading_groups_rows = list(
|
||||
db.session.execute(
|
||||
select(UnloadingReportGroup)
|
||||
.where(
|
||||
UnloadingReportGroup.report_id.in_(unloading_report_ids),
|
||||
UnloadingReportGroup.is_deleted.is_(False),
|
||||
)
|
||||
.order_by(UnloadingReportGroup.order.asc())
|
||||
).scalars().all()
|
||||
)
|
||||
groups_by_unloading: Dict[str, List[UnloadingReportGroup]] = {}
|
||||
for grp in unloading_groups_rows:
|
||||
groups_by_unloading.setdefault(grp.report_id, []).append(grp)
|
||||
|
||||
plan_cache: Dict[str, Dict[str, Any]] = {}
|
||||
skip_cache: Dict[str, Dict[str, set]] = {}
|
||||
baseline_unloading_cache: Dict[str, Dict[str, float]] = {}
|
||||
items: List[Dict[str, Any]] = []
|
||||
|
||||
for report in reports:
|
||||
recipe = recipes.get(report.recipe_id)
|
||||
if recipe is None:
|
||||
continue
|
||||
report_date = report.start_time.date() if report.start_time else date.today()
|
||||
iso = report_date.isoformat()
|
||||
plan = _get_plan_for_date(iso, plan_cache)
|
||||
if iso not in skip_cache:
|
||||
skip_cache[iso] = get_skipped_ingredient_ids(iso)
|
||||
skipped_ingredients = skip_cache[iso].get(report.recipe_id, set())
|
||||
trip = _find_trip_in_plan(plan, report.recipe_id)
|
||||
|
||||
ing_by_cid: Dict[str, Ingredient] = {}
|
||||
ing_by_name: Dict[str, Ingredient] = {}
|
||||
ing_by_id: Dict[str, Ingredient] = {}
|
||||
for ing in ingredients_by_recipe.get(report.recipe_id, []):
|
||||
ing_by_id[ing.id] = ing
|
||||
if ing.component_id:
|
||||
ing_by_cid[str(ing.component_id)] = ing
|
||||
ing_by_name[(ing.name or "").strip().lower()] = ing
|
||||
|
||||
report_comps = comps_by_report.get(report.id, [])
|
||||
matched_plan_ing_ids: set = set()
|
||||
components_out: List[Dict[str, Any]] = []
|
||||
|
||||
for comp in report_comps:
|
||||
cid = comp.component_id
|
||||
cname = str(comp.component_name or "—")
|
||||
ing = None
|
||||
if cid and str(cid) in ing_by_cid:
|
||||
ing = ing_by_cid[str(cid)]
|
||||
elif cname.lower() in ing_by_name:
|
||||
ing = ing_by_name[cname.lower()]
|
||||
|
||||
plan_ing = _match_plan_ingredient(trip, component_id=cid, component_name=cname)
|
||||
if plan_ing and plan_ing.get("id"):
|
||||
matched_plan_ing_ids.add(plan_ing["id"])
|
||||
|
||||
components_out.append(
|
||||
_component_row(
|
||||
recipe=recipe,
|
||||
ing=ing,
|
||||
plan_ing=plan_ing,
|
||||
comp=comp,
|
||||
report_date=report_date,
|
||||
skipped_ingredients=skipped_ingredients,
|
||||
)
|
||||
)
|
||||
|
||||
if trip:
|
||||
for plan_ing in trip.get("ingredients") or []:
|
||||
if not plan_ing.get("skippedToday"):
|
||||
continue
|
||||
pid = plan_ing.get("id")
|
||||
if pid and pid in matched_plan_ing_ids:
|
||||
continue
|
||||
if any(_plan_ing_matches_report_comp(plan_ing, c) for c in report_comps):
|
||||
continue
|
||||
|
||||
ing = ing_by_id.get(pid) if pid else None
|
||||
if ing is None:
|
||||
pcid = plan_ing.get("componentId")
|
||||
if pcid and str(pcid) in ing_by_cid:
|
||||
ing = ing_by_cid[str(pcid)]
|
||||
else:
|
||||
name_lo = str(
|
||||
plan_ing.get("originalName") or plan_ing.get("name") or ""
|
||||
).strip().lower()
|
||||
ing = ing_by_name.get(name_lo)
|
||||
|
||||
if pid:
|
||||
matched_plan_ing_ids.add(pid)
|
||||
components_out.append(
|
||||
_component_row(
|
||||
recipe=recipe,
|
||||
ing=ing,
|
||||
plan_ing=plan_ing,
|
||||
comp=None,
|
||||
report_date=report_date,
|
||||
skipped_ingredients=skipped_ingredients,
|
||||
)
|
||||
)
|
||||
|
||||
recipe_ings = ingredients_by_recipe.get(report.recipe_id, [])
|
||||
ing_order = {ing.id: idx for idx, ing in enumerate(recipe_ings)}
|
||||
|
||||
def _sort_key(row: Dict[str, Any]) -> tuple:
|
||||
iid = row.get("ingredientId")
|
||||
if iid and iid in ing_order:
|
||||
return (ing_order[iid], 0)
|
||||
lor = row.get("loadingOrder")
|
||||
if lor is not None:
|
||||
return (10_000, lor)
|
||||
return (20_000, row.get("name") or "")
|
||||
|
||||
components_out.sort(key=_sort_key)
|
||||
|
||||
unloading_report = unloading_by_loading.get(report.id)
|
||||
report_unloading_groups = (
|
||||
groups_by_unloading.get(unloading_report.id, []) if unloading_report else []
|
||||
)
|
||||
baseline_by_group = _baseline_unloading_weights(
|
||||
recipe,
|
||||
cache=baseline_unloading_cache,
|
||||
)
|
||||
unloading_out, mixer_out = _build_unloading_section(
|
||||
trip=trip,
|
||||
unloading_report=unloading_report,
|
||||
report_groups=report_unloading_groups,
|
||||
baseline_by_group=baseline_by_group,
|
||||
report_date=report_date,
|
||||
)
|
||||
|
||||
items.append(
|
||||
{
|
||||
"loadingReportId": report.id,
|
||||
"recipeId": report.recipe_id,
|
||||
"recipeName": report.recipe_name,
|
||||
"date": iso,
|
||||
"startTime": report.start_time.isoformat() if report.start_time else None,
|
||||
"clientId": report.client_id,
|
||||
"components": components_out,
|
||||
"unloadingGroups": unloading_out,
|
||||
"mixerRemainder": mixer_out,
|
||||
}
|
||||
)
|
||||
|
||||
return {"items": items, "reportCount": len(items)}
|
||||
@@ -0,0 +1,46 @@
|
||||
"""Цены компонентов для analytics."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from app import db
|
||||
from app.models import Component
|
||||
|
||||
|
||||
def component_prices(
|
||||
*,
|
||||
component_ids: Optional[List[str]] = None,
|
||||
names: Optional[List[str]] = None,
|
||||
) -> Dict[str, float]:
|
||||
"""component_id или имя -> price руб/кг."""
|
||||
prices: Dict[str, float] = {}
|
||||
ids = [x for x in (component_ids or []) if x]
|
||||
if ids:
|
||||
rows = db.session.execute(
|
||||
select(Component).where(
|
||||
Component.id.in_(ids),
|
||||
Component.is_deleted.is_(False),
|
||||
)
|
||||
).scalars().all()
|
||||
for row in rows:
|
||||
prices[row.id] = float(row.price or 0)
|
||||
for name in names or []:
|
||||
if not name or name in prices:
|
||||
continue
|
||||
row = db.session.execute(
|
||||
select(Component)
|
||||
.where(Component.name == name, Component.is_deleted.is_(False))
|
||||
.limit(1)
|
||||
).scalar_one_or_none()
|
||||
if row:
|
||||
prices[name] = float(row.price or 0)
|
||||
return prices
|
||||
|
||||
|
||||
def price_for(*, prices: Dict[str, float], component_id: Optional[str], name: str) -> float:
|
||||
if component_id and component_id in prices:
|
||||
return prices[component_id]
|
||||
return prices.get(name, 0.0)
|
||||
@@ -0,0 +1,17 @@
|
||||
"""Фильтрация отчётов analytics по recipe_id / recipe_ids."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import List, Optional
|
||||
|
||||
|
||||
def parse_recipe_ids(
|
||||
recipe_id: Optional[str] = None,
|
||||
recipe_ids: Optional[str] = None,
|
||||
) -> Optional[List[str]]:
|
||||
if recipe_id and str(recipe_id).strip():
|
||||
return [str(recipe_id).strip()]
|
||||
if not recipe_ids:
|
||||
return None
|
||||
ids = [part.strip() for part in str(recipe_ids).split(",") if part.strip()]
|
||||
return ids or None
|
||||
@@ -0,0 +1,41 @@
|
||||
"""Ферма и кормораздатчик по recipe_id для analytics export."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Dict, Iterable, Set
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from app import db
|
||||
from app.models import FeedDispenser, FeedingPeriod, PeriodRecipe
|
||||
|
||||
|
||||
def build_recipe_location_maps(
|
||||
recipe_ids: Iterable[str],
|
||||
) -> tuple[Dict[str, str], Dict[str, str]]:
|
||||
"""recipe_id -> farm, recipe_id -> dispenser_name."""
|
||||
ids = {str(rid) for rid in recipe_ids if rid}
|
||||
farm_by_recipe: Dict[str, str] = {}
|
||||
dispenser_by_recipe: Dict[str, str] = {}
|
||||
if not ids:
|
||||
return farm_by_recipe, dispenser_by_recipe
|
||||
|
||||
rows = db.session.execute(
|
||||
select(PeriodRecipe.recipe_id, FeedDispenser.farm, FeedDispenser.name)
|
||||
.join(FeedingPeriod, FeedingPeriod.id == PeriodRecipe.period_id)
|
||||
.join(FeedDispenser, FeedDispenser.id == FeedingPeriod.dispenser_id)
|
||||
.where(
|
||||
PeriodRecipe.recipe_id.in_(ids),
|
||||
PeriodRecipe.is_deleted.is_(False),
|
||||
FeedingPeriod.is_deleted.is_(False),
|
||||
FeedDispenser.is_deleted.is_(False),
|
||||
)
|
||||
).all()
|
||||
|
||||
for recipe_id, farm, disp_name in rows:
|
||||
rid = str(recipe_id)
|
||||
if rid not in farm_by_recipe and farm:
|
||||
farm_by_recipe[rid] = str(farm).strip()
|
||||
if rid not in dispenser_by_recipe and disp_name:
|
||||
dispenser_by_recipe[rid] = str(disp_name).strip()
|
||||
return farm_by_recipe, dispenser_by_recipe
|
||||
@@ -0,0 +1,198 @@
|
||||
"""Детальные строки отчётов загрузки для экспорта."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from app import db
|
||||
from app.models import (
|
||||
ComponentLoadingTime,
|
||||
LoadingReport,
|
||||
LoadingReportComponent,
|
||||
UnloadingReport,
|
||||
UnloadingReportGroup,
|
||||
)
|
||||
from app.services.analytics.date_range import resolve_date_bounds
|
||||
from app.services.analytics.recipe_filter import parse_recipe_ids
|
||||
from app.services.analytics.recipe_location import build_recipe_location_maps
|
||||
|
||||
UNLOADING_FEED_COMPONENT_LABEL = "Выгрузка полного миксера"
|
||||
_UNLOADING_NAME_PREFIX = "выгрузка:"
|
||||
|
||||
|
||||
def _parse_unloading_group_name(raw: str) -> str:
|
||||
text = (raw or "—").strip()
|
||||
if text.lower().startswith(_UNLOADING_NAME_PREFIX):
|
||||
parsed = text[len(_UNLOADING_NAME_PREFIX) :].strip()
|
||||
return parsed or "—"
|
||||
return text or "—"
|
||||
|
||||
|
||||
def _overload_kg(actual: float, target: float) -> float:
|
||||
"""Перерасход только при факте выше плана; недогруз не дублируем в эту колонку."""
|
||||
return round(max(0.0, actual - target), 2)
|
||||
|
||||
|
||||
def _dedupe_detail_rows(rows: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
||||
"""Убрать полные дубликаты строк (одинаковый рейс, время, компонент, план и факт)."""
|
||||
seen: set[tuple[Any, ...]] = set()
|
||||
out: List[Dict[str, Any]] = []
|
||||
for row in rows:
|
||||
key = (
|
||||
row.get("reportId"),
|
||||
row.get("kind"),
|
||||
row.get("feedComponent"),
|
||||
row.get("animalGroup"),
|
||||
row.get("dateTime"),
|
||||
row.get("recipeName"),
|
||||
row.get("targetKg"),
|
||||
row.get("actualKg"),
|
||||
)
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
out.append(row)
|
||||
return out
|
||||
|
||||
|
||||
def build_reports_detail_rows(
|
||||
*,
|
||||
date_from: Optional[str] = None,
|
||||
date_to: Optional[str] = None,
|
||||
recipe_id: Optional[str] = None,
|
||||
recipe_ids: Optional[str] = None,
|
||||
) -> List[Dict[str, Any]]:
|
||||
start_dt, end_dt = resolve_date_bounds(date_from, date_to)
|
||||
q = select(LoadingReport).where(
|
||||
LoadingReport.is_deleted.is_(False),
|
||||
LoadingReport.start_time >= start_dt,
|
||||
LoadingReport.start_time <= end_dt,
|
||||
)
|
||||
ids = parse_recipe_ids(recipe_id=recipe_id, recipe_ids=recipe_ids)
|
||||
if ids:
|
||||
q = q.where(LoadingReport.recipe_id.in_(ids) if len(ids) > 1 else LoadingReport.recipe_id == ids[0])
|
||||
reports = db.session.execute(q.order_by(LoadingReport.start_time.desc())).scalars().all()
|
||||
if not reports:
|
||||
return []
|
||||
|
||||
recipe_ids_set = {r.recipe_id for r in reports if r.recipe_id}
|
||||
farm_by_recipe, dispenser_by_recipe = build_recipe_location_maps(recipe_ids_set)
|
||||
|
||||
report_ids = [r.id for r in reports]
|
||||
components = db.session.execute(
|
||||
select(LoadingReportComponent).where(
|
||||
LoadingReportComponent.report_id.in_(report_ids),
|
||||
LoadingReportComponent.is_deleted.is_(False),
|
||||
).order_by(LoadingReportComponent.loading_order.asc())
|
||||
).scalars().all()
|
||||
loading_times = db.session.execute(
|
||||
select(ComponentLoadingTime).where(
|
||||
ComponentLoadingTime.report_id.in_(report_ids),
|
||||
ComponentLoadingTime.is_deleted.is_(False),
|
||||
)
|
||||
).scalars().all()
|
||||
unloading_rows = db.session.execute(
|
||||
select(UnloadingReport).where(
|
||||
UnloadingReport.loading_report_id.in_(report_ids),
|
||||
UnloadingReport.is_deleted.is_(False),
|
||||
)
|
||||
).scalars().all()
|
||||
|
||||
comps_by_report: Dict[str, List[LoadingReportComponent]] = {}
|
||||
for c in components:
|
||||
comps_by_report.setdefault(c.report_id, []).append(c)
|
||||
|
||||
load_sec_by_report_name: Dict[str, Dict[str, float]] = {}
|
||||
for t in loading_times:
|
||||
if not t.report_id or not t.component_name:
|
||||
continue
|
||||
load_sec_by_report_name.setdefault(t.report_id, {})[
|
||||
str(t.component_name)
|
||||
] = float(t.loading_duration or 0)
|
||||
|
||||
unloading_by_loading = {u.loading_report_id: u for u in unloading_rows}
|
||||
unloading_ids = [u.id for u in unloading_rows]
|
||||
unloading_groups: List[UnloadingReportGroup] = []
|
||||
if unloading_ids:
|
||||
unloading_groups = list(
|
||||
db.session.execute(
|
||||
select(UnloadingReportGroup).where(
|
||||
UnloadingReportGroup.report_id.in_(unloading_ids),
|
||||
UnloadingReportGroup.is_deleted.is_(False),
|
||||
).order_by(UnloadingReportGroup.order.asc())
|
||||
).scalars().all()
|
||||
)
|
||||
groups_by_unloading: Dict[str, List[UnloadingReportGroup]] = {}
|
||||
for g in unloading_groups:
|
||||
groups_by_unloading.setdefault(g.report_id, []).append(g)
|
||||
|
||||
rows: List[Dict[str, Any]] = []
|
||||
for report in reports:
|
||||
when = report.start_time.strftime("%d.%m.%Y %H:%M") if report.start_time else ""
|
||||
date_day = report.start_time.strftime("%d.%m.%Y") if report.start_time else ""
|
||||
farm = farm_by_recipe.get(report.recipe_id, "—")
|
||||
dispenser = dispenser_by_recipe.get(report.recipe_id, "—")
|
||||
mix_plan = int(report.target_mixing_time or 0)
|
||||
mix_fact = int(report.actual_mixing_time or 0)
|
||||
load_secs = load_sec_by_report_name.get(report.id, {})
|
||||
|
||||
for comp in comps_by_report.get(report.id, []):
|
||||
target = float(comp.target_weight or 0)
|
||||
actual = float(comp.actual_weight or 0)
|
||||
dev = round(actual - target, 2)
|
||||
cname = str(comp.component_name or "—")
|
||||
rows.append(
|
||||
{
|
||||
"kind": "component",
|
||||
"reportId": report.id,
|
||||
"dateDay": date_day,
|
||||
"farm": farm,
|
||||
"dispenserName": dispenser,
|
||||
"recipeName": report.recipe_name,
|
||||
"dateTime": when,
|
||||
"feedComponent": cname,
|
||||
"animalGroup": "",
|
||||
"name": cname,
|
||||
"targetKg": round(target, 2),
|
||||
"actualKg": round(actual, 2),
|
||||
"deviationKg": dev,
|
||||
"overloadKg": _overload_kg(actual, target),
|
||||
"loadingSec": round(load_secs.get(cname, 0), 1),
|
||||
"mixPlanSec": mix_plan,
|
||||
"mixFactSec": mix_fact,
|
||||
}
|
||||
)
|
||||
|
||||
unloading = unloading_by_loading.get(report.id)
|
||||
if unloading:
|
||||
for group in groups_by_unloading.get(unloading.id, []):
|
||||
target = float(group.target_weight or 0)
|
||||
actual = float(group.unloaded_weight or 0)
|
||||
group_name = _parse_unloading_group_name(str(group.name or ""))
|
||||
rows.append(
|
||||
{
|
||||
"kind": "unloading",
|
||||
"reportId": report.id,
|
||||
"dateDay": date_day,
|
||||
"farm": farm,
|
||||
"dispenserName": dispenser,
|
||||
"recipeName": report.recipe_name,
|
||||
"dateTime": when,
|
||||
"feedComponent": UNLOADING_FEED_COMPONENT_LABEL,
|
||||
"animalGroup": group_name,
|
||||
"name": f"выгрузка: {group_name}",
|
||||
"targetKg": round(target, 2),
|
||||
"actualKg": round(actual, 2),
|
||||
"deviationKg": round(actual - target, 2),
|
||||
"overloadKg": _overload_kg(actual, target),
|
||||
"loadingSec": None,
|
||||
"mixPlanSec": mix_plan,
|
||||
"mixFactSec": mix_fact,
|
||||
}
|
||||
)
|
||||
|
||||
rows = _dedupe_detail_rows(rows)
|
||||
rows.sort(key=lambda r: (r.get("dateDay") or "", r.get("recipeName") or "", r.get("name") or ""))
|
||||
return rows
|
||||
@@ -0,0 +1,217 @@
|
||||
"""Прогноз запаса: хватит по рецепту vs с учётом плана и факта."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date, timedelta
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
from app import db
|
||||
from app.models import Ingredient, LoadingReport, LoadingReportComponent, Recipe
|
||||
from app.services.daily_plan.builder import ALL_DISPENSERS_ID, build_daily_plan
|
||||
from app.services.sklad_metrics import (
|
||||
consumed_by_component_id,
|
||||
list_stock_balance_items,
|
||||
stock_item_key,
|
||||
)
|
||||
|
||||
_CRITICAL_DAYS = 2.0
|
||||
|
||||
|
||||
def _plan_kg_by_component(plan_date: str) -> Dict[str, float]:
|
||||
plan = build_daily_plan(dispenser_id=ALL_DISPENSERS_ID, plan_date=plan_date)
|
||||
out: Dict[str, float] = {}
|
||||
for period in plan.get("periods") or []:
|
||||
for trip in period.get("trips") or []:
|
||||
for ing in trip.get("ingredients") or []:
|
||||
if ing.get("skippedToday"):
|
||||
continue
|
||||
cid = ing.get("replacementComponentId") or ing.get("componentId")
|
||||
if not cid:
|
||||
continue
|
||||
out[str(cid)] = out.get(str(cid), 0.0) + float(ing.get("totalKg") or 0)
|
||||
return {k: round(v, 2) for k, v in out.items()}
|
||||
|
||||
|
||||
def format_days_label(days: Optional[float]) -> str:
|
||||
if days is None:
|
||||
return "—"
|
||||
if days < 1:
|
||||
hours = round(days * 24)
|
||||
return f"~{hours} ч"
|
||||
d = round(days, 1)
|
||||
if d == int(d):
|
||||
return f"~{int(d)} дн"
|
||||
return f"~{d} дн"
|
||||
|
||||
|
||||
def _days_left(remaining_kg: float, daily_kg: float) -> Optional[float]:
|
||||
if daily_kg <= 0 or remaining_kg <= 0:
|
||||
return None
|
||||
d = remaining_kg / daily_kg
|
||||
return round(min(d, 9999.9), 1) if d < 9999.9 else 9999.9
|
||||
|
||||
|
||||
def enrich_stock_balance_item(
|
||||
item: Dict[str, Any],
|
||||
*,
|
||||
plan_by_cid: Dict[str, float],
|
||||
consumed_period: Dict[str, float],
|
||||
lookback_days: int,
|
||||
plan_d: date,
|
||||
) -> Tuple[Dict[str, Any], Optional[str]]:
|
||||
"""Добавляет прогноз «хватит с учётом плана» к строке склада."""
|
||||
cid = str(item.get("component_id") or "").strip()
|
||||
name = str(item.get("name") or "—")
|
||||
remaining = float(item.get("remaining_kg") or 0)
|
||||
plan_recipe = float(item.get("planned_consumption_per_day_kg") or 0)
|
||||
plan_today = plan_by_cid.get(cid, plan_recipe) if cid else plan_recipe
|
||||
period_kg = consumed_period.get(cid, 0.0) if cid else 0.0
|
||||
if period_kg <= 0:
|
||||
period_kg = float(item.get("consumed_total_kg") or item.get("consumed_kg") or 0)
|
||||
avg_daily = round(period_kg / max(1, lookback_days), 2) if period_kg > 0 else 0.0
|
||||
|
||||
if avg_daily > 0:
|
||||
expected_daily = max(avg_daily, plan_today)
|
||||
else:
|
||||
expected_daily = plan_today if plan_today > 0 else plan_recipe
|
||||
|
||||
days_recipe = item.get("days_left_plan")
|
||||
days_adjusted = _days_left(remaining, expected_daily)
|
||||
|
||||
explanation_parts: List[str] = []
|
||||
if plan_today < plan_recipe - 0.01 and plan_recipe > 0:
|
||||
explanation_parts.append(
|
||||
f"убрали из плана на {plan_d.strftime('%d.%m')} → расход меньше на "
|
||||
f"{round(plan_recipe - plan_today, 1)} кг/сут"
|
||||
)
|
||||
if avg_daily > plan_recipe * 1.05 and plan_recipe > 0:
|
||||
explanation_parts.append(
|
||||
f"расход выше плана: ~{avg_daily} кг/сут за {lookback_days} дн"
|
||||
)
|
||||
|
||||
explanation = ""
|
||||
if explanation_parts:
|
||||
base = " · ".join(explanation_parts)
|
||||
if days_recipe is not None and days_adjusted is not None and days_adjusted != days_recipe:
|
||||
delta = round(days_adjusted - days_recipe, 1)
|
||||
sign = "+" if delta > 0 else ""
|
||||
explanation = f"{base} → запас {sign}{delta} дн"
|
||||
else:
|
||||
explanation = base
|
||||
|
||||
row = dict(item)
|
||||
row.update(
|
||||
{
|
||||
"forecast_key": stock_item_key(item),
|
||||
"planTodayKgPerDay": round(plan_today, 2),
|
||||
"avgConsumptionKgPerDay": avg_daily,
|
||||
"expectedDailyKg": round(expected_daily, 2),
|
||||
"days_left_adjusted": days_adjusted,
|
||||
"days_left_adjusted_label": format_days_label(days_adjusted),
|
||||
"daysLeftAdjusted": days_adjusted,
|
||||
"daysLeftAdjustedLabel": format_days_label(days_adjusted),
|
||||
"daysLeftRecipeLabel": format_days_label(days_recipe),
|
||||
"explanation": explanation,
|
||||
"removedFromPlanToday": plan_today < plan_recipe - 0.01 and plan_recipe > 0,
|
||||
}
|
||||
)
|
||||
|
||||
alert: Optional[str] = None
|
||||
if days_adjusted is not None and days_adjusted <= _CRITICAL_DAYS:
|
||||
alert = f"{name} — хватит {format_days_label(days_adjusted)}"
|
||||
elif row["removedFromPlanToday"] and days_adjusted and days_recipe:
|
||||
if days_adjusted > days_recipe + 0.3:
|
||||
alert = (
|
||||
f"{name} — запас {format_days_label(days_adjusted)} "
|
||||
f"(убрали из плана на сегодня)"
|
||||
)
|
||||
return row, alert
|
||||
|
||||
|
||||
def enrich_stock_balance_items(
|
||||
items: List[Dict[str, Any]],
|
||||
*,
|
||||
plan_date: Optional[str] = None,
|
||||
lookback_days: int = 7,
|
||||
) -> List[Dict[str, Any]]:
|
||||
today = date.today()
|
||||
iso = (plan_date or today.isoformat())[:10]
|
||||
try:
|
||||
plan_d = date.fromisoformat(iso)
|
||||
except ValueError:
|
||||
plan_d = today
|
||||
iso = today.isoformat()
|
||||
|
||||
from_d = (plan_d - timedelta(days=max(1, lookback_days))).isoformat()
|
||||
to_d = plan_d.isoformat()
|
||||
plan_by_cid = _plan_kg_by_component(iso)
|
||||
consumed_period = consumed_by_component_id(
|
||||
db, LoadingReport, LoadingReportComponent, date_from=from_d, date_to=to_d
|
||||
)
|
||||
|
||||
enriched: List[Dict[str, Any]] = []
|
||||
for item in items:
|
||||
row, _ = enrich_stock_balance_item(
|
||||
item,
|
||||
plan_by_cid=plan_by_cid,
|
||||
consumed_period=consumed_period,
|
||||
lookback_days=lookback_days,
|
||||
plan_d=plan_d,
|
||||
)
|
||||
enriched.append(row)
|
||||
return enriched
|
||||
|
||||
|
||||
def build_stock_forecast(
|
||||
*,
|
||||
plan_date: Optional[str] = None,
|
||||
lookback_days: int = 7,
|
||||
Component=None,
|
||||
) -> Dict[str, Any]:
|
||||
today = date.today()
|
||||
iso = (plan_date or today.isoformat())[:10]
|
||||
try:
|
||||
plan_d = date.fromisoformat(iso)
|
||||
except ValueError:
|
||||
plan_d = today
|
||||
iso = today.isoformat()
|
||||
|
||||
from_d = (plan_d - timedelta(days=max(1, lookback_days))).isoformat()
|
||||
to_d = plan_d.isoformat()
|
||||
|
||||
items = list_stock_balance_items(
|
||||
db,
|
||||
LoadingReport,
|
||||
LoadingReportComponent,
|
||||
Ingredient,
|
||||
Recipe,
|
||||
date_from=from_d,
|
||||
date_to=to_d,
|
||||
Component=Component,
|
||||
)
|
||||
plan_by_cid = _plan_kg_by_component(iso)
|
||||
consumed_period = consumed_by_component_id(
|
||||
db, LoadingReport, LoadingReportComponent, date_from=from_d, date_to=to_d
|
||||
)
|
||||
|
||||
enriched: List[Dict[str, Any]] = []
|
||||
alerts: List[str] = []
|
||||
|
||||
for item in items:
|
||||
row, alert = enrich_stock_balance_item(
|
||||
item,
|
||||
plan_by_cid=plan_by_cid,
|
||||
consumed_period=consumed_period,
|
||||
lookback_days=lookback_days,
|
||||
plan_d=plan_d,
|
||||
)
|
||||
enriched.append(row)
|
||||
if alert:
|
||||
alerts.append(alert)
|
||||
|
||||
return {
|
||||
"planDate": iso,
|
||||
"items": enriched,
|
||||
"alerts": alerts,
|
||||
"alertBanner": " · ".join(alerts[:5]) if alerts else "",
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
"""Строки листа «Итоги» для Excel (экономист)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import defaultdict
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from app import db
|
||||
from app.models import LoadingReport, LoadingReportComponent
|
||||
from app.services.analytics.date_range import resolve_date_bounds
|
||||
from app.services.analytics.finance_summary import _report_filters
|
||||
from app.services.analytics.prices import component_prices, price_for
|
||||
from app.services.analytics.recipe_filter import parse_recipe_ids
|
||||
from app.services.analytics.recipe_location import build_recipe_location_maps
|
||||
|
||||
|
||||
def build_summary_table_rows(
|
||||
*,
|
||||
date_from: Optional[str] = None,
|
||||
date_to: Optional[str] = None,
|
||||
recipe_id: Optional[str] = None,
|
||||
recipe_ids: Optional[str] = None,
|
||||
) -> List[Dict[str, Any]]:
|
||||
start_dt, end_dt = resolve_date_bounds(date_from, date_to)
|
||||
ids = parse_recipe_ids(recipe_id=recipe_id, recipe_ids=recipe_ids)
|
||||
reports = db.session.execute(
|
||||
_report_filters(start_dt=start_dt, end_dt=end_dt, recipe_ids=ids)
|
||||
).scalars().all()
|
||||
if not reports:
|
||||
return []
|
||||
|
||||
report_by_id = {r.id: r for r in reports}
|
||||
recipe_ids_set = {r.recipe_id for r in reports if r.recipe_id}
|
||||
farm_by_recipe, _ = build_recipe_location_maps(recipe_ids_set)
|
||||
|
||||
report_ids = [r.id for r in reports]
|
||||
components = db.session.execute(
|
||||
select(LoadingReportComponent).where(
|
||||
LoadingReportComponent.report_id.in_(report_ids),
|
||||
LoadingReportComponent.is_deleted.is_(False),
|
||||
)
|
||||
).scalars().all()
|
||||
|
||||
prices = component_prices(
|
||||
component_ids=[c.component_id for c in components if c.component_id],
|
||||
names=[c.component_name for c in components],
|
||||
)
|
||||
|
||||
agg: Dict[tuple[str, str], Dict[str, Any]] = defaultdict(
|
||||
lambda: {
|
||||
"farm": "",
|
||||
"component": "",
|
||||
"planKg": 0.0,
|
||||
"factKg": 0.0,
|
||||
"pricePerKg": 0.0,
|
||||
"overloadRub": 0.0,
|
||||
"underloadRub": 0.0,
|
||||
}
|
||||
)
|
||||
|
||||
for comp in components:
|
||||
report = report_by_id.get(comp.report_id)
|
||||
if not report:
|
||||
continue
|
||||
farm = farm_by_recipe.get(report.recipe_id, "—")
|
||||
cname = str(comp.component_name or "—")
|
||||
key = (farm, cname)
|
||||
row = agg[key]
|
||||
row["farm"] = farm
|
||||
row["component"] = cname
|
||||
|
||||
target = float(comp.target_weight or 0)
|
||||
actual = float(comp.actual_weight or 0)
|
||||
if target <= 0 and actual <= 0:
|
||||
continue
|
||||
|
||||
price = price_for(
|
||||
prices=prices,
|
||||
component_id=comp.component_id,
|
||||
name=cname,
|
||||
)
|
||||
row["planKg"] += target
|
||||
row["factKg"] += actual
|
||||
row["pricePerKg"] = price
|
||||
|
||||
dev_kg = actual - target
|
||||
dev_rub = dev_kg * price
|
||||
if dev_rub > 0:
|
||||
row["overloadRub"] += dev_rub
|
||||
elif dev_rub < 0:
|
||||
row["underloadRub"] += abs(dev_rub)
|
||||
|
||||
out: List[Dict[str, Any]] = []
|
||||
for row in agg.values():
|
||||
if row["planKg"] <= 0 and row["factKg"] <= 0:
|
||||
continue
|
||||
plan = round(row["planKg"], 2)
|
||||
fact = round(row["factKg"], 2)
|
||||
out.append(
|
||||
{
|
||||
"farm": row["farm"],
|
||||
"component": row["component"],
|
||||
"planKg": plan,
|
||||
"factKg": fact,
|
||||
"deviationKg": round(fact - plan, 2),
|
||||
"pricePerKg": round(row["pricePerKg"], 2),
|
||||
"overloadRub": round(row["overloadRub"], 2),
|
||||
"underloadRub": round(row["underloadRub"], 2),
|
||||
}
|
||||
)
|
||||
|
||||
out.sort(key=lambda r: (r["farm"], r["component"]))
|
||||
return out
|
||||
@@ -0,0 +1,402 @@
|
||||
"""Excel: итоги + сравнение + подробно."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
from typing import Any, Optional
|
||||
|
||||
from openpyxl import Workbook
|
||||
from openpyxl.formatting.rule import FormulaRule
|
||||
from openpyxl.styles import Alignment, Font, PatternFill
|
||||
from openpyxl.utils import get_column_letter
|
||||
from openpyxl.worksheet.worksheet import Worksheet
|
||||
|
||||
from app.services.analytics.export_context import (
|
||||
build_export_header_lines,
|
||||
net_rub_display,
|
||||
net_rub_dominant_hint,
|
||||
)
|
||||
from app.services.analytics.export_sections import parse_export_sections
|
||||
from app.services.analytics.finance_summary import build_finance_summary
|
||||
from app.services.analytics.plan_fact_layers import build_plan_fact_rows
|
||||
from app.services.analytics.reports_detail import build_reports_detail_rows
|
||||
from app.services.analytics.summary_export_rows import build_summary_table_rows
|
||||
|
||||
_OVERLOAD_FILL = PatternFill(start_color="FFEBEE", end_color="FFEBEE", fill_type="solid")
|
||||
_UNDERLOAD_FILL = PatternFill(start_color="FFF3E0", end_color="FFF3E0", fill_type="solid")
|
||||
_HEADER_FONT = Font(bold=True)
|
||||
|
||||
|
||||
def _apply_row_tint(ws: Worksheet, row: int, cols: int, *, overload_rub: float, underload_rub: float) -> None:
|
||||
fill = None
|
||||
if overload_rub > 0 and underload_rub <= 0:
|
||||
fill = _OVERLOAD_FILL
|
||||
elif underload_rub > 0 and overload_rub <= 0:
|
||||
fill = _UNDERLOAD_FILL
|
||||
elif overload_rub > 0 and underload_rub > 0:
|
||||
fill = _OVERLOAD_FILL if overload_rub >= underload_rub else _UNDERLOAD_FILL
|
||||
if not fill:
|
||||
return
|
||||
for col in range(1, cols + 1):
|
||||
ws.cell(row=row, column=col).fill = fill
|
||||
|
||||
|
||||
def _cell_width_estimate(value: Any) -> float:
|
||||
if value is None:
|
||||
return 0.0
|
||||
total = 0.0
|
||||
for ch in str(value):
|
||||
total += 1.35 if ord(ch) > 127 else 1.0
|
||||
return total
|
||||
|
||||
|
||||
def _auto_fit_columns(
|
||||
ws: Worksheet,
|
||||
*,
|
||||
min_width: float = 10,
|
||||
max_width: float = 64,
|
||||
padding: float = 3.0,
|
||||
) -> None:
|
||||
if ws.max_row < 1:
|
||||
return
|
||||
max_col = ws.max_column or 1
|
||||
for col_idx in range(1, max_col + 1):
|
||||
max_len = 0.0
|
||||
for row in ws.iter_rows(min_col=col_idx, max_col=col_idx, min_row=1, max_row=ws.max_row):
|
||||
max_len = max(max_len, _cell_width_estimate(row[0].value))
|
||||
if max_len <= 0:
|
||||
continue
|
||||
letter = get_column_letter(col_idx)
|
||||
ws.column_dimensions[letter].width = min(max(max_len + padding, min_width), max_width)
|
||||
|
||||
|
||||
def _style_table_header_row(ws: Worksheet, row: int, cols: int) -> None:
|
||||
for col in range(1, cols + 1):
|
||||
cell = ws.cell(row=row, column=col)
|
||||
cell.font = _HEADER_FONT
|
||||
cell.alignment = Alignment(wrap_text=True, vertical="center")
|
||||
|
||||
|
||||
# Процентная ширина столбцов листа «Подробно» (сумма = 100).
|
||||
# Текстовые колонки шире для переноса; числовые — фиксированный минимум.
|
||||
_DETAIL_SHEET_COL_PCT = [
|
||||
7, # Дата
|
||||
14, # Ферма
|
||||
10, # Кормораздатчик
|
||||
14, # Рейс
|
||||
10, # Время
|
||||
6, # Тип
|
||||
12, # Компонент
|
||||
9, # Группа
|
||||
8, # План
|
||||
8, # Факт
|
||||
8, # Отклонение
|
||||
8, # Перерасход
|
||||
6, # Загрузка
|
||||
6, # Смешивание план
|
||||
6, # Смешивание факт
|
||||
]
|
||||
_DETAIL_SHEET_WRAP_COLS = frozenset({2, 3, 4, 5, 7, 8})
|
||||
_DETAIL_SHEET_BASE_WIDTH = 118.0
|
||||
|
||||
|
||||
def _detail_sheet_col_widths() -> list[float]:
|
||||
scale = _DETAIL_SHEET_BASE_WIDTH / 100.0
|
||||
return [pct * scale for pct in _DETAIL_SHEET_COL_PCT]
|
||||
|
||||
|
||||
def _apply_detail_sheet_layout(ws: Worksheet, *, header_row: int, last_row: int, col_count: int) -> None:
|
||||
wrap_align = Alignment(wrap_text=True, vertical="top")
|
||||
num_align = Alignment(vertical="top")
|
||||
for col_idx, width in enumerate(_detail_sheet_col_widths(), start=1):
|
||||
if col_idx > col_count:
|
||||
break
|
||||
ws.column_dimensions[get_column_letter(col_idx)].width = width
|
||||
for row_idx in range(header_row, last_row + 1):
|
||||
for col_idx in range(1, col_count + 1):
|
||||
cell = ws.cell(row=row_idx, column=col_idx)
|
||||
cell.alignment = wrap_align if col_idx in _DETAIL_SHEET_WRAP_COLS else num_align
|
||||
|
||||
|
||||
def _write_export_header(
|
||||
ws: Worksheet,
|
||||
*,
|
||||
date_from: str,
|
||||
date_to: str,
|
||||
filter_farms: Optional[str],
|
||||
filter_dispensers: Optional[str],
|
||||
filter_recipes: Optional[str],
|
||||
) -> None:
|
||||
for line in build_export_header_lines(
|
||||
date_from=date_from,
|
||||
date_to=date_to,
|
||||
filter_farms=filter_farms,
|
||||
filter_dispensers=filter_dispensers,
|
||||
filter_recipes=filter_recipes,
|
||||
):
|
||||
ws.append([line])
|
||||
|
||||
|
||||
def _fill_summary_sheet(
|
||||
ws: Worksheet,
|
||||
*,
|
||||
summary: dict[str, Any],
|
||||
table_rows: list[dict[str, Any]],
|
||||
date_from: str,
|
||||
date_to: str,
|
||||
filter_farms: Optional[str],
|
||||
filter_dispensers: Optional[str],
|
||||
filter_recipes: Optional[str],
|
||||
) -> None:
|
||||
ws.title = "Итоги"
|
||||
_write_export_header(
|
||||
ws,
|
||||
date_from=date_from,
|
||||
date_to=date_to,
|
||||
filter_farms=filter_farms,
|
||||
filter_dispensers=filter_dispensers,
|
||||
filter_recipes=filter_recipes,
|
||||
)
|
||||
ws.append([])
|
||||
ws.append(["Перерасход, ₽", summary.get("overloadRub", 0)])
|
||||
ws.append(["Недогруз, ₽", summary.get("underloadRub", 0)])
|
||||
ws.append(["Итого по деньгам, ₽", net_rub_display(summary)])
|
||||
ws.append([net_rub_dominant_hint(summary)])
|
||||
ws.append([])
|
||||
headers = [
|
||||
"Ферма",
|
||||
"Компонент",
|
||||
"План, кг",
|
||||
"Факт, кг",
|
||||
"Отклонение, кг",
|
||||
"Цена за кг, ₽",
|
||||
"Перерасход, ₽",
|
||||
"Недогруз, ₽",
|
||||
]
|
||||
ws.append(headers)
|
||||
header_row = ws.max_row
|
||||
_style_table_header_row(ws, header_row, len(headers))
|
||||
|
||||
for row in table_rows:
|
||||
ws.append(
|
||||
[
|
||||
row.get("farm"),
|
||||
row.get("component"),
|
||||
row.get("planKg"),
|
||||
row.get("factKg"),
|
||||
row.get("deviationKg"),
|
||||
row.get("pricePerKg"),
|
||||
row.get("overloadRub"),
|
||||
row.get("underloadRub"),
|
||||
]
|
||||
)
|
||||
_apply_row_tint(
|
||||
ws,
|
||||
ws.max_row,
|
||||
len(headers),
|
||||
overload_rub=float(row.get("overloadRub") or 0),
|
||||
underload_rub=float(row.get("underloadRub") or 0),
|
||||
)
|
||||
|
||||
|
||||
def _fill_comparison_sheet(ws: Worksheet, plan_fact: dict[str, Any]) -> None:
|
||||
ws.title = "Сравнение"
|
||||
headers = [
|
||||
"Рейс",
|
||||
"Дата",
|
||||
"Компонент",
|
||||
"По рецепту (База), кг",
|
||||
"На сегодня (План), кг",
|
||||
"Сделали (Факт), кг",
|
||||
"Разница База/Факт, кг",
|
||||
"Разница План/Факт, кг",
|
||||
"Статус",
|
||||
]
|
||||
ws.append(headers)
|
||||
header_row = ws.max_row
|
||||
_style_table_header_row(ws, header_row, len(headers))
|
||||
|
||||
status_col = len(headers)
|
||||
status_col_letter = get_column_letter(status_col)
|
||||
|
||||
for item in plan_fact.get("items") or []:
|
||||
for comp in item.get("components") or []:
|
||||
base_kg = float(comp.get("baseKg") or 0)
|
||||
plan_kg = float(comp.get("planTodayKg") or 0)
|
||||
fact_kg = float(comp.get("actualKg") or 0)
|
||||
fault = comp.get("fault") or "none"
|
||||
status = {
|
||||
"execution": "Ошибка при загрузке",
|
||||
"excluded": "Исключено из плана",
|
||||
"adjusted": "План скорректирован",
|
||||
}.get(fault, "")
|
||||
ws.append(
|
||||
[
|
||||
item.get("recipeName"),
|
||||
item.get("date"),
|
||||
comp.get("name"),
|
||||
round(base_kg, 2),
|
||||
round(plan_kg, 2),
|
||||
round(fact_kg, 2),
|
||||
round(fact_kg - base_kg, 2),
|
||||
round(fact_kg - plan_kg, 2),
|
||||
status,
|
||||
]
|
||||
)
|
||||
row_num = ws.max_row
|
||||
if fault == "execution":
|
||||
for col in range(1, len(headers) + 1):
|
||||
ws.cell(row=row_num, column=col).fill = _OVERLOAD_FILL
|
||||
elif fault in ("adjusted", "excluded"):
|
||||
for col in range(1, len(headers) + 1):
|
||||
ws.cell(row=row_num, column=col).fill = _UNDERLOAD_FILL
|
||||
|
||||
if ws.max_row > header_row:
|
||||
ws.conditional_formatting.add(
|
||||
f"{status_col_letter}{header_row + 1}:{status_col_letter}{ws.max_row}",
|
||||
FormulaRule(
|
||||
formula=[f'{status_col_letter}{header_row + 1}="Ошибка при загрузке"'],
|
||||
fill=_OVERLOAD_FILL,
|
||||
),
|
||||
)
|
||||
|
||||
_auto_fit_columns(ws, min_width=12, max_width=70, padding=3.5)
|
||||
|
||||
|
||||
def _fill_detail_sheet(ws: Worksheet, detail_rows: list[dict[str, Any]]) -> None:
|
||||
ws.title = "Подробно"
|
||||
headers = [
|
||||
"Дата",
|
||||
"Ферма",
|
||||
"Кормораздатчик",
|
||||
"Рейс",
|
||||
"Время",
|
||||
"Тип",
|
||||
"Компонент",
|
||||
"Группа",
|
||||
"План, кг",
|
||||
"Факт, кг",
|
||||
"Отклонение, кг",
|
||||
"Перерасход, кг",
|
||||
"Загрузка, с",
|
||||
"Смешивание план, с",
|
||||
"Смешивание факт, с",
|
||||
]
|
||||
ws.append(headers)
|
||||
_style_table_header_row(ws, 1, len(headers))
|
||||
|
||||
for row in detail_rows:
|
||||
dev = float(row.get("deviationKg") or 0)
|
||||
target = float(row.get("targetKg") or 0)
|
||||
actual = float(row.get("actualKg") or 0)
|
||||
overload_kg = max(0.0, round(actual - target, 2))
|
||||
is_unloading = row.get("kind") == "unloading"
|
||||
values = [
|
||||
row.get("dateDay"),
|
||||
row.get("farm"),
|
||||
row.get("dispenserName"),
|
||||
row.get("recipeName"),
|
||||
row.get("dateTime"),
|
||||
"Выгрузка" if is_unloading else "Загрузка",
|
||||
row.get("feedComponent") if is_unloading else (row.get("feedComponent") or ""),
|
||||
row.get("animalGroup") if is_unloading else "",
|
||||
round(target, 2),
|
||||
round(actual, 2),
|
||||
round(dev, 2),
|
||||
overload_kg if overload_kg > 0 else None,
|
||||
row.get("loadingSec"),
|
||||
row.get("mixPlanSec"),
|
||||
row.get("mixFactSec"),
|
||||
]
|
||||
row_num = ws.max_row + 1
|
||||
for col_idx, value in enumerate(values, start=1):
|
||||
ws.cell(row=row_num, column=col_idx, value=value)
|
||||
if dev > 0.05:
|
||||
_apply_row_tint(ws, row_num, len(headers), overload_rub=1, underload_rub=0)
|
||||
elif dev < -0.05:
|
||||
_apply_row_tint(ws, row_num, len(headers), overload_rub=0, underload_rub=1)
|
||||
|
||||
_apply_detail_sheet_layout(
|
||||
ws,
|
||||
header_row=1,
|
||||
last_row=ws.max_row,
|
||||
col_count=len(headers),
|
||||
)
|
||||
|
||||
|
||||
def build_finance_summary_xlsx(
|
||||
*,
|
||||
date_from: str,
|
||||
date_to: str,
|
||||
recipe_id: str | None = None,
|
||||
recipe_ids: str | None = None,
|
||||
section: str | None = None,
|
||||
filter_farms: str | None = None,
|
||||
filter_dispensers: str | None = None,
|
||||
filter_recipes: str | None = None,
|
||||
) -> bytes:
|
||||
sections = parse_export_sections(section)
|
||||
summary = None
|
||||
table_rows: list[dict[str, Any]] = []
|
||||
plan_fact = None
|
||||
detail_rows = None
|
||||
|
||||
if "summary" in sections:
|
||||
summary = build_finance_summary(
|
||||
date_from=date_from, date_to=date_to, recipe_id=recipe_id, recipe_ids=recipe_ids
|
||||
)
|
||||
table_rows = build_summary_table_rows(
|
||||
date_from=date_from, date_to=date_to, recipe_id=recipe_id, recipe_ids=recipe_ids
|
||||
)
|
||||
if "comparison" in sections:
|
||||
plan_fact = build_plan_fact_rows(
|
||||
date_from=date_from, date_to=date_to, recipe_id=recipe_id, recipe_ids=recipe_ids
|
||||
)
|
||||
if "reports" in sections:
|
||||
detail_rows = build_reports_detail_rows(
|
||||
date_from=date_from, date_to=date_to, recipe_id=recipe_id, recipe_ids=recipe_ids
|
||||
)
|
||||
|
||||
wb = Workbook()
|
||||
wb.remove(wb.active)
|
||||
|
||||
if "summary" in sections:
|
||||
ws = wb.create_sheet("Итоги")
|
||||
_fill_summary_sheet(
|
||||
ws,
|
||||
summary=summary or {},
|
||||
table_rows=table_rows,
|
||||
date_from=date_from,
|
||||
date_to=date_to,
|
||||
filter_farms=filter_farms,
|
||||
filter_dispensers=filter_dispensers,
|
||||
filter_recipes=filter_recipes,
|
||||
)
|
||||
if "comparison" in sections:
|
||||
ws = wb.create_sheet("Сравнение")
|
||||
_fill_comparison_sheet(ws, plan_fact or {})
|
||||
if "reports" in sections:
|
||||
ws = wb.create_sheet("Подробно")
|
||||
_fill_detail_sheet(ws, detail_rows or [])
|
||||
|
||||
if not wb.sheetnames:
|
||||
ws = wb.create_sheet("Итоги")
|
||||
_fill_summary_sheet(
|
||||
ws,
|
||||
summary={},
|
||||
table_rows=[],
|
||||
date_from=date_from,
|
||||
date_to=date_to,
|
||||
filter_farms=filter_farms,
|
||||
filter_dispensers=filter_dispensers,
|
||||
filter_recipes=filter_recipes,
|
||||
)
|
||||
|
||||
for ws_cur in wb.worksheets:
|
||||
if ws_cur.title == "Подробно":
|
||||
continue
|
||||
_auto_fit_columns(ws_cur)
|
||||
|
||||
buf = io.BytesIO()
|
||||
wb.save(buf)
|
||||
return buf.getvalue()
|
||||
@@ -154,7 +154,18 @@ def _apply_fields(row, data: dict[str, Any], model) -> None:
|
||||
elif key not in ("enterprise_id", "created_at", "id"):
|
||||
extra[key] = value
|
||||
if model is ZootechRecipe and "ingredients" in data:
|
||||
row.payload_json = json.dumps(data.get("ingredients"), ensure_ascii=False)
|
||||
preserved: dict[str, Any] = {}
|
||||
if row.payload_json:
|
||||
try:
|
||||
existing = json.loads(row.payload_json)
|
||||
if isinstance(existing, dict) and existing.get("dispenser_id"):
|
||||
preserved["dispenser_id"] = existing["dispenser_id"]
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
if preserved:
|
||||
row.payload_json = json.dumps(preserved, ensure_ascii=False)
|
||||
else:
|
||||
row.payload_json = json.dumps(data.get("ingredients"), ensure_ascii=False)
|
||||
elif extra and "payload_json" in allowed:
|
||||
row.payload_json = json.dumps(extra, ensure_ascii=False)
|
||||
|
||||
|
||||
@@ -141,6 +141,18 @@ class ZootechDailyComponentNormAdjustment(_CatalogMixin, Base):
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid4()))
|
||||
|
||||
|
||||
class ZootechSkladStock(_CatalogMixin, Base):
|
||||
__tablename__ = "zootech_sklad_stock"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("enterprise_id", "component_id", name="uq_zootech_sklad_stock_ent_comp"),
|
||||
)
|
||||
|
||||
component_id: Mapped[str] = mapped_column(String(36), primary_key=True)
|
||||
total_kg: Mapped[float] = mapped_column(Float, nullable=False, default=0)
|
||||
inflow_kg: Mapped[float] = mapped_column(Float, nullable=False, default=0)
|
||||
sort_order: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
|
||||
|
||||
CATALOG_TABLE_ORDER = [
|
||||
"component",
|
||||
"recipe",
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""План на день — агрегат периодов и рейсов."""
|
||||
@@ -0,0 +1,328 @@
|
||||
"""Временная правка норм по компонентам в плане на день."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import defaultdict
|
||||
from typing import Any, Dict, List, Optional, Set
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from app import db
|
||||
from app.models import Component, DailyComponentNormAdjustment, Ingredient, Recipe
|
||||
from app.services.daily_plan.ingredient_weights import (
|
||||
ingredient_dry_matter_per_head,
|
||||
ingredient_dry_matter_percent,
|
||||
)
|
||||
from app.services.daily_plan.skips import (
|
||||
_parse_plan_date,
|
||||
_serialize_skip_dates,
|
||||
_skip_active_filters,
|
||||
_soft_unskip_part,
|
||||
_upsert_part_skip,
|
||||
get_skipped_ingredient_ids,
|
||||
resolve_skip_range,
|
||||
)
|
||||
|
||||
|
||||
def get_component_adjustment_map(
|
||||
plan_date: Optional[str] = None,
|
||||
) -> Dict[str, Dict[str, Optional[float]]]:
|
||||
"""component_id -> {dry_matter, dry_matter_locked, weight_per_head, dry_matter_per_head}."""
|
||||
d = _parse_plan_date(plan_date)
|
||||
rows = db.session.execute(
|
||||
select(
|
||||
DailyComponentNormAdjustment.component_id,
|
||||
DailyComponentNormAdjustment.dry_matter,
|
||||
DailyComponentNormAdjustment.dry_matter_locked,
|
||||
DailyComponentNormAdjustment.weight_per_head,
|
||||
DailyComponentNormAdjustment.dry_matter_per_head,
|
||||
).where(*_skip_active_filters(DailyComponentNormAdjustment, d))
|
||||
).all()
|
||||
out: Dict[str, Dict[str, Optional[float]]] = {}
|
||||
for cid, dm, locked, wph, dm_ph in rows:
|
||||
out[str(cid)] = {
|
||||
"dry_matter": float(dm) if dm is not None else None,
|
||||
"dry_matter_locked": bool(locked),
|
||||
"weight_per_head": float(wph) if wph is not None else None,
|
||||
"dry_matter_per_head": float(dm_ph) if dm_ph is not None else None,
|
||||
}
|
||||
return out
|
||||
|
||||
|
||||
def get_adjusted_recipe_ids(plan_date: Optional[str] = None) -> Set[str]:
|
||||
"""Рейсы с хотя бы одним ингредиентом с активной правкой нормы."""
|
||||
d = _parse_plan_date(plan_date)
|
||||
adj_map = get_component_adjustment_map(plan_date)
|
||||
if not adj_map:
|
||||
return set()
|
||||
skip_map = get_skipped_ingredient_ids(plan_date)
|
||||
rows = db.session.execute(
|
||||
select(Ingredient.recipe_id, Ingredient.id, Ingredient.component_id).where(
|
||||
Ingredient.is_deleted.is_(False),
|
||||
Ingredient.component_id.isnot(None),
|
||||
)
|
||||
).all()
|
||||
out: Set[str] = set()
|
||||
for recipe_id, ing_id, component_id in rows:
|
||||
if str(component_id) not in adj_map:
|
||||
continue
|
||||
if ing_id in skip_map.get(recipe_id, set()):
|
||||
continue
|
||||
out.add(recipe_id)
|
||||
return out
|
||||
|
||||
|
||||
def list_component_norm_adjustments(plan_date: Optional[str] = None) -> List[Dict[str, Any]]:
|
||||
d = _parse_plan_date(plan_date)
|
||||
rows = db.session.execute(
|
||||
select(DailyComponentNormAdjustment, Component.name)
|
||||
.join(Component, Component.id == DailyComponentNormAdjustment.component_id)
|
||||
.where(
|
||||
*_skip_active_filters(DailyComponentNormAdjustment, d),
|
||||
Component.is_deleted.is_(False),
|
||||
)
|
||||
.order_by(Component.name.asc())
|
||||
).all()
|
||||
return [
|
||||
{
|
||||
"id": row.id,
|
||||
"componentId": row.component_id,
|
||||
"componentName": name or "—",
|
||||
"dryMatter": row.dry_matter,
|
||||
"dryMatterLocked": bool(row.dry_matter_locked),
|
||||
"weightPerHead": row.weight_per_head,
|
||||
"dryMatterPerHead": row.dry_matter_per_head,
|
||||
**_serialize_skip_dates(row, fallback=d),
|
||||
}
|
||||
for row, name in rows
|
||||
]
|
||||
|
||||
|
||||
def _recipe_usages_summary(usages: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
||||
"""Уникальные рецепты, где компонент встречается в плане на день."""
|
||||
by_recipe: Dict[str, Dict[str, Any]] = {}
|
||||
for usage in usages:
|
||||
rid = str(usage.get("recipeId") or "")
|
||||
if not rid:
|
||||
continue
|
||||
if rid not in by_recipe:
|
||||
by_recipe[rid] = {
|
||||
"recipeId": rid,
|
||||
"recipeName": usage.get("recipeName") or "—",
|
||||
"masterWeightPerHead": usage.get("masterWeightPerHead"),
|
||||
"masterDryMatterPerHead": usage.get("masterDryMatterPerHead"),
|
||||
"masterDryMatterPct": usage.get("masterDryMatterPct"),
|
||||
"dryMatterLocked": bool(usage.get("dryMatterLocked")),
|
||||
"tripCount": 1,
|
||||
}
|
||||
else:
|
||||
by_recipe[rid]["tripCount"] = int(by_recipe[rid].get("tripCount") or 0) + 1
|
||||
return sorted(by_recipe.values(), key=lambda row: (row.get("recipeName") or "").lower())
|
||||
|
||||
|
||||
def _master_norm_snapshot(
|
||||
component_id: str,
|
||||
usages: List[Dict[str, Any]],
|
||||
) -> Dict[str, Any]:
|
||||
wph_vals = [float(u["masterWeightPerHead"]) for u in usages if u.get("masterWeightPerHead") is not None]
|
||||
dm_vals = [float(u["masterDryMatterPerHead"]) for u in usages if u.get("masterDryMatterPerHead") is not None]
|
||||
dm_pct_vals = [float(u["masterDryMatterPct"]) for u in usages if u.get("masterDryMatterPct") is not None]
|
||||
return {
|
||||
"masterWeightPerHeadMin": min(wph_vals) if wph_vals else None,
|
||||
"masterWeightPerHeadMax": max(wph_vals) if wph_vals else None,
|
||||
"masterDryMatterPerHeadMin": min(dm_vals) if dm_vals else None,
|
||||
"masterDryMatterPerHeadMax": max(dm_vals) if dm_vals else None,
|
||||
"masterDryMatterPctMin": min(dm_pct_vals) if dm_pct_vals else None,
|
||||
"masterDryMatterPctMax": max(dm_pct_vals) if dm_pct_vals else None,
|
||||
"masterWeightPerHead": round(sum(wph_vals) / len(wph_vals), 3) if wph_vals else None,
|
||||
"masterDryMatterPerHead": round(sum(dm_vals) / len(dm_vals), 4) if dm_vals else None,
|
||||
"masterDryMatterPct": round(sum(dm_pct_vals) / len(dm_pct_vals), 2) if dm_pct_vals else None,
|
||||
}
|
||||
|
||||
|
||||
def list_component_norms_for_plan(
|
||||
plan_date: Optional[str] = None,
|
||||
*,
|
||||
dispenser_id: Optional[str] = None,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Уникальные компоненты плана с мастер-нормами и активными правками."""
|
||||
iso_date = _parse_plan_date(plan_date).isoformat()
|
||||
if not dispenser_id:
|
||||
return []
|
||||
from app.services.daily_plan.builder import build_daily_plan
|
||||
|
||||
try:
|
||||
plan = build_daily_plan(dispenser_id=dispenser_id, plan_date=iso_date)
|
||||
except LookupError:
|
||||
return []
|
||||
|
||||
skip_map = get_skipped_ingredient_ids(iso_date)
|
||||
adj_map = get_component_adjustment_map(iso_date)
|
||||
comp_ids: Set[str] = set()
|
||||
usages: Dict[str, List[Dict[str, Any]]] = defaultdict(list)
|
||||
comp_names: Dict[str, str] = {}
|
||||
comp_dm: Dict[str, float] = {}
|
||||
plan_effective: Dict[str, Dict[str, Optional[float]]] = {}
|
||||
|
||||
for period in plan.get("periods") or []:
|
||||
for trip in period.get("trips") or []:
|
||||
recipe_id = trip.get("recipeId")
|
||||
dry_matter_locked = bool(trip.get("dryMatterLocked"))
|
||||
for ing in trip.get("ingredients") or []:
|
||||
if ing.get("skippedToday"):
|
||||
continue
|
||||
cid = str(ing.get("componentId") or ing.get("originalComponentId") or "")
|
||||
if not cid:
|
||||
continue
|
||||
if cid not in plan_effective:
|
||||
plan_effective[cid] = {
|
||||
"weightPerHead": (
|
||||
float(ing["weightPerHead"])
|
||||
if ing.get("weightPerHead") is not None
|
||||
else None
|
||||
),
|
||||
"dryMatterPerHead": (
|
||||
float(ing["dryMatterPerHead"])
|
||||
if ing.get("dryMatterPerHead") is not None
|
||||
else None
|
||||
),
|
||||
"dryMatterPct": (
|
||||
float(ing["dryMatterPct"])
|
||||
if ing.get("dryMatterPct") is not None
|
||||
else None
|
||||
),
|
||||
}
|
||||
comp_ids.add(cid)
|
||||
comp_names[cid] = str(ing.get("originalName") or ing.get("name") or "—")
|
||||
if ing.get("dryMatterPct") is not None:
|
||||
comp_dm[cid] = float(ing["dryMatterPct"])
|
||||
master_wph = ing.get("originalWeightPerHead")
|
||||
master_dm = ing.get("originalDryMatterPerHead")
|
||||
if master_wph is None:
|
||||
master_wph = ing.get("weightPerHead")
|
||||
if master_dm is None:
|
||||
master_dm = ing.get("dryMatterPerHead")
|
||||
master_dm_pct = ing.get("originalDryMatterPct")
|
||||
if master_dm_pct is None:
|
||||
master_dm_pct = ing.get("dryMatterPct")
|
||||
usages[cid].append(
|
||||
{
|
||||
"recipeId": recipe_id,
|
||||
"recipeName": trip.get("recipeName"),
|
||||
"ingredientId": ing.get("id"),
|
||||
"masterWeightPerHead": master_wph,
|
||||
"masterDryMatterPerHead": master_dm,
|
||||
"masterDryMatterPct": master_dm_pct,
|
||||
"dryMatterLocked": dry_matter_locked,
|
||||
}
|
||||
)
|
||||
|
||||
if comp_ids:
|
||||
comps = db.session.execute(
|
||||
select(Component).where(
|
||||
Component.id.in_(comp_ids),
|
||||
Component.is_deleted.is_(False),
|
||||
)
|
||||
).scalars().all()
|
||||
for comp in comps:
|
||||
comp_names[comp.id] = comp.name
|
||||
comp_dm[comp.id] = float(comp.dry_matter or 0)
|
||||
|
||||
rows: List[Dict[str, Any]] = []
|
||||
for cid in sorted(comp_ids, key=lambda x: (comp_names.get(x) or x).lower()):
|
||||
usage_list = usages.get(cid, [])
|
||||
adj = adj_map.get(cid)
|
||||
snap = _master_norm_snapshot(cid, usage_list)
|
||||
eff = plan_effective.get(cid) or {}
|
||||
rows.append(
|
||||
{
|
||||
"componentId": cid,
|
||||
"componentName": comp_names.get(cid, "—"),
|
||||
"dryMatterPct": comp_dm.get(cid, 0),
|
||||
"usageCount": len(usage_list),
|
||||
"recipes": sorted({u.get("recipeName") or "" for u in usage_list if u.get("recipeName")}),
|
||||
"usages": _recipe_usages_summary(usage_list),
|
||||
**snap,
|
||||
"planDryMatterPct": adj.get("dry_matter") if adj else eff.get("dryMatterPct"),
|
||||
"planDryMatterLocked": bool(adj.get("dry_matter_locked")) if adj else None,
|
||||
"planWeightPerHead": eff.get("weightPerHead"),
|
||||
"planDryMatterPerHead": eff.get("dryMatterPerHead"),
|
||||
"adjustedToday": bool(adj),
|
||||
}
|
||||
)
|
||||
return rows
|
||||
|
||||
|
||||
def adjust_component_norm(
|
||||
component_id: str,
|
||||
plan_date: Optional[str] = None,
|
||||
*,
|
||||
dry_matter: Optional[float] = None,
|
||||
dry_matter_locked: bool = False,
|
||||
weight_per_head: Optional[float] = None,
|
||||
dry_matter_per_head: Optional[float] = None,
|
||||
duration: Optional[str] = None,
|
||||
until_date: Optional[str] = None,
|
||||
user: str = "system",
|
||||
) -> DailyComponentNormAdjustment:
|
||||
if dry_matter is None and weight_per_head is None and dry_matter_per_head is None:
|
||||
raise ValueError("Укажите dryMatter (СВ%)")
|
||||
start = _parse_plan_date(plan_date)
|
||||
_, valid_until = resolve_skip_range(start, duration=duration, until_date=until_date)
|
||||
component = db.session.execute(
|
||||
select(Component).where(
|
||||
Component.id == component_id,
|
||||
Component.is_deleted.is_(False),
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if component is None:
|
||||
raise LookupError("Компонент не найден")
|
||||
|
||||
if dry_matter is not None:
|
||||
weight_per_head = None
|
||||
dry_matter_per_head = None
|
||||
|
||||
return _upsert_part_skip(
|
||||
DailyComponentNormAdjustment,
|
||||
lookup_filters=(DailyComponentNormAdjustment.component_id == component_id,),
|
||||
create_fields={
|
||||
"component_id": component_id,
|
||||
"plan_date": start,
|
||||
"valid_until": valid_until if valid_until != start else None,
|
||||
"dry_matter": float(dry_matter) if dry_matter is not None else None,
|
||||
"dry_matter_locked": bool(dry_matter_locked),
|
||||
"weight_per_head": float(weight_per_head) if weight_per_head is not None else None,
|
||||
"dry_matter_per_head": float(dry_matter_per_head)
|
||||
if dry_matter_per_head is not None
|
||||
else None,
|
||||
},
|
||||
user=user,
|
||||
)
|
||||
|
||||
|
||||
def undo_component_norm_adjustment(
|
||||
component_id: str,
|
||||
plan_date: Optional[str] = None,
|
||||
*,
|
||||
user: str = "system",
|
||||
) -> bool:
|
||||
d = _parse_plan_date(plan_date)
|
||||
return _soft_unskip_part(
|
||||
DailyComponentNormAdjustment,
|
||||
lookup_filters=(
|
||||
DailyComponentNormAdjustment.component_id == component_id,
|
||||
*_skip_active_filters(DailyComponentNormAdjustment, d),
|
||||
),
|
||||
user=user,
|
||||
table_name="daily_component_norm_adjustment",
|
||||
)
|
||||
|
||||
|
||||
def master_ingredient_norms(ing: Ingredient, components_by_id: Dict[str, Component]) -> Dict[str, float]:
|
||||
dm_pct = ingredient_dry_matter_percent(ing, components_by_id)
|
||||
dm_ph = ingredient_dry_matter_per_head(ing, dry_matter_percent=dm_pct)
|
||||
return {
|
||||
"weightPerHead": float(ing.weight_per_head or 0),
|
||||
"dryMatterPerHead": round(dm_ph, 4),
|
||||
"dryMatterPct": dm_pct,
|
||||
}
|
||||
@@ -0,0 +1,622 @@
|
||||
"""Сборка плана на день из периодов, рейсов и ингредиентов."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date, datetime
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from sqlalchemy import exists, select
|
||||
|
||||
from app import db
|
||||
from app.models import (
|
||||
Component,
|
||||
FeedDispenser,
|
||||
FeedingPeriod,
|
||||
Ingredient,
|
||||
PeriodRecipe,
|
||||
Recipe,
|
||||
UnloadingGroup,
|
||||
)
|
||||
from app.services.daily_plan.adjustments import (
|
||||
get_component_adjustment_map,
|
||||
list_component_norm_adjustments,
|
||||
)
|
||||
from app.services.daily_plan.ingredient_weights import resolve_plan_ingredient_weights
|
||||
from app.services.daily_plan.replacements import (
|
||||
get_ingredient_replacement_map,
|
||||
list_ingredient_replacements,
|
||||
)
|
||||
from app.services.daily_plan.skips import (
|
||||
get_skipped_ingredient_ids,
|
||||
get_skipped_recipe_ids,
|
||||
get_skipped_unloading_group_ids,
|
||||
list_ingredient_skips,
|
||||
list_skips,
|
||||
list_unloading_group_skips,
|
||||
)
|
||||
from app.services.period_recipes_query import recipes_for_period_ordered
|
||||
from app.services.recipe_calculator import calculate_ingredients, calculate_recipe
|
||||
|
||||
ALL_DISPENSERS_ID = "__all_dispensers__"
|
||||
ALL_MILLS_ID = "__all_mills__"
|
||||
|
||||
|
||||
def _parse_plan_date(value: Optional[str]) -> str:
|
||||
if value:
|
||||
try:
|
||||
return date.fromisoformat(str(value).strip()[:10]).isoformat()
|
||||
except ValueError:
|
||||
pass
|
||||
return date.today().isoformat()
|
||||
|
||||
|
||||
def _ingredient_display_name(ing: Ingredient, comp_names: Dict[str, str]) -> str:
|
||||
raw = (ing.name or "").strip()
|
||||
if raw:
|
||||
return raw
|
||||
if ing.component_id and ing.component_id in comp_names:
|
||||
return comp_names[ing.component_id]
|
||||
return "—"
|
||||
|
||||
|
||||
def _distribution_label(dist_type: Optional[str], value: float) -> str:
|
||||
if (dist_type or "percent") == "heads":
|
||||
rounded = int(value) if value == int(value) else value
|
||||
return f"{rounded} гол."
|
||||
rounded = int(value) if value == int(value) else value
|
||||
return f"{rounded}%"
|
||||
|
||||
|
||||
def _load_components_by_id(comp_ids: List[str]) -> Dict[str, Component]:
|
||||
if not comp_ids:
|
||||
return {}
|
||||
comps = db.session.execute(
|
||||
select(Component).where(
|
||||
Component.id.in_(set(comp_ids)),
|
||||
Component.is_deleted.is_(False),
|
||||
)
|
||||
).scalars().all()
|
||||
return {c.id: c for c in comps}
|
||||
|
||||
|
||||
def _serialize_trip(
|
||||
recipe: Recipe,
|
||||
*,
|
||||
order: int,
|
||||
skipped_ingredient_ids: Optional[set[str]] = None,
|
||||
skipped_group_ids: Optional[set[str]] = None,
|
||||
replacement_by_ingredient: Optional[Dict[str, str]] = None,
|
||||
component_adjustment_map: Optional[Dict[str, Dict[str, Optional[float]]]] = None,
|
||||
) -> Dict[str, Any]:
|
||||
ingredients = db.session.execute(
|
||||
select(Ingredient)
|
||||
.where(Ingredient.recipe_id == recipe.id, Ingredient.is_deleted.is_(False))
|
||||
.order_by(Ingredient.order.asc())
|
||||
).scalars().all()
|
||||
comp_ids = [i.component_id for i in ingredients if i.component_id]
|
||||
repl_ids = list((replacement_by_ingredient or {}).values())
|
||||
comp_ids.extend(repl_ids)
|
||||
components_by_id = _load_components_by_id(comp_ids)
|
||||
comp_names = {cid: c.name for cid, c in components_by_id.items()}
|
||||
|
||||
heads = int(recipe.heads_per_trip or 0)
|
||||
skip_ings = skipped_ingredient_ids or set()
|
||||
skip_groups = skipped_group_ids or set()
|
||||
repl_map = replacement_by_ingredient or {}
|
||||
adj_map = component_adjustment_map or {}
|
||||
dm_map = {cid: float(c.dry_matter or 0) for cid, c in components_by_id.items()}
|
||||
|
||||
prepared: List[Dict[str, Any]] = []
|
||||
calc_inputs: List[Dict[str, Any]] = []
|
||||
calc_index_by_ing: Dict[str, int] = {}
|
||||
|
||||
for ing in ingredients:
|
||||
is_skipped = ing.id in skip_ings
|
||||
replacement_id = repl_map.get(ing.id) if not is_skipped else None
|
||||
component_adj = adj_map.get(str(ing.component_id)) if ing.component_id else None
|
||||
weights = resolve_plan_ingredient_weights(
|
||||
ing,
|
||||
recipe,
|
||||
heads=heads,
|
||||
components_by_id=components_by_id,
|
||||
replacement_component_id=replacement_id,
|
||||
component_adjustment=component_adj,
|
||||
)
|
||||
original_name = _ingredient_display_name(ing, comp_names)
|
||||
replaced_today = bool(replacement_id)
|
||||
adjusted_today = bool(weights.get("adjustedToday"))
|
||||
display_name = original_name
|
||||
replacement_name = None
|
||||
if replaced_today:
|
||||
replacement_name = comp_names.get(replacement_id, "—")
|
||||
display_name = f"{original_name} → {replacement_name}"
|
||||
|
||||
trip_kg = 0.0
|
||||
if not is_skipped:
|
||||
calc_index_by_ing[ing.id] = len(calc_inputs)
|
||||
calc_inputs.append(
|
||||
{
|
||||
"weightPerHead": weights["weightPerHead"],
|
||||
"dryMatter": weights["dryMatterPct"],
|
||||
"component_id": replacement_id or ing.component_id,
|
||||
}
|
||||
)
|
||||
|
||||
prepared.append(
|
||||
{
|
||||
"ing": ing,
|
||||
"is_skipped": is_skipped,
|
||||
"weights": weights,
|
||||
"original_name": original_name,
|
||||
"display_name": display_name,
|
||||
"replacement_id": replacement_id,
|
||||
"replacement_name": replacement_name,
|
||||
"replaced_today": replaced_today,
|
||||
"adjusted_today": adjusted_today,
|
||||
"trip_kg": trip_kg,
|
||||
}
|
||||
)
|
||||
|
||||
groups = db.session.execute(
|
||||
select(UnloadingGroup)
|
||||
.where(UnloadingGroup.recipe_id == recipe.id, UnloadingGroup.is_deleted.is_(False))
|
||||
.order_by(UnloadingGroup.order.asc())
|
||||
).scalars().all()
|
||||
|
||||
active_groups = [g for g in groups if g.id not in skip_groups]
|
||||
calc_groups_payload = [
|
||||
{
|
||||
"distributionType": g.distribution_type or "percent",
|
||||
"value": float(g.value or 0),
|
||||
}
|
||||
for g in active_groups
|
||||
]
|
||||
|
||||
calc_result = calculate_recipe(
|
||||
calc_inputs,
|
||||
heads_count=heads,
|
||||
trip_percent=float(recipe.trip_percent or 100),
|
||||
unloading_groups=calc_groups_payload,
|
||||
component_dry_matter_map=dm_map,
|
||||
)
|
||||
calc_ingredients = calc_result.get("ingredients") or []
|
||||
calc_unloading = calc_result.get("unloadingGroups") or []
|
||||
total_weight = float(calc_result.get("totals", {}).get("totalTripWeight") or 0)
|
||||
unloading_total = float(calc_result.get("unloadingTotals", {}).get("totalWeightKg") or 0)
|
||||
|
||||
baseline_inputs = [
|
||||
{
|
||||
"weightPerHead": item["weights"]["weightPerHead"],
|
||||
"dryMatter": item["weights"]["dryMatterPct"],
|
||||
"component_id": item["replacement_id"] or item["ing"].component_id,
|
||||
}
|
||||
for item in prepared
|
||||
]
|
||||
baseline_ingredients = calculate_ingredients(
|
||||
baseline_inputs,
|
||||
heads_count=heads,
|
||||
trip_percent=float(recipe.trip_percent or 100),
|
||||
component_dry_matter_map=dm_map,
|
||||
)
|
||||
|
||||
group_weight_by_id = {
|
||||
g.id: float(calc_unloading[idx].get("calculatedWeight") or 0)
|
||||
for idx, g in enumerate(active_groups)
|
||||
if idx < len(calc_unloading)
|
||||
}
|
||||
|
||||
ing_rows = []
|
||||
for idx, item in enumerate(prepared):
|
||||
ing = item["ing"]
|
||||
weights = item["weights"]
|
||||
if not item["is_skipped"]:
|
||||
calc_idx = calc_index_by_ing.get(ing.id)
|
||||
if calc_idx is not None and calc_idx < len(calc_ingredients):
|
||||
item["trip_kg"] = float(calc_ingredients[calc_idx].get("tripWeight") or 0)
|
||||
|
||||
row: Dict[str, Any] = {
|
||||
"id": ing.id,
|
||||
"name": item["display_name"],
|
||||
"originalName": item["original_name"],
|
||||
"weightPerHead": weights["weightPerHead"],
|
||||
"totalKg": item["trip_kg"],
|
||||
"dryMatterPct": weights["dryMatterPct"],
|
||||
"dryMatterPerHead": weights["dryMatterPerHead"],
|
||||
"componentId": ing.component_id,
|
||||
"originalComponentId": ing.component_id,
|
||||
"replacementComponentId": item["replacement_id"],
|
||||
"replacementName": item["replacement_name"],
|
||||
"skippedToday": item["is_skipped"],
|
||||
"replacedToday": item["replaced_today"],
|
||||
"adjustedToday": item["adjusted_today"],
|
||||
}
|
||||
if item["replaced_today"] or item["adjusted_today"]:
|
||||
row.update(
|
||||
{
|
||||
"originalWeightPerHead": weights.get("originalWeightPerHead"),
|
||||
"originalDryMatterPerHead": weights.get("originalDryMatterPerHead"),
|
||||
"originalDryMatterPct": weights.get("originalDryMatterPct"),
|
||||
}
|
||||
)
|
||||
if item["replaced_today"]:
|
||||
row["recalculationMode"] = weights.get("recalculationMode")
|
||||
if item["is_skipped"] and idx < len(baseline_ingredients):
|
||||
baseline = baseline_ingredients[idx]
|
||||
row["baselineWeightPerHead"] = float(baseline.get("weightPerHead") or 0)
|
||||
row["baselineTotalKg"] = float(baseline.get("tripWeight") or 0)
|
||||
|
||||
ing_rows.append(row)
|
||||
|
||||
return {
|
||||
"order": order,
|
||||
"recipeId": recipe.id,
|
||||
"recipeName": recipe.name,
|
||||
"headsPerTrip": heads,
|
||||
"mixingTimeSec": int(recipe.mixing_time or 0),
|
||||
"tripPercent": float(recipe.trip_percent or 100),
|
||||
"dryMatterLocked": bool(recipe.dry_matter_locked),
|
||||
"totalWeightKg": round(total_weight, 2),
|
||||
"unloadingTotalKg": round(unloading_total, 2),
|
||||
"ingredients": ing_rows,
|
||||
"unloadingGroups": [
|
||||
{
|
||||
"id": g.id,
|
||||
"name": g.name,
|
||||
"weightKg": 0.0
|
||||
if g.id in skip_groups
|
||||
else float(group_weight_by_id.get(g.id, 0)),
|
||||
"distributionType": g.distribution_type,
|
||||
"distributionLabel": _distribution_label(
|
||||
g.distribution_type, float(g.value or 0)
|
||||
),
|
||||
"value": float(g.value or 0),
|
||||
"skippedToday": g.id in skip_groups,
|
||||
}
|
||||
for g in groups
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def _periods_for_dispenser(
|
||||
dispenser: FeedDispenser,
|
||||
*,
|
||||
skipped_ids: set[str],
|
||||
skipped_ingredients: Dict[str, set[str]],
|
||||
skipped_groups: Dict[str, set[str]],
|
||||
replacement_map: Dict[str, Dict[str, str]],
|
||||
component_adjustment_map: Dict[str, Dict[str, Optional[float]]],
|
||||
) -> List[Dict[str, Any]]:
|
||||
periods = db.session.execute(
|
||||
select(FeedingPeriod)
|
||||
.where(
|
||||
FeedingPeriod.dispenser_id == dispenser.id,
|
||||
FeedingPeriod.is_deleted.is_(False),
|
||||
FeedingPeriod.is_active.is_(True),
|
||||
)
|
||||
.order_by(FeedingPeriod.created_at.asc())
|
||||
).unique().scalars().all()
|
||||
|
||||
payload = []
|
||||
for period in periods:
|
||||
recipes = [
|
||||
r for r in recipes_for_period_ordered(period.id) if r.id not in skipped_ids
|
||||
]
|
||||
trips = [
|
||||
_serialize_trip(
|
||||
r,
|
||||
order=idx + 1,
|
||||
skipped_ingredient_ids=skipped_ingredients.get(r.id, set()),
|
||||
skipped_group_ids=skipped_groups.get(r.id, set()),
|
||||
replacement_by_ingredient=replacement_map.get(r.id, {}),
|
||||
component_adjustment_map=component_adjustment_map,
|
||||
)
|
||||
for idx, r in enumerate(recipes)
|
||||
]
|
||||
payload.append(
|
||||
{
|
||||
"id": period.id,
|
||||
"name": period.name,
|
||||
"trips": trips,
|
||||
}
|
||||
)
|
||||
return payload
|
||||
|
||||
|
||||
def _mill_trips_fallback(
|
||||
dispenser_id: str,
|
||||
*,
|
||||
skipped_ids: set[str],
|
||||
skipped_ingredients: Dict[str, set[str]],
|
||||
skipped_groups: Dict[str, set[str]],
|
||||
replacement_map: Dict[str, Dict[str, str]],
|
||||
component_adjustment_map: Dict[str, Dict[str, Optional[float]]],
|
||||
) -> List[Dict[str, Any]]:
|
||||
recipes = db.session.execute(
|
||||
select(Recipe)
|
||||
.where(
|
||||
Recipe.is_deleted.is_(False),
|
||||
~exists(
|
||||
select(1).where(
|
||||
PeriodRecipe.recipe_id == Recipe.id,
|
||||
PeriodRecipe.is_deleted.is_(False),
|
||||
)
|
||||
),
|
||||
)
|
||||
.order_by(Recipe.updated_at.desc())
|
||||
).scalars().all()
|
||||
recipes = [r for r in recipes if r.id not in skipped_ids]
|
||||
trips = [
|
||||
_serialize_trip(
|
||||
r,
|
||||
order=idx + 1,
|
||||
skipped_ingredient_ids=skipped_ingredients.get(r.id, set()),
|
||||
skipped_group_ids=skipped_groups.get(r.id, set()),
|
||||
replacement_by_ingredient=replacement_map.get(r.id, {}),
|
||||
)
|
||||
for idx, r in enumerate(recipes)
|
||||
]
|
||||
if not trips:
|
||||
return []
|
||||
return [{"id": None, "name": "Рейсы", "trips": trips}]
|
||||
|
||||
|
||||
def _aggregate_ingredient_totals(periods: List[Dict[str, Any]]) -> Dict[str, Any]:
|
||||
totals: Dict[str, float] = {}
|
||||
grand_total = 0.0
|
||||
for period in periods:
|
||||
for trip in period.get("trips") or []:
|
||||
for ing in trip.get("ingredients") or []:
|
||||
if ing.get("skippedToday"):
|
||||
continue
|
||||
if ing.get("replacedToday") and ing.get("replacementName"):
|
||||
name = str(ing.get("replacementName") or "—")
|
||||
else:
|
||||
name = str(ing.get("originalName") or ing.get("name") or "—")
|
||||
kg = float(ing.get("totalKg") or 0)
|
||||
totals[name] = totals.get(name, 0.0) + kg
|
||||
grand_total += kg
|
||||
rows = [
|
||||
{"name": name, "totalKg": round(kg, 2)}
|
||||
for name, kg in sorted(totals.items(), key=lambda x: x[0].lower())
|
||||
]
|
||||
return {
|
||||
"rows": rows,
|
||||
"grandTotalKg": round(grand_total, 2),
|
||||
}
|
||||
|
||||
|
||||
def _periods_for_dispenser_named(
|
||||
dispenser: FeedDispenser,
|
||||
*,
|
||||
prefix_name: bool = False,
|
||||
skipped_ids: set[str],
|
||||
skipped_ingredients: Dict[str, set[str]],
|
||||
skipped_groups: Dict[str, set[str]],
|
||||
replacement_map: Dict[str, Dict[str, str]],
|
||||
component_adjustment_map: Dict[str, Dict[str, Optional[float]]],
|
||||
) -> List[Dict[str, Any]]:
|
||||
if dispenser.type == "mill":
|
||||
periods = _mill_trips_fallback(
|
||||
dispenser.id,
|
||||
skipped_ids=skipped_ids,
|
||||
skipped_ingredients=skipped_ingredients,
|
||||
skipped_groups=skipped_groups,
|
||||
replacement_map=replacement_map,
|
||||
component_adjustment_map=component_adjustment_map,
|
||||
)
|
||||
else:
|
||||
periods = _periods_for_dispenser(
|
||||
dispenser,
|
||||
skipped_ids=skipped_ids,
|
||||
skipped_ingredients=skipped_ingredients,
|
||||
skipped_groups=skipped_groups,
|
||||
replacement_map=replacement_map,
|
||||
component_adjustment_map=component_adjustment_map,
|
||||
)
|
||||
if not prefix_name:
|
||||
return periods
|
||||
prefixed: List[Dict[str, Any]] = []
|
||||
for period in periods:
|
||||
item = dict(period)
|
||||
item["name"] = f"{dispenser.name} · {period.get('name') or 'Период'}"
|
||||
item["dispenserId"] = dispenser.id
|
||||
prefixed.append(item)
|
||||
return prefixed
|
||||
|
||||
|
||||
def _plan_extras(
|
||||
periods: List[Dict[str, Any]],
|
||||
*,
|
||||
iso_date: str,
|
||||
) -> Dict[str, Any]:
|
||||
totals = _aggregate_ingredient_totals(periods)
|
||||
return {
|
||||
"ingredientTotals": totals["rows"],
|
||||
"ingredientGrandTotalKg": totals["grandTotalKg"],
|
||||
"ingredientReplacements": list_ingredient_replacements(iso_date),
|
||||
"componentNormAdjustments": list_component_norm_adjustments(iso_date),
|
||||
}
|
||||
|
||||
|
||||
def _plan_payload_base(plan_date: Optional[str]) -> tuple[str, set[str], List, List, List]:
|
||||
iso_date = _parse_plan_date(plan_date)
|
||||
skipped_ids = get_skipped_recipe_ids(iso_date)
|
||||
skipped_trips = list_skips(iso_date)
|
||||
skipped_ingredients = list_ingredient_skips(iso_date)
|
||||
skipped_groups = list_unloading_group_skips(iso_date)
|
||||
return iso_date, skipped_ids, skipped_trips, skipped_ingredients, skipped_groups
|
||||
|
||||
|
||||
def _part_skip_maps(plan_date: Optional[str]) -> tuple[Dict[str, set[str]], Dict[str, set[str]]]:
|
||||
iso_date = _parse_plan_date(plan_date)
|
||||
return get_skipped_ingredient_ids(iso_date), get_skipped_unloading_group_ids(iso_date)
|
||||
|
||||
|
||||
def _build_all_dispensers_plan(plan_date: Optional[str]) -> Dict[str, Any]:
|
||||
iso_date, skipped_ids, skipped_trips, skipped_ingredients, skipped_groups = _plan_payload_base(
|
||||
plan_date
|
||||
)
|
||||
skip_ing_map, skip_grp_map = _part_skip_maps(plan_date)
|
||||
replacement_map = get_ingredient_replacement_map(iso_date)
|
||||
component_adjustment_map = get_component_adjustment_map(iso_date)
|
||||
dispensers = db.session.execute(
|
||||
select(FeedDispenser)
|
||||
.where(
|
||||
FeedDispenser.is_deleted.is_(False),
|
||||
FeedDispenser.type == "dispenser",
|
||||
)
|
||||
.order_by(FeedDispenser.name.asc())
|
||||
).scalars().all()
|
||||
periods: List[Dict[str, Any]] = []
|
||||
for dispenser in dispensers:
|
||||
periods.extend(
|
||||
_periods_for_dispenser_named(
|
||||
dispenser,
|
||||
prefix_name=True,
|
||||
skipped_ids=skipped_ids,
|
||||
skipped_ingredients=skip_ing_map,
|
||||
skipped_groups=skip_grp_map,
|
||||
replacement_map=replacement_map,
|
||||
component_adjustment_map=component_adjustment_map,
|
||||
)
|
||||
)
|
||||
return {
|
||||
"date": iso_date,
|
||||
"generatedAt": datetime.now().isoformat(timespec="seconds"),
|
||||
"dispenserId": ALL_DISPENSERS_ID,
|
||||
"dispenserName": "Все кормораздатчики",
|
||||
"farm": "",
|
||||
"dispenserType": "dispenser",
|
||||
"periods": periods,
|
||||
**_plan_extras(periods, iso_date=iso_date),
|
||||
"skippedTrips": skipped_trips,
|
||||
"skippedIngredients": skipped_ingredients,
|
||||
"skippedUnloadingGroups": skipped_groups,
|
||||
}
|
||||
|
||||
|
||||
def _build_all_mills_plan(plan_date: Optional[str]) -> Dict[str, Any]:
|
||||
iso_date, skipped_ids, skipped_trips, skipped_ingredients, skipped_groups = _plan_payload_base(
|
||||
plan_date
|
||||
)
|
||||
skip_ing_map, skip_grp_map = _part_skip_maps(plan_date)
|
||||
replacement_map = get_ingredient_replacement_map(iso_date)
|
||||
component_adjustment_map = get_component_adjustment_map(iso_date)
|
||||
mills = db.session.execute(
|
||||
select(FeedDispenser)
|
||||
.where(
|
||||
FeedDispenser.is_deleted.is_(False),
|
||||
FeedDispenser.type == "mill",
|
||||
)
|
||||
.order_by(FeedDispenser.name.asc())
|
||||
).scalars().all()
|
||||
periods = _mill_trips_fallback(
|
||||
ALL_MILLS_ID,
|
||||
skipped_ids=skipped_ids,
|
||||
skipped_ingredients=skip_ing_map,
|
||||
skipped_groups=skip_grp_map,
|
||||
replacement_map=replacement_map,
|
||||
component_adjustment_map=component_adjustment_map,
|
||||
)
|
||||
if mills:
|
||||
mill_names = ", ".join(m.name for m in mills)
|
||||
farm = mills[0].farm if len(mills) == 1 else ""
|
||||
else:
|
||||
mill_names = ""
|
||||
farm = ""
|
||||
return {
|
||||
"date": iso_date,
|
||||
"generatedAt": datetime.now().isoformat(timespec="seconds"),
|
||||
"dispenserId": ALL_MILLS_ID,
|
||||
"dispenserName": "Все кормоцеха",
|
||||
"farm": farm,
|
||||
"dispenserType": "mill",
|
||||
"scopeNote": mill_names,
|
||||
"periods": periods,
|
||||
**_plan_extras(periods, iso_date=iso_date),
|
||||
"skippedTrips": skipped_trips,
|
||||
"skippedIngredients": skipped_ingredients,
|
||||
"skippedUnloadingGroups": skipped_groups,
|
||||
}
|
||||
|
||||
|
||||
def build_daily_plan(
|
||||
*,
|
||||
dispenser_id: str,
|
||||
plan_date: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""План на дату: текущая конфигурация периодов кормораздатчика."""
|
||||
if dispenser_id == ALL_DISPENSERS_ID:
|
||||
return _build_all_dispensers_plan(plan_date)
|
||||
if dispenser_id == ALL_MILLS_ID:
|
||||
return _build_all_mills_plan(plan_date)
|
||||
|
||||
dispenser = db.session.execute(
|
||||
select(FeedDispenser).where(
|
||||
FeedDispenser.id == dispenser_id,
|
||||
FeedDispenser.is_deleted.is_(False),
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if dispenser is None:
|
||||
raise LookupError("Кормораздатчик не найден")
|
||||
|
||||
iso_date, skipped_ids, skipped_trips, skipped_ingredients, skipped_groups = _plan_payload_base(
|
||||
plan_date
|
||||
)
|
||||
skip_ing_map, skip_grp_map = _part_skip_maps(plan_date)
|
||||
replacement_map = get_ingredient_replacement_map(iso_date)
|
||||
component_adjustment_map = get_component_adjustment_map(iso_date)
|
||||
periods = _periods_for_dispenser_named(
|
||||
dispenser,
|
||||
skipped_ids=skipped_ids,
|
||||
skipped_ingredients=skip_ing_map,
|
||||
skipped_groups=skip_grp_map,
|
||||
replacement_map=replacement_map,
|
||||
component_adjustment_map=component_adjustment_map,
|
||||
)
|
||||
|
||||
return {
|
||||
"date": iso_date,
|
||||
"generatedAt": datetime.now().isoformat(timespec="seconds"),
|
||||
"dispenserId": dispenser.id,
|
||||
"dispenserName": dispenser.name,
|
||||
"farm": dispenser.farm,
|
||||
"dispenserType": dispenser.type,
|
||||
"periods": periods,
|
||||
**_plan_extras(periods, iso_date=iso_date),
|
||||
"skippedTrips": skipped_trips,
|
||||
"skippedIngredients": skipped_ingredients,
|
||||
"skippedUnloadingGroups": skipped_groups,
|
||||
}
|
||||
|
||||
|
||||
def recipe_total_weights_by_id(
|
||||
*,
|
||||
dispenser_id: str,
|
||||
plan_date: Optional[str] = None,
|
||||
) -> Dict[str, float]:
|
||||
"""totalWeightKg по recipeId из плана на день (список рейсов на терминале)."""
|
||||
plan = build_daily_plan(dispenser_id=dispenser_id, plan_date=plan_date)
|
||||
weights: Dict[str, float] = {}
|
||||
for period in plan.get("periods") or []:
|
||||
for trip in period.get("trips") or []:
|
||||
recipe_id = trip.get("recipeId")
|
||||
if recipe_id:
|
||||
weights[str(recipe_id)] = float(trip.get("totalWeightKg") or 0)
|
||||
return weights
|
||||
|
||||
|
||||
def trip_overlay_for_recipe(recipe: Recipe, plan_date: str) -> Dict[str, Any]:
|
||||
"""Рейс из плана на день для одного recipe — те же веса, что в build_daily_plan."""
|
||||
skipped_ing_by_recipe = get_skipped_ingredient_ids(plan_date)
|
||||
skipped_grp_by_recipe = get_skipped_unloading_group_ids(plan_date)
|
||||
replacement_map = get_ingredient_replacement_map(plan_date)
|
||||
component_adjustment_map = get_component_adjustment_map(plan_date)
|
||||
return _serialize_trip(
|
||||
recipe,
|
||||
order=1,
|
||||
skipped_ingredient_ids=skipped_ing_by_recipe.get(recipe.id, set()),
|
||||
skipped_group_ids=skipped_grp_by_recipe.get(recipe.id, set()),
|
||||
replacement_by_ingredient=replacement_map.get(recipe.id, {}),
|
||||
component_adjustment_map=component_adjustment_map,
|
||||
)
|
||||
@@ -0,0 +1,184 @@
|
||||
"""Веса строки рейса в плане на день с учётом замены компонента и правки нормы."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from app.models import Component, Ingredient, Recipe
|
||||
|
||||
_EPSILON = 1e-6
|
||||
|
||||
|
||||
def ingredient_dry_matter_percent(ing: Ingredient, components_by_id: Dict[str, Component]) -> float:
|
||||
dm = float(ing.dry_matter or 0)
|
||||
if dm > 0:
|
||||
return dm
|
||||
if ing.component_id and ing.component_id in components_by_id:
|
||||
return float(components_by_id[ing.component_id].dry_matter or 0)
|
||||
return 0.0
|
||||
|
||||
|
||||
def ingredient_dry_matter_per_head(
|
||||
ing: Ingredient,
|
||||
*,
|
||||
dry_matter_percent: Optional[float] = None,
|
||||
) -> float:
|
||||
if ing.dry_matter_per_head is not None and float(ing.dry_matter_per_head) > 0:
|
||||
return float(ing.dry_matter_per_head)
|
||||
wph = float(ing.weight_per_head or 0)
|
||||
dm = dry_matter_percent if dry_matter_percent is not None else float(ing.dry_matter or 0)
|
||||
if wph > 0 and dm > 0:
|
||||
return wph * (dm / 100.0)
|
||||
return 0.0
|
||||
|
||||
|
||||
def _round_wph(value: float) -> float:
|
||||
if 0 < value < 0.01:
|
||||
return round(value, 3)
|
||||
return round(value, 2)
|
||||
|
||||
|
||||
def _total_kg(weight_per_head: float, heads: int, fallback_amount: float) -> float:
|
||||
if weight_per_head > 0:
|
||||
return round(weight_per_head * max(heads, 0), 2)
|
||||
return round(float(fallback_amount or 0), 2)
|
||||
|
||||
|
||||
def _apply_component_adjustment(
|
||||
*,
|
||||
recipe: Recipe,
|
||||
component_id: Optional[str],
|
||||
components_by_id: Dict[str, Component],
|
||||
master_wph: float,
|
||||
master_dm_ph: float,
|
||||
master_dm_pct: float,
|
||||
component_adjustment: Optional[Dict[str, Optional[float]]],
|
||||
) -> tuple[float, float, float, bool]:
|
||||
if not component_adjustment or not component_id:
|
||||
return master_wph, master_dm_ph, master_dm_pct, False
|
||||
|
||||
adj_dm_pct = component_adjustment.get("dry_matter")
|
||||
if adj_dm_pct is not None:
|
||||
new_dm_pct = float(adj_dm_pct)
|
||||
locked = bool(getattr(recipe, "dry_matter_locked", False))
|
||||
if locked:
|
||||
new_dm_ph = master_dm_ph
|
||||
if new_dm_pct > _EPSILON:
|
||||
new_wph = (master_dm_ph * 100.0) / new_dm_pct
|
||||
if new_wph < 0.01 and master_dm_ph >= 0.001:
|
||||
new_wph = 0.01
|
||||
else:
|
||||
new_wph = 0.0
|
||||
return _round_wph(new_wph), round(new_dm_ph, 4), new_dm_pct, True
|
||||
new_dm_ph = master_wph * (new_dm_pct / 100.0) if master_wph > 0 else master_dm_ph
|
||||
return master_wph, round(new_dm_ph, 4), new_dm_pct, True
|
||||
|
||||
adj_wph = component_adjustment.get("weight_per_head")
|
||||
adj_dm_ph = component_adjustment.get("dry_matter_per_head")
|
||||
if adj_wph is None and adj_dm_ph is None:
|
||||
return master_wph, master_dm_ph, master_dm_pct, False
|
||||
|
||||
comp = components_by_id.get(component_id)
|
||||
dm_pct = float(comp.dry_matter or 0) if comp else master_dm_pct
|
||||
locked = bool(getattr(recipe, "dry_matter_locked", False))
|
||||
|
||||
if locked and adj_dm_ph is not None:
|
||||
new_dm_ph = float(adj_dm_ph)
|
||||
new_wph = (new_dm_ph * 100.0) / dm_pct if dm_pct > _EPSILON else 0.0
|
||||
return _round_wph(new_wph), new_dm_ph, dm_pct, True
|
||||
if not locked and adj_wph is not None:
|
||||
new_wph = float(adj_wph)
|
||||
new_dm_ph = new_wph * (dm_pct / 100.0) if dm_pct > 0 else 0.0
|
||||
return _round_wph(new_wph), round(new_dm_ph, 4), dm_pct, True
|
||||
if adj_dm_ph is not None:
|
||||
new_dm_ph = float(adj_dm_ph)
|
||||
new_wph = (new_dm_ph * 100.0) / dm_pct if dm_pct > _EPSILON else master_wph
|
||||
return _round_wph(new_wph), new_dm_ph, dm_pct, True
|
||||
if adj_wph is not None:
|
||||
new_wph = float(adj_wph)
|
||||
new_dm_ph = new_wph * (dm_pct / 100.0) if dm_pct > 0 else master_dm_ph
|
||||
return _round_wph(new_wph), round(new_dm_ph, 4), dm_pct, True
|
||||
return master_wph, master_dm_ph, master_dm_pct, False
|
||||
|
||||
|
||||
def resolve_plan_ingredient_weights(
|
||||
ing: Ingredient,
|
||||
recipe: Recipe,
|
||||
*,
|
||||
heads: int,
|
||||
components_by_id: Dict[str, Component],
|
||||
replacement_component_id: Optional[str] = None,
|
||||
component_adjustment: Optional[Dict[str, Optional[float]]] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Эффективные веса для плана.
|
||||
|
||||
Приоритет: adjustment (по component_id) → замена → базовый рецепт.
|
||||
Мастер-значения ingredient не изменяются.
|
||||
"""
|
||||
master_wph = float(ing.weight_per_head or 0)
|
||||
master_dm_pct = ingredient_dry_matter_percent(ing, components_by_id)
|
||||
master_dm_ph = ingredient_dry_matter_per_head(ing, dry_matter_percent=master_dm_pct)
|
||||
|
||||
effective_wph, effective_dm_ph, effective_dm_pct, adjusted_today = _apply_component_adjustment(
|
||||
recipe=recipe,
|
||||
component_id=ing.component_id,
|
||||
components_by_id=components_by_id,
|
||||
master_wph=master_wph,
|
||||
master_dm_ph=master_dm_ph,
|
||||
master_dm_pct=master_dm_pct,
|
||||
component_adjustment=component_adjustment,
|
||||
)
|
||||
|
||||
replacement = (
|
||||
components_by_id.get(replacement_component_id)
|
||||
if replacement_component_id
|
||||
else None
|
||||
)
|
||||
if replacement is None:
|
||||
wph = effective_wph
|
||||
return {
|
||||
"weightPerHead": wph,
|
||||
"totalKg": _total_kg(wph, heads, float(ing.amount or 0)),
|
||||
"dryMatterPct": effective_dm_pct,
|
||||
"dryMatterPerHead": round(effective_dm_ph, 4),
|
||||
"originalWeightPerHead": master_wph,
|
||||
"originalDryMatterPerHead": round(master_dm_ph, 4),
|
||||
"originalDryMatterPct": master_dm_pct,
|
||||
"adjustedToday": adjusted_today,
|
||||
"replacedToday": False,
|
||||
}
|
||||
|
||||
repl_dm_pct = float(replacement.dry_matter or 0)
|
||||
locked = bool(getattr(recipe, "dry_matter_locked", False))
|
||||
|
||||
if locked:
|
||||
new_dm_ph = effective_dm_ph
|
||||
if repl_dm_pct > _EPSILON:
|
||||
new_wph = (new_dm_ph * 100.0) / repl_dm_pct
|
||||
else:
|
||||
new_wph = 0.0
|
||||
mode = "dry_matter"
|
||||
else:
|
||||
new_wph = effective_wph
|
||||
new_dm_ph = new_wph * (repl_dm_pct / 100.0) if repl_dm_pct > 0 else 0.0
|
||||
mode = "weight"
|
||||
|
||||
new_wph = _round_wph(new_wph)
|
||||
return {
|
||||
"weightPerHead": new_wph,
|
||||
"totalKg": _total_kg(new_wph, heads, float(ing.amount or 0)),
|
||||
"dryMatterPct": repl_dm_pct,
|
||||
"dryMatterPerHead": round(new_dm_ph, 4),
|
||||
"originalWeightPerHead": master_wph,
|
||||
"originalDryMatterPerHead": round(master_dm_ph, 4),
|
||||
"originalDryMatterPct": master_dm_pct,
|
||||
"recalculationMode": mode,
|
||||
"adjustedToday": adjusted_today,
|
||||
"replacedToday": True,
|
||||
}
|
||||
|
||||
|
||||
# Backward-compatible aliases for internal callers
|
||||
_ingredient_dry_matter_percent = ingredient_dry_matter_percent
|
||||
_ingredient_dry_matter_per_head = ingredient_dry_matter_per_head
|
||||
@@ -0,0 +1,30 @@
|
||||
"""Рецепт для экрана оператора загрузки (весы / дублёр) — с overlay «План на день»."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date as date_cls
|
||||
from typing import Any
|
||||
|
||||
from app.models import Recipe
|
||||
|
||||
|
||||
def plan_ingredients_for_loading(
|
||||
recipe: Recipe, *, plan_date: str | None = None
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Ингредиенты с весами плана на день, как на киоске тракториста (без пропущенных)."""
|
||||
from app.routes.recipes import _serialize_recipe
|
||||
|
||||
resolved = (plan_date or date_cls.today().isoformat())[:10]
|
||||
payload = _serialize_recipe(recipe, plan_date=resolved, exclude_skipped=True)
|
||||
rows: list[dict[str, Any]] = []
|
||||
for ing in payload.get("ingredients") or []:
|
||||
rows.append(
|
||||
{
|
||||
"id": ing.get("id"),
|
||||
"name": (ing.get("name") or "—").strip() or "—",
|
||||
"amount": float(ing.get("amount") or 0),
|
||||
"order": int(ing.get("order") or 0),
|
||||
}
|
||||
)
|
||||
rows.sort(key=lambda row: (row["order"], str(row.get("id") or "")))
|
||||
return rows
|
||||
@@ -0,0 +1,365 @@
|
||||
"""Уведомления центра «К» при изменениях плана на день."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import date
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from app import db
|
||||
from app.models import Component, Ingredient, Recipe, UnloadingGroup
|
||||
from app.services.notification_center_service import create_notification, format_detail_timestamp
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _q(name: Optional[str]) -> str:
|
||||
text = (name or "").strip()
|
||||
return f"«{text}»" if text else "«без названия»"
|
||||
|
||||
|
||||
def _recipe_name(recipe_id: str) -> str:
|
||||
recipe = db.session.execute(
|
||||
select(Recipe.name).where(Recipe.id == recipe_id, Recipe.is_deleted.is_(False))
|
||||
).scalar_one_or_none()
|
||||
return recipe or "без названия"
|
||||
|
||||
|
||||
def _ingredient_name(ingredient_id: str) -> str:
|
||||
row = db.session.execute(
|
||||
select(Ingredient.name).where(
|
||||
Ingredient.id == ingredient_id,
|
||||
Ingredient.is_deleted.is_(False),
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
return row or "без названия"
|
||||
|
||||
|
||||
def _group_name(group_id: str) -> str:
|
||||
row = db.session.execute(
|
||||
select(UnloadingGroup.name).where(
|
||||
UnloadingGroup.id == group_id,
|
||||
UnloadingGroup.is_deleted.is_(False),
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
return row or "без названия"
|
||||
|
||||
|
||||
def _component_name(component_id: str) -> str:
|
||||
row = db.session.execute(
|
||||
select(Component.name).where(
|
||||
Component.id == component_id,
|
||||
Component.is_deleted.is_(False),
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
return row or "без названия"
|
||||
|
||||
|
||||
def _plan_date_label(plan_date: date) -> str:
|
||||
months = (
|
||||
"января",
|
||||
"февраля",
|
||||
"марта",
|
||||
"апреля",
|
||||
"мая",
|
||||
"июня",
|
||||
"июля",
|
||||
"августа",
|
||||
"сентября",
|
||||
"октября",
|
||||
"ноября",
|
||||
"декабря",
|
||||
)
|
||||
return f"{plan_date.day} {months[plan_date.month - 1]} {plan_date.year}"
|
||||
|
||||
|
||||
def _duration_hint(*, plan_date: date, valid_until: Optional[date]) -> str:
|
||||
if valid_until and valid_until > plan_date:
|
||||
return f", действует до {_plan_date_label(valid_until)}"
|
||||
return ""
|
||||
|
||||
|
||||
def _on_date(plan_date: date) -> str:
|
||||
return f"на {_plan_date_label(plan_date)}"
|
||||
|
||||
|
||||
def _actor(user: str) -> str:
|
||||
name = (user or "").strip()
|
||||
if not name or name == "system":
|
||||
return "система"
|
||||
return name
|
||||
|
||||
|
||||
def _detail_body(*parts: str, when: str, user: str) -> str:
|
||||
core = ". ".join(p for p in parts if p)
|
||||
return f"{core}. {when}, {_actor(user)}"
|
||||
|
||||
|
||||
def _notify(
|
||||
*,
|
||||
title: str,
|
||||
detail: str,
|
||||
kind: str,
|
||||
plan_date: date,
|
||||
user: str,
|
||||
) -> None:
|
||||
try:
|
||||
create_notification(
|
||||
title=title,
|
||||
detail=detail,
|
||||
kind=kind,
|
||||
category="daily_plan",
|
||||
page="daily_plan",
|
||||
link_kind="daily_plan",
|
||||
link_id=plan_date.isoformat(),
|
||||
user_login=user or None,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("[DAILY-PLAN-NOTIFY] %s", title)
|
||||
|
||||
|
||||
def notify_trip_skipped(
|
||||
recipe_id: str,
|
||||
plan_date: date,
|
||||
*,
|
||||
valid_until: Optional[date] = None,
|
||||
user: str = "system",
|
||||
) -> None:
|
||||
when = format_detail_timestamp()
|
||||
recipe = _q(_recipe_name(recipe_id))
|
||||
dur = _duration_hint(plan_date=plan_date, valid_until=valid_until)
|
||||
_notify(
|
||||
title=f"Рейс {recipe} убран из плана",
|
||||
detail=_detail_body(
|
||||
f"Рейс {recipe} исключён из плана {_on_date(plan_date)}{dur}",
|
||||
when=when,
|
||||
user=user,
|
||||
),
|
||||
kind="warning",
|
||||
plan_date=plan_date,
|
||||
user=user,
|
||||
)
|
||||
|
||||
|
||||
def notify_trip_unskipped(recipe_id: str, plan_date: date, *, user: str = "system") -> None:
|
||||
when = format_detail_timestamp()
|
||||
recipe = _q(_recipe_name(recipe_id))
|
||||
_notify(
|
||||
title=f"Рейс {recipe} снова в плане",
|
||||
detail=_detail_body(
|
||||
f"Рейс {recipe} снова включён в план {_on_date(plan_date)}",
|
||||
when=when,
|
||||
user=user,
|
||||
),
|
||||
kind="info",
|
||||
plan_date=plan_date,
|
||||
user=user,
|
||||
)
|
||||
|
||||
|
||||
def notify_ingredient_skipped(
|
||||
recipe_id: str,
|
||||
ingredient_id: str,
|
||||
plan_date: date,
|
||||
*,
|
||||
valid_until: Optional[date] = None,
|
||||
user: str = "system",
|
||||
) -> None:
|
||||
when = format_detail_timestamp()
|
||||
recipe = _q(_recipe_name(recipe_id))
|
||||
component = _q(_ingredient_name(ingredient_id))
|
||||
dur = _duration_hint(plan_date=plan_date, valid_until=valid_until)
|
||||
_notify(
|
||||
title=f"{component} убран из плана {recipe}",
|
||||
detail=_detail_body(
|
||||
f"Компонент {component} убран из плана рейса {recipe} {_on_date(plan_date)}{dur}",
|
||||
when=when,
|
||||
user=user,
|
||||
),
|
||||
kind="warning",
|
||||
plan_date=plan_date,
|
||||
user=user,
|
||||
)
|
||||
|
||||
|
||||
def notify_ingredient_unskipped(
|
||||
recipe_id: str,
|
||||
ingredient_id: str,
|
||||
plan_date: date,
|
||||
*,
|
||||
user: str = "system",
|
||||
) -> None:
|
||||
when = format_detail_timestamp()
|
||||
recipe = _q(_recipe_name(recipe_id))
|
||||
component = _q(_ingredient_name(ingredient_id))
|
||||
_notify(
|
||||
title=f"{component} снова в плане {recipe}",
|
||||
detail=_detail_body(
|
||||
f"Компонент {component} снова в плане рейса {recipe} {_on_date(plan_date)}",
|
||||
when=when,
|
||||
user=user,
|
||||
),
|
||||
kind="info",
|
||||
plan_date=plan_date,
|
||||
user=user,
|
||||
)
|
||||
|
||||
|
||||
def notify_ingredients_unskipped_all(recipe_id: str, plan_date: date, *, count: int, user: str) -> None:
|
||||
when = format_detail_timestamp()
|
||||
recipe = _q(_recipe_name(recipe_id))
|
||||
_notify(
|
||||
title=f"Компоненты снова в плане {recipe}",
|
||||
detail=_detail_body(
|
||||
f"Сняты все исключения компонентов ({count}) рейса {recipe} {_on_date(plan_date)}",
|
||||
when=when,
|
||||
user=user,
|
||||
),
|
||||
kind="info",
|
||||
plan_date=plan_date,
|
||||
user=user,
|
||||
)
|
||||
|
||||
|
||||
def notify_unloading_group_skipped(
|
||||
recipe_id: str,
|
||||
group_id: str,
|
||||
plan_date: date,
|
||||
*,
|
||||
valid_until: Optional[date] = None,
|
||||
user: str = "system",
|
||||
) -> None:
|
||||
when = format_detail_timestamp()
|
||||
recipe = _q(_recipe_name(recipe_id))
|
||||
group = _q(_group_name(group_id))
|
||||
dur = _duration_hint(plan_date=plan_date, valid_until=valid_until)
|
||||
_notify(
|
||||
title=f"Группа {group} убрана из плана {recipe}",
|
||||
detail=_detail_body(
|
||||
f"Группа {group} убрана из плана рейса {recipe} {_on_date(plan_date)}{dur}",
|
||||
when=when,
|
||||
user=user,
|
||||
),
|
||||
kind="warning",
|
||||
plan_date=plan_date,
|
||||
user=user,
|
||||
)
|
||||
|
||||
|
||||
def notify_unloading_group_unskipped(
|
||||
recipe_id: str,
|
||||
group_id: str,
|
||||
plan_date: date,
|
||||
*,
|
||||
user: str = "system",
|
||||
) -> None:
|
||||
when = format_detail_timestamp()
|
||||
recipe = _q(_recipe_name(recipe_id))
|
||||
group = _q(_group_name(group_id))
|
||||
_notify(
|
||||
title=f"Группа {group} снова в плане {recipe}",
|
||||
detail=_detail_body(
|
||||
f"Группа {group} снова в плане рейса {recipe} {_on_date(plan_date)}",
|
||||
when=when,
|
||||
user=user,
|
||||
),
|
||||
kind="info",
|
||||
plan_date=plan_date,
|
||||
user=user,
|
||||
)
|
||||
|
||||
|
||||
def notify_unloading_groups_unskipped_all(recipe_id: str, plan_date: date, *, count: int, user: str) -> None:
|
||||
when = format_detail_timestamp()
|
||||
recipe = _q(_recipe_name(recipe_id))
|
||||
_notify(
|
||||
title=f"Группы снова в плане {recipe}",
|
||||
detail=_detail_body(
|
||||
f"Сняты все исключения групп ({count}) рейса {recipe} {_on_date(plan_date)}",
|
||||
when=when,
|
||||
user=user,
|
||||
),
|
||||
kind="info",
|
||||
plan_date=plan_date,
|
||||
user=user,
|
||||
)
|
||||
|
||||
|
||||
def notify_ingredient_replaced(
|
||||
recipe_id: str,
|
||||
ingredient_id: str,
|
||||
replacement_component_id: str,
|
||||
plan_date: date,
|
||||
*,
|
||||
valid_until: Optional[date] = None,
|
||||
user: str = "system",
|
||||
) -> None:
|
||||
when = format_detail_timestamp()
|
||||
recipe = _q(_recipe_name(recipe_id))
|
||||
original = _q(_ingredient_name(ingredient_id))
|
||||
replacement = _q(_component_name(replacement_component_id))
|
||||
dur = _duration_hint(plan_date=plan_date, valid_until=valid_until)
|
||||
_notify(
|
||||
title=f"{original} → {replacement} в {recipe}",
|
||||
detail=_detail_body(
|
||||
f"В рейсе {recipe} вместо {original} {_on_date(plan_date)} будет {replacement}{dur}",
|
||||
when=when,
|
||||
user=user,
|
||||
),
|
||||
kind="info",
|
||||
plan_date=plan_date,
|
||||
user=user,
|
||||
)
|
||||
|
||||
|
||||
def notify_component_replaced_in_plan(
|
||||
component_id: str,
|
||||
replacement_component_id: str,
|
||||
plan_date: date,
|
||||
*,
|
||||
recipe_count: int,
|
||||
valid_until: Optional[date] = None,
|
||||
user: str = "system",
|
||||
) -> None:
|
||||
when = format_detail_timestamp()
|
||||
original = _q(_component_name(component_id))
|
||||
replacement = _q(_component_name(replacement_component_id))
|
||||
dur = _duration_hint(plan_date=plan_date, valid_until=valid_until)
|
||||
count = max(int(recipe_count or 0), 1)
|
||||
recipes_word = "рецепте" if count == 1 else "рецептах"
|
||||
_notify(
|
||||
title=f"{original} → {replacement} в {count} {recipes_word}",
|
||||
detail=_detail_body(
|
||||
f"Во всех рейсах плана вместо {original} {_on_date(plan_date)} будет {replacement}{dur}",
|
||||
when=when,
|
||||
user=user,
|
||||
),
|
||||
kind="info",
|
||||
plan_date=plan_date,
|
||||
user=user,
|
||||
)
|
||||
|
||||
|
||||
def notify_ingredient_replacement_undone(
|
||||
recipe_id: str,
|
||||
ingredient_id: str,
|
||||
plan_date: date,
|
||||
*,
|
||||
user: str = "system",
|
||||
) -> None:
|
||||
when = format_detail_timestamp()
|
||||
recipe = _q(_recipe_name(recipe_id))
|
||||
original = _q(_ingredient_name(ingredient_id))
|
||||
_notify(
|
||||
title=f"{original} снова в плане {recipe}",
|
||||
detail=_detail_body(
|
||||
f"Замена отменена: {original} снова в плане рейса {recipe} {_on_date(plan_date)}",
|
||||
when=when,
|
||||
user=user,
|
||||
),
|
||||
kind="info",
|
||||
plan_date=plan_date,
|
||||
user=user,
|
||||
)
|
||||
@@ -0,0 +1,129 @@
|
||||
"""PDF плана на день (reportlab)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
from typing import Any, Dict, List
|
||||
|
||||
from reportlab.lib import colors
|
||||
from reportlab.lib.pagesizes import A4
|
||||
from reportlab.lib.styles import getSampleStyleSheet
|
||||
from reportlab.lib.units import mm
|
||||
from reportlab.platypus import Paragraph, SimpleDocTemplate, Spacer, Table, TableStyle
|
||||
|
||||
from app.services.feed_accounting.pdf_feed_accounting import _register_cyrillic_font
|
||||
|
||||
|
||||
def _styles():
|
||||
font = _register_cyrillic_font()
|
||||
styles = getSampleStyleSheet()
|
||||
styles["Title"].fontName = font
|
||||
styles["Normal"].fontName = font
|
||||
styles["Heading2"].fontName = font
|
||||
return styles, font
|
||||
|
||||
|
||||
def build_daily_plan_pdf(plan: Dict[str, Any]) -> bytes:
|
||||
buf = io.BytesIO()
|
||||
doc = SimpleDocTemplate(buf, pagesize=A4, leftMargin=14 * mm, rightMargin=14 * mm)
|
||||
styles, font = _styles()
|
||||
story: List[Any] = []
|
||||
|
||||
story.append(Paragraph("План на день", styles["Title"]))
|
||||
story.append(
|
||||
Paragraph(
|
||||
f"Дата: {plan.get('date', '—')} · {plan.get('dispenserName', '—')} · {plan.get('farm', '')}",
|
||||
styles["Normal"],
|
||||
)
|
||||
)
|
||||
story.append(Spacer(1, 8))
|
||||
|
||||
periods = plan.get("periods") or []
|
||||
if not periods:
|
||||
story.append(Paragraph("Нет периодов или рейсов для выбранного кормораздатчика.", styles["Normal"]))
|
||||
else:
|
||||
for period in periods:
|
||||
story.append(Paragraph(str(period.get("name") or "Период"), styles["Heading2"]))
|
||||
for trip in period.get("trips") or []:
|
||||
story.append(
|
||||
Paragraph(
|
||||
f"Рейс {trip.get('order', '')}: {trip.get('recipeName', '—')} "
|
||||
f"({trip.get('headsPerTrip', 0)} гол., смеш. {trip.get('mixingTimeSec', 0)} с)",
|
||||
styles["Normal"],
|
||||
)
|
||||
)
|
||||
data = [["Компонент", "кг/гол", "Всего, кг"]]
|
||||
for ing in trip.get("ingredients") or []:
|
||||
data.append(
|
||||
[
|
||||
str(ing.get("name") or "—"),
|
||||
str(ing.get("weightPerHead") or ""),
|
||||
str(ing.get("totalKg") or ""),
|
||||
]
|
||||
)
|
||||
if len(data) == 1:
|
||||
data.append(["—", "", ""])
|
||||
tbl = Table(data, colWidths=[80 * mm, 35 * mm, 35 * mm])
|
||||
tbl.setStyle(
|
||||
TableStyle(
|
||||
[
|
||||
("FONTNAME", (0, 0), (-1, -1), font),
|
||||
("FONTSIZE", (0, 0), (-1, -1), 8),
|
||||
("BACKGROUND", (0, 0), (-1, 0), colors.lightgrey),
|
||||
("GRID", (0, 0), (-1, -1), 0.25, colors.grey),
|
||||
]
|
||||
)
|
||||
)
|
||||
story.append(tbl)
|
||||
groups = trip.get("unloadingGroups") or []
|
||||
if groups:
|
||||
gdata = [["Группа", "кг", "Распределение"]]
|
||||
for g in groups:
|
||||
gdata.append(
|
||||
[
|
||||
str(g.get("name") or "—"),
|
||||
str(g.get("weightKg") or ""),
|
||||
str(
|
||||
g.get("distributionLabel")
|
||||
or g.get("distributionType")
|
||||
or ""
|
||||
),
|
||||
]
|
||||
)
|
||||
gtbl = Table(gdata, colWidths=[55 * mm, 30 * mm, 40 * mm])
|
||||
gtbl.setStyle(
|
||||
TableStyle(
|
||||
[
|
||||
("FONTNAME", (0, 0), (-1, -1), font),
|
||||
("FONTSIZE", (0, 0), (-1, -1), 8),
|
||||
("GRID", (0, 0), (-1, -1), 0.25, colors.grey),
|
||||
]
|
||||
)
|
||||
)
|
||||
story.append(gtbl)
|
||||
story.append(Spacer(1, 6))
|
||||
|
||||
totals = plan.get("ingredientTotals") or []
|
||||
if totals:
|
||||
story.append(Paragraph("Итого по компонентам", styles["Heading2"]))
|
||||
tdata = [["Компонент", "Всего, кг"]]
|
||||
for row in totals:
|
||||
tdata.append([str(row.get("name") or "—"), str(row.get("totalKg") or "")])
|
||||
grand = plan.get("ingredientGrandTotalKg")
|
||||
if grand is not None:
|
||||
tdata.append(["Итого", str(grand)])
|
||||
ttbl = Table(tdata, colWidths=[100 * mm, 40 * mm])
|
||||
ttbl.setStyle(
|
||||
TableStyle(
|
||||
[
|
||||
("FONTNAME", (0, 0), (-1, -1), font),
|
||||
("FONTSIZE", (0, 0), (-1, -1), 9),
|
||||
("BACKGROUND", (0, 0), (-1, 0), colors.lightgrey),
|
||||
("GRID", (0, 0), (-1, -1), 0.25, colors.grey),
|
||||
]
|
||||
)
|
||||
)
|
||||
story.append(ttbl)
|
||||
|
||||
doc.build(story)
|
||||
return buf.getvalue()
|
||||
@@ -0,0 +1,320 @@
|
||||
"""Временная замена компонентов в плане на день."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, List, Optional, Set, Tuple
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from app import db
|
||||
from app.models import Component, DailyIngredientReplacement, Ingredient, Recipe
|
||||
from app.services.daily_plan.skips import (
|
||||
_parse_plan_date,
|
||||
_serialize_skip_dates,
|
||||
_skip_active_filters,
|
||||
_soft_delete_skip_row,
|
||||
_soft_unskip_part,
|
||||
_upsert_part_skip,
|
||||
resolve_skip_range,
|
||||
)
|
||||
|
||||
|
||||
def _component_payload(component: Component, *, similar: bool = False) -> Dict[str, Any]:
|
||||
return {
|
||||
"id": component.id,
|
||||
"name": component.name,
|
||||
"type": component.type or "",
|
||||
"dryMatter": float(component.dry_matter or 0),
|
||||
"protein": float(component.protein or 0),
|
||||
"energy": float(component.energy or 0),
|
||||
"similar": similar,
|
||||
}
|
||||
|
||||
|
||||
def _similarity_score(source: Component, candidate: Component) -> float:
|
||||
if source.id == candidate.id:
|
||||
return -1.0
|
||||
score = 0.0
|
||||
src_type = (source.type or "").strip().lower()
|
||||
cand_type = (candidate.type or "").strip().lower()
|
||||
if src_type and cand_type and src_type == cand_type:
|
||||
score += 100.0
|
||||
dm_diff = abs(float(source.dry_matter or 0) - float(candidate.dry_matter or 0))
|
||||
score += max(0.0, 50.0 - dm_diff * 2.0)
|
||||
prot_diff = abs(float(source.protein or 0) - float(candidate.protein or 0))
|
||||
score += max(0.0, 20.0 - prot_diff)
|
||||
return score
|
||||
|
||||
|
||||
def find_component_alternatives(
|
||||
component_id: str,
|
||||
*,
|
||||
query: str = "",
|
||||
limit: int = 20,
|
||||
) -> Dict[str, Any]:
|
||||
"""Похожие компоненты и поиск по всем активным."""
|
||||
source = db.session.execute(
|
||||
select(Component).where(
|
||||
Component.id == component_id,
|
||||
Component.is_deleted.is_(False),
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if source is None:
|
||||
raise LookupError("Компонент не найден")
|
||||
|
||||
q = (query or "").strip().lower()
|
||||
try:
|
||||
limit_val = max(1, min(int(limit), 100))
|
||||
except (TypeError, ValueError):
|
||||
limit_val = 20
|
||||
|
||||
candidates = db.session.execute(
|
||||
select(Component).where(
|
||||
Component.is_active.is_(True),
|
||||
Component.is_deleted.is_(False),
|
||||
Component.id != component_id,
|
||||
)
|
||||
).scalars().all()
|
||||
|
||||
scored: List[Tuple[float, Component]] = []
|
||||
for candidate in candidates:
|
||||
if q and q not in (candidate.name or "").lower():
|
||||
continue
|
||||
score = _similarity_score(source, candidate)
|
||||
if score < 0:
|
||||
continue
|
||||
scored.append((score, candidate))
|
||||
|
||||
scored.sort(key=lambda item: (-item[0], (item[1].name or "").lower()))
|
||||
similar_cutoff = 80.0 if (source.type or "").strip() else 40.0
|
||||
similar: List[Dict[str, Any]] = []
|
||||
all_items: List[Dict[str, Any]] = []
|
||||
for score, candidate in scored:
|
||||
is_similar = score >= similar_cutoff
|
||||
payload = _component_payload(candidate, similar=is_similar)
|
||||
all_items.append(payload)
|
||||
if is_similar and len(similar) < 8:
|
||||
similar.append(payload)
|
||||
if len(all_items) >= limit_val:
|
||||
break
|
||||
|
||||
return {
|
||||
"sourceComponent": _component_payload(source),
|
||||
"similar": similar,
|
||||
"results": all_items,
|
||||
}
|
||||
|
||||
|
||||
def get_ingredient_replacement_map(
|
||||
plan_date: Optional[str] = None,
|
||||
) -> Dict[str, Dict[str, str]]:
|
||||
"""recipe_id -> ingredient_id -> replacement_component_id."""
|
||||
d = _parse_plan_date(plan_date)
|
||||
rows = db.session.execute(
|
||||
select(
|
||||
DailyIngredientReplacement.recipe_id,
|
||||
DailyIngredientReplacement.ingredient_id,
|
||||
DailyIngredientReplacement.replacement_component_id,
|
||||
).where(*_skip_active_filters(DailyIngredientReplacement, d))
|
||||
).all()
|
||||
out: Dict[str, Dict[str, str]] = {}
|
||||
for recipe_id, ingredient_id, replacement_id in rows:
|
||||
out.setdefault(recipe_id, {})[ingredient_id] = replacement_id
|
||||
return out
|
||||
|
||||
|
||||
def get_replaced_recipe_ids(plan_date: Optional[str] = None) -> Set[str]:
|
||||
d = _parse_plan_date(plan_date)
|
||||
rows = db.session.execute(
|
||||
select(DailyIngredientReplacement.recipe_id).where(
|
||||
*_skip_active_filters(DailyIngredientReplacement, d)
|
||||
)
|
||||
).scalars().all()
|
||||
return set(rows)
|
||||
|
||||
|
||||
def list_ingredient_replacements(plan_date: Optional[str] = None) -> List[Dict[str, Any]]:
|
||||
d = _parse_plan_date(plan_date)
|
||||
rows = db.session.execute(
|
||||
select(
|
||||
DailyIngredientReplacement,
|
||||
Recipe.name,
|
||||
Ingredient.name,
|
||||
Component.name,
|
||||
)
|
||||
.join(Recipe, Recipe.id == DailyIngredientReplacement.recipe_id)
|
||||
.join(Ingredient, Ingredient.id == DailyIngredientReplacement.ingredient_id)
|
||||
.join(Component, Component.id == DailyIngredientReplacement.replacement_component_id)
|
||||
.where(
|
||||
*_skip_active_filters(DailyIngredientReplacement, d),
|
||||
Recipe.is_deleted.is_(False),
|
||||
Ingredient.is_deleted.is_(False),
|
||||
Component.is_deleted.is_(False),
|
||||
)
|
||||
.order_by(Recipe.name.asc(), Ingredient.order.asc())
|
||||
).all()
|
||||
return [
|
||||
{
|
||||
"id": row.id,
|
||||
"recipeId": row.recipe_id,
|
||||
"recipeName": recipe_name,
|
||||
"ingredientId": row.ingredient_id,
|
||||
"ingredientName": ing_name or "—",
|
||||
"replacementComponentId": row.replacement_component_id,
|
||||
"replacementName": replacement_name or "—",
|
||||
**_serialize_skip_dates(row, fallback=d),
|
||||
}
|
||||
for row, recipe_name, ing_name, replacement_name in rows
|
||||
]
|
||||
|
||||
|
||||
def replace_ingredient(
|
||||
recipe_id: str,
|
||||
ingredient_id: str,
|
||||
replacement_component_id: str,
|
||||
plan_date: Optional[str] = None,
|
||||
*,
|
||||
duration: Optional[str] = None,
|
||||
until_date: Optional[str] = None,
|
||||
user: str = "system",
|
||||
) -> DailyIngredientReplacement:
|
||||
start = _parse_plan_date(plan_date)
|
||||
_, valid_until = resolve_skip_range(start, duration=duration, until_date=until_date)
|
||||
recipe = db.session.execute(
|
||||
select(Recipe).where(Recipe.id == recipe_id, Recipe.is_deleted.is_(False))
|
||||
).scalar_one_or_none()
|
||||
if recipe is None:
|
||||
raise LookupError("Рецепт не найден")
|
||||
ingredient = db.session.execute(
|
||||
select(Ingredient).where(
|
||||
Ingredient.id == ingredient_id,
|
||||
Ingredient.recipe_id == recipe_id,
|
||||
Ingredient.is_deleted.is_(False),
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if ingredient is None:
|
||||
raise LookupError("Компонент рейса не найден")
|
||||
replacement = db.session.execute(
|
||||
select(Component).where(
|
||||
Component.id == replacement_component_id,
|
||||
Component.is_deleted.is_(False),
|
||||
Component.is_active.is_(True),
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if replacement is None:
|
||||
raise LookupError("Компонент-замена не найден")
|
||||
if ingredient.component_id and ingredient.component_id == replacement_component_id:
|
||||
raise LookupError("Выберите другой компонент")
|
||||
|
||||
return _upsert_part_skip(
|
||||
DailyIngredientReplacement,
|
||||
lookup_filters=(
|
||||
DailyIngredientReplacement.recipe_id == recipe_id,
|
||||
DailyIngredientReplacement.ingredient_id == ingredient_id,
|
||||
),
|
||||
create_fields={
|
||||
"recipe_id": recipe_id,
|
||||
"ingredient_id": ingredient_id,
|
||||
"replacement_component_id": replacement_component_id,
|
||||
"plan_date": start,
|
||||
"valid_until": valid_until if valid_until != start else None,
|
||||
},
|
||||
user=user,
|
||||
)
|
||||
|
||||
|
||||
def collect_component_replacement_targets(
|
||||
component_id: str,
|
||||
plan_date: Optional[str] = None,
|
||||
*,
|
||||
dispenser_id: Optional[str] = None,
|
||||
) -> List[Tuple[str, str]]:
|
||||
"""Уникальные пары (recipe_id, ingredient_id) в плане для мастер-компонента."""
|
||||
from app.services.daily_plan.builder import build_daily_plan
|
||||
|
||||
iso_date = _parse_plan_date(plan_date).isoformat()
|
||||
if not dispenser_id:
|
||||
return []
|
||||
try:
|
||||
plan = build_daily_plan(dispenser_id=dispenser_id, plan_date=iso_date)
|
||||
except LookupError:
|
||||
return []
|
||||
|
||||
seen: Set[Tuple[str, str]] = set()
|
||||
targets: List[Tuple[str, str]] = []
|
||||
for period in plan.get("periods") or []:
|
||||
for trip in period.get("trips") or []:
|
||||
recipe_id = str(trip.get("recipeId") or "")
|
||||
if not recipe_id:
|
||||
continue
|
||||
for ing in trip.get("ingredients") or []:
|
||||
if ing.get("skippedToday"):
|
||||
continue
|
||||
orig = str(ing.get("originalComponentId") or ing.get("componentId") or "")
|
||||
if orig != component_id:
|
||||
continue
|
||||
ingredient_id = str(ing.get("id") or "")
|
||||
if not ingredient_id:
|
||||
continue
|
||||
key = (recipe_id, ingredient_id)
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
targets.append(key)
|
||||
return targets
|
||||
|
||||
|
||||
def replace_component_in_plan(
|
||||
component_id: str,
|
||||
replacement_component_id: str,
|
||||
plan_date: Optional[str] = None,
|
||||
*,
|
||||
dispenser_id: Optional[str] = None,
|
||||
duration: Optional[str] = None,
|
||||
until_date: Optional[str] = None,
|
||||
user: str = "system",
|
||||
) -> List[DailyIngredientReplacement]:
|
||||
"""Заменить компонент во всех рейсах плана, где он используется."""
|
||||
if component_id == replacement_component_id:
|
||||
raise LookupError("Выберите другой компонент")
|
||||
targets = collect_component_replacement_targets(
|
||||
component_id,
|
||||
plan_date,
|
||||
dispenser_id=dispenser_id,
|
||||
)
|
||||
if not targets:
|
||||
raise LookupError("Компонент не найден в плане на эту дату")
|
||||
rows: List[DailyIngredientReplacement] = []
|
||||
for recipe_id, ingredient_id in targets:
|
||||
rows.append(
|
||||
replace_ingredient(
|
||||
recipe_id,
|
||||
ingredient_id,
|
||||
replacement_component_id,
|
||||
plan_date,
|
||||
duration=duration,
|
||||
until_date=until_date,
|
||||
user=user,
|
||||
)
|
||||
)
|
||||
return rows
|
||||
|
||||
|
||||
def undo_ingredient_replacement(
|
||||
recipe_id: str,
|
||||
ingredient_id: str,
|
||||
plan_date: Optional[str] = None,
|
||||
*,
|
||||
user: str = "system",
|
||||
) -> bool:
|
||||
d = _parse_plan_date(plan_date)
|
||||
return _soft_unskip_part(
|
||||
DailyIngredientReplacement,
|
||||
lookup_filters=(
|
||||
DailyIngredientReplacement.recipe_id == recipe_id,
|
||||
DailyIngredientReplacement.ingredient_id == ingredient_id,
|
||||
*_skip_active_filters(DailyIngredientReplacement, d),
|
||||
),
|
||||
user=user,
|
||||
table_name="daily_ingredient_replacement",
|
||||
)
|
||||
@@ -0,0 +1,548 @@
|
||||
"""Исключение рейсов и частей рейса из плана на день."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date, timedelta
|
||||
from typing import Any, Dict, List, Optional, Set, TypeVar
|
||||
|
||||
from flask import request, session
|
||||
from sqlalchemy import func, select
|
||||
|
||||
from app import db
|
||||
from app.models import (
|
||||
DailyIngredientSkip,
|
||||
DailyTripSkip,
|
||||
DailyUnloadingGroupSkip,
|
||||
Ingredient,
|
||||
Recipe,
|
||||
UnloadingGroup,
|
||||
)
|
||||
from app.timeutil import utc_now_naive
|
||||
|
||||
TRecipe = TypeVar("TRecipe")
|
||||
|
||||
|
||||
def is_kiosk_recipe_list_request() -> bool:
|
||||
"""Запрос списка рейсов с терминала/киоска (не редактор зоотехника)."""
|
||||
if request.args.get("for") in ("terminal", "kiosk"):
|
||||
return True
|
||||
if request.headers.get("X-Wesp-Kiosk") == "1":
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def is_zootech_recipe_list_view() -> bool:
|
||||
"""Редактор зоотехника: все рейсы + skippedToday; терминал — без skip."""
|
||||
if is_kiosk_recipe_list_request():
|
||||
return False
|
||||
return bool(session.get("authenticated", False))
|
||||
|
||||
|
||||
def filter_recipes_for_list_view(
|
||||
recipes: List[TRecipe],
|
||||
*,
|
||||
plan_date: Optional[str] = None,
|
||||
) -> tuple[List[TRecipe], Set[str]]:
|
||||
"""Список рейсов для ответа API с учётом skip на дату."""
|
||||
skipped = get_skipped_recipe_ids(plan_date)
|
||||
if is_zootech_recipe_list_view():
|
||||
return recipes, skipped
|
||||
visible = [r for r in recipes if getattr(r, "id", None) not in skipped]
|
||||
return visible, skipped
|
||||
|
||||
|
||||
def _parse_plan_date(value: Optional[str]) -> date:
|
||||
if value:
|
||||
try:
|
||||
return date.fromisoformat(str(value).strip()[:10])
|
||||
except ValueError:
|
||||
pass
|
||||
return date.today()
|
||||
|
||||
|
||||
def _skip_end_expr(model):
|
||||
return func.coalesce(model.valid_until, model.plan_date)
|
||||
|
||||
|
||||
def _skip_active_filters(model, target: date):
|
||||
end = _skip_end_expr(model)
|
||||
return (
|
||||
model.is_deleted.is_(False),
|
||||
model.plan_date <= target,
|
||||
end >= target,
|
||||
)
|
||||
|
||||
|
||||
def resolve_skip_range(
|
||||
start: date,
|
||||
*,
|
||||
duration: Optional[str] = None,
|
||||
until_date: Optional[str] = None,
|
||||
) -> tuple[date, date]:
|
||||
"""Диапазон skip: today | week | date (until_date)."""
|
||||
dur = (duration or "today").strip().lower()
|
||||
if dur == "week":
|
||||
days_to_sunday = 6 - start.weekday()
|
||||
return start, start + timedelta(days=days_to_sunday)
|
||||
if dur == "date" and until_date:
|
||||
try:
|
||||
end = date.fromisoformat(str(until_date).strip()[:10])
|
||||
except ValueError:
|
||||
end = start
|
||||
return start, max(start, end)
|
||||
return start, start
|
||||
|
||||
|
||||
def _serialize_skip_dates(row, *, fallback: date) -> Dict[str, str]:
|
||||
start = row.plan_date.isoformat() if row.plan_date else fallback.isoformat()
|
||||
end_val = row.valid_until or row.plan_date
|
||||
end = end_val.isoformat() if end_val else start
|
||||
return {"date": start, "validUntil": end}
|
||||
|
||||
|
||||
def get_skipped_recipe_ids(plan_date: Optional[str] = None) -> Set[str]:
|
||||
"""Активные skip рейсов на дату."""
|
||||
d = _parse_plan_date(plan_date)
|
||||
rows = db.session.execute(
|
||||
select(DailyTripSkip.recipe_id).where(*_skip_active_filters(DailyTripSkip, d))
|
||||
).scalars().all()
|
||||
return set(rows)
|
||||
|
||||
|
||||
def get_recipe_ids_with_any_skip(plan_date: Optional[str] = None) -> Set[str]:
|
||||
"""Рейсы с любым активным skip или заменой на дату."""
|
||||
from app.services.daily_plan.replacements import get_replaced_recipe_ids
|
||||
|
||||
d = _parse_plan_date(plan_date)
|
||||
ids: Set[str] = set()
|
||||
for model in (DailyTripSkip, DailyIngredientSkip, DailyUnloadingGroupSkip):
|
||||
rows = db.session.execute(
|
||||
select(model.recipe_id).where(*_skip_active_filters(model, d))
|
||||
).scalars().all()
|
||||
ids.update(rows)
|
||||
ids.update(get_replaced_recipe_ids(plan_date))
|
||||
from app.services.daily_plan.adjustments import get_adjusted_recipe_ids
|
||||
|
||||
ids.update(get_adjusted_recipe_ids(plan_date))
|
||||
return ids
|
||||
|
||||
|
||||
def list_skips(plan_date: Optional[str] = None) -> List[Dict[str, Any]]:
|
||||
"""Список исключённых рейсов на дату (для UI)."""
|
||||
d = _parse_plan_date(plan_date)
|
||||
skips = db.session.execute(
|
||||
select(DailyTripSkip, Recipe.name)
|
||||
.join(Recipe, Recipe.id == DailyTripSkip.recipe_id)
|
||||
.where(
|
||||
*_skip_active_filters(DailyTripSkip, d),
|
||||
Recipe.is_deleted.is_(False),
|
||||
)
|
||||
.order_by(Recipe.name.asc())
|
||||
).all()
|
||||
return [
|
||||
{
|
||||
"id": skip.id,
|
||||
"recipeId": skip.recipe_id,
|
||||
"recipeName": name,
|
||||
**_serialize_skip_dates(skip, fallback=d),
|
||||
}
|
||||
for skip, name in skips
|
||||
]
|
||||
|
||||
|
||||
def get_skipped_ingredient_ids(plan_date: Optional[str] = None) -> Dict[str, Set[str]]:
|
||||
"""recipe_id -> ingredient_id для активных skip на дату."""
|
||||
d = _parse_plan_date(plan_date)
|
||||
rows = db.session.execute(
|
||||
select(DailyIngredientSkip.recipe_id, DailyIngredientSkip.ingredient_id).where(
|
||||
*_skip_active_filters(DailyIngredientSkip, d)
|
||||
)
|
||||
).all()
|
||||
out: Dict[str, Set[str]] = {}
|
||||
for recipe_id, ingredient_id in rows:
|
||||
out.setdefault(recipe_id, set()).add(ingredient_id)
|
||||
return out
|
||||
|
||||
|
||||
def get_skipped_unloading_group_ids(plan_date: Optional[str] = None) -> Dict[str, Set[str]]:
|
||||
"""recipe_id -> unloading_group_id для активных skip на дату."""
|
||||
d = _parse_plan_date(plan_date)
|
||||
rows = db.session.execute(
|
||||
select(
|
||||
DailyUnloadingGroupSkip.recipe_id,
|
||||
DailyUnloadingGroupSkip.unloading_group_id,
|
||||
).where(
|
||||
*_skip_active_filters(DailyUnloadingGroupSkip, d)
|
||||
)
|
||||
).all()
|
||||
out: Dict[str, Set[str]] = {}
|
||||
for recipe_id, group_id in rows:
|
||||
out.setdefault(recipe_id, set()).add(group_id)
|
||||
return out
|
||||
|
||||
|
||||
def list_ingredient_skips(plan_date: Optional[str] = None) -> List[Dict[str, Any]]:
|
||||
"""Список исключённых компонентов на дату (для UI)."""
|
||||
d = _parse_plan_date(plan_date)
|
||||
rows = db.session.execute(
|
||||
select(DailyIngredientSkip, Recipe.name, Ingredient.name)
|
||||
.join(Recipe, Recipe.id == DailyIngredientSkip.recipe_id)
|
||||
.join(Ingredient, Ingredient.id == DailyIngredientSkip.ingredient_id)
|
||||
.where(
|
||||
*_skip_active_filters(DailyIngredientSkip, d),
|
||||
Recipe.is_deleted.is_(False),
|
||||
Ingredient.is_deleted.is_(False),
|
||||
)
|
||||
.order_by(Recipe.name.asc(), Ingredient.order.asc())
|
||||
).all()
|
||||
return [
|
||||
{
|
||||
"id": skip.id,
|
||||
"recipeId": skip.recipe_id,
|
||||
"recipeName": recipe_name,
|
||||
"ingredientId": skip.ingredient_id,
|
||||
"ingredientName": ing_name or "—",
|
||||
**_serialize_skip_dates(skip, fallback=d),
|
||||
}
|
||||
for skip, recipe_name, ing_name in rows
|
||||
]
|
||||
|
||||
|
||||
def list_unloading_group_skips(plan_date: Optional[str] = None) -> List[Dict[str, Any]]:
|
||||
"""Список исключённых групп выгрузки на дату (для UI)."""
|
||||
d = _parse_plan_date(plan_date)
|
||||
rows = db.session.execute(
|
||||
select(DailyUnloadingGroupSkip, Recipe.name, UnloadingGroup.name)
|
||||
.join(Recipe, Recipe.id == DailyUnloadingGroupSkip.recipe_id)
|
||||
.join(UnloadingGroup, UnloadingGroup.id == DailyUnloadingGroupSkip.unloading_group_id)
|
||||
.where(
|
||||
*_skip_active_filters(DailyUnloadingGroupSkip, d),
|
||||
Recipe.is_deleted.is_(False),
|
||||
UnloadingGroup.is_deleted.is_(False),
|
||||
)
|
||||
.order_by(Recipe.name.asc(), UnloadingGroup.order.asc())
|
||||
).all()
|
||||
return [
|
||||
{
|
||||
"id": skip.id,
|
||||
"recipeId": skip.recipe_id,
|
||||
"recipeName": recipe_name,
|
||||
"unloadingGroupId": skip.unloading_group_id,
|
||||
"groupName": group_name or "—",
|
||||
**_serialize_skip_dates(skip, fallback=d),
|
||||
}
|
||||
for skip, recipe_name, group_name in rows
|
||||
]
|
||||
|
||||
|
||||
def list_all_skips(plan_date: Optional[str] = None) -> Dict[str, Any]:
|
||||
"""Все исключения на дату для UI."""
|
||||
return {
|
||||
"trips": list_skips(plan_date),
|
||||
"ingredients": list_ingredient_skips(plan_date),
|
||||
"unloadingGroups": list_unloading_group_skips(plan_date),
|
||||
}
|
||||
|
||||
|
||||
def skip_trip(
|
||||
recipe_id: str,
|
||||
plan_date: Optional[str] = None,
|
||||
*,
|
||||
duration: Optional[str] = None,
|
||||
until_date: Optional[str] = None,
|
||||
user: str = "system",
|
||||
) -> DailyTripSkip:
|
||||
"""Исключить рейс из плана (upsert / restore)."""
|
||||
start = _parse_plan_date(plan_date)
|
||||
_, valid_until = resolve_skip_range(start, duration=duration, until_date=until_date)
|
||||
recipe = db.session.execute(
|
||||
select(Recipe).where(Recipe.id == recipe_id, Recipe.is_deleted.is_(False))
|
||||
).scalar_one_or_none()
|
||||
if recipe is None:
|
||||
raise LookupError("Рецепт не найден")
|
||||
|
||||
existing = db.session.execute(
|
||||
select(DailyTripSkip).where(
|
||||
DailyTripSkip.recipe_id == recipe_id,
|
||||
DailyTripSkip.is_deleted.is_(False),
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
|
||||
now = utc_now_naive()
|
||||
if existing is not None:
|
||||
existing.plan_date = start
|
||||
existing.valid_until = valid_until if valid_until != start else None
|
||||
if existing.is_deleted:
|
||||
existing.is_deleted = False
|
||||
existing.deleted_at = None
|
||||
existing.deleted_by = None
|
||||
existing.updated_by = user
|
||||
existing.updated_at = now
|
||||
existing.version = int(existing.version or 1) + 1
|
||||
db.session.commit()
|
||||
return existing
|
||||
|
||||
row = DailyTripSkip(
|
||||
recipe_id=recipe_id,
|
||||
plan_date=start,
|
||||
valid_until=valid_until if valid_until != start else None,
|
||||
created_by=user,
|
||||
updated_by=user,
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
db.session.add(row)
|
||||
db.session.commit()
|
||||
return row
|
||||
|
||||
|
||||
def unskip_trip(
|
||||
recipe_id: str,
|
||||
plan_date: Optional[str] = None,
|
||||
*,
|
||||
user: str = "system",
|
||||
) -> bool:
|
||||
"""Вернуть рейс в план на дату. True если skip был активен."""
|
||||
d = _parse_plan_date(plan_date)
|
||||
existing = db.session.execute(
|
||||
select(DailyTripSkip).where(
|
||||
DailyTripSkip.recipe_id == recipe_id,
|
||||
*_skip_active_filters(DailyTripSkip, d),
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if existing is None:
|
||||
return False
|
||||
return _soft_delete_skip_row(existing, user=user, table_name="daily_trip_skip")
|
||||
|
||||
|
||||
def _soft_delete_skip_row(row, *, user: str, table_name: str) -> bool:
|
||||
from app.services.sync_manager import enqueue_sync_queue_task
|
||||
|
||||
row.soft_delete(deleted_by_user=user)
|
||||
row.version = int(row.version or 1) + 1
|
||||
row.updated_by = user
|
||||
row.updated_at = utc_now_naive()
|
||||
db.session.commit()
|
||||
enqueue_sync_queue_task(table_name, row.id, "delete", priority=1)
|
||||
return True
|
||||
|
||||
|
||||
def _upsert_part_skip(model, *, lookup_filters, create_fields, user: str):
|
||||
existing = db.session.execute(
|
||||
select(model).where(*lookup_filters, model.is_deleted.is_(False))
|
||||
).scalar_one_or_none()
|
||||
now = utc_now_naive()
|
||||
if existing is not None:
|
||||
for key, value in create_fields.items():
|
||||
setattr(existing, key, value)
|
||||
existing.updated_by = user
|
||||
existing.updated_at = now
|
||||
existing.version = int(existing.version or 1) + 1
|
||||
db.session.commit()
|
||||
return existing
|
||||
deleted = db.session.execute(
|
||||
select(model).where(*lookup_filters, model.is_deleted.is_(True))
|
||||
).scalar_one_or_none()
|
||||
if deleted is not None:
|
||||
for key, value in create_fields.items():
|
||||
setattr(deleted, key, value)
|
||||
deleted.is_deleted = False
|
||||
deleted.deleted_at = None
|
||||
deleted.deleted_by = None
|
||||
deleted.updated_by = user
|
||||
deleted.updated_at = now
|
||||
deleted.version = int(deleted.version or 1) + 1
|
||||
db.session.commit()
|
||||
return deleted
|
||||
row = model(**create_fields, created_by=user, updated_by=user, created_at=now, updated_at=now)
|
||||
db.session.add(row)
|
||||
db.session.commit()
|
||||
return row
|
||||
|
||||
|
||||
def _soft_unskip_part(model, *, lookup_filters, user: str, table_name: str) -> bool:
|
||||
existing = db.session.execute(
|
||||
select(model).where(*lookup_filters, model.is_deleted.is_(False))
|
||||
).scalar_one_or_none()
|
||||
if existing is None:
|
||||
return False
|
||||
return _soft_delete_skip_row(existing, user=user, table_name=table_name)
|
||||
|
||||
|
||||
def skip_ingredient(
|
||||
recipe_id: str,
|
||||
ingredient_id: str,
|
||||
plan_date: Optional[str] = None,
|
||||
*,
|
||||
duration: Optional[str] = None,
|
||||
until_date: Optional[str] = None,
|
||||
user: str = "system",
|
||||
) -> DailyIngredientSkip:
|
||||
"""Исключить компонент рейса из плана."""
|
||||
start = _parse_plan_date(plan_date)
|
||||
_, valid_until = resolve_skip_range(start, duration=duration, until_date=until_date)
|
||||
recipe = db.session.execute(
|
||||
select(Recipe).where(Recipe.id == recipe_id, Recipe.is_deleted.is_(False))
|
||||
).scalar_one_or_none()
|
||||
if recipe is None:
|
||||
raise LookupError("Рецепт не найден")
|
||||
ingredient = db.session.execute(
|
||||
select(Ingredient).where(
|
||||
Ingredient.id == ingredient_id,
|
||||
Ingredient.recipe_id == recipe_id,
|
||||
Ingredient.is_deleted.is_(False),
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if ingredient is None:
|
||||
raise LookupError("Компонент не найден")
|
||||
|
||||
return _upsert_part_skip(
|
||||
DailyIngredientSkip,
|
||||
lookup_filters=(
|
||||
DailyIngredientSkip.recipe_id == recipe_id,
|
||||
DailyIngredientSkip.ingredient_id == ingredient_id,
|
||||
),
|
||||
create_fields={
|
||||
"recipe_id": recipe_id,
|
||||
"ingredient_id": ingredient_id,
|
||||
"plan_date": start,
|
||||
"valid_until": valid_until if valid_until != start else None,
|
||||
},
|
||||
user=user,
|
||||
)
|
||||
|
||||
|
||||
def unskip_ingredient(
|
||||
recipe_id: str,
|
||||
ingredient_id: str,
|
||||
plan_date: Optional[str] = None,
|
||||
*,
|
||||
user: str = "system",
|
||||
) -> bool:
|
||||
"""Вернуть компонент в план на дату."""
|
||||
d = _parse_plan_date(plan_date)
|
||||
return _soft_unskip_part(
|
||||
DailyIngredientSkip,
|
||||
lookup_filters=(
|
||||
DailyIngredientSkip.recipe_id == recipe_id,
|
||||
DailyIngredientSkip.ingredient_id == ingredient_id,
|
||||
*_skip_active_filters(DailyIngredientSkip, d),
|
||||
),
|
||||
user=user,
|
||||
table_name="daily_ingredient_skip",
|
||||
)
|
||||
|
||||
|
||||
def unskip_all_ingredient_parts(
|
||||
recipe_id: str,
|
||||
plan_date: Optional[str] = None,
|
||||
*,
|
||||
user: str = "system",
|
||||
) -> int:
|
||||
"""Снять все skip компонентов рейса, активные на дату."""
|
||||
d = _parse_plan_date(plan_date)
|
||||
rows = db.session.execute(
|
||||
select(DailyIngredientSkip).where(
|
||||
DailyIngredientSkip.recipe_id == recipe_id,
|
||||
*_skip_active_filters(DailyIngredientSkip, d),
|
||||
)
|
||||
).scalars().all()
|
||||
count = 0
|
||||
for row in rows:
|
||||
if _soft_delete_skip_row(row, user=user, table_name="daily_ingredient_skip"):
|
||||
count += 1
|
||||
return count
|
||||
|
||||
|
||||
def skip_unloading_group(
|
||||
recipe_id: str,
|
||||
unloading_group_id: str,
|
||||
plan_date: Optional[str] = None,
|
||||
*,
|
||||
duration: Optional[str] = None,
|
||||
until_date: Optional[str] = None,
|
||||
user: str = "system",
|
||||
) -> DailyUnloadingGroupSkip:
|
||||
"""Исключить группу выгрузки из плана."""
|
||||
start = _parse_plan_date(plan_date)
|
||||
_, valid_until = resolve_skip_range(start, duration=duration, until_date=until_date)
|
||||
recipe = db.session.execute(
|
||||
select(Recipe).where(Recipe.id == recipe_id, Recipe.is_deleted.is_(False))
|
||||
).scalar_one_or_none()
|
||||
if recipe is None:
|
||||
raise LookupError("Рецепт не найден")
|
||||
group = db.session.execute(
|
||||
select(UnloadingGroup).where(
|
||||
UnloadingGroup.id == unloading_group_id,
|
||||
UnloadingGroup.recipe_id == recipe_id,
|
||||
UnloadingGroup.is_deleted.is_(False),
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if group is None:
|
||||
raise LookupError("Группа выгрузки не найдена")
|
||||
|
||||
return _upsert_part_skip(
|
||||
DailyUnloadingGroupSkip,
|
||||
lookup_filters=(
|
||||
DailyUnloadingGroupSkip.recipe_id == recipe_id,
|
||||
DailyUnloadingGroupSkip.unloading_group_id == unloading_group_id,
|
||||
),
|
||||
create_fields={
|
||||
"recipe_id": recipe_id,
|
||||
"unloading_group_id": unloading_group_id,
|
||||
"plan_date": start,
|
||||
"valid_until": valid_until if valid_until != start else None,
|
||||
},
|
||||
user=user,
|
||||
)
|
||||
|
||||
|
||||
def unskip_unloading_group(
|
||||
recipe_id: str,
|
||||
unloading_group_id: str,
|
||||
plan_date: Optional[str] = None,
|
||||
*,
|
||||
user: str = "system",
|
||||
) -> bool:
|
||||
"""Вернуть группу выгрузки в план на дату."""
|
||||
d = _parse_plan_date(plan_date)
|
||||
return _soft_unskip_part(
|
||||
DailyUnloadingGroupSkip,
|
||||
lookup_filters=(
|
||||
DailyUnloadingGroupSkip.recipe_id == recipe_id,
|
||||
DailyUnloadingGroupSkip.unloading_group_id == unloading_group_id,
|
||||
*_skip_active_filters(DailyUnloadingGroupSkip, d),
|
||||
),
|
||||
user=user,
|
||||
table_name="daily_unloading_group_skip",
|
||||
)
|
||||
|
||||
|
||||
def unskip_all_unloading_group_parts(
|
||||
recipe_id: str,
|
||||
plan_date: Optional[str] = None,
|
||||
*,
|
||||
user: str = "system",
|
||||
) -> int:
|
||||
"""Снять все skip групп выгрузки рейса, активные на дату."""
|
||||
d = _parse_plan_date(plan_date)
|
||||
rows = db.session.execute(
|
||||
select(DailyUnloadingGroupSkip).where(
|
||||
DailyUnloadingGroupSkip.recipe_id == recipe_id,
|
||||
*_skip_active_filters(DailyUnloadingGroupSkip, d),
|
||||
)
|
||||
).scalars().all()
|
||||
count = 0
|
||||
for row in rows:
|
||||
if _soft_delete_skip_row(row, user=user, table_name="daily_unloading_group_skip"):
|
||||
count += 1
|
||||
return count
|
||||
|
||||
|
||||
def recipe_part_skip_flags(
|
||||
recipe_id: str, plan_date: Optional[str] = None
|
||||
) -> tuple[bool, bool]:
|
||||
"""Есть ли skip ингредиента / группы выгрузки у рейса на дату."""
|
||||
skipped_ings = get_skipped_ingredient_ids(plan_date)
|
||||
skipped_grps = get_skipped_unloading_group_ids(plan_date)
|
||||
return bool(skipped_ings.get(recipe_id)), bool(skipped_grps.get(recipe_id))
|
||||
@@ -0,0 +1 @@
|
||||
"""Zootech lab module (Neoton tab integration)."""
|
||||
@@ -0,0 +1,4 @@
|
||||
from .engine import calculate_ration
|
||||
from .diff import compare_master_execution
|
||||
|
||||
__all__ = ["calculate_ration", "compare_master_execution"]
|
||||
@@ -0,0 +1,112 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from app.modules.zootech.lab.calc.derived import apply_content_derived
|
||||
from app.modules.zootech.lab.calc.nutrients import norm_diff, weighted_average
|
||||
from app.modules.zootech.lab.indicators import RATION_ALL_INDICATORS
|
||||
|
||||
|
||||
def _compound_active(lines: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
return [
|
||||
l
|
||||
for l in lines
|
||||
if l.get("in_compound") and (l.get("daily_kg") or 0) > 0
|
||||
]
|
||||
|
||||
|
||||
def _sum_kg(lines: list[dict[str, Any]]) -> float:
|
||||
return sum(float(l.get("daily_kg") or 0) for l in lines)
|
||||
|
||||
|
||||
def _compound_indicators(
|
||||
lines: list[dict[str, Any]],
|
||||
total_kg: float,
|
||||
norms: dict[str, dict[str, float | None]],
|
||||
) -> list[dict[str, Any]]:
|
||||
content_by_key: dict[str, float | None] = {}
|
||||
for defn in RATION_ALL_INDICATORS:
|
||||
if defn.get("derived"):
|
||||
continue
|
||||
content_by_key[defn["key"]] = weighted_average(
|
||||
lines,
|
||||
total_kg,
|
||||
defn["nutrient_keys"],
|
||||
indicator_key=defn.get("key"),
|
||||
)
|
||||
for defn in RATION_ALL_INDICATORS:
|
||||
if not defn.get("derived"):
|
||||
continue
|
||||
content_by_key[defn["key"]] = apply_content_derived(
|
||||
content_by_key,
|
||||
defn,
|
||||
compound_mode=True,
|
||||
)
|
||||
rows = []
|
||||
for defn in RATION_ALL_INDICATORS:
|
||||
key = defn["key"]
|
||||
content = content_by_key.get(key)
|
||||
bounds = norms.get(key, {})
|
||||
min_v = bounds.get("min")
|
||||
max_v = bounds.get("max")
|
||||
diff = norm_diff(content, min_v, max_v)
|
||||
if content is None and min_v is None and max_v is None:
|
||||
continue
|
||||
rows.append(
|
||||
{
|
||||
"key": key,
|
||||
"label": defn["label"],
|
||||
"unit": defn["unit"],
|
||||
"min": min_v,
|
||||
"max": max_v,
|
||||
"content": content,
|
||||
"diff": diff,
|
||||
}
|
||||
)
|
||||
return rows
|
||||
|
||||
|
||||
def calculate_compound_feed(
|
||||
lines: list[dict[str, Any]],
|
||||
norms: dict[str, dict[str, float | None]] | None = None,
|
||||
*,
|
||||
profile_mass_kg: float | None = None,
|
||||
heads_per_trip: int = 1,
|
||||
) -> dict[str, Any] | None:
|
||||
del profile_mass_kg, heads_per_trip
|
||||
active = _compound_active(lines)
|
||||
if not active:
|
||||
return None
|
||||
total_kg = _sum_kg(active)
|
||||
cost = None
|
||||
any_cost = False
|
||||
for line in active:
|
||||
kg = line.get("daily_kg")
|
||||
price = line.get("price_per_kg")
|
||||
if kg is None or price is None:
|
||||
continue
|
||||
cost = (cost or 0) + float(kg) * float(price)
|
||||
any_cost = True
|
||||
return {
|
||||
"totals": [
|
||||
{"key": "compound_kg", "label": "Масса комбикорма, кг", "value": total_kg},
|
||||
{
|
||||
"key": "compound_cost",
|
||||
"label": "Стоимость комбикорма",
|
||||
"value": cost if any_cost else None,
|
||||
},
|
||||
],
|
||||
"indicators": _compound_indicators(active, total_kg, norms or {}),
|
||||
"lines": [
|
||||
{
|
||||
"ingredient_name": line.get("ingredient_name") or "—",
|
||||
"daily_kg": line.get("daily_kg"),
|
||||
"share_pct": (
|
||||
(float(line["daily_kg"]) / total_kg) * 100
|
||||
if total_kg > 0 and line.get("daily_kg") is not None
|
||||
else None
|
||||
),
|
||||
}
|
||||
for line in active
|
||||
],
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from app.modules.zootech.lab.calc.nutrients import rnb
|
||||
|
||||
|
||||
def apply_content_derived(
|
||||
content_by_key: dict[str, float | None],
|
||||
defn: dict[str, Any],
|
||||
*,
|
||||
total_kg: float = 0,
|
||||
heads_per_trip: int = 1,
|
||||
profile_mass_kg: float | None = None,
|
||||
compound_mode: bool = False,
|
||||
) -> float | None:
|
||||
derived = defn.get("derived")
|
||||
if derived == "alias":
|
||||
return content_by_key.get(defn.get("alias_of"))
|
||||
if derived == "pct_of_dm":
|
||||
src = content_by_key.get(defn.get("from_key"))
|
||||
dm = content_by_key.get("dry_matter")
|
||||
if src is None or not dm or dm <= 0:
|
||||
return None
|
||||
return src / dm * 100.0
|
||||
if derived == "g_per_kg_dm":
|
||||
src = content_by_key.get(defn.get("from_key"))
|
||||
dm = content_by_key.get("dry_matter")
|
||||
if src is None or not dm or dm <= 0:
|
||||
return None
|
||||
if compound_mode:
|
||||
return src
|
||||
return src / (dm / 1000.0)
|
||||
if derived == "nel_per_kg_dm":
|
||||
dm = content_by_key.get("dry_matter")
|
||||
nel = content_by_key.get("nel")
|
||||
if not dm or dm <= 0 or nel is None:
|
||||
return None
|
||||
if compound_mode:
|
||||
return nel
|
||||
return nel / (dm / 1000.0)
|
||||
if derived == "ratio":
|
||||
num = content_by_key.get(defn.get("ratio_num"))
|
||||
den = content_by_key.get(defn.get("ratio_den"))
|
||||
if num is None or den is None or den == 0:
|
||||
return None
|
||||
return num / den
|
||||
if derived == "dm_pct_bw":
|
||||
dm = content_by_key.get("dry_matter")
|
||||
if dm is None or not profile_mass_kg or profile_mass_kg <= 0:
|
||||
return None
|
||||
return (dm / 1000.0 / profile_mass_kg) * 100.0
|
||||
if derived == "ration_pct_bw":
|
||||
if not profile_mass_kg or profile_mass_kg <= 0 or total_kg <= 0:
|
||||
return None
|
||||
heads = max(int(heads_per_trip or 1), 1)
|
||||
kg_per_head = total_kg / heads
|
||||
return (kg_per_head / profile_mass_kg) * 100.0
|
||||
if derived in ("rnb", "bra_rnb"):
|
||||
return rnb(
|
||||
content_by_key.get("crude_protein"),
|
||||
content_by_key.get("usp"),
|
||||
)
|
||||
return None
|
||||
@@ -0,0 +1,53 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from app.modules.zootech.lab.constants import DIFF_TOLERANCE_KG
|
||||
|
||||
|
||||
def compare_master_execution(
|
||||
master_lines: list[dict[str, Any]],
|
||||
execution_lines: list[dict[str, Any]],
|
||||
) -> dict[str, Any]:
|
||||
master_map: dict[str, float] = {}
|
||||
for line in master_lines:
|
||||
if not line.get("in_ration"):
|
||||
continue
|
||||
cid = line.get("component_id")
|
||||
if not cid:
|
||||
continue
|
||||
master_map[str(cid)] = float(line.get("daily_kg") or 0)
|
||||
|
||||
exec_map: dict[str, float] = {}
|
||||
for line in execution_lines:
|
||||
cid = line.get("component_id")
|
||||
if not cid:
|
||||
continue
|
||||
exec_map[str(cid)] = float(line.get("daily_kg_total") or 0)
|
||||
|
||||
all_ids = set(master_map) | set(exec_map)
|
||||
diff_lines = []
|
||||
has_changes = False
|
||||
for cid in sorted(all_ids):
|
||||
m = master_map.get(cid)
|
||||
e = exec_map.get(cid)
|
||||
reasons = []
|
||||
if m is None:
|
||||
reasons.append("missing_in_master")
|
||||
has_changes = True
|
||||
if e is None:
|
||||
reasons.append("missing_in_execution")
|
||||
has_changes = True
|
||||
if m is not None and e is not None and abs(m - e) > DIFF_TOLERANCE_KG:
|
||||
reasons.append("kg_mismatch")
|
||||
has_changes = True
|
||||
if reasons:
|
||||
diff_lines.append(
|
||||
{
|
||||
"component_id": cid,
|
||||
"master_kg": m,
|
||||
"execution_kg": e,
|
||||
"reasons": reasons,
|
||||
}
|
||||
)
|
||||
return {"has_changes": has_changes, "lines": diff_lines}
|
||||
@@ -0,0 +1,184 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
from app.modules.zootech.lab.calc.compound import calculate_compound_feed
|
||||
from app.modules.zootech.lab.calc.derived import apply_content_derived
|
||||
from app.modules.zootech.lab.calc.nutrients import daily_intake_total, get_nutrient_value, norm_diff, weighted_average
|
||||
from app.modules.zootech.lab.constants import RATION_TOTAL_KEYS
|
||||
from app.modules.zootech.lab.indicators import RATION_ALL_INDICATORS
|
||||
|
||||
|
||||
def _active_lines(lines: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
return [
|
||||
l
|
||||
for l in lines
|
||||
if l.get("in_ration") and (l.get("daily_kg") or 0) > 0
|
||||
]
|
||||
|
||||
|
||||
def _sum_kg(lines: list[dict[str, Any]]) -> float:
|
||||
return sum(float(l.get("daily_kg") or 0) for l in lines)
|
||||
|
||||
|
||||
def _sum_cost(lines: list[dict[str, Any]]) -> float | None:
|
||||
total = 0.0
|
||||
any_cost = False
|
||||
for line in lines:
|
||||
kg = line.get("daily_kg")
|
||||
price = line.get("price_per_kg")
|
||||
if kg is None or price is None:
|
||||
continue
|
||||
total += float(kg) * float(price)
|
||||
any_cost = True
|
||||
return total if any_cost else None
|
||||
|
||||
|
||||
def _sum_ration_percent(lines: list[dict[str, Any]], total_kg: float) -> float | None:
|
||||
if total_kg <= 0:
|
||||
return None
|
||||
return sum((float(l.get("daily_kg") or 0) / total_kg) * 100 for l in lines)
|
||||
|
||||
|
||||
def _compute_totals(
|
||||
ration_type: str,
|
||||
active: list[dict[str, Any]],
|
||||
total_kg: float,
|
||||
) -> list[dict[str, Any]]:
|
||||
defs = RATION_TOTAL_KEYS.get(ration_type, RATION_TOTAL_KEYS["BEEF"])
|
||||
ration_kg = _sum_kg(active)
|
||||
values = {
|
||||
"total_kg": total_kg if total_kg > 0 else None,
|
||||
"ration_kg": ration_kg if ration_kg > 0 else None,
|
||||
"ration_pct_sum": _sum_ration_percent(active, total_kg),
|
||||
"cost_total": _sum_cost(active),
|
||||
}
|
||||
return [{"key": d["key"], "label": d["label"], "value": values.get(d["key"])} for d in defs]
|
||||
|
||||
|
||||
def _indicator_content(
|
||||
defn: dict[str, Any],
|
||||
active: list[dict[str, Any]],
|
||||
total_kg: float,
|
||||
*,
|
||||
heads_per_trip: int,
|
||||
) -> float | None:
|
||||
key = defn.get("key")
|
||||
if defn.get("aggregation") == "weighted_avg":
|
||||
return weighted_average(
|
||||
active,
|
||||
total_kg,
|
||||
defn["nutrient_keys"],
|
||||
indicator_key=key,
|
||||
)
|
||||
return daily_intake_total(
|
||||
active,
|
||||
defn["nutrient_keys"],
|
||||
heads_per_trip=heads_per_trip,
|
||||
indicator_key=key,
|
||||
)
|
||||
|
||||
|
||||
def _compute_indicators(
|
||||
active: list[dict[str, Any]],
|
||||
total_kg: float,
|
||||
norms: dict[str, dict[str, float | None]],
|
||||
*,
|
||||
heads_per_trip: int = 1,
|
||||
profile_mass_kg: float | None = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
content_by_key: dict[str, float | None] = {}
|
||||
for defn in RATION_ALL_INDICATORS:
|
||||
if defn.get("derived"):
|
||||
continue
|
||||
content_by_key[defn["key"]] = _indicator_content(
|
||||
defn, active, total_kg, heads_per_trip=heads_per_trip
|
||||
)
|
||||
|
||||
for defn in RATION_ALL_INDICATORS:
|
||||
if not defn.get("derived"):
|
||||
continue
|
||||
content_by_key[defn["key"]] = apply_content_derived(
|
||||
content_by_key,
|
||||
defn,
|
||||
total_kg=total_kg,
|
||||
heads_per_trip=heads_per_trip,
|
||||
profile_mass_kg=profile_mass_kg,
|
||||
)
|
||||
|
||||
rows = []
|
||||
for defn in RATION_ALL_INDICATORS:
|
||||
key = defn["key"]
|
||||
content = content_by_key.get(key)
|
||||
bounds = norms.get(key, {})
|
||||
min_v = bounds.get("min")
|
||||
max_v = bounds.get("max")
|
||||
diff = norm_diff(content, min_v, max_v)
|
||||
if content is None and min_v is None and max_v is None:
|
||||
continue
|
||||
rows.append(
|
||||
{
|
||||
"key": key,
|
||||
"label": defn["label"],
|
||||
"unit": defn["unit"],
|
||||
"min": min_v,
|
||||
"max": max_v,
|
||||
"content": content,
|
||||
"diff": diff,
|
||||
}
|
||||
)
|
||||
return rows
|
||||
|
||||
|
||||
def _missing_nutrient_warnings(active: list[dict[str, Any]]) -> list[str]:
|
||||
warnings: list[str] = []
|
||||
for line in active:
|
||||
nutrients = line.get("nutrients") or {}
|
||||
cp = get_nutrient_value(
|
||||
line.get("dry_matter"),
|
||||
nutrients,
|
||||
["Сыр. Протеин"],
|
||||
indicator_key="crude_protein",
|
||||
)
|
||||
if cp is not None:
|
||||
continue
|
||||
name = line.get("ingredient_name") or line.get("component_id") or "?"
|
||||
warnings.append(f"nutrients_missing:{name}")
|
||||
return warnings
|
||||
|
||||
|
||||
def calculate_ration(
|
||||
ration_type: str,
|
||||
lines: list[dict[str, Any]],
|
||||
norms: dict[str, dict[str, float | None]] | None = None,
|
||||
*,
|
||||
heads_per_trip: int = 1,
|
||||
profile_mass_kg: float | None = None,
|
||||
) -> dict[str, Any]:
|
||||
norms = norms or {}
|
||||
errors: list[str] = []
|
||||
active = _active_lines(lines)
|
||||
total_kg = _sum_kg(active)
|
||||
heads = max(int(heads_per_trip or 1), 1)
|
||||
if not active:
|
||||
errors.append("Нет строк сырья «в рационе» с дозировкой кг/день")
|
||||
warnings = _missing_nutrient_warnings(active)
|
||||
compound = calculate_compound_feed(
|
||||
lines, norms, profile_mass_kg=profile_mass_kg, heads_per_trip=heads
|
||||
)
|
||||
return {
|
||||
"calculated_at": datetime.now(timezone.utc).isoformat(),
|
||||
"engine": "native",
|
||||
"totals": _compute_totals(ration_type, active, total_kg),
|
||||
"indicators": _compute_indicators(
|
||||
active,
|
||||
total_kg,
|
||||
norms,
|
||||
heads_per_trip=heads,
|
||||
profile_mass_kg=profile_mass_kg,
|
||||
),
|
||||
"compound": compound,
|
||||
"errors": errors,
|
||||
"warnings": warnings,
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
"""Группы кормов для авторациона — канонический тип компонента + legacy-маппинг."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from app.modules.zootech.wesp_bridge_models import Component
|
||||
|
||||
FEED_GROUPS: tuple[dict[str, Any], ...] = (
|
||||
{
|
||||
"id": "rough",
|
||||
"label": "База — грубые",
|
||||
"shortLabel": "База",
|
||||
"required": True,
|
||||
"minPick": 1,
|
||||
"hint": "Без каркаса партия не взлетит. Я не бизнесмен — я специалист.",
|
||||
"step": 1,
|
||||
},
|
||||
{
|
||||
"id": "succulent",
|
||||
"label": "Влага — сочные",
|
||||
"shortLabel": "Влага",
|
||||
"required": False,
|
||||
"minPick": 0,
|
||||
"hint": "Сочное сырьё. Можно не мешать — но чистота пострадает.",
|
||||
"step": 2,
|
||||
},
|
||||
{
|
||||
"id": "concentrate",
|
||||
"label": "Энергия — концентраты",
|
||||
"shortLabel": "Энергия",
|
||||
"required": False,
|
||||
"minPick": 0,
|
||||
"hint": "Концентрат дозируй как реагент — точно. Держись подальше от моей территории.",
|
||||
"step": 3,
|
||||
},
|
||||
{
|
||||
"id": "other",
|
||||
"label": "Добавки",
|
||||
"shortLabel": "Добавки",
|
||||
"required": False,
|
||||
"minPick": 0,
|
||||
"hint": "Минералы и премикс. Необязательно. Но Хайзенберг бы не пропустил.",
|
||||
"step": 4,
|
||||
},
|
||||
)
|
||||
|
||||
# Канонические значения component.type (выбор в /components)
|
||||
FEED_COMPONENT_TYPES: tuple[dict[str, str], ...] = (
|
||||
{
|
||||
"value": "Грубые корма",
|
||||
"feedGroup": "rough",
|
||||
"description": "Сено, солома.",
|
||||
},
|
||||
{
|
||||
"value": "Сочные корма",
|
||||
"feedGroup": "succulent",
|
||||
"description": "Силос, корнеплоды.",
|
||||
},
|
||||
{
|
||||
"value": "Концентрированные",
|
||||
"feedGroup": "concentrate",
|
||||
"description": "Зерно, комбикорм, жмых, шрот",
|
||||
},
|
||||
{
|
||||
"value": "Добавки",
|
||||
"feedGroup": "other",
|
||||
"description": "Премикс, минералы, витамины, КЖП",
|
||||
},
|
||||
)
|
||||
|
||||
_GROUP_BY_ID = {g["id"]: g for g in FEED_GROUPS}
|
||||
|
||||
_LEGACY_TYPE_TO_GROUP: dict[str, str] = {
|
||||
"зерновые": "concentrate",
|
||||
"энергетические": "concentrate",
|
||||
"белковые": "concentrate",
|
||||
"минеральные": "other",
|
||||
"витаминные": "other",
|
||||
}
|
||||
|
||||
# Старый тип «Объемные корма» — уточните до Грубые/Сочные; эвристика по имени
|
||||
_ROUGH_NAME_KEYS = ("солом", "сено", "hay", "straw")
|
||||
_SUCCULENT_NAME_KEYS = ("силос", "сенаж", "сочн", "корнеплод", "свекл", "морков", "тыкв", "зелен")
|
||||
_CONCENTRATE_NAME_KEYS = ("зерн", "концентр", "комбикорм", "комбик", "жмых", "шрот", "дробин", "пивн")
|
||||
|
||||
|
||||
def _norm(text: str | None) -> str:
|
||||
return (text or "").strip().lower()
|
||||
|
||||
|
||||
def _canonical_type_map() -> dict[str, str]:
|
||||
return {_norm(t["value"]): t["feedGroup"] for t in FEED_COMPONENT_TYPES}
|
||||
|
||||
|
||||
def _canonical_values() -> list[str]:
|
||||
return [t["value"] for t in FEED_COMPONENT_TYPES]
|
||||
|
||||
|
||||
def list_component_feed_types() -> list[dict[str, str]]:
|
||||
return [
|
||||
{
|
||||
"value": t["value"],
|
||||
"feedGroup": t["feedGroup"],
|
||||
"description": t["description"],
|
||||
"groupLabel": _GROUP_BY_ID[t["feedGroup"]]["label"],
|
||||
}
|
||||
for t in FEED_COMPONENT_TYPES
|
||||
]
|
||||
|
||||
|
||||
def is_canonical_feed_type(component_type: str | None) -> bool:
|
||||
return _norm(component_type) in _canonical_type_map()
|
||||
|
||||
|
||||
def feed_group_for_type(component_type: str | None) -> str | None:
|
||||
"""Группа авторациона по component.type (канон или legacy)."""
|
||||
ctype = _norm(component_type)
|
||||
if not ctype:
|
||||
return None
|
||||
canonical = _canonical_type_map().get(ctype)
|
||||
if canonical:
|
||||
return canonical
|
||||
if ctype in ("объемные корма", "объёмные корма"):
|
||||
return None
|
||||
return _LEGACY_TYPE_TO_GROUP.get(ctype)
|
||||
|
||||
|
||||
def classify_feed_group(comp: Component) -> str:
|
||||
"""Группа для авторациона: сначала component.type, иначе эвристика по имени (legacy)."""
|
||||
by_type = feed_group_for_type(comp.type)
|
||||
if by_type:
|
||||
return by_type
|
||||
|
||||
name = _norm(comp.name)
|
||||
|
||||
def has_any(keys: tuple[str, ...]) -> bool:
|
||||
return any(k in name for k in keys)
|
||||
|
||||
if has_any(_ROUGH_NAME_KEYS):
|
||||
return "rough"
|
||||
if has_any(_SUCCULENT_NAME_KEYS):
|
||||
return "succulent"
|
||||
if has_any(_CONCENTRATE_NAME_KEYS):
|
||||
return "concentrate"
|
||||
if _norm(comp.type) in ("объемные корма", "объёмные корма"):
|
||||
return "succulent"
|
||||
return "other"
|
||||
|
||||
|
||||
def list_feed_groups_api() -> list[dict[str, Any]]:
|
||||
return [
|
||||
{
|
||||
"id": g["id"],
|
||||
"label": g["label"],
|
||||
"shortLabel": g["shortLabel"],
|
||||
"required": g["required"],
|
||||
"minPick": g["minPick"],
|
||||
"hint": g["hint"],
|
||||
"step": g["step"],
|
||||
}
|
||||
for g in FEED_GROUPS
|
||||
]
|
||||
|
||||
|
||||
def parse_group_selections(raw: Any) -> dict[str, list[str]]:
|
||||
if not isinstance(raw, dict):
|
||||
return {}
|
||||
out: dict[str, list[str]] = {}
|
||||
for gid in _GROUP_BY_ID:
|
||||
vals = raw.get(gid) or []
|
||||
if isinstance(vals, list):
|
||||
out[gid] = [str(x) for x in vals if x]
|
||||
return out
|
||||
|
||||
|
||||
def flatten_group_selections(selections: dict[str, list[str]]) -> list[str]:
|
||||
seen: list[str] = []
|
||||
for gid in _GROUP_BY_ID:
|
||||
for cid in selections.get(gid) or []:
|
||||
if cid not in seen:
|
||||
seen.append(cid)
|
||||
return seen
|
||||
|
||||
|
||||
def validate_group_selections(selections: dict[str, list[str]]) -> list[str]:
|
||||
errors: list[str] = []
|
||||
for g in FEED_GROUPS:
|
||||
gid = g["id"]
|
||||
picked = selections.get(gid) or []
|
||||
if g["required"] and len(picked) < int(g["minPick"]):
|
||||
errors.append(f"{gid}:need_{g['minPick']}")
|
||||
total = len(flatten_group_selections(selections))
|
||||
if total < 3:
|
||||
errors.append("pool:need_3")
|
||||
return errors
|
||||
|
||||
|
||||
def triplet_meets_group_rules(
|
||||
triplet_ids: set[str],
|
||||
selections: dict[str, list[str]],
|
||||
) -> bool:
|
||||
for g in FEED_GROUPS:
|
||||
if not g["required"]:
|
||||
continue
|
||||
pool = set(selections.get(g["id"]) or [])
|
||||
if not pool:
|
||||
continue
|
||||
if not triplet_ids & pool:
|
||||
return False
|
||||
return True
|
||||
@@ -0,0 +1,417 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import itertools
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
from app.modules.zootech.lab.calc.engine import calculate_ration
|
||||
from app.modules.zootech.lab.calc.feed_groups import (
|
||||
FEED_GROUPS,
|
||||
flatten_group_selections,
|
||||
triplet_meets_group_rules,
|
||||
validate_group_selections,
|
||||
)
|
||||
from app.modules.zootech.lab.calc.feed_groups import classify_feed_group as _classify_feed_group
|
||||
from app.modules.zootech.lab.calc.formulate_optimize import optimize_shares_for_triplet
|
||||
from app.modules.zootech.lab.calc.formulate_score import (
|
||||
build_calc_lines,
|
||||
build_triplet_score_model,
|
||||
score_model_shares,
|
||||
violation_score,
|
||||
)
|
||||
from app.modules.zootech.lab.calc.formulate_validate import validate_component, validate_components
|
||||
from app.modules.zootech.lab.calc.norms_resolver import NormsParams, NormsResolveRequest, normalize_norms_method, resolve_norms
|
||||
from app.modules.zootech.lab.calc.nutrients import get_nutrient_value
|
||||
from app.modules.zootech.lab.indicators import RATION_ALL_INDICATORS
|
||||
from app.modules.zootech.lab.models import LabAnimalProfile
|
||||
from app.modules.zootech.lab.services.component_nutrients import nutrients_calc_dict_batch
|
||||
from app.modules.zootech.lab.services.profile_norms import load_norms_dict
|
||||
from app.modules.zootech.wesp_bridge_models import Component
|
||||
|
||||
|
||||
@dataclass
|
||||
class FormulateRequest:
|
||||
profile_id: str
|
||||
candidate_ids: list[str] = field(default_factory=list)
|
||||
group_selections: dict[str, list[str]] = field(default_factory=dict)
|
||||
main_feed_ids: list[str] = field(default_factory=list) # legacy → rough
|
||||
mass_kg: float | None = None
|
||||
milk_yield_kg: float | None = None
|
||||
heads_per_trip: int | None = None
|
||||
total_kg_per_head: float = 7.3
|
||||
optimize_keys: list[str] = field(default_factory=list)
|
||||
objective: str = "min_cost"
|
||||
cost_weight: float = 100.0
|
||||
grid_step: float = 0.1
|
||||
prefilter_k: int = 18
|
||||
min_share: float = 0.05
|
||||
norms_method: str = "wesp"
|
||||
norms_params: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
_DEFAULT_OPTIMIZE_KEYS = (
|
||||
"dry_matter",
|
||||
"usp",
|
||||
"nel",
|
||||
"crude_protein",
|
||||
"rnb",
|
||||
"nfc_pct_dm_uk",
|
||||
)
|
||||
|
||||
|
||||
def _indicator_def(key: str) -> dict[str, Any] | None:
|
||||
for defn in RATION_ALL_INDICATORS:
|
||||
if defn["key"] == key:
|
||||
return defn
|
||||
return None
|
||||
|
||||
|
||||
def _resolve_norms(profile: LabAnimalProfile, req: FormulateRequest) -> tuple[dict, dict]:
|
||||
stored = load_norms_dict(profile.id)
|
||||
mass = req.mass_kg if req.mass_kg is not None else profile.mass_kg
|
||||
milk = req.milk_yield_kg if req.milk_yield_kg is not None else profile.milk_yield_kg
|
||||
from app.modules.zootech.lab.services.norms_params import load_norms_params
|
||||
|
||||
method = normalize_norms_method(req.norms_method or profile.norms_method)
|
||||
params = load_norms_params(profile)
|
||||
if req.norms_params:
|
||||
merged = {
|
||||
"milkFatPct": params.milk_fat_pct,
|
||||
"lactationNo": params.lactation_no,
|
||||
"lactationStage": params.lactation_stage,
|
||||
"bodyCondition": params.body_condition,
|
||||
"housingSystem": params.housing_system,
|
||||
"koncOeSv": params.konc_oe_sv,
|
||||
}
|
||||
merged.update(req.norms_params)
|
||||
params = NormsParams.from_dict(merged)
|
||||
resolved, meta = resolve_norms(
|
||||
NormsResolveRequest(
|
||||
method=method,
|
||||
stored=stored,
|
||||
mass_kg=mass,
|
||||
milk_yield_kg=milk,
|
||||
ration_type=profile.ration_type,
|
||||
force_dynamic=method == "wesp",
|
||||
params=params,
|
||||
)
|
||||
)
|
||||
dynamic = meta.get("dynamicNorms") or meta.get("dynamic") or {}
|
||||
return resolved, {"normsMethod": method, "dynamicNorms": dynamic, "normsMeta": meta.get("meta")}
|
||||
|
||||
|
||||
def _rough_component_value(
|
||||
comp: Component,
|
||||
optimize_keys: list[str],
|
||||
norms: dict[str, dict[str, float | None]],
|
||||
nutrient_cache: dict[str, dict[str, float]],
|
||||
) -> float | None:
|
||||
nutrients = nutrient_cache.get(comp.id, {})
|
||||
dm_pct = comp.dry_matter
|
||||
total = 0.0
|
||||
counted = 0
|
||||
for key in optimize_keys:
|
||||
defn = _indicator_def(key)
|
||||
if defn is None or defn.get("derived"):
|
||||
continue
|
||||
bounds = norms.get(key) or {}
|
||||
target_min = bounds.get("min")
|
||||
target_max = bounds.get("max")
|
||||
if target_min is None and target_max is None:
|
||||
continue
|
||||
target = None
|
||||
if target_min is not None and target_max is not None:
|
||||
target = (float(target_min) + float(target_max)) / 2.0
|
||||
elif target_max is not None:
|
||||
target = float(target_max) * 0.9
|
||||
elif target_min is not None:
|
||||
target = float(target_min) * 1.1
|
||||
if target is None or target == 0:
|
||||
continue
|
||||
val = get_nutrient_value(
|
||||
dm_pct,
|
||||
nutrients,
|
||||
defn.get("nutrient_keys") or [],
|
||||
indicator_key=key,
|
||||
)
|
||||
if val is None:
|
||||
continue
|
||||
rel = (float(val) - target) / abs(target)
|
||||
total += rel * rel
|
||||
counted += 1
|
||||
return total / counted if counted else None
|
||||
|
||||
|
||||
def _prefilter_candidates(
|
||||
candidates: list[Component],
|
||||
optimize_keys: list[str],
|
||||
norms: dict[str, dict[str, float | None]],
|
||||
nutrient_cache: dict[str, dict[str, float]],
|
||||
*,
|
||||
prefilter_k: int,
|
||||
must_keep_ids: set[str] | None = None,
|
||||
) -> list[Component]:
|
||||
must_keep_ids = must_keep_ids or set()
|
||||
pinned = [c for c in candidates if c.id in must_keep_ids]
|
||||
rest = [c for c in candidates if c.id not in must_keep_ids]
|
||||
slots = max(prefilter_k - len(pinned), 0)
|
||||
if len(candidates) <= prefilter_k:
|
||||
return candidates
|
||||
if slots <= 0:
|
||||
return pinned[:prefilter_k]
|
||||
|
||||
prices = [float(c.price) for c in rest if c.price is not None]
|
||||
median_price = sorted(prices)[len(prices) // 2] if prices else 1.0
|
||||
if median_price <= 0:
|
||||
median_price = 1.0
|
||||
|
||||
scored: list[tuple[float, Component]] = []
|
||||
for comp in rest:
|
||||
price = float(comp.price) if comp.price is not None else median_price
|
||||
cost_part = price / median_price
|
||||
nutrient_part = _rough_component_value(comp, optimize_keys, norms, nutrient_cache)
|
||||
if nutrient_part is None:
|
||||
score = cost_part
|
||||
else:
|
||||
score = 0.4 * cost_part + 0.6 * nutrient_part
|
||||
scored.append((score, comp))
|
||||
scored.sort(key=lambda x: x[0])
|
||||
return pinned + [comp for _, comp in scored[:slots]]
|
||||
|
||||
|
||||
def _load_profile(profile_id: str) -> LabAnimalProfile:
|
||||
profile = LabAnimalProfile.query.filter_by(id=profile_id, is_deleted=False).first()
|
||||
if profile is None:
|
||||
raise LookupError("Профиль не найден")
|
||||
return profile
|
||||
|
||||
|
||||
def _load_eligible_components(candidate_ids: list[str]) -> tuple[list[Component], list[dict[str, Any]]]:
|
||||
unique = list(dict.fromkeys(candidate_ids))
|
||||
validations = validate_components(unique)
|
||||
ineligible = [v for v in validations if not v["eligible"]]
|
||||
if ineligible:
|
||||
raise ValueError("ineligible_components", ineligible)
|
||||
comps: list[Component] = []
|
||||
for cid in unique:
|
||||
comp = Component.query.filter_by(id=cid, is_deleted=False).first()
|
||||
if comp is None:
|
||||
raise ValueError("component_not_found", cid)
|
||||
comps.append(comp)
|
||||
return comps, validations
|
||||
|
||||
|
||||
def _resolve_group_selections(req: FormulateRequest) -> dict[str, list[str]]:
|
||||
selections = dict(req.group_selections or {})
|
||||
if req.main_feed_ids and not selections.get("rough"):
|
||||
selections["rough"] = list(req.main_feed_ids)
|
||||
return selections
|
||||
|
||||
|
||||
def formulate(req: FormulateRequest) -> dict[str, Any]:
|
||||
group_selections = _resolve_group_selections(req)
|
||||
candidate_ids = list(req.candidate_ids)
|
||||
if group_selections:
|
||||
pool_errors = validate_group_selections(group_selections)
|
||||
if pool_errors:
|
||||
raise ValueError("group_selection_invalid", pool_errors)
|
||||
candidate_ids = flatten_group_selections(group_selections)
|
||||
|
||||
if len(candidate_ids) < 3:
|
||||
raise ValueError("candidate_ids_min_3")
|
||||
if len(set(candidate_ids)) != len(candidate_ids):
|
||||
raise ValueError("candidate_ids_duplicate")
|
||||
|
||||
profile = _load_profile(req.profile_id)
|
||||
candidates, _ = _load_eligible_components(candidate_ids)
|
||||
nutrient_cache = nutrients_calc_dict_batch(candidate_ids)
|
||||
norms, dynamic = _resolve_norms(profile, req)
|
||||
optimize_keys = req.optimize_keys or list(_DEFAULT_OPTIMIZE_KEYS)
|
||||
heads = max(int(req.heads_per_trip or 10), 1)
|
||||
herd_scale = float(req.total_kg_per_head) * heads
|
||||
profile_mass_kg = req.mass_kg if req.mass_kg is not None else profile.mass_kg
|
||||
|
||||
must_keep: set[str] = set()
|
||||
for g in FEED_GROUPS:
|
||||
if g["required"]:
|
||||
must_keep.update(group_selections.get(g["id"]) or [])
|
||||
|
||||
prefilter_applied = len(candidates) > req.prefilter_k
|
||||
shortlist = _prefilter_candidates(
|
||||
candidates,
|
||||
optimize_keys,
|
||||
norms,
|
||||
nutrient_cache,
|
||||
prefilter_k=req.prefilter_k,
|
||||
must_keep_ids=must_keep,
|
||||
)
|
||||
|
||||
started = time.perf_counter()
|
||||
evaluations = 0
|
||||
triplets_evaluated = 0
|
||||
triplet_winners: list[dict[str, Any]] = []
|
||||
|
||||
def _eval_triplet(
|
||||
triplet: tuple[Component, Component, Component],
|
||||
) -> dict[str, Any] | None:
|
||||
nonlocal evaluations
|
||||
model = build_triplet_score_model(
|
||||
triplet,
|
||||
nutrient_cache=nutrient_cache,
|
||||
norms=norms,
|
||||
optimize_keys=optimize_keys,
|
||||
herd_scale=herd_scale,
|
||||
heads=heads,
|
||||
cost_weight=req.cost_weight,
|
||||
profile_mass_kg=profile_mass_kg,
|
||||
)
|
||||
|
||||
def score_fn(shares: tuple[float, float, float]) -> float:
|
||||
_violation, _cost_head, score = score_model_shares(model, shares)
|
||||
return score
|
||||
|
||||
opt = optimize_shares_for_triplet(
|
||||
triplet,
|
||||
score_fn,
|
||||
min_share=req.min_share,
|
||||
grid_step=req.grid_step,
|
||||
)
|
||||
if opt is None:
|
||||
return None
|
||||
evaluations += opt.evaluations
|
||||
violation, cost_head, score = score_model_shares(model, opt.shares)
|
||||
lines = build_calc_lines(triplet, opt.shares, herd_scale, nutrient_cache)
|
||||
daily = [opt.shares[i] * herd_scale for i in range(3)]
|
||||
return {
|
||||
"score": score,
|
||||
"violation": violation,
|
||||
"costHead": cost_head,
|
||||
"costTotal": cost_head * heads,
|
||||
"triplet": triplet,
|
||||
"shares": opt.shares,
|
||||
"daily": daily,
|
||||
"lines": lines,
|
||||
}
|
||||
|
||||
for triplet in itertools.combinations(shortlist, 3):
|
||||
triplet_ids = {c.id for c in triplet}
|
||||
if not triplet_meets_group_rules(triplet_ids, group_selections):
|
||||
continue
|
||||
triplets_evaluated += 1
|
||||
best_triplet_result = _eval_triplet(triplet)
|
||||
if best_triplet_result is None:
|
||||
continue
|
||||
triplet_winners.append(best_triplet_result)
|
||||
|
||||
if not triplet_winners:
|
||||
if group_selections:
|
||||
raise ValueError("no_feasible_solution_groups")
|
||||
raise ValueError("no_feasible_solution")
|
||||
|
||||
triplet_winners.sort(key=lambda x: x["score"])
|
||||
best = triplet_winners[0]
|
||||
|
||||
full_calc = calculate_ration(
|
||||
profile.ration_type or "DAIRY",
|
||||
best["lines"],
|
||||
norms,
|
||||
heads_per_trip=heads,
|
||||
profile_mass_kg=profile_mass_kg,
|
||||
)
|
||||
best["calc"] = full_calc
|
||||
best["violation"] = violation_score(
|
||||
full_calc.get("indicators") or [],
|
||||
optimize_keys,
|
||||
norms,
|
||||
)
|
||||
best["score"] = best["violation"] * req.cost_weight + best["costHead"]
|
||||
|
||||
duration_ms = int((time.perf_counter() - started) * 1000)
|
||||
|
||||
alternatives = [
|
||||
{
|
||||
"componentIds": [c.id for c in item["triplet"]],
|
||||
"names": [c.name for c in item["triplet"]],
|
||||
"score": item["score"],
|
||||
"violation": item["violation"],
|
||||
"costPerHead": item["costHead"],
|
||||
}
|
||||
for item in triplet_winners[:3]
|
||||
]
|
||||
alternatives.sort(key=lambda x: x["score"])
|
||||
alternatives = alternatives[:3]
|
||||
|
||||
result_lines = []
|
||||
for i, comp in enumerate(best["triplet"]):
|
||||
s1, s2, s3 = best["shares"]
|
||||
share = (s1, s2, s3)[i]
|
||||
result_lines.append(
|
||||
{
|
||||
"componentId": comp.id,
|
||||
"name": comp.name,
|
||||
"dailyKg": round(best["daily"][i], 4),
|
||||
"sharePct": round(share * 100, 2),
|
||||
"pricePerKg": comp.price,
|
||||
"dryMatterPct": comp.dry_matter,
|
||||
}
|
||||
)
|
||||
|
||||
violation = best["violation"]
|
||||
return {
|
||||
"lines": result_lines,
|
||||
"candidatePoolSize": len(candidates),
|
||||
"groupSelections": group_selections,
|
||||
"shortlistedIds": [c.id for c in shortlist],
|
||||
"costTotal": best["costTotal"],
|
||||
"costPerHead": best["costHead"],
|
||||
"score": best["score"],
|
||||
"violation": violation,
|
||||
"feasible": violation < 0.01,
|
||||
"indicators": best["calc"].get("indicators") or [],
|
||||
"totals": best["calc"].get("totals") or [],
|
||||
"optimizeKeys": optimize_keys,
|
||||
"dynamicNorms": dynamic.get("dynamicNorms") or None,
|
||||
"normsMethod": dynamic.get("normsMethod"),
|
||||
"normsMeta": dynamic.get("normsMeta"),
|
||||
"alternatives": alternatives,
|
||||
"searchStats": {
|
||||
"evaluations": evaluations,
|
||||
"tripletsEvaluated": triplets_evaluated,
|
||||
"durationMs": duration_ms,
|
||||
"prefilterApplied": prefilter_applied,
|
||||
"prefilterK": req.prefilter_k,
|
||||
"scoreEngine": "fast",
|
||||
"optimizer": "slsqp",
|
||||
"normsMethod": dynamic.get("normsMethod"),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def list_formulate_components() -> list[dict[str, Any]]:
|
||||
rows = (
|
||||
Component.query.filter_by(is_active=True, is_deleted=False)
|
||||
.order_by(Component.name)
|
||||
.all()
|
||||
)
|
||||
out: list[dict[str, Any]] = []
|
||||
for comp in rows:
|
||||
v = validate_component(comp.id)
|
||||
feed_group = _classify_feed_group(comp)
|
||||
out.append(
|
||||
{
|
||||
"id": comp.id,
|
||||
"name": comp.name,
|
||||
"type": comp.type,
|
||||
"feedGroup": feed_group,
|
||||
"eligible": v["eligible"],
|
||||
"missing": v["missing"],
|
||||
"warnings": v["warnings"],
|
||||
"dryMatterPct": v["dryMatterPct"],
|
||||
"hasPrice": v["hasPrice"],
|
||||
"price": v["price"],
|
||||
"mainFeedDmGPerKg": v.get("mainFeedDmGPerKg"),
|
||||
"isMainFeed": v.get("isMainFeed", False),
|
||||
}
|
||||
)
|
||||
return out
|
||||
@@ -0,0 +1,112 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Callable
|
||||
|
||||
from scipy.optimize import minimize
|
||||
|
||||
from app.modules.zootech.wesp_bridge_models import Component
|
||||
|
||||
ShareTuple = tuple[float, float, float]
|
||||
ScoreFn = Callable[[ShareTuple], float]
|
||||
|
||||
|
||||
@dataclass
|
||||
class OptimizeSharesResult:
|
||||
shares: ShareTuple
|
||||
score: float
|
||||
evaluations: int
|
||||
|
||||
|
||||
def _normalize_shares(s1: float, s2: float, min_share: float) -> ShareTuple | None:
|
||||
s3 = 1.0 - s1 - s2
|
||||
ms = max(min_share, 0.0)
|
||||
if s1 < ms - 1e-9 or s2 < ms - 1e-9 or s3 < ms - 1e-9:
|
||||
return None
|
||||
if abs(s1 + s2 + s3 - 1.0) > 1e-6:
|
||||
return None
|
||||
return (round(s1, 8), round(s2, 8), round(s3, 8))
|
||||
|
||||
|
||||
def _start_points(min_share: float, grid_step: float) -> list[tuple[float, float]]:
|
||||
"""Multi-start seeds: simplex center, corners, and a few grid_step hints."""
|
||||
ms = max(min_share, 0.0)
|
||||
max_pair = max(1.0 - 2 * ms, ms)
|
||||
center = round((1.0 - ms) / 3.0, 6)
|
||||
points: list[tuple[float, float]] = [
|
||||
(center, center),
|
||||
(ms, ms),
|
||||
(max_pair, ms),
|
||||
(ms, max_pair),
|
||||
(max_pair, max_pair),
|
||||
]
|
||||
step = max(grid_step, 0.1)
|
||||
if ms <= step <= max_pair:
|
||||
points.append((step, ms))
|
||||
points.append((ms, step))
|
||||
deduped: list[tuple[float, float]] = []
|
||||
seen: set[tuple[float, float]] = set()
|
||||
for s1, s2 in points:
|
||||
if s1 + s2 > 1.0 - ms + 1e-9:
|
||||
continue
|
||||
key = (round(s1, 6), round(s2, 6))
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
deduped.append(key)
|
||||
return deduped
|
||||
|
||||
|
||||
def optimize_shares_for_triplet(
|
||||
triplet: tuple[Component, Component, Component],
|
||||
score_fn: ScoreFn,
|
||||
*,
|
||||
min_share: float,
|
||||
grid_step: float = 0.1,
|
||||
) -> OptimizeSharesResult | None:
|
||||
del triplet
|
||||
ms = max(min_share, 0.0)
|
||||
max_s1 = max(1.0 - 2 * ms, ms)
|
||||
evaluations = 0
|
||||
|
||||
def objective(x: Any) -> float:
|
||||
nonlocal evaluations
|
||||
evaluations += 1
|
||||
shares = _normalize_shares(float(x[0]), float(x[1]), ms)
|
||||
if shares is None:
|
||||
return 1e18
|
||||
return score_fn(shares)
|
||||
|
||||
bounds = [(ms, max_s1), (ms, max_s1)]
|
||||
constraints = [{"type": "ineq", "fun": lambda x: 1.0 - ms - float(x[0]) - float(x[1])}]
|
||||
|
||||
best_score: float | None = None
|
||||
best_shares: ShareTuple | None = None
|
||||
|
||||
for s1, s2 in _start_points(ms, grid_step):
|
||||
if s1 + s2 > 1.0 - ms + 1e-9:
|
||||
continue
|
||||
try:
|
||||
res = minimize(
|
||||
objective,
|
||||
[s1, s2],
|
||||
method="SLSQP",
|
||||
bounds=bounds,
|
||||
constraints=constraints,
|
||||
options={"ftol": 1e-8, "maxiter": 40},
|
||||
)
|
||||
except Exception:
|
||||
continue
|
||||
if not res.success and res.fun >= 1e17:
|
||||
continue
|
||||
shares = _normalize_shares(float(res.x[0]), float(res.x[1]), ms)
|
||||
if shares is None:
|
||||
continue
|
||||
score = float(res.fun)
|
||||
if best_score is None or score < best_score:
|
||||
best_score = score
|
||||
best_shares = shares
|
||||
|
||||
if best_shares is None or best_score is None:
|
||||
return None
|
||||
return OptimizeSharesResult(shares=best_shares, score=best_score, evaluations=evaluations)
|
||||
@@ -0,0 +1,371 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from app.modules.zootech.lab.calc.derived import apply_content_derived
|
||||
from app.modules.zootech.lab.calc.nutrients import daily_intake_total, get_nutrient_value, norm_diff, weighted_average
|
||||
from app.modules.zootech.lab.indicators import RATION_ALL_INDICATORS, indicator_by_key
|
||||
from app.modules.zootech.wesp_bridge_models import Component
|
||||
|
||||
|
||||
def resolve_score_closure(optimize_keys: list[str]) -> frozenset[str]:
|
||||
"""Collect base + derived indicator keys needed to score optimize_keys."""
|
||||
needed: set[str] = set(optimize_keys)
|
||||
changed = True
|
||||
while changed:
|
||||
changed = False
|
||||
for key in list(needed):
|
||||
defn = indicator_by_key(key)
|
||||
if defn is None:
|
||||
continue
|
||||
derived = defn.get("derived")
|
||||
if derived == "alias":
|
||||
dep = defn.get("alias_of")
|
||||
if dep and dep not in needed:
|
||||
needed.add(dep)
|
||||
changed = True
|
||||
elif derived in ("pct_of_dm", "g_per_kg_dm", "nel_per_kg_dm"):
|
||||
dep = defn.get("from_key")
|
||||
if dep and dep not in needed:
|
||||
needed.add(dep)
|
||||
changed = True
|
||||
elif derived in ("rnb", "bra_rnb"):
|
||||
for dep in ("crude_protein", "usp"):
|
||||
if dep not in needed:
|
||||
needed.add(dep)
|
||||
changed = True
|
||||
elif derived == "ratio":
|
||||
for dep in (defn.get("ratio_num"), defn.get("ratio_den")):
|
||||
if dep and dep not in needed:
|
||||
needed.add(dep)
|
||||
changed = True
|
||||
elif derived == "dm_pct_bw":
|
||||
if "dry_matter" not in needed:
|
||||
needed.add("dry_matter")
|
||||
changed = True
|
||||
return frozenset(needed)
|
||||
|
||||
|
||||
def _indicator_content(
|
||||
defn: dict[str, Any],
|
||||
active: list[dict[str, Any]],
|
||||
total_kg: float,
|
||||
*,
|
||||
heads_per_trip: int,
|
||||
) -> float | None:
|
||||
key = defn.get("key")
|
||||
if defn.get("aggregation") == "weighted_avg":
|
||||
return weighted_average(
|
||||
active,
|
||||
total_kg,
|
||||
defn["nutrient_keys"],
|
||||
indicator_key=key,
|
||||
)
|
||||
return daily_intake_total(
|
||||
active,
|
||||
defn["nutrient_keys"],
|
||||
heads_per_trip=heads_per_trip,
|
||||
indicator_key=key,
|
||||
)
|
||||
|
||||
|
||||
def compute_score_indicators(
|
||||
active: list[dict[str, Any]],
|
||||
total_kg: float,
|
||||
norms: dict[str, dict[str, float | None]],
|
||||
optimize_keys: list[str],
|
||||
*,
|
||||
heads_per_trip: int = 1,
|
||||
profile_mass_kg: float | None = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
closure = resolve_score_closure(optimize_keys)
|
||||
content_by_key: dict[str, float | None] = {}
|
||||
|
||||
for defn in RATION_ALL_INDICATORS:
|
||||
key = defn["key"]
|
||||
if key not in closure or defn.get("derived"):
|
||||
continue
|
||||
content_by_key[key] = _indicator_content(
|
||||
defn, active, total_kg, heads_per_trip=heads_per_trip
|
||||
)
|
||||
|
||||
for defn in RATION_ALL_INDICATORS:
|
||||
key = defn["key"]
|
||||
if key not in closure or not defn.get("derived"):
|
||||
continue
|
||||
content_by_key[key] = apply_content_derived(
|
||||
content_by_key,
|
||||
defn,
|
||||
total_kg=total_kg,
|
||||
heads_per_trip=heads_per_trip,
|
||||
profile_mass_kg=profile_mass_kg,
|
||||
)
|
||||
|
||||
rows: list[dict[str, Any]] = []
|
||||
for key in optimize_keys:
|
||||
if key not in closure:
|
||||
continue
|
||||
defn = indicator_by_key(key)
|
||||
if defn is None:
|
||||
continue
|
||||
content = content_by_key.get(key)
|
||||
bounds = norms.get(key, {})
|
||||
min_v = bounds.get("min")
|
||||
max_v = bounds.get("max")
|
||||
diff = norm_diff(content, min_v, max_v)
|
||||
if content is None and min_v is None and max_v is None:
|
||||
continue
|
||||
rows.append(
|
||||
{
|
||||
"key": key,
|
||||
"label": defn["label"],
|
||||
"unit": defn["unit"],
|
||||
"min": min_v,
|
||||
"max": max_v,
|
||||
"content": content,
|
||||
"diff": diff,
|
||||
}
|
||||
)
|
||||
return rows
|
||||
|
||||
|
||||
def violation_score(
|
||||
indicators: list[dict[str, Any]],
|
||||
optimize_keys: list[str],
|
||||
norms: dict[str, dict[str, float | None]],
|
||||
) -> float:
|
||||
by_key = {row.get("key"): row for row in indicators if row.get("key")}
|
||||
total = 0.0
|
||||
counted = 0
|
||||
for key in optimize_keys:
|
||||
bounds = norms.get(key) or {}
|
||||
if bounds.get("min") is None and bounds.get("max") is None:
|
||||
continue
|
||||
row = by_key.get(key)
|
||||
if row is None:
|
||||
continue
|
||||
diff = row.get("diff")
|
||||
if diff is None:
|
||||
diff = norm_diff(row.get("content"), bounds.get("min"), bounds.get("max"))
|
||||
if diff is None:
|
||||
continue
|
||||
total += float(diff) ** 2
|
||||
counted += 1
|
||||
return total if counted else 0.0
|
||||
|
||||
|
||||
def _violation_from_content(
|
||||
content_by_key: dict[str, float | None],
|
||||
optimize_keys: list[str],
|
||||
norms: dict[str, dict[str, float | None]],
|
||||
) -> float:
|
||||
total = 0.0
|
||||
counted = 0
|
||||
for key in optimize_keys:
|
||||
bounds = norms.get(key) or {}
|
||||
if bounds.get("min") is None and bounds.get("max") is None:
|
||||
continue
|
||||
content = content_by_key.get(key)
|
||||
diff = norm_diff(content, bounds.get("min"), bounds.get("max"))
|
||||
if diff is None:
|
||||
continue
|
||||
total += float(diff) ** 2
|
||||
counted += 1
|
||||
return total if counted else 0.0
|
||||
|
||||
|
||||
@dataclass
|
||||
class TripletScoreModel:
|
||||
triplet: tuple[Component, Component, Component]
|
||||
nutrient_cache: dict[str, dict[str, float]]
|
||||
herd_scale: float
|
||||
heads: int
|
||||
cost_weight: float
|
||||
profile_mass_kg: float | None
|
||||
optimize_keys: list[str]
|
||||
norms: dict[str, dict[str, float | None]]
|
||||
closure: frozenset[str]
|
||||
intake_unit: dict[str, tuple[float | None, float | None, float | None]]
|
||||
weighted_vals: dict[str, tuple[float | None, float | None, float | None]]
|
||||
prices: tuple[float, float, float]
|
||||
|
||||
|
||||
def build_triplet_score_model(
|
||||
triplet: tuple[Component, Component, Component],
|
||||
*,
|
||||
nutrient_cache: dict[str, dict[str, float]],
|
||||
norms: dict[str, dict[str, float | None]],
|
||||
optimize_keys: list[str],
|
||||
herd_scale: float,
|
||||
heads: int,
|
||||
cost_weight: float,
|
||||
profile_mass_kg: float | None,
|
||||
) -> TripletScoreModel:
|
||||
closure = resolve_score_closure(optimize_keys)
|
||||
kg_per_share = herd_scale / max(heads, 1)
|
||||
intake_unit: dict[str, tuple[float | None, float | None, float | None]] = {}
|
||||
weighted_vals: dict[str, tuple[float | None, float | None, float | None]] = {}
|
||||
|
||||
for defn in RATION_ALL_INDICATORS:
|
||||
key = defn["key"]
|
||||
if key not in closure or defn.get("derived"):
|
||||
continue
|
||||
vals: list[float | None] = []
|
||||
for comp in triplet:
|
||||
nutrients = nutrient_cache.get(comp.id, {})
|
||||
vals.append(
|
||||
get_nutrient_value(
|
||||
comp.dry_matter,
|
||||
nutrients,
|
||||
defn["nutrient_keys"],
|
||||
indicator_key=key,
|
||||
)
|
||||
)
|
||||
tup = (vals[0], vals[1], vals[2])
|
||||
if defn.get("aggregation") == "weighted_avg":
|
||||
weighted_vals[key] = tup
|
||||
else:
|
||||
intake_unit[key] = tuple(v * kg_per_share if v is not None else None for v in vals)
|
||||
|
||||
prices = tuple(
|
||||
float(comp.price) if comp.price is not None else 0.0 for comp in triplet
|
||||
)
|
||||
return TripletScoreModel(
|
||||
triplet=triplet,
|
||||
nutrient_cache=nutrient_cache,
|
||||
herd_scale=herd_scale,
|
||||
heads=heads,
|
||||
cost_weight=cost_weight,
|
||||
profile_mass_kg=profile_mass_kg,
|
||||
optimize_keys=optimize_keys,
|
||||
norms=norms,
|
||||
closure=closure,
|
||||
intake_unit=intake_unit,
|
||||
weighted_vals=weighted_vals,
|
||||
prices=prices,
|
||||
)
|
||||
|
||||
|
||||
def _content_from_shares(
|
||||
model: TripletScoreModel,
|
||||
shares: tuple[float, float, float],
|
||||
) -> dict[str, float | None]:
|
||||
content_by_key: dict[str, float | None] = {}
|
||||
total_kg = model.herd_scale
|
||||
|
||||
for key, coeffs in model.intake_unit.items():
|
||||
parts = [
|
||||
shares[i] * coeffs[i]
|
||||
for i in range(3)
|
||||
if coeffs[i] is not None
|
||||
]
|
||||
content_by_key[key] = sum(parts) if parts else None
|
||||
|
||||
for key, vals in model.weighted_vals.items():
|
||||
num = 0.0
|
||||
den = 0.0
|
||||
for i in range(3):
|
||||
if vals[i] is None:
|
||||
continue
|
||||
num += shares[i] * vals[i]
|
||||
den += shares[i]
|
||||
content_by_key[key] = (num / den) if den > 0 else None
|
||||
|
||||
for defn in RATION_ALL_INDICATORS:
|
||||
key = defn["key"]
|
||||
if key not in model.closure or not defn.get("derived"):
|
||||
continue
|
||||
content_by_key[key] = apply_content_derived(
|
||||
content_by_key,
|
||||
defn,
|
||||
total_kg=total_kg,
|
||||
heads_per_trip=model.heads,
|
||||
profile_mass_kg=model.profile_mass_kg,
|
||||
)
|
||||
return content_by_key
|
||||
|
||||
|
||||
def score_model_shares(
|
||||
model: TripletScoreModel,
|
||||
shares: tuple[float, float, float],
|
||||
) -> tuple[float, float, float]:
|
||||
"""Return (violation, cost_head, score) without building line dicts."""
|
||||
content = _content_from_shares(model, shares)
|
||||
violation = _violation_from_content(content, model.optimize_keys, model.norms)
|
||||
cost_total = sum(shares[i] * model.prices[i] * model.herd_scale for i in range(3))
|
||||
cost_head = cost_total / max(model.heads, 1)
|
||||
score = violation * model.cost_weight + cost_head
|
||||
return violation, cost_head, score
|
||||
|
||||
|
||||
def build_calc_lines(
|
||||
triplet: tuple[Component, Component, Component],
|
||||
shares: tuple[float, float, float],
|
||||
herd_scale: float,
|
||||
nutrient_cache: dict[str, dict[str, float]],
|
||||
) -> list[dict[str, Any]]:
|
||||
lines: list[dict[str, Any]] = []
|
||||
for i, comp in enumerate(triplet):
|
||||
daily_kg = shares[i] * herd_scale
|
||||
lines.append(
|
||||
{
|
||||
"component_id": comp.id,
|
||||
"ingredient_name": comp.name,
|
||||
"daily_kg": daily_kg,
|
||||
"in_ration": True,
|
||||
"in_compound": False,
|
||||
"dry_matter": comp.dry_matter,
|
||||
"price_per_kg": comp.price,
|
||||
"nutrients": nutrient_cache.get(comp.id, {}),
|
||||
}
|
||||
)
|
||||
return lines
|
||||
|
||||
|
||||
def cost_per_head(
|
||||
triplet: tuple[Component, Component, Component],
|
||||
shares: tuple[float, float, float],
|
||||
herd_scale: float,
|
||||
heads: int,
|
||||
) -> float:
|
||||
total = 0.0
|
||||
any_cost = False
|
||||
for i, comp in enumerate(triplet):
|
||||
kg = shares[i] * herd_scale
|
||||
price = comp.price
|
||||
if kg is None or price is None:
|
||||
continue
|
||||
total += float(kg) * float(price)
|
||||
any_cost = True
|
||||
if not any_cost:
|
||||
return 0.0
|
||||
return total / max(heads, 1)
|
||||
|
||||
|
||||
def score_triplet_shares(
|
||||
triplet: tuple[Component, Component, Component],
|
||||
shares: tuple[float, float, float],
|
||||
*,
|
||||
nutrient_cache: dict[str, dict[str, float]],
|
||||
norms: dict[str, dict[str, float | None]],
|
||||
optimize_keys: list[str],
|
||||
herd_scale: float,
|
||||
heads: int,
|
||||
cost_weight: float,
|
||||
profile_mass_kg: float | None,
|
||||
) -> tuple[float, float, float, list[dict[str, Any]]]:
|
||||
"""Return (violation, cost_head, score, lines)."""
|
||||
model = build_triplet_score_model(
|
||||
triplet,
|
||||
nutrient_cache=nutrient_cache,
|
||||
norms=norms,
|
||||
optimize_keys=optimize_keys,
|
||||
herd_scale=herd_scale,
|
||||
heads=heads,
|
||||
cost_weight=cost_weight,
|
||||
profile_mass_kg=profile_mass_kg,
|
||||
)
|
||||
violation, cost_head, score = score_model_shares(model, shares)
|
||||
lines = build_calc_lines(triplet, shares, herd_scale, nutrient_cache)
|
||||
return violation, cost_head, score, lines
|
||||
@@ -0,0 +1,86 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from app.modules.zootech.lab.calc.feed_groups import classify_feed_group
|
||||
from app.modules.zootech.lab.calc.gfe_policies import default_om_digestibility_pct
|
||||
from app.modules.zootech.lab.nutrient_schema import read_from_mapping
|
||||
from app.modules.zootech.lab.services.component_nutrients import derive_context_for_component, nutrients_full_dict, nutrients_is_empty
|
||||
from app.modules.zootech.wesp_bridge_models import Component
|
||||
|
||||
_REQUIRED_EAV_KEYS = ("Сыр. Протеин", "Сырая клетч", "Сырой жир")
|
||||
_MAIN_FEED_KEYS = ("Осн.Корм", "СВ Основной корм")
|
||||
_OMD_KEYS = ("ВРХ Орг Вещ", "КРС Орг Вещ")
|
||||
|
||||
|
||||
def main_feed_dm_g_per_kg(component_id: str | None) -> float | None:
|
||||
if not component_id:
|
||||
return None
|
||||
full = nutrients_full_dict(component_id)
|
||||
val = read_from_mapping(full, _MAIN_FEED_KEYS)
|
||||
return float(val) if val is not None else None
|
||||
|
||||
|
||||
def validate_component(component_id: str) -> dict[str, Any]:
|
||||
comp = Component.query.filter_by(id=component_id, is_deleted=False).first()
|
||||
if comp is None:
|
||||
return {
|
||||
"id": component_id,
|
||||
"name": None,
|
||||
"eligible": False,
|
||||
"missing": ["component_not_found"],
|
||||
"warnings": [],
|
||||
"dryMatterPct": None,
|
||||
"hasPrice": False,
|
||||
"price": None,
|
||||
}
|
||||
|
||||
missing: list[str] = []
|
||||
warnings: list[str] = []
|
||||
dry_matter_pct = comp.dry_matter
|
||||
if dry_matter_pct is None or float(dry_matter_pct) <= 0:
|
||||
missing.append("dry_matter")
|
||||
|
||||
full = nutrients_full_dict(component_id)
|
||||
if nutrients_is_empty(component_id):
|
||||
missing.append("nutrients_empty")
|
||||
else:
|
||||
for key in _REQUIRED_EAV_KEYS:
|
||||
if read_from_mapping(full, (key,)) is None:
|
||||
missing.append(key)
|
||||
|
||||
has_price = comp.price is not None and float(comp.price) >= 0
|
||||
if not has_price:
|
||||
warnings.append("price_missing")
|
||||
|
||||
main_feed_dm = read_from_mapping(full, _MAIN_FEED_KEYS)
|
||||
if main_feed_dm is None:
|
||||
warnings.append("main_feed_unset")
|
||||
|
||||
feed_group = classify_feed_group(comp)
|
||||
omd = read_from_mapping(full, _OMD_KEYS)
|
||||
if feed_group in ("rough", "succulent") and omd is None:
|
||||
warnings.append("omd_missing")
|
||||
elif omd is None and comp.dry_matter and float(comp.dry_matter) > 0:
|
||||
ctx = derive_context_for_component(component_id, full)
|
||||
warnings.append(
|
||||
f"omd_defaulted:{default_om_digestibility_pct(ctx):.0f}"
|
||||
)
|
||||
|
||||
return {
|
||||
"id": comp.id,
|
||||
"name": comp.name,
|
||||
"eligible": len(missing) == 0,
|
||||
"missing": missing,
|
||||
"warnings": warnings,
|
||||
"dryMatterPct": dry_matter_pct,
|
||||
"hasPrice": has_price,
|
||||
"price": comp.price,
|
||||
"mainFeedDmGPerKg": main_feed_dm,
|
||||
"isMainFeed": main_feed_dm is not None and float(main_feed_dm) > 0,
|
||||
"feedGroup": feed_group,
|
||||
}
|
||||
|
||||
|
||||
def validate_components(component_ids: list[str]) -> list[dict[str, Any]]:
|
||||
return [validate_component(cid) for cid in component_ids]
|
||||
@@ -0,0 +1,94 @@
|
||||
"""Динамические нормы по уравнениям GfE (Германия)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
USP_DYNAMIC_KEY = "usp"
|
||||
NEL_DYNAMIC_KEY = "nel"
|
||||
|
||||
# GfE 2001 Milchkühe — Erhaltung + Milch (Standardmilch / FCM)
|
||||
NEL_MAINTENANCE_COEFF = 0.293 # MJ NEL / (kg LM)^0,75 / Tag
|
||||
NEL_PER_KG_MILK_MJ = 3.3 # MJ NEL / kg Milch (FCM)
|
||||
|
||||
|
||||
def gfe_usp_min_g(
|
||||
mass_kg: float | None,
|
||||
milk_yield_kg: float | None = None,
|
||||
) -> float | None:
|
||||
"""
|
||||
Минимальная суточная потребность в усвояемом протеине (уСП / nXP), г/сут.
|
||||
|
||||
GfE: 0,09 × (масса^0,75) × 6,25 + удой × 85 г.
|
||||
"""
|
||||
if mass_kg is None or mass_kg <= 0:
|
||||
return None
|
||||
maintenance = 0.09 * (mass_kg**0.75) * 6.25
|
||||
milk = max(float(milk_yield_kg or 0), 0.0) * 85.0
|
||||
return maintenance + milk
|
||||
|
||||
|
||||
def gfe_nel_min_mj(
|
||||
mass_kg: float | None,
|
||||
milk_yield_kg: float | None = None,
|
||||
) -> float | None:
|
||||
"""
|
||||
Минимальная суточная потребность в ЧЭЛ (NEL), МДж/сут.
|
||||
|
||||
GfE 2001: 0,293 × LM^0,75 + удой × 3,3 (MJ NEL на кг молока).
|
||||
"""
|
||||
if mass_kg is None or mass_kg <= 0:
|
||||
return None
|
||||
maintenance = NEL_MAINTENANCE_COEFF * (mass_kg**0.75)
|
||||
milk = max(float(milk_yield_kg or 0), 0.0) * NEL_PER_KG_MILK_MJ
|
||||
return maintenance + milk
|
||||
|
||||
|
||||
def preview_dynamic_norms(
|
||||
mass_kg: float | None,
|
||||
milk_yield_kg: float | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Расчётные min по GfE для UI (без учёта сохранённых норм)."""
|
||||
_, dynamic = apply_dynamic_norms(
|
||||
{},
|
||||
mass_kg=mass_kg,
|
||||
milk_yield_kg=milk_yield_kg,
|
||||
)
|
||||
return dynamic
|
||||
|
||||
|
||||
def apply_dynamic_norms(
|
||||
stored: dict[str, dict[str, float | None]],
|
||||
*,
|
||||
mass_kg: float | None,
|
||||
milk_yield_kg: float | None,
|
||||
ration_type: str | None = None,
|
||||
force_dynamic: bool = False,
|
||||
) -> tuple[dict[str, dict[str, float | None]], dict[str, Any]]:
|
||||
"""
|
||||
Заполняет нормы по GfE, если в БД min не задан.
|
||||
force_dynamic=True — пересчитать min уСП/ЧЭЛ по массе и удою даже при нормах в БД
|
||||
(автоготовка с явными mass_kg / milk_yield_kg).
|
||||
Возвращает (resolved_norms, dynamic_meta).
|
||||
"""
|
||||
del ration_type
|
||||
resolved: dict[str, dict[str, float | None]] = {
|
||||
key: {"min": bounds.get("min"), "max": bounds.get("max")}
|
||||
for key, bounds in stored.items()
|
||||
}
|
||||
dynamic: dict[str, Any] = {}
|
||||
|
||||
for key, compute, formula in (
|
||||
(USP_DYNAMIC_KEY, gfe_usp_min_g, "0.09×масса^0.75×6.25 + удой×85"),
|
||||
(NEL_DYNAMIC_KEY, gfe_nel_min_mj, "0.293×масса^0.75 + удой×3.3"),
|
||||
):
|
||||
bounds = resolved.get(key, {"min": None, "max": None})
|
||||
if force_dynamic or bounds.get("min") is None:
|
||||
computed = compute(mass_kg, milk_yield_kg)
|
||||
if computed is not None:
|
||||
entry = dict(bounds)
|
||||
entry["min"] = computed
|
||||
resolved[key] = entry
|
||||
dynamic[key] = {"min": computed, "formula": formula}
|
||||
|
||||
return resolved, dynamic
|
||||
@@ -0,0 +1,84 @@
|
||||
"""WESP-политики расчёта на базе GfE 2001 (отличия от zootech Excel — осознанные)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Literal
|
||||
|
||||
FeedGroup = Literal["rough", "succulent", "concentrate", "other", "unknown"]
|
||||
|
||||
# --- GfE 2001 константы (формулы не меняем) ---
|
||||
NEL_Q_COEFF = 0.004
|
||||
NEL_Q_REF_PCT = 57.0
|
||||
NEL_BASE = 0.6
|
||||
|
||||
GE_CP = 0.0239
|
||||
GE_FAT = 0.0398
|
||||
GE_FIBER = 0.0201
|
||||
GE_NFE = 0.0175
|
||||
|
||||
ME_FAT = 0.0312
|
||||
ME_FIBER = 0.0136
|
||||
ME_OR_RESIDUE = 0.0147
|
||||
ME_CP = 0.00234
|
||||
|
||||
USP_FAT_THRESHOLD_G_PER_KG_DM = 70.0 # 7% СЖ/кг СВ
|
||||
|
||||
# DCAB (Na+K)-(Cl+S), мэкв при минералах в г/кг СВ
|
||||
DCAB_NA = 43.5
|
||||
DCAB_K = 25.6
|
||||
DCAB_CL = 28.2
|
||||
DCAB_S = 62.4
|
||||
|
||||
# Дефолты переваримости при пустых коэфф. (Excel legacy, кроме ОВ)
|
||||
DEFAULT_CP_DIGEST_PCT = 86.0
|
||||
DEFAULT_FAT_DIGEST_PCT = 75.0
|
||||
DEFAULT_FIBER_DIGEST_PCT = 86.0
|
||||
DEFAULT_NFE_DIGEST_PCT = 94.0
|
||||
DEFAULT_INSOLUBLE_PROTEIN_PCT = 15.0
|
||||
DEFAULT_PROTEIN_FRACTION_PCT = 18.9
|
||||
|
||||
# WESP: дефолт ВРХ орг. вещ. при пустом поле — по классу корма
|
||||
DEFAULT_OMD_ROUGH_PCT = 65.0
|
||||
DEFAULT_OMD_SUCCULENT_PCT = 72.0
|
||||
DEFAULT_OMD_CONCENTRATE_PCT = 91.0
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DeriveContext:
|
||||
feed_group: FeedGroup = "unknown"
|
||||
is_main_feed: bool = False
|
||||
|
||||
@classmethod
|
||||
def from_cells(cls, *, main_feed_g: float = 0.0, feed_group: FeedGroup = "unknown") -> DeriveContext:
|
||||
return cls(
|
||||
feed_group=feed_group,
|
||||
is_main_feed=main_feed_g > 0,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def infer_from_cells(cls, cells: dict[str, float], feed_group: FeedGroup = "unknown") -> DeriveContext:
|
||||
main_feed = cells.get("E", 0.0)
|
||||
return cls.from_cells(main_feed_g=main_feed, feed_group=feed_group)
|
||||
|
||||
|
||||
def default_om_digestibility_pct(ctx: DeriveContext | None) -> float:
|
||||
"""Дефолт переваримости ОВ (%) при отсутствии ВРХ Орг Вещ."""
|
||||
if ctx is None:
|
||||
return DEFAULT_OMD_CONCENTRATE_PCT
|
||||
if ctx.is_main_feed or ctx.feed_group == "rough":
|
||||
return DEFAULT_OMD_ROUGH_PCT
|
||||
if ctx.feed_group == "succulent":
|
||||
return DEFAULT_OMD_SUCCULENT_PCT
|
||||
return DEFAULT_OMD_CONCENTRATE_PCT
|
||||
|
||||
|
||||
def default_digestibility_coefficients() -> dict[str, float]:
|
||||
return {
|
||||
"cp": DEFAULT_CP_DIGEST_PCT,
|
||||
"fat": DEFAULT_FAT_DIGEST_PCT,
|
||||
"fiber": DEFAULT_FIBER_DIGEST_PCT,
|
||||
"nfe": DEFAULT_NFE_DIGEST_PCT,
|
||||
"insoluble_protein": DEFAULT_INSOLUBLE_PROTEIN_PCT,
|
||||
"protein_fraction": DEFAULT_PROTEIN_FRACTION_PCT,
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
"""Каталог показателей zootech «База сырья» (109 колонок)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from openpyxl.utils import column_index_from_string, get_column_letter
|
||||
|
||||
# Заголовки row 3 в xlsx_extracted/data_csv_cleaned/База сырья.csv
|
||||
INGREDIENT_HEADERS: tuple[str, ...] = (
|
||||
"№",
|
||||
"Наименование",
|
||||
"Цена 1 кг",
|
||||
"СВ",
|
||||
"Осн.Корм",
|
||||
"Сыр. Протеин",
|
||||
"уСП",
|
||||
"БРА",
|
||||
"ЧЭЛ- КРС",
|
||||
"ОЭ-КРС",
|
||||
"Сырая клетч",
|
||||
"Структур клетч",
|
||||
"Сырой жир",
|
||||
"НДК",
|
||||
"КДК",
|
||||
"NFC",
|
||||
"Ca",
|
||||
"P",
|
||||
"Mg",
|
||||
"Fe",
|
||||
"Zn",
|
||||
"Cu",
|
||||
"Co",
|
||||
"Mn",
|
||||
"Se",
|
||||
"J",
|
||||
"Na",
|
||||
"K",
|
||||
"CL",
|
||||
"S",
|
||||
"DCAB Форм",
|
||||
"Сахар и Крохм",
|
||||
"Нераств Крохм",
|
||||
"Сахар",
|
||||
"Крахмал",
|
||||
"Нераств крахмал",
|
||||
"Вит А",
|
||||
"Вит D",
|
||||
"Вит Е",
|
||||
"Вит В1",
|
||||
"Вит В2",
|
||||
"Вит В6",
|
||||
"Вит В12",
|
||||
"Пант Кальц",
|
||||
"Никот Ки-та",
|
||||
"Фол ки-та",
|
||||
"Холин",
|
||||
"Биотин",
|
||||
"Сырая зола",
|
||||
"БЕР",
|
||||
"Лизин",
|
||||
"Метионин",
|
||||
"Треонин",
|
||||
"Триптофан",
|
||||
"Изолейцин",
|
||||
"Лейцин",
|
||||
"Валин",
|
||||
"Каротин",
|
||||
"b -Каротин",
|
||||
"Линолевая к-та",
|
||||
"Линоленовая ки-та",
|
||||
"Масляная ки-та",
|
||||
"Арахидоновая ки-та",
|
||||
"Полиэновая ки-та",
|
||||
"Мочевина",
|
||||
"СВ Основной корм",
|
||||
"ВРХ Орг Вещ",
|
||||
"Перевар Орг Вещ",
|
||||
"КРС Протеин",
|
||||
"Переварим Протеин",
|
||||
"КРС Сырой жир",
|
||||
"Переварим Сырой жир",
|
||||
"КРС Сырая клетч",
|
||||
"Переварим сырая клетч",
|
||||
"КРС БЭВ",
|
||||
"Переварим БЭВ",
|
||||
"ВЕ",
|
||||
"OЭ КРС форм",
|
||||
"ЧЭЛ - КРС Форм",
|
||||
"НДК Общ",
|
||||
"НДК Осн. Корм",
|
||||
"КДК общ",
|
||||
"% нераствор протеин",
|
||||
"Нерастворим прот",
|
||||
"СЖ/кг СВ",
|
||||
"НСП/кг СВ",
|
||||
"СП/кг СВ",
|
||||
"пОВ/кг СВ",
|
||||
"пСЖ/кг СВ",
|
||||
"уСП<7%CЖ",
|
||||
"уСП>7%CЖ",
|
||||
"уСП/кг СВ формул",
|
||||
"уСП в ОР",
|
||||
"уСП формул",
|
||||
"БРА",
|
||||
"Нераств крохмал",
|
||||
"доля крохмала",
|
||||
"Доля белка",
|
||||
"% перев в кишках",
|
||||
"OEB",
|
||||
"Синтез Мдж",
|
||||
"Промеж рез 1",
|
||||
"Промеж рез 2",
|
||||
"Метаб ОЕТ",
|
||||
"Метаб лизин",
|
||||
"Метабол Треон",
|
||||
"Метабол Лейцин",
|
||||
"Метабол Изолейц",
|
||||
"Метабол Валин",
|
||||
)
|
||||
|
||||
HEADER_TO_LETTER: dict[str, str] = {
|
||||
header: get_column_letter(i + 1) for i, header in enumerate(INGREDIENT_HEADERS)
|
||||
}
|
||||
|
||||
LETTER_TO_HEADER: dict[str, str] = {
|
||||
get_column_letter(i + 1): header for i, header in enumerate(INGREDIENT_HEADERS)
|
||||
}
|
||||
|
||||
# Колонки с формулами в шаблонной строке 6 (native port, не Excel runtime).
|
||||
DERIVED_LETTERS: frozenset[str] = frozenset(
|
||||
{
|
||||
"AE",
|
||||
"AF",
|
||||
"AG",
|
||||
"AX",
|
||||
"BN",
|
||||
"BP",
|
||||
"BR",
|
||||
"BT",
|
||||
"BV",
|
||||
"BX",
|
||||
"BY",
|
||||
"BZ",
|
||||
"CA",
|
||||
"CF",
|
||||
"CG",
|
||||
"CH",
|
||||
"CI",
|
||||
"CJ",
|
||||
"CK",
|
||||
"CL",
|
||||
"CM",
|
||||
"CN",
|
||||
"CP",
|
||||
"CQ",
|
||||
"CX",
|
||||
"CY",
|
||||
"CZ",
|
||||
"DA",
|
||||
"DB",
|
||||
"DC",
|
||||
"DD",
|
||||
"DE",
|
||||
}
|
||||
)
|
||||
|
||||
# Отображаемые поля G–J синхронизируются с расчётными CP/CQ/CA/BZ.
|
||||
DISPLAY_SYNC: tuple[tuple[str, str], ...] = (
|
||||
("G", "CP"), # уСП
|
||||
("H", "CQ"), # RNB (legacy заголовок «БРА», дубль CQ)
|
||||
("I", "CA"), # ЧЭЛ- КРС
|
||||
("J", "BZ"), # ОЭ-КРС
|
||||
)
|
||||
|
||||
DERIVED_HEADERS: frozenset[str] = frozenset(
|
||||
LETTER_TO_HEADER[letter] for letter in DERIVED_LETTERS
|
||||
) | frozenset(LETTER_TO_HEADER[g] for g, _ in DISPLAY_SYNC)
|
||||
|
||||
INPUT_HEADERS: frozenset[str] = frozenset(INGREDIENT_HEADERS) - DERIVED_HEADERS - frozenset(
|
||||
("№", "Наименование", "Цена 1 кг")
|
||||
)
|
||||
|
||||
# Дублирующий заголовок «БРА»/RNB (H и CQ) — в derive используем CQ.
|
||||
DUPLICATE_HEADERS: frozenset[str] = frozenset({"БРА"})
|
||||
|
||||
|
||||
def letter_index(letter: str) -> int:
|
||||
return column_index_from_string(letter) - 1
|
||||
@@ -0,0 +1,216 @@
|
||||
"""Native derive формул zootech «База сырья» — WESP GfE 2001 engine."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from app.modules.zootech.lab.calc.gfe_policies import (
|
||||
DCAB_CL,
|
||||
DCAB_K,
|
||||
DCAB_NA,
|
||||
DCAB_S,
|
||||
DEFAULT_CP_DIGEST_PCT,
|
||||
DEFAULT_FAT_DIGEST_PCT,
|
||||
DEFAULT_FIBER_DIGEST_PCT,
|
||||
DEFAULT_INSOLUBLE_PROTEIN_PCT,
|
||||
DEFAULT_NFE_DIGEST_PCT,
|
||||
DEFAULT_PROTEIN_FRACTION_PCT,
|
||||
DeriveContext,
|
||||
GE_CP,
|
||||
GE_FAT,
|
||||
GE_FIBER,
|
||||
GE_NFE,
|
||||
ME_CP,
|
||||
ME_FAT,
|
||||
ME_FIBER,
|
||||
ME_OR_RESIDUE,
|
||||
NEL_BASE,
|
||||
NEL_Q_COEFF,
|
||||
NEL_Q_REF_PCT,
|
||||
USP_FAT_THRESHOLD_G_PER_KG_DM,
|
||||
default_om_digestibility_pct,
|
||||
)
|
||||
from app.modules.zootech.lab.calc.ingredient_catalog import (
|
||||
DISPLAY_SYNC,
|
||||
HEADER_TO_LETTER,
|
||||
INGREDIENT_HEADERS,
|
||||
LETTER_TO_HEADER,
|
||||
)
|
||||
|
||||
|
||||
def _parse_num(value: Any) -> float | None:
|
||||
if value is None or value == "":
|
||||
return None
|
||||
try:
|
||||
n = float(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
return None if n != n else n
|
||||
|
||||
|
||||
def _normalize_key(value: str) -> str:
|
||||
return " ".join((value or "").split()).strip().lower()
|
||||
|
||||
|
||||
def _v(cells: dict[str, float], letter: str, default: float = 0.0) -> float:
|
||||
return cells.get(letter, default)
|
||||
|
||||
|
||||
def _if_pos(test: float, when_true, when_false: float = 0.0) -> float:
|
||||
"""Excel IF(test>0, …) — ветка when_true не вычисляется при test<=0."""
|
||||
if test > 0:
|
||||
return when_true() if callable(when_true) else when_true
|
||||
return when_false
|
||||
|
||||
|
||||
def dict_to_cells(data: dict[str, Any] | None) -> dict[str, float]:
|
||||
"""Словарь {заголовок: значение} → {буква колонки: значение}."""
|
||||
cells: dict[str, float] = {}
|
||||
if not data:
|
||||
return cells
|
||||
norm_index = {_normalize_key(h): h for h in INGREDIENT_HEADERS}
|
||||
for raw_key, raw_val in data.items():
|
||||
n = _parse_num(raw_val)
|
||||
if n is None:
|
||||
continue
|
||||
nk = _normalize_key(str(raw_key))
|
||||
header = norm_index.get(nk)
|
||||
if header is None:
|
||||
continue
|
||||
letter = HEADER_TO_LETTER.get(header)
|
||||
if letter:
|
||||
cells[letter] = n
|
||||
return cells
|
||||
|
||||
|
||||
def cells_to_dict(cells: dict[str, float]) -> dict[str, float]:
|
||||
out: dict[str, float] = {}
|
||||
for letter, value in cells.items():
|
||||
header = LETTER_TO_HEADER.get(letter)
|
||||
if header and header not in ("№", "Наименование", "Цена 1 кг"):
|
||||
out[header] = value
|
||||
return out
|
||||
|
||||
|
||||
def derive_cells(
|
||||
cells: dict[str, float],
|
||||
*,
|
||||
context: DeriveContext | None = None,
|
||||
) -> dict[str, float]:
|
||||
"""Пересчёт derived-колонок по цепочке формул row 6 «База сырья»."""
|
||||
c = dict(cells)
|
||||
ctx = context or DeriveContext.infer_from_cells(c)
|
||||
omd_default = default_om_digestibility_pct(ctx)
|
||||
|
||||
c["AE"] = DCAB_NA * _v(c, "AA") + DCAB_K * _v(c, "AB") - DCAB_CL * _v(c, "AC") - DCAB_S * _v(c, "AD")
|
||||
c["AG"] = _v(c, "AJ") * _v(c, "AI") / 100.0
|
||||
c["AF"] = _v(c, "AI") - c["AG"] + _v(c, "AH")
|
||||
c["AX"] = _v(c, "D") - _v(c, "F") - _v(c, "K") - _v(c, "M") - _v(c, "AW")
|
||||
c["BN"] = _if_pos(_v(c, "E"), lambda: _v(c, "D") / _v(c, "E") * 1000.0)
|
||||
c["BP"] = _if_pos(
|
||||
_v(c, "BO"),
|
||||
lambda: (_v(c, "D") - _v(c, "AW")) * _v(c, "BO") / 100.0,
|
||||
(_v(c, "D") - _v(c, "AW")) * omd_default / 100.0,
|
||||
)
|
||||
c["BR"] = _if_pos(
|
||||
_v(c, "BQ"), lambda: _v(c, "F") * _v(c, "BQ") / 100.0, _v(c, "F") * DEFAULT_CP_DIGEST_PCT / 100.0
|
||||
)
|
||||
c["BT"] = _if_pos(
|
||||
_v(c, "BS"), lambda: _v(c, "M") * _v(c, "BS") / 100.0, _v(c, "M") * DEFAULT_FAT_DIGEST_PCT / 100.0
|
||||
)
|
||||
c["BV"] = _if_pos(
|
||||
_v(c, "BU"), lambda: _v(c, "K") * _v(c, "BU") / 100.0, _v(c, "K") * DEFAULT_FIBER_DIGEST_PCT / 100.0
|
||||
)
|
||||
c["BX"] = _if_pos(
|
||||
_v(c, "BW"), lambda: c["AX"] * _v(c, "BW") / 100.0, c["AX"] * DEFAULT_NFE_DIGEST_PCT / 100.0
|
||||
)
|
||||
c["BY"] = GE_CP * _v(c, "F") + GE_FAT * _v(c, "M") + GE_FIBER * _v(c, "K") + GE_NFE * c["AX"]
|
||||
c["BZ"] = (
|
||||
ME_FAT * c["BT"]
|
||||
+ ME_FIBER * c["BV"]
|
||||
+ ME_OR_RESIDUE * (c["BP"] - c["BT"] - c["BV"])
|
||||
+ ME_CP * _v(c, "F")
|
||||
)
|
||||
c["CA"] = _if_pos(
|
||||
c["BY"],
|
||||
lambda: (NEL_BASE * (1.0 + NEL_Q_COEFF * (c["BZ"] / c["BY"] * 100.0 - NEL_Q_REF_PCT)) * c["BZ"]),
|
||||
)
|
||||
c["CF"] = _if_pos(
|
||||
_v(c, "CE"),
|
||||
lambda: _v(c, "F") * _v(c, "CE") / 100.0,
|
||||
_v(c, "F") * DEFAULT_INSOLUBLE_PROTEIN_PCT / 100.0,
|
||||
)
|
||||
c["CG"] = _if_pos(_v(c, "D"), lambda: _v(c, "M") * 1000.0 / _v(c, "D"))
|
||||
c["CH"] = _if_pos(_v(c, "D"), lambda: c["CF"] * 1000.0 / _v(c, "D"))
|
||||
c["CI"] = _if_pos(_v(c, "D"), lambda: _v(c, "F") * 1000.0 / _v(c, "D"))
|
||||
c["CJ"] = _if_pos(_v(c, "D"), lambda: c["BP"] / _v(c, "D"))
|
||||
c["CK"] = _if_pos(_v(c, "D"), lambda: c["BT"] / _v(c, "D"))
|
||||
c["CL"] = _if_pos(c["CI"], lambda: (187.7 - 115.4 * c["CH"] / c["CI"]) * c["CJ"] + 1.03 * c["CH"])
|
||||
c["CM"] = _if_pos(c["CI"], lambda: (196.1 - 127.5 * c["CH"] / c["CI"]) * (c["CJ"] - c["CK"]) + 1.03 * c["CH"])
|
||||
co = _v(c, "CO")
|
||||
if c["CG"] < USP_FAT_THRESHOLD_G_PER_KG_DM + 0.01:
|
||||
c["CN"] = c["CL"]
|
||||
elif c["CG"] > USP_FAT_THRESHOLD_G_PER_KG_DM:
|
||||
c["CN"] = c["CM"]
|
||||
else:
|
||||
c["CN"] = 0.0
|
||||
if co < 1.01:
|
||||
c["CP"] = c["CN"] * _v(c, "D") / 1000.0
|
||||
elif co > 1.0:
|
||||
c["CP"] = co
|
||||
else:
|
||||
c["CP"] = 0.0
|
||||
c["CQ"] = (_v(c, "F") - c["CP"]) / 6.25
|
||||
c["CX"] = _if_pos(
|
||||
_v(c, "CT"),
|
||||
lambda: _v(c, "F") * _v(c, "CT") / 100.0,
|
||||
_v(c, "F") * DEFAULT_PROTEIN_FRACTION_PCT / 100.0,
|
||||
)
|
||||
nel = c["CA"]
|
||||
c["CY"] = nel * _v(c, "CW")
|
||||
f_val = _v(c, "F")
|
||||
bo = _v(c, "BO")
|
||||
if f_val == 0.0:
|
||||
c["CZ"] = 0.0
|
||||
c["DA"] = 0.0
|
||||
c["DB"] = 0.0
|
||||
c["DC"] = 0.0
|
||||
c["DD"] = 0.0
|
||||
c["DE"] = 0.0
|
||||
else:
|
||||
c["DA"] = c["CX"] * (_v(c, "AY") / 10.0) / (f_val / 10.0) * (bo / 100.0) + c["CY"] * 0.071 * 0.8
|
||||
c["CZ"] = _if_pos(
|
||||
bo,
|
||||
lambda: c["CX"] * (_v(c, "AZ") / 10.0) / (f_val / 10.0) * (bo / 100.0) + c["CY"] * 0.018 * 0.8,
|
||||
)
|
||||
c["DB"] = _if_pos(
|
||||
bo,
|
||||
lambda: c["CX"] * (_v(c, "BA") / 10.0) / (f_val / 10.0) * (bo / 100.0) + c["CY"] * 0.044 * 0.8,
|
||||
)
|
||||
c["DC"] = _if_pos(
|
||||
_v(c, "BD"),
|
||||
lambda: c["CX"] * (_v(c, "BD") / 10.0) / (f_val / 10.0) * (bo / 100.0) + c["CY"] * 0.063 * 0.8,
|
||||
)
|
||||
c["DD"] = _if_pos(
|
||||
_v(c, "BC"),
|
||||
lambda: c["CX"] * (_v(c, "BC") / 10.0) / (f_val / 10.0) * (bo / 100.0) + c["CY"] * 0.049 * 0.8,
|
||||
)
|
||||
c["DE"] = _if_pos(
|
||||
_v(c, "BE"),
|
||||
lambda: c["CX"] * (_v(c, "BE") / 10.0) / (f_val / 10.0) * (bo / 100.0) + c["CY"] * 0.048 * 0.8,
|
||||
)
|
||||
|
||||
for display, source in DISPLAY_SYNC:
|
||||
c[display] = c[source]
|
||||
|
||||
return c
|
||||
|
||||
|
||||
def derive_ingredient_nutrients(
|
||||
data: dict[str, Any] | None,
|
||||
*,
|
||||
context: DeriveContext | None = None,
|
||||
) -> dict[str, float]:
|
||||
"""Полный набор показателей: входные + пересчитанные derived."""
|
||||
cells = dict_to_cells(data)
|
||||
return cells_to_dict(derive_cells(cells, context=context))
|
||||
@@ -0,0 +1,38 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from app.modules.zootech.lab.calc.nutrients import parse_num
|
||||
from app.modules.zootech.lab.constants import NORM_COLUMN_ALIASES, RATION_QUALITY_INDICATORS
|
||||
|
||||
|
||||
def merge_norms_from_profile(profile_data: Any, ration_type: str) -> dict[str, dict[str, float | None]]:
|
||||
del ration_type
|
||||
if not profile_data or not isinstance(profile_data, dict):
|
||||
return {}
|
||||
indicators = profile_data.get("indicators")
|
||||
if isinstance(indicators, dict):
|
||||
out: dict[str, dict[str, float | None]] = {}
|
||||
for key, bounds in indicators.items():
|
||||
if not isinstance(bounds, dict):
|
||||
continue
|
||||
out[str(key)] = {
|
||||
"min": parse_num(bounds.get("min")),
|
||||
"max": parse_num(bounds.get("max")),
|
||||
}
|
||||
return out
|
||||
out = {}
|
||||
for defn in RATION_QUALITY_INDICATORS:
|
||||
key = defn["key"]
|
||||
aliases = NORM_COLUMN_ALIASES.get(key)
|
||||
if not aliases:
|
||||
continue
|
||||
for alias in aliases:
|
||||
entry = profile_data.get(alias)
|
||||
if isinstance(entry, dict):
|
||||
out[key] = {
|
||||
"min": parse_num(entry.get("min")),
|
||||
"max": parse_num(entry.get("max")),
|
||||
}
|
||||
break
|
||||
return out
|
||||
@@ -0,0 +1,86 @@
|
||||
"""Производные min/max норм из базовых показателей RACION."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from app.modules.zootech.lab.indicators import RATION_ALL_INDICATORS, indicator_by_key
|
||||
|
||||
|
||||
def _norm_min(bounds: dict[str, float | None] | None) -> float | None:
|
||||
if not bounds:
|
||||
return None
|
||||
v = bounds.get("min")
|
||||
return float(v) if v is not None else None
|
||||
|
||||
|
||||
def _apply_derived_min(
|
||||
norms: dict[str, dict[str, float | None]],
|
||||
defn: dict[str, Any],
|
||||
*,
|
||||
mass_kg: float | None = None,
|
||||
) -> float | None:
|
||||
derived = defn.get("derived")
|
||||
key = defn["key"]
|
||||
if derived == "alias":
|
||||
src = norms.get(defn.get("alias_of") or "")
|
||||
if src and src.get("min") is not None:
|
||||
return src["min"]
|
||||
return None
|
||||
if derived == "pct_of_dm":
|
||||
src = _norm_min(norms.get(defn.get("from_key") or ""))
|
||||
dm = _norm_min(norms.get("dry_matter"))
|
||||
if src is None or not dm or dm <= 0:
|
||||
return None
|
||||
return src / dm * 100.0
|
||||
if derived == "g_per_kg_dm":
|
||||
src = _norm_min(norms.get(defn.get("from_key") or ""))
|
||||
dm = _norm_min(norms.get("dry_matter"))
|
||||
if src is None or not dm or dm <= 0:
|
||||
return None
|
||||
return src / (dm / 1000.0)
|
||||
if derived == "nel_per_kg_dm":
|
||||
dm = _norm_min(norms.get("dry_matter"))
|
||||
nel = _norm_min(norms.get("nel"))
|
||||
if not dm or dm <= 0 or nel is None:
|
||||
return None
|
||||
return nel / (dm / 1000.0)
|
||||
if derived == "ratio":
|
||||
num = _norm_min(norms.get(defn.get("ratio_num") or ""))
|
||||
den = _norm_min(norms.get(defn.get("ratio_den") or ""))
|
||||
if num is None or den is None or den == 0:
|
||||
return None
|
||||
return num / den
|
||||
if derived == "dm_pct_bw":
|
||||
dm = _norm_min(norms.get("dry_matter"))
|
||||
if dm is None or not mass_kg or mass_kg <= 0:
|
||||
return None
|
||||
return (dm / 1000.0 / mass_kg) * 100.0
|
||||
if derived == "ration_pct_bw":
|
||||
return None
|
||||
if derived in ("rnb", "bra_rnb"):
|
||||
return _norm_min(norms.get(key))
|
||||
return None
|
||||
|
||||
|
||||
def apply_derived_norms(
|
||||
norms: dict[str, dict[str, float | None]],
|
||||
*,
|
||||
mass_kg: float | None = None,
|
||||
) -> tuple[dict[str, dict[str, float | None]], dict[str, Any]]:
|
||||
"""Дополняет norms производными min; max не трогает."""
|
||||
out = {k: dict(v) for k, v in norms.items()}
|
||||
dynamic: dict[str, Any] = {}
|
||||
for defn in RATION_ALL_INDICATORS:
|
||||
if not defn.get("derived"):
|
||||
continue
|
||||
key = defn["key"]
|
||||
if _norm_min(out.get(key)) is not None:
|
||||
continue
|
||||
val = _apply_derived_min(out, defn, mass_kg=mass_kg)
|
||||
if val is None:
|
||||
continue
|
||||
rounded = round(val, 3)
|
||||
out[key] = {"min": rounded, "max": out.get(key, {}).get("max")}
|
||||
dynamic[key] = {"min": rounded, "derived": defn.get("derived")}
|
||||
return out, dynamic
|
||||
@@ -0,0 +1,192 @@
|
||||
"""Роутер методик суточных норм: WESP / Москва / Петербург."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Literal
|
||||
|
||||
from app.modules.zootech.lab.calc.gfe_norms import apply_dynamic_norms
|
||||
from app.modules.zootech.lab.calc.racion.moscow import MoscowDairyParams, resolve_moscow_dairy_norms
|
||||
from app.modules.zootech.lab.calc.racion.piter import PiterDairyParams, PiterPrepError, resolve_piter_dairy_norms
|
||||
from app.modules.zootech.lab.indicators import RATION_ALL_INDICATORS
|
||||
|
||||
NormsMethod = Literal["wesp", "racion_moscow", "racion_piter"]
|
||||
|
||||
VALID_NORMS_METHODS: frozenset[str] = frozenset({"wesp", "racion_moscow", "racion_piter"})
|
||||
|
||||
|
||||
@dataclass
|
||||
class NormsParams:
|
||||
milk_fat_pct: float | None = None
|
||||
lactation_no: int | None = None
|
||||
lactation_stage: int | None = None
|
||||
body_condition: int | None = None
|
||||
housing_system: int | None = None
|
||||
konc_oe_sv: float | None = None
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, raw: dict[str, Any] | None) -> NormsParams:
|
||||
if not raw:
|
||||
return cls()
|
||||
return cls(
|
||||
milk_fat_pct=_flt(raw.get("milkFatPct", raw.get("milk_fat_pct"))),
|
||||
lactation_no=_int(raw.get("lactationNo", raw.get("lactation_no"))),
|
||||
lactation_stage=_int(raw.get("lactationStage", raw.get("lactation_stage"))),
|
||||
body_condition=_int(raw.get("bodyCondition", raw.get("body_condition"))),
|
||||
housing_system=_int(raw.get("housingSystem", raw.get("housing_system"))),
|
||||
konc_oe_sv=_flt(raw.get("koncOeSv", raw.get("konc_oe_sv"))),
|
||||
)
|
||||
|
||||
|
||||
def _flt(v: Any) -> float | None:
|
||||
if v is None or v == "":
|
||||
return None
|
||||
try:
|
||||
return float(v)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _int(v: Any) -> int | None:
|
||||
if v is None or v == "":
|
||||
return None
|
||||
try:
|
||||
return int(v)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def normalize_norms_method(method: str | None) -> NormsMethod:
|
||||
m = (method or "wesp").strip().lower()
|
||||
if m not in VALID_NORMS_METHODS:
|
||||
return "wesp"
|
||||
return m # type: ignore[return-value]
|
||||
|
||||
|
||||
@dataclass
|
||||
class NormsResolveRequest:
|
||||
method: NormsMethod = "wesp"
|
||||
stored: dict[str, dict[str, float | None]] = field(default_factory=dict)
|
||||
mass_kg: float | None = None
|
||||
milk_yield_kg: float | None = None
|
||||
ration_type: str | None = None
|
||||
force_dynamic: bool = False
|
||||
params: NormsParams = field(default_factory=NormsParams)
|
||||
|
||||
|
||||
def merge_hybrid_norms(
|
||||
racion_resolved: dict[str, dict[str, float | None]],
|
||||
stored: dict[str, dict[str, float | None]],
|
||||
*,
|
||||
dynamic_meta: dict[str, Any] | None = None,
|
||||
) -> tuple[dict[str, dict[str, float | None]], dict[str, Any]]:
|
||||
"""
|
||||
RACION + derived имеют приоритет по min.
|
||||
stored дополняет ключи без RACION-min; max всегда из stored, если задан.
|
||||
"""
|
||||
out: dict[str, dict[str, float | None]] = {}
|
||||
coverage: dict[str, list[str]] = {
|
||||
"racion": [],
|
||||
"derived": [],
|
||||
"fallback": [],
|
||||
"missing": [],
|
||||
}
|
||||
dynamic = dynamic_meta or {}
|
||||
all_keys = {d["key"] for d in RATION_ALL_INDICATORS}
|
||||
all_keys.update(racion_resolved.keys())
|
||||
all_keys.update(stored.keys())
|
||||
|
||||
for key in sorted(all_keys):
|
||||
rac = racion_resolved.get(key) or {}
|
||||
st = stored.get(key) or {}
|
||||
rac_min = rac.get("min")
|
||||
st_min = st.get("min")
|
||||
st_max = st.get("max")
|
||||
|
||||
source: str | None = None
|
||||
min_v: float | None = None
|
||||
if rac_min is not None:
|
||||
min_v = rac_min
|
||||
src = (dynamic.get(key) or {}).get("source")
|
||||
source = "derived" if src == "derived" else "racion"
|
||||
elif st_min is not None:
|
||||
min_v = st_min
|
||||
source = "fallback"
|
||||
|
||||
max_v = st_max if st_max is not None else rac.get("max")
|
||||
if min_v is not None or max_v is not None:
|
||||
out[key] = {"min": min_v, "max": max_v}
|
||||
if source == "racion":
|
||||
coverage["racion"].append(key)
|
||||
elif source == "derived":
|
||||
coverage["derived"].append(key)
|
||||
elif source == "fallback":
|
||||
coverage["fallback"].append(key)
|
||||
elif key in {d["key"] for d in RATION_ALL_INDICATORS}:
|
||||
coverage["missing"].append(key)
|
||||
|
||||
coverage["withMin"] = len([k for k, b in out.items() if b.get("min") is not None])
|
||||
coverage["total"] = len(RATION_ALL_INDICATORS)
|
||||
return out, coverage
|
||||
|
||||
|
||||
def _require_dairy_params(req: NormsResolveRequest) -> tuple[float, float]:
|
||||
if req.mass_kg is None or req.mass_kg <= 0:
|
||||
raise ValueError("Для методики Москва/Петербург укажите живую массу, кг")
|
||||
if req.milk_yield_kg is None or req.milk_yield_kg <= 0:
|
||||
raise ValueError("Для методики Москва/Петербург укажите суточный удой, кг")
|
||||
return float(req.mass_kg), float(req.milk_yield_kg)
|
||||
|
||||
|
||||
def _moscow_params(req: NormsResolveRequest) -> MoscowDairyParams:
|
||||
mass, milk = _require_dairy_params(req)
|
||||
p = req.params
|
||||
return MoscowDairyParams(
|
||||
mass_kg=mass,
|
||||
milk_yield_kg=milk,
|
||||
milk_fat_pct=p.milk_fat_pct if p.milk_fat_pct is not None else 4.0,
|
||||
lactation_no=p.lactation_no if p.lactation_no is not None else 2,
|
||||
body_condition=p.body_condition if p.body_condition is not None else 1,
|
||||
housing_system=p.housing_system if p.housing_system is not None else 1,
|
||||
)
|
||||
|
||||
|
||||
def _piter_params(req: NormsResolveRequest) -> PiterDairyParams:
|
||||
mass, milk = _require_dairy_params(req)
|
||||
p = req.params
|
||||
konc = p.konc_oe_sv
|
||||
if konc is None or konc <= 0:
|
||||
raise ValueError("Для методики Петербург укажите концентрацию ОЭ/СВ, МДж/кг СВ")
|
||||
return PiterDairyParams(
|
||||
mass_kg=mass,
|
||||
milk_yield_kg=milk,
|
||||
milk_fat_pct=p.milk_fat_pct if p.milk_fat_pct is not None else 4.0,
|
||||
lactation_no=p.lactation_no if p.lactation_no is not None else 2,
|
||||
body_condition=p.body_condition if p.body_condition is not None else 1,
|
||||
housing_system=p.housing_system if p.housing_system is not None else 1,
|
||||
konc_oe_sv=float(konc),
|
||||
)
|
||||
|
||||
|
||||
def resolve_norms(req: NormsResolveRequest) -> tuple[dict[str, dict[str, float | None]], dict[str, Any]]:
|
||||
method = normalize_norms_method(req.method)
|
||||
if method == "wesp":
|
||||
resolved, dynamic = apply_dynamic_norms(
|
||||
req.stored,
|
||||
mass_kg=req.mass_kg,
|
||||
milk_yield_kg=req.milk_yield_kg,
|
||||
ration_type=req.ration_type,
|
||||
force_dynamic=req.force_dynamic,
|
||||
)
|
||||
return resolved, {"normsMethod": "wesp", "dynamicNorms": dynamic}
|
||||
|
||||
try:
|
||||
if method == "racion_moscow":
|
||||
racion, pack = resolve_moscow_dairy_norms(_moscow_params(req))
|
||||
else:
|
||||
racion, pack = resolve_piter_dairy_norms(_piter_params(req))
|
||||
except PiterPrepError as exc:
|
||||
raise ValueError(str(exc)) from exc
|
||||
|
||||
resolved, coverage = merge_hybrid_norms(racion, req.stored, dynamic_meta=pack.get("dynamic"))
|
||||
return resolved, {"normsMethod": method, "coverage": coverage, **pack}
|
||||
@@ -0,0 +1,135 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Mapping
|
||||
|
||||
from app.modules.zootech.lab.nutrient_schema import resolve_sv_g_per_kg
|
||||
|
||||
|
||||
def normalize_key(value: str) -> str:
|
||||
return " ".join((value or "").split()).strip().lower()
|
||||
|
||||
|
||||
def parse_num(value: Any) -> float | None:
|
||||
if value is None or value == "":
|
||||
return None
|
||||
try:
|
||||
n = float(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
return None if n != n else n
|
||||
|
||||
|
||||
def get_nutrient_value(
|
||||
dry_matter: float | None,
|
||||
nutrients: Mapping[str, Any] | None,
|
||||
nutrient_keys: list[str],
|
||||
*,
|
||||
indicator_key: str | None = None,
|
||||
) -> float | None:
|
||||
"""Только точное совпадение ключа (заголовок или indicator_key slug)."""
|
||||
nutrients = nutrients or {}
|
||||
if indicator_key:
|
||||
for k, v in nutrients.items():
|
||||
if normalize_key(str(k)) == normalize_key(indicator_key):
|
||||
n = parse_num(v)
|
||||
if n is not None:
|
||||
return n
|
||||
for search in nutrient_keys:
|
||||
target = normalize_key(search)
|
||||
for k, v in nutrients.items():
|
||||
if normalize_key(str(k)) != target:
|
||||
continue
|
||||
n = parse_num(v)
|
||||
if n is not None:
|
||||
return n
|
||||
if any(normalize_key(k) == "св" for k in nutrient_keys):
|
||||
return resolve_sv_g_per_kg(nutrients, dry_matter)
|
||||
return None
|
||||
|
||||
|
||||
def weighted_average(
|
||||
lines: list[dict[str, Any]],
|
||||
total_kg: float,
|
||||
nutrient_keys: list[str],
|
||||
*,
|
||||
indicator_key: str | None = None,
|
||||
) -> float | None:
|
||||
if total_kg <= 0:
|
||||
return None
|
||||
total = 0.0
|
||||
weight = 0.0
|
||||
for line in lines:
|
||||
kg = float(line.get("daily_kg") or 0)
|
||||
if kg <= 0:
|
||||
continue
|
||||
v = get_nutrient_value(
|
||||
line.get("dry_matter"),
|
||||
line.get("nutrients"),
|
||||
nutrient_keys,
|
||||
indicator_key=indicator_key,
|
||||
)
|
||||
if v is None:
|
||||
continue
|
||||
total += kg * v
|
||||
weight += kg
|
||||
if weight <= 0:
|
||||
return None
|
||||
return total / weight
|
||||
|
||||
|
||||
def daily_intake_total(
|
||||
lines: list[dict[str, Any]],
|
||||
nutrient_keys: list[str],
|
||||
*,
|
||||
heads_per_trip: int = 1,
|
||||
indicator_key: str | None = None,
|
||||
) -> float | None:
|
||||
"""Суточная доза на голову (г или МДж): Σ (кг/день/гол × г/кг). Как Excel Рацион КРС."""
|
||||
heads = max(int(heads_per_trip or 1), 1)
|
||||
total = 0.0
|
||||
any_value = False
|
||||
for line in lines:
|
||||
herd_kg = float(line.get("daily_kg") or 0)
|
||||
if herd_kg <= 0:
|
||||
continue
|
||||
kg = herd_kg / heads
|
||||
v = get_nutrient_value(
|
||||
line.get("dry_matter"),
|
||||
line.get("nutrients"),
|
||||
nutrient_keys,
|
||||
indicator_key=indicator_key,
|
||||
)
|
||||
if v is None:
|
||||
continue
|
||||
total += kg * v
|
||||
any_value = True
|
||||
return total if any_value else None
|
||||
|
||||
|
||||
def rnb(
|
||||
crude_protein: float | None,
|
||||
usp: float | None,
|
||||
) -> float | None:
|
||||
"""RNB (ruminal nitrogen balance, GfE): (сырой протеин − уСП) / 6,25."""
|
||||
if crude_protein is None or usp is None:
|
||||
return None
|
||||
return (crude_protein - usp) / 6.25
|
||||
|
||||
|
||||
def bra_rnb(crude_protein: float | None, usp: float | None) -> float | None:
|
||||
"""Deprecated alias for :func:`rnb`."""
|
||||
return rnb(crude_protein, usp)
|
||||
|
||||
|
||||
def norm_diff(
|
||||
content: float | None,
|
||||
min_val: float | None,
|
||||
max_val: float | None,
|
||||
) -> float | None:
|
||||
if content is None:
|
||||
return None
|
||||
if min_val is not None and content < min_val:
|
||||
return content - min_val
|
||||
if max_val is not None and content > max_val:
|
||||
return content - max_val
|
||||
return 0.0
|
||||
@@ -0,0 +1 @@
|
||||
"""Российские методики суточных норм (Москва / Петербург)."""
|
||||
@@ -0,0 +1,25 @@
|
||||
"""Линейная интерполяция (аналог FRAC в методичке)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
def frac(numerator: float, denominator: float) -> float:
|
||||
if denominator == 0:
|
||||
return 0.0
|
||||
return numerator / denominator
|
||||
|
||||
|
||||
def lerp(x: float, x1: float, n1: float, x2: float, n2: float) -> float:
|
||||
"""Norma = n1 + frac(n2 - n1, x2 - x1) * (x - x1)."""
|
||||
if x2 == x1:
|
||||
return n1
|
||||
return n1 + frac(n2 - n1, x2 - x1) * (x - x1)
|
||||
|
||||
|
||||
def popr_index(udoy: float, boundaries: list[float]) -> int:
|
||||
"""1-based индекс столбца POPR_K по суточному удою."""
|
||||
idx = 1
|
||||
for i, bound in enumerate(boundaries, start=1):
|
||||
if udoy >= bound:
|
||||
idx = i + 1
|
||||
return min(idx, len(boundaries) + 1)
|
||||
@@ -0,0 +1,107 @@
|
||||
"""Методика Москва — лактирующие коровы (NORM_1_1_CALC)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from app.modules.zootech.lab.calc.racion.interp import popr_index
|
||||
from app.modules.zootech.lab.calc.norms_derived import apply_derived_norms
|
||||
from app.modules.zootech.lab.calc.racion.npitv_map import DAIRY_LACTIR_NPITV, NPITV_TO_INDICATOR
|
||||
from app.modules.zootech.lab.calc.racion.tables import load_moskwa_lactir
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class MoscowDairyParams:
|
||||
mass_kg: float
|
||||
milk_yield_kg: float
|
||||
milk_fat_pct: float = 4.0
|
||||
lactation_no: int = 2
|
||||
body_condition: int = 1
|
||||
housing_system: int = 1
|
||||
|
||||
|
||||
def _row_lookup(rows: list[dict], npitv: int) -> dict | None:
|
||||
for row in rows:
|
||||
if row["npitv"] == npitv and row["pom"] == 1:
|
||||
return row
|
||||
return None
|
||||
|
||||
|
||||
def _popr_value(row: dict, udoy: float, boundaries: list[float]) -> tuple[float | None, float | None]:
|
||||
popr = row.get("popr_k") or []
|
||||
if not popr:
|
||||
return None, None
|
||||
idx = popr_index(udoy, boundaries) - 1
|
||||
idx = max(0, min(idx, len(popr) - 1))
|
||||
p_k = popr[idx]
|
||||
koef = float(row.get("koef") or 0)
|
||||
return p_k, koef
|
||||
|
||||
|
||||
def compute_moscow_norm(npitv: int, params: MoscowDairyParams) -> float | None:
|
||||
data = load_moskwa_lactir()
|
||||
boundaries = data.get("udoy_boundaries") or []
|
||||
row = _row_lookup(data.get("rows") or [], npitv)
|
||||
if row is None:
|
||||
return None
|
||||
|
||||
mass = params.mass_kg
|
||||
udoy = params.milk_yield_kg
|
||||
jir = params.milk_fat_pct
|
||||
wmassa = mass * 1.02 if params.body_condition > 1 else mass
|
||||
|
||||
p_k, koef = _popr_value(row, udoy, boundaries)
|
||||
if p_k is None:
|
||||
return None
|
||||
|
||||
norma: float | None = None
|
||||
if npitv == 1:
|
||||
temp = 0.005 if udoy <= 22 else 0.0025
|
||||
norma = temp * (wmassa - 500) + p_k * udoy - ((4 - jir) * udoy) / 148
|
||||
elif npitv == 2:
|
||||
temp = 0.09 if udoy <= 22 else 0.065
|
||||
norma = temp * (wmassa - 500) + p_k * udoy - ((4 - jir) * udoy) / 15
|
||||
elif npitv == 3:
|
||||
temp = 0.017 if udoy <= 22 else 0.015
|
||||
norma = temp * (wmassa - 500) + p_k * udoy
|
||||
elif 4 <= npitv <= 24:
|
||||
norma = p_k * udoy + (wmassa - 500) * koef
|
||||
if npitv == 10:
|
||||
norma *= 0.393
|
||||
else:
|
||||
return None
|
||||
|
||||
if norma is None:
|
||||
return None
|
||||
if params.lactation_no == 1:
|
||||
norma *= 0.95
|
||||
elif params.lactation_no == 3:
|
||||
norma *= 1.05
|
||||
if params.housing_system == 2:
|
||||
norma *= 1.1
|
||||
return round(norma, 3)
|
||||
|
||||
|
||||
def resolve_moscow_dairy_norms(params: MoscowDairyParams) -> tuple[dict[str, dict[str, float | None]], dict[str, Any]]:
|
||||
resolved: dict[str, dict[str, float | None]] = {}
|
||||
dynamic: dict[str, Any] = {}
|
||||
for npitv in DAIRY_LACTIR_NPITV:
|
||||
value = compute_moscow_norm(npitv, params)
|
||||
if value is None:
|
||||
continue
|
||||
key = NPITV_TO_INDICATOR.get(npitv)
|
||||
if not key:
|
||||
continue
|
||||
resolved[key] = {"min": value, "max": None}
|
||||
dynamic[key] = {"min": value, "npitv": npitv, "method": "racion_moscow", "source": "racion"}
|
||||
resolved, derived_dyn = apply_derived_norms(resolved, mass_kg=params.mass_kg)
|
||||
for k, v in derived_dyn.items():
|
||||
dynamic[k] = {**v, "method": "racion_moscow", "source": "derived"}
|
||||
meta = {
|
||||
"method": "racion_moscow",
|
||||
"massKg": params.mass_kg,
|
||||
"milkYieldKg": params.milk_yield_kg,
|
||||
"milkFatPct": params.milk_fat_pct,
|
||||
}
|
||||
return resolved, {"meta": meta, "dynamic": dynamic}
|
||||
@@ -0,0 +1,42 @@
|
||||
"""NPitV (справочник питательных веществ) → ключи показателей WESP."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
# NORMY_MOSKWA_LACTIR / NORM_1_1_CALC: NPitV 1–24 (pom=1)
|
||||
NPITV_TO_INDICATOR: dict[int, str] = {
|
||||
1: "feed_units",
|
||||
2: "oe",
|
||||
3: "dry_matter",
|
||||
4: "crude_protein",
|
||||
5: "digestible_protein",
|
||||
6: "crude_fat",
|
||||
7: "nel",
|
||||
8: "rnb",
|
||||
9: "usp",
|
||||
10: "sodium",
|
||||
11: "magnesium",
|
||||
12: "starch",
|
||||
13: "potassium",
|
||||
14: "calcium",
|
||||
15: "phosphorus",
|
||||
16: "iron",
|
||||
17: "copper",
|
||||
18: "zinc",
|
||||
19: "manganese",
|
||||
20: "cobalt",
|
||||
21: "iodine",
|
||||
22: "carotene",
|
||||
23: "vitamin_d",
|
||||
24: "vitamin_e",
|
||||
}
|
||||
|
||||
DAIRY_LACTIR_NPITV: tuple[int, ...] = tuple(range(1, 25))
|
||||
|
||||
# Обратная совместимость
|
||||
DAIRY_CORE_NPITV: tuple[int, ...] = (2, 3, 4, 5, 7, 8, 9, 10, 12, 14, 15)
|
||||
|
||||
INDICATOR_TO_NPITV: dict[str, int] = {v: k for k, v in NPITV_TO_INDICATOR.items()}
|
||||
|
||||
|
||||
def indicator_for_npitv(npitv: int) -> str | None:
|
||||
return NPITV_TO_INDICATOR.get(npitv)
|
||||
@@ -0,0 +1,193 @@
|
||||
"""Методика Петербург — лактирующие коровы (NORM_1_2_CALC)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from app.modules.zootech.lab.calc.norms_derived import apply_derived_norms
|
||||
from app.modules.zootech.lab.calc.racion.interp import lerp
|
||||
from app.modules.zootech.lab.calc.racion.moscow import MoscowDairyParams
|
||||
from app.modules.zootech.lab.calc.racion.npitv_map import DAIRY_LACTIR_NPITV, NPITV_TO_INDICATOR
|
||||
from app.modules.zootech.lab.calc.racion.piter_prep import PiterPrepError, PiterPrepResult, prepare_piter_calc
|
||||
from app.modules.zootech.lab.calc.racion.tables import load_piter_lactir
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PiterDairyParams(MoscowDairyParams):
|
||||
konc_oe_sv: float = 10.3
|
||||
|
||||
|
||||
def _konc_bracket(konc: float, konc_values: list[float]) -> tuple[float, float]:
|
||||
sorted_k = sorted(set(konc_values))
|
||||
positive = [k for k in sorted_k if k > 0]
|
||||
if not positive:
|
||||
return konc, konc
|
||||
if konc <= positive[0]:
|
||||
return positive[0], positive[min(1, len(positive) - 1)]
|
||||
for i in range(len(positive) - 1):
|
||||
if positive[i] <= konc <= positive[i + 1]:
|
||||
return positive[i], positive[i + 1]
|
||||
return positive[-2], positive[-1]
|
||||
|
||||
|
||||
def _udoy_bracket(udoy_jir: float, udoys: list[float]) -> tuple[float, float]:
|
||||
sorted_u = sorted(set(udoys))
|
||||
if len(sorted_u) < 2:
|
||||
return sorted_u[0], sorted_u[0]
|
||||
if udoy_jir <= sorted_u[0]:
|
||||
return sorted_u[0], sorted_u[1]
|
||||
for i in range(len(sorted_u) - 1):
|
||||
if sorted_u[i] <= udoy_jir < sorted_u[i + 1]:
|
||||
return sorted_u[i], sorted_u[i + 1]
|
||||
return sorted_u[-2], sorted_u[-1]
|
||||
|
||||
|
||||
def _norm_at_mass(
|
||||
entry_normy: list[float],
|
||||
mass_ind: int,
|
||||
wmassa: float,
|
||||
m_a: float,
|
||||
m_b: float,
|
||||
) -> float | None:
|
||||
i = mass_ind - 1
|
||||
if i < 0 or i + 1 >= len(entry_normy):
|
||||
return None
|
||||
n_a, n_b = entry_normy[i], entry_normy[i + 1]
|
||||
if n_a <= 0 or n_b <= 0:
|
||||
return None
|
||||
return lerp(wmassa, m_a, n_a, m_b, n_b)
|
||||
|
||||
|
||||
def _entries_for(data: dict, npitv: int) -> list[dict]:
|
||||
npv = 4 if npitv == 5 else npitv
|
||||
return [e for e in data.get("entries") or [] if e["npitv"] == npv]
|
||||
|
||||
|
||||
def _compute_with_konc(
|
||||
entries: list[dict],
|
||||
npitv: int,
|
||||
params: PiterDairyParams,
|
||||
prep: PiterPrepResult,
|
||||
konc_pred: float,
|
||||
konc_sled: float,
|
||||
) -> float | None:
|
||||
by_konc = {k: [e for e in entries if abs(e["konc"] - k) < 1e-6] for k in (konc_pred, konc_sled)}
|
||||
|
||||
def _at_konc(konc: float) -> float | None:
|
||||
rows = by_konc.get(konc) or []
|
||||
if not rows:
|
||||
return None
|
||||
udoys = [e["udoy"] for e in rows]
|
||||
ud_pred, ud_sled = _udoy_bracket(prep.udoy_jir, udoys)
|
||||
if ud_pred == ud_sled:
|
||||
return None
|
||||
e_pred = next((e for e in rows if e["udoy"] == ud_pred), None)
|
||||
e_sled = next((e for e in rows if e["udoy"] == ud_sled), None)
|
||||
if not e_pred or not e_sled:
|
||||
return None
|
||||
n01 = _norm_at_mass(e_pred["normy"], prep.mass_ind, prep.wmassa, prep.m_a, prep.m_b)
|
||||
n02 = _norm_at_mass(e_sled["normy"], prep.mass_ind, prep.wmassa, prep.m_a, prep.m_b)
|
||||
if n01 is None or n02 is None:
|
||||
return None
|
||||
return lerp(prep.udoy_jir, ud_pred, n01, ud_sled, n02)
|
||||
|
||||
norma1 = _at_konc(konc_pred)
|
||||
norma2 = _at_konc(konc_sled)
|
||||
if norma1 is None or norma2 is None or konc_pred == konc_sled:
|
||||
return None
|
||||
norma = lerp(params.konc_oe_sv, konc_pred, norma1, konc_sled, norma2)
|
||||
if npitv == 5:
|
||||
norma *= 0.65
|
||||
return round(norma, 3)
|
||||
|
||||
|
||||
def _compute_konc_independent(
|
||||
entries: list[dict],
|
||||
params: PiterDairyParams,
|
||||
prep: PiterPrepResult,
|
||||
) -> float | None:
|
||||
rows = [e for e in entries if e["konc"] == -10]
|
||||
if not rows:
|
||||
return None
|
||||
udoys = [e["udoy"] for e in rows]
|
||||
ud_pred, ud_sled = _udoy_bracket(prep.udoy_jir, udoys)
|
||||
if ud_pred == ud_sled:
|
||||
return None
|
||||
e_pred = next((e for e in rows if e["udoy"] == ud_pred), None)
|
||||
e_sled = next((e for e in rows if e["udoy"] == ud_sled), None)
|
||||
if not e_pred or not e_sled:
|
||||
return None
|
||||
n01 = _norm_at_mass(e_pred["normy"], prep.mass_ind, prep.wmassa, prep.m_a, prep.m_b)
|
||||
n02 = _norm_at_mass(e_sled["normy"], prep.mass_ind, prep.wmassa, prep.m_a, prep.m_b)
|
||||
if n01 is None or n02 is None:
|
||||
return None
|
||||
norma = lerp(prep.udoy_jir, ud_pred, n01, ud_sled, n02)
|
||||
if params.housing_system == 2:
|
||||
norma *= 1.1
|
||||
return round(norma, 3)
|
||||
|
||||
|
||||
def compute_piter_norm(npitv: int, params: PiterDairyParams, prep: PiterPrepResult | None = None) -> float | None:
|
||||
data = load_piter_lactir()
|
||||
entries = _entries_for(data, npitv)
|
||||
if not entries:
|
||||
return None
|
||||
|
||||
if prep is None:
|
||||
prep = prepare_piter_calc(
|
||||
mass_kg=params.mass_kg,
|
||||
milk_yield_kg=params.milk_yield_kg,
|
||||
milk_fat_pct=params.milk_fat_pct,
|
||||
konc_oe_sv=params.konc_oe_sv,
|
||||
body_condition=params.body_condition,
|
||||
)
|
||||
|
||||
konc_vals = sorted({e["konc"] for e in entries if e["konc"] > 0})
|
||||
if konc_vals:
|
||||
min_k = min(konc_vals)
|
||||
if min_k > 0:
|
||||
konc_pred, konc_sled = _konc_bracket(params.konc_oe_sv, konc_vals)
|
||||
return _compute_with_konc(entries, npitv, params, prep, konc_pred, konc_sled)
|
||||
return _compute_konc_independent(entries, params, prep)
|
||||
|
||||
|
||||
def resolve_piter_dairy_norms(params: PiterDairyParams) -> tuple[dict[str, dict[str, float | None]], dict[str, Any]]:
|
||||
prep = prepare_piter_calc(
|
||||
mass_kg=params.mass_kg,
|
||||
milk_yield_kg=params.milk_yield_kg,
|
||||
milk_fat_pct=params.milk_fat_pct,
|
||||
konc_oe_sv=params.konc_oe_sv,
|
||||
body_condition=params.body_condition,
|
||||
)
|
||||
resolved: dict[str, dict[str, float | None]] = {}
|
||||
dynamic: dict[str, Any] = {}
|
||||
for npitv in DAIRY_LACTIR_NPITV:
|
||||
value = compute_piter_norm(npitv, params, prep=prep)
|
||||
if value is None:
|
||||
continue
|
||||
key = NPITV_TO_INDICATOR.get(npitv)
|
||||
if not key:
|
||||
continue
|
||||
resolved[key] = {"min": value, "max": None}
|
||||
dynamic[key] = {"min": value, "npitv": npitv, "method": "racion_piter", "source": "racion"}
|
||||
resolved, derived_dyn = apply_derived_norms(resolved, mass_kg=params.mass_kg)
|
||||
for k, v in derived_dyn.items():
|
||||
dynamic[k] = {**v, "method": "racion_piter", "source": "derived"}
|
||||
meta = {
|
||||
"method": "racion_piter",
|
||||
"massKg": params.mass_kg,
|
||||
"milkYieldKg": params.milk_yield_kg,
|
||||
"koncOeSv": params.konc_oe_sv,
|
||||
"prep": {
|
||||
"massInd": prep.mass_ind,
|
||||
"mA": prep.m_a,
|
||||
"mB": prep.m_b,
|
||||
"koncPred": prep.konc_pred,
|
||||
"koncSled": prep.konc_sled,
|
||||
},
|
||||
}
|
||||
return resolved, {"meta": meta, "dynamic": dynamic}
|
||||
|
||||
|
||||
__all__ = ["PiterDairyParams", "PiterPrepError", "compute_piter_norm", "resolve_piter_dairy_norms"]
|
||||
@@ -0,0 +1,202 @@
|
||||
"""Подготовка параметров NORM_1_2_PREP (интервалы массы и концентрации)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from app.modules.zootech.lab.calc.racion.interp import lerp
|
||||
from app.modules.zootech.lab.calc.racion.tables import load_normy_info
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PiterPrepResult:
|
||||
mass_ind: int
|
||||
konc_pred: float
|
||||
konc_sled: float
|
||||
m_a: float
|
||||
m_b: float
|
||||
udoy_jir: float
|
||||
wmassa: float
|
||||
|
||||
|
||||
class PiterPrepError(ValueError):
|
||||
def __init__(self, code: int, message: str, *, info: str | None = None) -> None:
|
||||
super().__init__(message)
|
||||
self.code = code
|
||||
self.info = info
|
||||
|
||||
|
||||
def _info_values(nperem: int) -> list[float]:
|
||||
data = load_normy_info()
|
||||
for row in data.get("rows") or []:
|
||||
if row.get("nperem") == nperem:
|
||||
return list(row.get("znachenie") or [])
|
||||
return []
|
||||
|
||||
|
||||
def _fl_from_str(values: list[float], index: int) -> float | None:
|
||||
"""1-based index как FlFromStr в RACION."""
|
||||
i = index - 1
|
||||
if i < 0 or i >= len(values):
|
||||
return None
|
||||
return float(values[i])
|
||||
|
||||
|
||||
def _wmassa(mass_kg: float, body_condition: int) -> float:
|
||||
if body_condition > 1:
|
||||
return mass_kg * 1.02
|
||||
return mass_kg
|
||||
|
||||
|
||||
def prepare_piter_calc(
|
||||
*,
|
||||
mass_kg: float,
|
||||
milk_yield_kg: float,
|
||||
milk_fat_pct: float,
|
||||
konc_oe_sv: float,
|
||||
body_condition: int = 1,
|
||||
) -> PiterPrepResult:
|
||||
"""Порт NORM_1_2_PREP: интервалы для NORM_1_2_CALC."""
|
||||
if milk_yield_kg <= 0:
|
||||
raise PiterPrepError(-11, "Укажите суточный удой, кг")
|
||||
if milk_fat_pct <= 0:
|
||||
raise PiterPrepError(-12, "Укажите жирность молока, %")
|
||||
if mass_kg <= 0:
|
||||
raise PiterPrepError(-14, "Укажите живую массу, кг")
|
||||
if konc_oe_sv <= 0:
|
||||
raise PiterPrepError(-15, "Укажите концентрацию ОЭ/СВ, МДж/кг СВ")
|
||||
|
||||
wmassa = _wmassa(mass_kg, body_condition)
|
||||
udoy_jir = milk_yield_kg * milk_fat_pct * 0.25
|
||||
|
||||
kol_konc_vals = _info_values(7)
|
||||
kol_mass_vals = _info_values(7)
|
||||
if len(kol_konc_vals) < 2 or len(kol_mass_vals) < 2:
|
||||
raise PiterPrepError(-1, "Справочник NORMY_INFO (NPerem=7) не задан")
|
||||
kol_konc = int(kol_konc_vals[0])
|
||||
kol_mass = int(kol_mass_vals[1])
|
||||
if kol_konc < 2 or kol_mass < 2:
|
||||
raise PiterPrepError(-1, "Некорректные размеры таблицы концентраций/масс")
|
||||
|
||||
masses = _info_values(6) or _info_values(14)
|
||||
koncss = _info_values(3)
|
||||
if not masses or not koncss or len(masses) < 2 or len(koncss) < 2:
|
||||
raise PiterPrepError(-1, "Справочник NORMY_INFO: массы или концентрации не заданы")
|
||||
|
||||
# Интервал концентрации
|
||||
konc_ind = 1
|
||||
for i in range(2, kol_konc):
|
||||
v = _fl_from_str(koncss, i)
|
||||
if v is not None and konc_oe_sv >= v:
|
||||
konc_ind = i
|
||||
konc_pred = _fl_from_str(koncss, konc_ind)
|
||||
konc_sled = _fl_from_str(koncss, konc_ind + 1)
|
||||
if konc_pred is None or konc_sled is None or konc_pred < 0 or konc_sled < 0 or konc_pred == konc_sled:
|
||||
raise PiterPrepError(-1, "Не удалось определить интервал концентрации")
|
||||
|
||||
# Интервал массы
|
||||
mass_ind = 1
|
||||
for i in range(2, kol_mass):
|
||||
v = _fl_from_str(masses, i)
|
||||
if v is not None and wmassa >= v:
|
||||
mass_ind = i
|
||||
m_a = _fl_from_str(masses, mass_ind)
|
||||
m_b = _fl_from_str(masses, mass_ind + 1)
|
||||
if m_a is None or m_b is None or m_a < 0 or m_b < 0 or m_a == m_b:
|
||||
raise PiterPrepError(-1, "Не удалось определить интервал массы")
|
||||
|
||||
# Проверка удоя (NPerem=4,5)
|
||||
udoy_str_4 = _info_values(4)
|
||||
udoy_str_5 = _info_values(5)
|
||||
udoy_min = _fl_from_str(udoy_str_4, 1) if udoy_str_4 else None
|
||||
udoy_max = _fl_from_str(udoy_str_5, kol_konc) if udoy_str_5 else None
|
||||
|
||||
if udoy_min is not None and udoy_max is not None:
|
||||
if udoy_jir < udoy_min or udoy_jir > udoy_max:
|
||||
jir_str = _info_values(2)
|
||||
gr_udoy1 = max(udoy_min * 4 / milk_fat_pct, udoy_min)
|
||||
gr_udoy2 = min(udoy_max * 4 / milk_fat_pct, udoy_max)
|
||||
gr_jir1 = udoy_min * 4 / milk_yield_kg
|
||||
gr_jir2 = udoy_max * 4 / milk_yield_kg
|
||||
if jir_str:
|
||||
if len(jir_str) >= 1:
|
||||
gr_jir1 = max(gr_jir1, jir_str[0])
|
||||
if len(jir_str) >= 2:
|
||||
gr_jir2 = min(gr_jir2, jir_str[1])
|
||||
code = -4 if udoy_jir < udoy_min else -5
|
||||
info = f"{gr_udoy1:.4f};{gr_udoy2:.4f};{gr_jir1:.3f};{gr_jir2:.3f};"
|
||||
raise PiterPrepError(code, "Удой вне допустимого диапазона для жирности", info=info)
|
||||
|
||||
# Допустимый диапазон концентрации для udoy_jir
|
||||
i = 1
|
||||
if udoy_jir >= (_fl_from_str(udoy_str_4, 1) or 0):
|
||||
while i < kol_konc - 1:
|
||||
nxt = _fl_from_str(udoy_str_4, i + 1)
|
||||
if nxt is None or nxt > udoy_jir:
|
||||
break
|
||||
i += 1
|
||||
else:
|
||||
while i < kol_konc - 1:
|
||||
nxt = _fl_from_str(udoy_str_4, i + 1)
|
||||
cur = _fl_from_str(udoy_str_4, i)
|
||||
if nxt is None or cur is None or nxt != cur:
|
||||
break
|
||||
i += 1
|
||||
ud_a = _fl_from_str(udoy_str_4, i)
|
||||
ud_b = _fl_from_str(udoy_str_4, i + 1)
|
||||
konc_a = _fl_from_str(koncss, i)
|
||||
konc_b = _fl_from_str(koncss, i + 1)
|
||||
if ud_a is not None and ud_b is not None and konc_a is not None and konc_b is not None:
|
||||
if ud_a == ud_b:
|
||||
konc_max = konc_b
|
||||
else:
|
||||
konc_max = lerp(udoy_jir, ud_a, konc_a, ud_b, konc_b)
|
||||
|
||||
i = kol_konc
|
||||
if udoy_jir <= (_fl_from_str(udoy_str_5, kol_konc) or udoy_jir):
|
||||
while i > 2:
|
||||
prev = _fl_from_str(udoy_str_5, i - 1)
|
||||
if prev is None or prev < udoy_jir:
|
||||
break
|
||||
i -= 1
|
||||
else:
|
||||
while i > 2:
|
||||
prev = _fl_from_str(udoy_str_5, i - 1)
|
||||
last = _fl_from_str(udoy_str_5, kol_konc)
|
||||
if prev is None or last is None or prev != last:
|
||||
break
|
||||
i -= 1
|
||||
ud_a2 = _fl_from_str(udoy_str_5, i - 1)
|
||||
ud_b2 = _fl_from_str(udoy_str_5, i)
|
||||
konc_a2 = _fl_from_str(koncss, i - 1)
|
||||
konc_b2 = _fl_from_str(koncss, i)
|
||||
if ud_a2 is not None and ud_b2 is not None and konc_a2 is not None and konc_b2 is not None:
|
||||
if ud_a2 == ud_b2:
|
||||
konc_min = konc_b2
|
||||
else:
|
||||
konc_min = lerp(udoy_jir, ud_a2, konc_a2, ud_b2, konc_b2)
|
||||
|
||||
konc_lo = _fl_from_str(koncss, 1) or konc_min
|
||||
konc_hi = _fl_from_str(koncss, kol_konc) or konc_max
|
||||
if konc_min < konc_lo:
|
||||
konc_min = konc_lo
|
||||
if konc_max > konc_hi:
|
||||
konc_max = konc_hi
|
||||
if round(konc_oe_sv, 1) < round(konc_min, 1) or round(konc_oe_sv, 1) > round(konc_max, 1):
|
||||
code = -2 if round(konc_oe_sv, 1) < round(konc_min, 1) else -3
|
||||
info = f"{round(konc_min, 1)};{round(konc_max, 1)};"
|
||||
raise PiterPrepError(
|
||||
code,
|
||||
f"Концентрация ОЭ/СВ вне допустимого диапазона ({round(konc_min, 1)}–{round(konc_max, 1)})",
|
||||
info=info,
|
||||
)
|
||||
|
||||
return PiterPrepResult(
|
||||
mass_ind=mass_ind,
|
||||
konc_pred=float(konc_pred),
|
||||
konc_sled=float(konc_sled),
|
||||
m_a=float(m_a),
|
||||
m_b=float(m_b),
|
||||
udoy_jir=udoy_jir,
|
||||
wmassa=wmassa,
|
||||
)
|
||||
@@ -0,0 +1,75 @@
|
||||
"""Загрузка справочников норм RACION (БД → JSON fallback)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
|
||||
_SEED_DIR = Path(__file__).resolve().parents[4] / "data" / "seed" / "racion"
|
||||
|
||||
|
||||
def _read_json(name: str) -> dict:
|
||||
path = _SEED_DIR / name
|
||||
if not path.exists():
|
||||
raise FileNotFoundError(f"Справочник не найден: {path}")
|
||||
return json.loads(path.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def _load_from_db(loader_name: str) -> dict | None:
|
||||
try:
|
||||
from flask import has_app_context
|
||||
|
||||
if not has_app_context():
|
||||
return None
|
||||
from app.modules.zootech.lab.services.racion_reference import (
|
||||
load_moskwa_lactir_from_db,
|
||||
load_normy_info_from_db,
|
||||
load_piter_lactir_from_db,
|
||||
)
|
||||
|
||||
loaders = {
|
||||
"moskwa": load_moskwa_lactir_from_db,
|
||||
"piter": load_piter_lactir_from_db,
|
||||
"info": load_normy_info_from_db,
|
||||
}
|
||||
fn = loaders.get(loader_name)
|
||||
if fn is None:
|
||||
return None
|
||||
data = fn()
|
||||
return data if data else None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def load_moskwa_lactir() -> dict:
|
||||
data = _load_from_db("moskwa")
|
||||
if data:
|
||||
return data
|
||||
return _read_json("moskwa_lactir.json")
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def load_piter_lactir() -> dict:
|
||||
data = _load_from_db("piter")
|
||||
if data:
|
||||
return data
|
||||
return _read_json("piter_lactir.json")
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def load_normy_info() -> dict:
|
||||
data = _load_from_db("info")
|
||||
if data:
|
||||
return data
|
||||
try:
|
||||
return _read_json("normy_info.json")
|
||||
except FileNotFoundError:
|
||||
return {"rows": [], "mass_kg_values": [400, 450, 500, 550, 600, 650, 700, 750]}
|
||||
|
||||
|
||||
def clear_tables_cache() -> None:
|
||||
load_moskwa_lactir.cache_clear()
|
||||
load_piter_lactir.cache_clear()
|
||||
load_normy_info.cache_clear()
|
||||
@@ -0,0 +1,21 @@
|
||||
from .apply_from_master import apply_from_master
|
||||
from .ensure_empty_master import ensure_empty_master
|
||||
from .recalculate import recalculate_ration
|
||||
from .seed_demo_component_nutrients import seed_demo_component_nutrients_once
|
||||
from .seed_from_execution import seed_from_execution
|
||||
from .sync_from_execution import sync_from_execution
|
||||
from .delete_profile import delete_animal_profile
|
||||
from .upsert_profile import upsert_animal_profile
|
||||
from .upsert_ration import upsert_ration
|
||||
|
||||
__all__ = [
|
||||
"upsert_ration",
|
||||
"recalculate_ration",
|
||||
"apply_from_master",
|
||||
"seed_demo_component_nutrients_once",
|
||||
"seed_from_execution",
|
||||
"ensure_empty_master",
|
||||
"sync_from_execution",
|
||||
"delete_animal_profile",
|
||||
"upsert_animal_profile",
|
||||
]
|
||||
@@ -0,0 +1,76 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from app.modules.zootech.wesp_bridge_db import db
|
||||
from app.modules.zootech.lab.commands.write_audit import write_audit
|
||||
from app.modules.zootech.lab.db_guard import retry_locked
|
||||
from app.modules.zootech.lab.loaders.execution_loader import load_execution
|
||||
from app.modules.zootech.lab.loaders.ration_loader import load_ration
|
||||
from app.modules.zootech.wesp_bridge_models import Component, Ingredient, Recipe
|
||||
from app.modules.zootech.wesp_bridge_models import default_uuid
|
||||
from app.modules.zootech.wesp_bridge_models import _enqueue_recipe_children_sync
|
||||
|
||||
|
||||
@retry_locked
|
||||
def apply_from_master(recipe_id: str, user_id: str = "system") -> dict:
|
||||
snapshot = load_ration(recipe_id)
|
||||
if not snapshot.exists:
|
||||
raise LookupError("Мастер рациона не найден")
|
||||
recipe = Recipe.query.filter_by(id=recipe_id, is_deleted=False).first()
|
||||
if recipe is None:
|
||||
raise LookupError("Рецепт не найден")
|
||||
heads = int(recipe.heads_per_trip or 0)
|
||||
if heads <= 0:
|
||||
raise ValueError("Количество голов должно быть > 0")
|
||||
|
||||
execution = load_execution(recipe_id)
|
||||
by_component = {str(l.component_id): l for l in execution.lines if l.component_id}
|
||||
master_by_component = {
|
||||
str(l.component_id): l
|
||||
for l in snapshot.lines
|
||||
if l.component_id and l.in_ration
|
||||
}
|
||||
|
||||
touched_ingredient_ids: list[str] = []
|
||||
for comp_id, master_line in master_by_component.items():
|
||||
daily_kg = float(master_line.daily_kg or 0)
|
||||
weight_per_head = daily_kg / heads
|
||||
ing = by_component.get(comp_id)
|
||||
comp = Component.query.get(comp_id)
|
||||
dm_pct = float(comp.dry_matter or 0) if comp else float(master_line.dry_matter or 0)
|
||||
if ing:
|
||||
row = Ingredient.query.get(ing.ingredient_id)
|
||||
if row is None:
|
||||
continue
|
||||
row.weight_per_head = weight_per_head
|
||||
row.amount = weight_per_head
|
||||
row.dry_matter = dm_pct
|
||||
row.updated_by = user_id
|
||||
touched_ingredient_ids.append(row.id)
|
||||
else:
|
||||
comp_name = master_line.ingredient_name or comp_id
|
||||
new_ing = Ingredient(
|
||||
id=default_uuid(),
|
||||
recipe_id=recipe_id,
|
||||
component_id=comp_id,
|
||||
name=comp_name[:100],
|
||||
amount=weight_per_head,
|
||||
weight_per_head=weight_per_head,
|
||||
dry_matter=dm_pct,
|
||||
order=len(touched_ingredient_ids),
|
||||
created_by=user_id,
|
||||
updated_by=user_id,
|
||||
)
|
||||
db.session.add(new_ing)
|
||||
touched_ingredient_ids.append(new_ing.id)
|
||||
|
||||
master_ids = set(master_by_component)
|
||||
for line in execution.lines:
|
||||
if line.component_id and str(line.component_id) not in master_ids:
|
||||
row = Ingredient.query.get(line.ingredient_id)
|
||||
if row and not row.is_deleted:
|
||||
row.soft_delete(user_id)
|
||||
|
||||
_enqueue_recipe_children_sync(recipe_id)
|
||||
write_audit("APPLY_FROM_MASTER", "lab_recipe_ration", recipe_id, user_id)
|
||||
db.session.commit()
|
||||
return {"recipeId": recipe_id, "ingredientsUpdated": len(touched_ingredient_ids)}
|
||||
@@ -0,0 +1,202 @@
|
||||
"""Аудит данных lab-модуля (профили, нормы, рационы) — без компонентов."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from app.modules.zootech.wesp_bridge_db import db
|
||||
from app.modules.zootech.lab.constants import RATION_QUALITY_INDICATORS
|
||||
from app.modules.zootech.lab.models import (
|
||||
LabAnimalProfile,
|
||||
LabProfileNorm,
|
||||
LabRationCalcIndicator,
|
||||
LabRationLine,
|
||||
LabRecipeRation,
|
||||
)
|
||||
from app.modules.zootech.lab.calc.feed_groups import classify_feed_group
|
||||
from app.modules.zootech.lab.nutrient_schema import read_from_mapping
|
||||
from app.modules.zootech.lab.services.component_nutrients import nutrients_full_dict
|
||||
from app.modules.zootech.wesp_bridge_models import Component, Recipe
|
||||
|
||||
_OMD_KEYS = ("ВРХ Орг Вещ", "КРС Орг Вещ")
|
||||
|
||||
_log = logging.getLogger(__name__)
|
||||
|
||||
CALC_NORM_KEYS = tuple(d["key"] for d in RATION_QUALITY_INDICATORS)
|
||||
|
||||
|
||||
@dataclass
|
||||
class AuditIssue:
|
||||
level: str # error | warn | info
|
||||
area: str
|
||||
message: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class LabDataAuditReport:
|
||||
issues: list[AuditIssue] = field(default_factory=list)
|
||||
profiles: int = 0
|
||||
profiles_with_norms: int = 0
|
||||
profile_norms_beef: int = 0
|
||||
profile_norms_dairy: int = 0
|
||||
recipe_rations: int = 0
|
||||
ration_lines: int = 0
|
||||
calc_indicators: int = 0
|
||||
components: int = 0
|
||||
components_omd_missing: int = 0
|
||||
|
||||
def add(self, level: str, area: str, message: str) -> None:
|
||||
self.issues.append(AuditIssue(level, area, message))
|
||||
|
||||
def ok(self) -> bool:
|
||||
return not any(i.level == "error" for i in self.issues)
|
||||
|
||||
|
||||
def _audit_components(report: LabDataAuditReport) -> None:
|
||||
components = Component.query.filter_by(is_deleted=False).all()
|
||||
report.components = len(components)
|
||||
for comp in components:
|
||||
full = nutrients_full_dict(comp.id)
|
||||
if not full:
|
||||
continue
|
||||
feed_group = classify_feed_group(comp)
|
||||
omd = read_from_mapping(full, _OMD_KEYS)
|
||||
if feed_group in ("rough", "succulent") and omd is None:
|
||||
report.components_omd_missing += 1
|
||||
report.add(
|
||||
"warn",
|
||||
"component",
|
||||
f"Компонент {comp.name!r} ({feed_group}): нет ВРХ Орг Вещ",
|
||||
)
|
||||
oe = read_from_mapping(full, ("ОЭ-КРС", " ОЭ-КРС", "OЭ КРС форм"))
|
||||
nel = read_from_mapping(full, ("ЧЭЛ- КРС", " ЧЭЛ- КРС", "ЧЭЛ - КРС Форм"))
|
||||
if (oe is not None and oe < 0) or (nel is not None and nel < 0):
|
||||
report.add(
|
||||
"warn",
|
||||
"component",
|
||||
f"Компонент {comp.name!r}: отрицательная энергия (ОЭ={oe}, ЧЭЛ={nel})",
|
||||
)
|
||||
|
||||
|
||||
def audit_lab_data(*, include_components: bool = False) -> LabDataAuditReport:
|
||||
report = LabDataAuditReport()
|
||||
|
||||
profiles = LabAnimalProfile.query.filter_by(is_deleted=False).all()
|
||||
report.profiles = len(profiles)
|
||||
|
||||
for profile in profiles:
|
||||
norms = LabProfileNorm.query.filter_by(profile_id=profile.id).all()
|
||||
norm_keys = {n.indicator_key for n in norms}
|
||||
if norms:
|
||||
report.profiles_with_norms += 1
|
||||
else:
|
||||
report.add("warn", "profile", f"Профиль {profile.profile_key!r} без норм (lab_profile_norm пуст)")
|
||||
|
||||
if profile.mass_kg is None:
|
||||
report.add("warn", "profile", f"Профиль {profile.profile_key!r}: mass_kg не задан")
|
||||
|
||||
missing = [k for k in CALC_NORM_KEYS if k not in norm_keys]
|
||||
if norms and missing:
|
||||
report.add(
|
||||
"info",
|
||||
"profile",
|
||||
f"Профиль {profile.profile_key!r}: нет ключей {', '.join(missing[:5])}"
|
||||
+ ("…" if len(missing) > 5 else ""),
|
||||
)
|
||||
|
||||
for norm in norms:
|
||||
if norm.min_value is None and norm.max_value is None:
|
||||
report.add(
|
||||
"warn",
|
||||
"profile",
|
||||
f"Профиль {profile.profile_key!r}: {norm.indicator_key} без min и max",
|
||||
)
|
||||
|
||||
for ration_type in ("BEEF", "DAIRY"):
|
||||
count = (
|
||||
db.session.query(LabProfileNorm)
|
||||
.join(LabAnimalProfile, LabAnimalProfile.id == LabProfileNorm.profile_id)
|
||||
.filter(
|
||||
LabAnimalProfile.ration_type == ration_type,
|
||||
LabAnimalProfile.is_deleted.is_(False),
|
||||
)
|
||||
.count()
|
||||
)
|
||||
if ration_type == "BEEF":
|
||||
report.profile_norms_beef = count
|
||||
else:
|
||||
report.profile_norms_dairy = count
|
||||
if report.profile_norms_beef == 0 and report.profile_norms_dairy == 0:
|
||||
report.add(
|
||||
"warn",
|
||||
"profile_norm",
|
||||
"lab_profile_norm пуст — нормы не импортированы (scripts/import_seed.py --norms)",
|
||||
)
|
||||
|
||||
headers = (
|
||||
LabRecipeRation.query.filter_by(is_deleted=False)
|
||||
.join(Recipe, Recipe.id == LabRecipeRation.recipe_id)
|
||||
.filter(Recipe.is_deleted.is_(False))
|
||||
.all()
|
||||
)
|
||||
report.recipe_rations = len(headers)
|
||||
|
||||
for header in headers:
|
||||
recipe = Recipe.query.get(header.recipe_id)
|
||||
name = recipe.name if recipe else header.recipe_id
|
||||
if not header.animal_profile_id:
|
||||
report.add("warn", "ration", f"Рецепт {name!r}: нет animal_profile_id")
|
||||
elif LabAnimalProfile.query.get(header.animal_profile_id) is None:
|
||||
report.add("error", "ration", f"Рецепт {name!r}: профиль {header.animal_profile_id} не найден")
|
||||
|
||||
lines = LabRationLine.query.filter_by(
|
||||
recipe_id=header.recipe_id, is_deleted=False
|
||||
).all()
|
||||
active = [ln for ln in lines if ln.in_ration and (ln.daily_kg or 0) > 0]
|
||||
if not active:
|
||||
report.add("info", "ration", f"Рецепт {name!r}: нет активных строк рациона")
|
||||
|
||||
for line in active:
|
||||
if not line.component_id:
|
||||
report.add("warn", "ration_line", f"Рецепт {name!r}, строка {line.row_index}: нет component_id")
|
||||
if line.daily_kg is None:
|
||||
report.add("warn", "ration_line", f"Рецепт {name!r}, строка {line.row_index}: daily_kg пуст")
|
||||
|
||||
if header.calculated_at and not LabRationCalcIndicator.query.filter_by(
|
||||
recipe_id=header.recipe_id
|
||||
).first():
|
||||
report.add("warn", "calc", f"Рецепт {name!r}: calculated_at есть, lab_ration_calc_indicator пуст")
|
||||
|
||||
report.ration_lines = LabRationLine.query.filter_by(is_deleted=False).count()
|
||||
report.calc_indicators = LabRationCalcIndicator.query.count()
|
||||
|
||||
if report.profiles == 0:
|
||||
report.add("warn", "profile", "Нет профилей стада (lab_animal_profile)")
|
||||
|
||||
if include_components:
|
||||
_audit_components(report)
|
||||
|
||||
_log.info(
|
||||
"lab audit: profiles=%s with_norms=%s rations=%s issues=%s",
|
||||
report.profiles,
|
||||
report.profiles_with_norms,
|
||||
report.recipe_rations,
|
||||
len(report.issues),
|
||||
)
|
||||
return report
|
||||
|
||||
|
||||
def format_audit_report(report: LabDataAuditReport) -> str:
|
||||
lines = [
|
||||
"=== Lab data audit ===",
|
||||
f"Профили: {report.profiles} (с нормами: {report.profiles_with_norms})",
|
||||
f"lab_profile_norm: BEEF={report.profile_norms_beef}, DAIRY={report.profile_norms_dairy}",
|
||||
f"lab_recipe_ration: {report.recipe_rations}, строк: {report.ration_lines}",
|
||||
f"lab_ration_calc_indicator: {report.calc_indicators}",
|
||||
f"components: {report.components} (без ВРХ rough/succulent: {report.components_omd_missing})",
|
||||
f"Замечаний: {len(report.issues)}",
|
||||
]
|
||||
for issue in report.issues:
|
||||
lines.append(f" [{issue.level}] {issue.area}: {issue.message}")
|
||||
return "\n".join(lines)
|
||||
@@ -0,0 +1,84 @@
|
||||
"""Удаление legacy/fp_* профилей стада (одноразовая ops-команда)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from app.modules.zootech.wesp_bridge_db import db
|
||||
from app.modules.zootech.lab.models import LabAnimalProfile, LabProfileNorm, LabRecipeRation
|
||||
|
||||
_log = logging.getLogger(__name__)
|
||||
|
||||
_LEGACY_KEY = re.compile(r"^(dairy|beef)-\d+$", re.IGNORECASE)
|
||||
_FP_KEY = re.compile(r"^fp_", re.IGNORECASE)
|
||||
|
||||
|
||||
@dataclass
|
||||
class CleanupReport:
|
||||
dry_run: bool = True
|
||||
profiles_to_delete: list[str] = field(default_factory=list)
|
||||
recipe_ids_cleared: list[str] = field(default_factory=list)
|
||||
norms_deleted: int = 0
|
||||
profiles_deleted: int = 0
|
||||
errors: list[str] = field(default_factory=list)
|
||||
|
||||
|
||||
def _should_delete(profile_key: str | None) -> bool:
|
||||
key = (profile_key or "").strip()
|
||||
if not key:
|
||||
return False
|
||||
if key.startswith("lab_math_"):
|
||||
return False
|
||||
if _LEGACY_KEY.match(key):
|
||||
return True
|
||||
if _FP_KEY.match(key):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def cleanup_legacy_profiles(*, dry_run: bool = True, user_id: str = "system") -> CleanupReport:
|
||||
report = CleanupReport(dry_run=dry_run)
|
||||
profiles = LabAnimalProfile.query.filter_by(is_deleted=False).all()
|
||||
to_delete = [p for p in profiles if _should_delete(p.profile_key)]
|
||||
|
||||
for profile in to_delete:
|
||||
report.profiles_to_delete.append(profile.profile_key or profile.id)
|
||||
|
||||
norms_count = LabProfileNorm.query.filter_by(profile_id=profile.id).count()
|
||||
report.norms_deleted += norms_count
|
||||
|
||||
rations = LabRecipeRation.query.filter_by(animal_profile_id=profile.id, is_deleted=False).all()
|
||||
for header in rations:
|
||||
if header.recipe_id not in report.recipe_ids_cleared:
|
||||
report.recipe_ids_cleared.append(header.recipe_id)
|
||||
|
||||
if dry_run:
|
||||
continue
|
||||
|
||||
LabProfileNorm.query.filter_by(profile_id=profile.id).delete(synchronize_session=False)
|
||||
for header in rations:
|
||||
header.animal_profile_id = None
|
||||
header.updated_by = user_id
|
||||
if hasattr(profile, "soft_delete"):
|
||||
profile.soft_delete(user_id)
|
||||
else:
|
||||
db.session.delete(profile)
|
||||
report.profiles_deleted += 1
|
||||
|
||||
if not dry_run:
|
||||
db.session.commit()
|
||||
_log.info(
|
||||
"cleanup_legacy_profiles user=%s deleted=%s rations_cleared=%s",
|
||||
user_id,
|
||||
report.profiles_deleted,
|
||||
len(report.recipe_ids_cleared),
|
||||
)
|
||||
else:
|
||||
_log.info(
|
||||
"cleanup_legacy_profiles dry_run profiles=%s rations=%s",
|
||||
len(report.profiles_to_delete),
|
||||
len(report.recipe_ids_cleared),
|
||||
)
|
||||
return report
|
||||
@@ -0,0 +1,30 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from app.modules.zootech.wesp_bridge_db import db
|
||||
from app.modules.zootech.lab.db_guard import retry_locked
|
||||
from app.modules.zootech.lab.models import (
|
||||
LabAnimalProfile,
|
||||
LabProfileNorm,
|
||||
LabRecipeRation,
|
||||
)
|
||||
@retry_locked
|
||||
def delete_animal_profile(profile_id: str, user_id: str = "system") -> dict:
|
||||
pid = (profile_id or "").strip()
|
||||
if not pid:
|
||||
raise LookupError("Профиль не найден")
|
||||
|
||||
profile = LabAnimalProfile.query.filter_by(id=pid, is_deleted=False).first()
|
||||
if profile is None:
|
||||
raise LookupError("Профиль не найден")
|
||||
|
||||
LabProfileNorm.query.filter_by(profile_id=profile.id).delete(synchronize_session=False)
|
||||
|
||||
rations = LabRecipeRation.query.filter_by(animal_profile_id=profile.id, is_deleted=False).all()
|
||||
for header in rations:
|
||||
header.animal_profile_id = None
|
||||
header.updated_by = user_id
|
||||
|
||||
profile.updated_by = user_id
|
||||
profile.soft_delete(user_id)
|
||||
db.session.commit()
|
||||
return {"id": profile.id, "deleted": True}
|
||||
@@ -0,0 +1,20 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from app.modules.zootech.wesp_bridge_db import db
|
||||
from app.modules.zootech.lab.db_guard import retry_locked
|
||||
from app.modules.zootech.lab.models import LabRecipeRation
|
||||
from app.modules.zootech.wesp_bridge_models import Recipe
|
||||
|
||||
|
||||
@retry_locked
|
||||
def ensure_empty_master(recipe_id: str, user_id: str = "system") -> dict:
|
||||
recipe = Recipe.query.filter_by(id=recipe_id, is_deleted=False).first()
|
||||
if recipe is None:
|
||||
raise LookupError("Рецепт не найден")
|
||||
existing = LabRecipeRation.query.filter_by(recipe_id=recipe_id, is_deleted=False).first()
|
||||
if existing is not None:
|
||||
return {"recipeId": recipe_id, "created": False}
|
||||
header = LabRecipeRation(recipe_id=recipe_id, created_by=user_id, updated_by=user_id)
|
||||
db.session.add(header)
|
||||
db.session.commit()
|
||||
return {"recipeId": recipe_id, "created": True}
|
||||
@@ -0,0 +1,299 @@
|
||||
"""Generic ETL: CSV из data/seed/ → lab profiles и component nutrients."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import csv
|
||||
import logging
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
|
||||
from app.modules.zootech.wesp_bridge_db import db
|
||||
from app.modules.zootech.lab.calc.ingredient_catalog import INGREDIENT_HEADERS
|
||||
from app.modules.zootech.lab.calc.ingredient_derive import derive_ingredient_nutrients
|
||||
from app.modules.zootech.lab.models import LabAnimalProfile
|
||||
from app.modules.zootech.lab.norm_catalog import norm_header, norm_title_to_key
|
||||
from app.modules.zootech.lab.seed_paths import norms_dir, nutrients_dir
|
||||
from app.modules.zootech.lab.services.component_nutrients import save_component_nutrients
|
||||
from app.modules.zootech.lab.services.profile_norms import save_norms_from_payload
|
||||
from app.modules.zootech.wesp_bridge_models import default_uuid
|
||||
from app.modules.zootech.wesp_bridge_models import Component
|
||||
|
||||
_log = logging.getLogger(__name__)
|
||||
|
||||
_SKIP_NUTRIENT_HEADERS = frozenset({"№", "Наименование", "Цена 1 кг"})
|
||||
|
||||
|
||||
@dataclass
|
||||
class NormsImportStats:
|
||||
profiles_created: int = 0
|
||||
profiles_updated: int = 0
|
||||
norm_rows: int = 0
|
||||
skipped: int = 0
|
||||
unmapped_headers: list[str] = field(default_factory=list)
|
||||
errors: list[str] = field(default_factory=list)
|
||||
|
||||
|
||||
@dataclass
|
||||
class NutrientsImportStats:
|
||||
imported: int = 0
|
||||
skipped: int = 0
|
||||
unmatched: int = 0
|
||||
errors: list[str] = field(default_factory=list)
|
||||
|
||||
|
||||
def _parse_min_max_groups(h1: list[str], h2: list[str]) -> list[tuple[str, int, int | None]]:
|
||||
groups: list[tuple[str, int, int | None]] = []
|
||||
current_name: str | None = None
|
||||
limit = min(len(h1), len(h2))
|
||||
for i in range(limit):
|
||||
b = (h2[i] or "").strip().lower().replace("і", "и")
|
||||
n = (h1[i] or "").strip()
|
||||
if n and n not in ("№", "Наименование", "Масса", "КРС"):
|
||||
current_name = norm_header(n)
|
||||
if b.startswith("мин") and current_name:
|
||||
max_i = i + 1 if i + 1 < limit and "мах" in (h2[i + 1] or "").lower() else None
|
||||
groups.append((current_name, i, max_i))
|
||||
return groups
|
||||
|
||||
|
||||
def norm_profile_key(ration_type: str, external_no: int) -> str:
|
||||
return f"norm_{ration_type.lower()}_{external_no:04d}"
|
||||
|
||||
|
||||
def parse_norm_profile_row(
|
||||
row: list[str],
|
||||
groups: list[tuple[str, int, int | None]],
|
||||
ration_type: str,
|
||||
*,
|
||||
unmapped: set[str],
|
||||
profile_key_fn=norm_profile_key,
|
||||
) -> dict | None:
|
||||
if len(row) < 2 or not (row[1] or "").strip():
|
||||
return None
|
||||
try:
|
||||
external_no = int(float((row[0] or "").strip()))
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
if external_no <= 0:
|
||||
return None
|
||||
|
||||
indicators: dict[str, dict[str, float | None]] = {}
|
||||
for header, min_i, max_i in groups:
|
||||
key = norm_title_to_key(header)
|
||||
if not key:
|
||||
unmapped.add(header)
|
||||
continue
|
||||
|
||||
def _num(idx: int | None) -> float | None:
|
||||
if idx is None or idx >= len(row):
|
||||
return None
|
||||
raw = (row[idx] or "").strip()
|
||||
if not raw:
|
||||
return None
|
||||
try:
|
||||
return float(raw)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
min_v, max_v = _num(min_i), _num(max_i)
|
||||
if min_v is None and max_v is None:
|
||||
continue
|
||||
indicators[key] = {"min": min_v, "max": max_v}
|
||||
|
||||
mass_kg = None
|
||||
if len(row) > 2 and (row[2] or "").strip():
|
||||
try:
|
||||
mass_kg = float(row[2])
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
return {
|
||||
"external_no": external_no,
|
||||
"profile_key": profile_key_fn(ration_type, external_no),
|
||||
"label": row[1].strip(),
|
||||
"ration_type": ration_type,
|
||||
"mass_kg": mass_kg,
|
||||
"indicators": indicators,
|
||||
}
|
||||
|
||||
|
||||
_IMPORT_BATCH_SIZE = 50
|
||||
|
||||
|
||||
def _lookup_reference_profile(profile_key: str) -> LabAnimalProfile | None:
|
||||
"""Поиск по profile_key включая soft-deleted (UNIQUE на всю таблицу)."""
|
||||
return LabAnimalProfile.query.filter_by(profile_key=profile_key).first()
|
||||
|
||||
|
||||
def _restore_reference_profile(profile: LabAnimalProfile) -> None:
|
||||
if not profile.is_deleted:
|
||||
return
|
||||
profile.is_deleted = False
|
||||
profile.deleted_at = None
|
||||
profile.deleted_by = None
|
||||
|
||||
|
||||
def _import_norm_sheet(
|
||||
csv_path: Path,
|
||||
ration_type: str,
|
||||
stats: NormsImportStats,
|
||||
unmapped: set[str],
|
||||
*,
|
||||
replace_reference: bool = True,
|
||||
) -> None:
|
||||
if not csv_path.is_file():
|
||||
stats.errors.append(f"Файл не найден: {csv_path}")
|
||||
return
|
||||
|
||||
with csv_path.open(encoding="utf-8") as f:
|
||||
rows = list(csv.reader(f))
|
||||
if len(rows) < 8:
|
||||
stats.errors.append(f"Слишком мало строк: {csv_path}")
|
||||
return
|
||||
|
||||
groups = _parse_min_max_groups(rows[2], rows[3])
|
||||
batch_count = 0
|
||||
for row in rows[6:]:
|
||||
parsed = parse_norm_profile_row(row, groups, ration_type, unmapped=unmapped)
|
||||
if parsed is None:
|
||||
stats.skipped += 1
|
||||
continue
|
||||
|
||||
profile = _lookup_reference_profile(parsed["profile_key"])
|
||||
if profile is not None:
|
||||
if not replace_reference and not profile.is_deleted:
|
||||
stats.skipped += 1
|
||||
continue
|
||||
if not replace_reference and profile.is_deleted:
|
||||
stats.skipped += 1
|
||||
continue
|
||||
_restore_reference_profile(profile)
|
||||
stats.profiles_updated += 1
|
||||
else:
|
||||
profile = LabAnimalProfile(
|
||||
id=default_uuid(),
|
||||
profile_key=parsed["profile_key"],
|
||||
created_by="seed-import",
|
||||
)
|
||||
db.session.add(profile)
|
||||
stats.profiles_created += 1
|
||||
|
||||
profile.label = parsed["label"]
|
||||
profile.ration_type = ration_type
|
||||
profile.external_no = parsed["external_no"]
|
||||
save_norms_from_payload(
|
||||
profile,
|
||||
{
|
||||
"massKg": parsed["mass_kg"],
|
||||
"externalNo": parsed["external_no"],
|
||||
"indicators": parsed["indicators"],
|
||||
},
|
||||
)
|
||||
stats.norm_rows += len(parsed["indicators"])
|
||||
batch_count += 1
|
||||
if batch_count >= _IMPORT_BATCH_SIZE:
|
||||
db.session.commit()
|
||||
batch_count = 0
|
||||
|
||||
|
||||
def import_norm_profiles(
|
||||
csv_dir: Path | None = None,
|
||||
*,
|
||||
replace_reference: bool = True,
|
||||
) -> NormsImportStats:
|
||||
stats = NormsImportStats()
|
||||
base = csv_dir or norms_dir()
|
||||
unmapped: set[str] = set()
|
||||
|
||||
_import_norm_sheet(
|
||||
base / "Нормы КРС.csv", "BEEF", stats, unmapped, replace_reference=replace_reference
|
||||
)
|
||||
_import_norm_sheet(
|
||||
base / "Нормы Дойн.csv", "DAIRY", stats, unmapped, replace_reference=replace_reference
|
||||
)
|
||||
stats.unmapped_headers = sorted(unmapped)
|
||||
db.session.commit()
|
||||
|
||||
_log.info(
|
||||
"seed norms import: created=%s updated=%s norm_rows=%s",
|
||||
stats.profiles_created,
|
||||
stats.profiles_updated,
|
||||
stats.norm_rows,
|
||||
)
|
||||
return stats
|
||||
|
||||
|
||||
def _parse_nutrient_row(row: list[str]) -> dict[str, float]:
|
||||
nutrients: dict[str, float] = {}
|
||||
for i, header in enumerate(INGREDIENT_HEADERS):
|
||||
if header in _SKIP_NUTRIENT_HEADERS or i >= len(row):
|
||||
continue
|
||||
raw = (row[i] or "").strip()
|
||||
if not raw:
|
||||
continue
|
||||
try:
|
||||
nutrients[header] = float(raw)
|
||||
except ValueError:
|
||||
continue
|
||||
return nutrients
|
||||
|
||||
|
||||
def _find_component(name: str, external_no: int | None) -> Component | None:
|
||||
if external_no is not None:
|
||||
hit = Component.query.filter_by(external_no=external_no, is_deleted=False).first()
|
||||
if hit:
|
||||
return hit
|
||||
if name:
|
||||
hit = Component.query.filter(Component.name == name, Component.is_deleted.is_(False)).first()
|
||||
if hit:
|
||||
return hit
|
||||
return None
|
||||
|
||||
|
||||
def import_component_nutrients(
|
||||
csv_path: Path | None = None,
|
||||
*,
|
||||
derive: bool = True,
|
||||
dry_run: bool = False,
|
||||
) -> NutrientsImportStats:
|
||||
path = csv_path or (nutrients_dir() / "База сырья.csv")
|
||||
stats = NutrientsImportStats()
|
||||
if not path.is_file():
|
||||
stats.errors.append(f"CSV not found: {path}")
|
||||
return stats
|
||||
|
||||
with path.open(encoding="utf-8") as f:
|
||||
rows = list(csv.reader(f))
|
||||
|
||||
for row in rows[5:]:
|
||||
name = row[1].strip() if len(row) > 1 else ""
|
||||
if not name:
|
||||
stats.skipped += 1
|
||||
continue
|
||||
try:
|
||||
external_no = int(float(row[0])) if row[0].strip() else None
|
||||
except (ValueError, IndexError):
|
||||
external_no = None
|
||||
|
||||
comp = _find_component(name, external_no)
|
||||
if comp is None:
|
||||
stats.unmatched += 1
|
||||
continue
|
||||
|
||||
nutrients = _parse_nutrient_row(row)
|
||||
if derive:
|
||||
nutrients = derive_ingredient_nutrients(nutrients)
|
||||
|
||||
if not dry_run:
|
||||
save_component_nutrients(comp.id, nutrients, user_id="seed-import")
|
||||
stats.imported += 1
|
||||
|
||||
if not dry_run:
|
||||
db.session.commit()
|
||||
_log.info(
|
||||
"seed nutrients import: imported=%s skipped=%s unmatched=%s",
|
||||
stats.imported,
|
||||
stats.skipped,
|
||||
stats.unmatched,
|
||||
)
|
||||
return stats
|
||||
@@ -0,0 +1,69 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
|
||||
from app.modules.zootech.wesp_bridge_db import db
|
||||
from app.modules.zootech.lab.calc.engine import calculate_ration
|
||||
from app.modules.zootech.lab.commands.write_audit import write_audit, write_calculation_run
|
||||
from app.modules.zootech.lab.db_guard import retry_locked
|
||||
from app.modules.zootech.lab.commands.seed_demo_component_nutrients import (
|
||||
ensure_component_nutrients_if_empty,
|
||||
supplement_component_nutrients_if_sparse,
|
||||
)
|
||||
from app.modules.zootech.lab.loaders.ration_loader import load_ration, ration_to_calc_lines
|
||||
from app.modules.zootech.lab.models import LabAnimalProfile, LabRecipeRation
|
||||
from app.modules.zootech.lab.services.ration_calc_store import save_calc_result
|
||||
|
||||
|
||||
@retry_locked
|
||||
def recalculate_ration(recipe_id: str, user_id: str = "system") -> dict:
|
||||
snapshot = load_ration(recipe_id)
|
||||
if not snapshot.lines:
|
||||
raise ValueError("Пустой мастер — добавьте строки рациона")
|
||||
|
||||
seeded_any = False
|
||||
for line in snapshot.lines:
|
||||
if not line.component_id:
|
||||
continue
|
||||
if ensure_component_nutrients_if_empty(line.component_id, user_id=user_id):
|
||||
seeded_any = True
|
||||
elif supplement_component_nutrients_if_sparse(line.component_id, user_id=user_id):
|
||||
seeded_any = True
|
||||
if seeded_any:
|
||||
db.session.commit()
|
||||
snapshot = load_ration(recipe_id)
|
||||
|
||||
profile_mass_kg = None
|
||||
if snapshot.animal_profile_id:
|
||||
prof = LabAnimalProfile.query.get(snapshot.animal_profile_id)
|
||||
if prof is not None:
|
||||
profile_mass_kg = prof.mass_kg
|
||||
|
||||
started = time.perf_counter()
|
||||
result = calculate_ration(
|
||||
snapshot.ration_type,
|
||||
ration_to_calc_lines(snapshot),
|
||||
snapshot.norms,
|
||||
heads_per_trip=snapshot.heads_per_trip,
|
||||
profile_mass_kg=profile_mass_kg,
|
||||
)
|
||||
duration_ms = int((time.perf_counter() - started) * 1000)
|
||||
|
||||
header = LabRecipeRation.query.filter_by(recipe_id=recipe_id).first()
|
||||
if header is None:
|
||||
header = LabRecipeRation(recipe_id=recipe_id, created_by=user_id, updated_by=user_id)
|
||||
db.session.add(header)
|
||||
save_calc_result(recipe_id, result, header)
|
||||
header.updated_by = user_id
|
||||
|
||||
status = "FAILED" if result.get("errors") else "COMPLETED"
|
||||
write_calculation_run(
|
||||
recipe_id,
|
||||
status,
|
||||
{"indicators": result.get("indicators", [])},
|
||||
duration_ms=duration_ms,
|
||||
error_message="; ".join(result.get("errors") or []) or None,
|
||||
)
|
||||
write_audit("RECALCULATE", "lab_recipe_ration", recipe_id, user_id)
|
||||
db.session.commit()
|
||||
return result
|
||||
@@ -0,0 +1,343 @@
|
||||
"""Стартовые nutrients для компонентов без EAV (демо-шаблоны, не лабораторный анализ).
|
||||
|
||||
При старте и пересчёте рациона заполняет только пустые `lab_component_nutrient_value`.
|
||||
Маркер в data/ — журнал последнего прогона, не блокирует новые компоненты.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from app.modules.zootech.wesp_bridge_db import db
|
||||
from app.modules.zootech.lab.nutrient_schema import read_from_mapping
|
||||
from app.modules.zootech.lab.services.component_nutrients import nutrients_full_dict, nutrients_is_empty, save_component_nutrients
|
||||
from app.modules.zootech.wesp_bridge_models import Component
|
||||
|
||||
_log = logging.getLogger(__name__)
|
||||
|
||||
_MARKER_NAME = ".lab_demo_nutrients_seeded"
|
||||
|
||||
# Доп. поля для полной матрицы рациона (демо; заменить лабораторными при наличии).
|
||||
_MINERAL_SUPPLEMENT: dict[str, float] = {
|
||||
"Ca": 8.0,
|
||||
"P": 5.5,
|
||||
"Mg": 3.2,
|
||||
"Na": 1.2,
|
||||
"K": 6.5,
|
||||
"DCAB Форм": 45.0,
|
||||
"Сырая зола": 55.0,
|
||||
"Каротин": 5.0,
|
||||
}
|
||||
|
||||
_CARB_SUPPLEMENT: dict[str, float] = {
|
||||
"Сахар": 80.0,
|
||||
"Крахмал": 280.0,
|
||||
"Сахар и Крохм": 420.0,
|
||||
"Нераств. Крохмал": 35.0,
|
||||
}
|
||||
|
||||
_ROUGHAGE_TYPES = frozenset({"объемные корма", "грубые корма", "сочные корма"})
|
||||
|
||||
_SUPPLEMENT_KEYS: tuple[str, ...] = tuple({**_MINERAL_SUPPLEMENT, **_CARB_SUPPLEMENT}.keys())
|
||||
|
||||
_NAMED_SEEDS: dict[str, dict[str, Any]] = {
|
||||
"комбикорм": {
|
||||
"dry_matter": 88.0,
|
||||
"nutrients": {
|
||||
"Сыр. Протеин": 170,
|
||||
"уСП": 142,
|
||||
"БРА": 36,
|
||||
"ОЭ-КРС": 12.5,
|
||||
"ЧЭЛ- КРС": 7.8,
|
||||
"Сырая клетчатка": 95,
|
||||
"Структур. клетчатка": 28,
|
||||
"Сырой жир": 38,
|
||||
},
|
||||
},
|
||||
"жом свекловичный гранулы": {
|
||||
"dry_matter": 90.0,
|
||||
"nutrients": {
|
||||
"Сыр. Протеин": 105,
|
||||
"уСП": 88,
|
||||
"ОЭ-КРС": 11.8,
|
||||
"ЧЭЛ- КРС": 7.2,
|
||||
"Сырая клетчатка": 220,
|
||||
"Структур. клетчатка": 48,
|
||||
"Сырой жир": 12,
|
||||
},
|
||||
},
|
||||
"жом": {
|
||||
"dry_matter": 90.0,
|
||||
"nutrients": {
|
||||
"Сыр. Протеин": 105,
|
||||
"уСП": 88,
|
||||
"ОЭ-КРС": 11.8,
|
||||
"ЧЭЛ- КРС": 7.2,
|
||||
"Сырая клетчатка": 220,
|
||||
"Структур. клетчатка": 48,
|
||||
"Сырой жир": 12,
|
||||
},
|
||||
},
|
||||
"дробина": {
|
||||
"dry_matter": 91.0,
|
||||
"nutrients": {
|
||||
"Сыр. Протеин": 100,
|
||||
"уСП": 88,
|
||||
"ОЭ-КРС": 11.5,
|
||||
"ЧЭЛ- КРС": 7.0,
|
||||
"Сырая клетчатка": 200,
|
||||
"Структур. клетчатка": 40,
|
||||
"Сырой жир": 12,
|
||||
},
|
||||
},
|
||||
"добавки": {
|
||||
"dry_matter": 99.0,
|
||||
"nutrients": {
|
||||
"Сыр. Протеин": 8,
|
||||
"ОЭ-КРС": 2.0,
|
||||
"ЧЭЛ- КРС": 1.2,
|
||||
"Сырая клетчатка": 5,
|
||||
"Сырой жир": 3,
|
||||
},
|
||||
},
|
||||
"сено": {
|
||||
"dry_matter": 85.0,
|
||||
"nutrients": {
|
||||
"Сыр. Протеин": 78,
|
||||
"уСП": 62,
|
||||
"ОЭ-КРС": 5.9,
|
||||
"ЧЭЛ- КРС": 3.4,
|
||||
"Сырая клетчатка": 328,
|
||||
"Структур. клетчатка": 265,
|
||||
"Сырой жир": 22,
|
||||
},
|
||||
},
|
||||
"анионовые соли": {
|
||||
"dry_matter": 95.0,
|
||||
"nutrients": {
|
||||
"Сыр. Протеин": 12,
|
||||
"ОЭ-КРС": 1.2,
|
||||
"ЧЭЛ- КРС": 0.8,
|
||||
"Сырая клетчатка": 8,
|
||||
"Сырой жир": 2,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
_TYPE_DEFAULTS: dict[str, dict[str, Any]] = {
|
||||
"зерновые": {
|
||||
"dry_matter": 88.0,
|
||||
"nutrients": {
|
||||
"Сыр. Протеин": 120,
|
||||
"уСП": 105,
|
||||
"БРА": 28,
|
||||
"ОЭ-КРС": 11.0,
|
||||
"ЧЭЛ- КРС": 6.8,
|
||||
"Сырая клетчатка": 85,
|
||||
"Структур. клетчатка": 22,
|
||||
"Сырой жир": 35,
|
||||
},
|
||||
},
|
||||
"белковые": {
|
||||
"dry_matter": 90.0,
|
||||
"nutrients": {
|
||||
"Сыр. Протеин": 380,
|
||||
"уСП": 300,
|
||||
"БРА": 80,
|
||||
"ОЭ-КРС": 11.5,
|
||||
"ЧЭЛ- КРС": 7.0,
|
||||
"Сырая клетчатка": 120,
|
||||
"Сырой жир": 45,
|
||||
},
|
||||
},
|
||||
"минеральные": {
|
||||
"dry_matter": 95.0,
|
||||
"nutrients": {
|
||||
"Сыр. Протеин": 12,
|
||||
"ОЭ-КРС": 1.2,
|
||||
"ЧЭЛ- КРС": 0.8,
|
||||
"Сырая клетчатка": 8,
|
||||
"Сырой жир": 2,
|
||||
},
|
||||
},
|
||||
"витаминные": {
|
||||
"dry_matter": 99.0,
|
||||
"nutrients": {
|
||||
"Сыр. Протеин": 8,
|
||||
"ОЭ-КРС": 2.0,
|
||||
"ЧЭЛ- КРС": 1.2,
|
||||
"Сырая клетчатка": 5,
|
||||
"Сырой жир": 3,
|
||||
},
|
||||
},
|
||||
"энергетические": {
|
||||
"dry_matter": 90.0,
|
||||
"nutrients": {
|
||||
"Сыр. Протеин": 100,
|
||||
"уСП": 88,
|
||||
"ОЭ-КРС": 11.5,
|
||||
"ЧЭЛ- КРС": 7.0,
|
||||
"Сырая клетчатка": 200,
|
||||
"Структур. клетчатка": 40,
|
||||
"Сырой жир": 12,
|
||||
},
|
||||
},
|
||||
"объемные корма": {
|
||||
"dry_matter": 85.0,
|
||||
"nutrients": {
|
||||
"Сыр. Протеин": 78,
|
||||
"уСП": 62,
|
||||
"ОЭ-КРС": 5.9,
|
||||
"ЧЭЛ- КРС": 3.4,
|
||||
"Сырая клетчатка": 328,
|
||||
"Структур. клетчатка": 265,
|
||||
"Сырой жир": 22,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
_FALLBACK = {
|
||||
"dry_matter": 88.0,
|
||||
"nutrients": {
|
||||
"Сыр. Протеин": 100,
|
||||
"уСП": 85,
|
||||
"ОЭ-КРС": 10.0,
|
||||
"ЧЭЛ- КРС": 6.2,
|
||||
"Сырая клетчатка": 150,
|
||||
"Сырой жир": 25,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _normalize_name(name: str | None) -> str:
|
||||
return (name or "").strip().lower()
|
||||
|
||||
|
||||
def _merge_supplement(nutrients: dict[str, Any], component: Component) -> dict[str, float]:
|
||||
merged = dict(_MINERAL_SUPPLEMENT)
|
||||
type_key = _normalize_name(component.type)
|
||||
if type_key not in _ROUGHAGE_TYPES:
|
||||
merged.update(_CARB_SUPPLEMENT)
|
||||
merged.update(nutrients)
|
||||
return {k: float(v) for k, v in merged.items() if v is not None}
|
||||
|
||||
|
||||
def _payload_for_component(component: Component) -> dict[str, Any]:
|
||||
name_key = _normalize_name(component.name)
|
||||
if name_key in _NAMED_SEEDS:
|
||||
payload = _NAMED_SEEDS[name_key]
|
||||
else:
|
||||
payload = None
|
||||
for key in sorted(_NAMED_SEEDS, key=len, reverse=True):
|
||||
if key in name_key:
|
||||
payload = _NAMED_SEEDS[key]
|
||||
break
|
||||
if payload is None:
|
||||
type_key = _normalize_name(component.type)
|
||||
payload = _TYPE_DEFAULTS.get(type_key, _FALLBACK)
|
||||
return {
|
||||
"dry_matter": payload.get("dry_matter"),
|
||||
"nutrients": _merge_supplement(payload.get("nutrients") or {}, component),
|
||||
}
|
||||
|
||||
|
||||
def _apply_demo_payload(component: Component, payload: dict[str, Any], *, user_id: str) -> None:
|
||||
if payload.get("dry_matter") is not None:
|
||||
dm = float(payload["dry_matter"])
|
||||
current_dm = float(component.dry_matter or 0)
|
||||
if dm > 0 and (current_dm <= 0 or current_dm > 100):
|
||||
component.dry_matter = dm
|
||||
save_component_nutrients(
|
||||
component.id,
|
||||
payload["nutrients"],
|
||||
user_id=user_id,
|
||||
)
|
||||
component.protein = 0.0
|
||||
component.energy = 0.0
|
||||
component.updated_by = "demo-nutrients-seed"
|
||||
|
||||
|
||||
def supplement_component_nutrients_if_sparse(
|
||||
component_id: str | None,
|
||||
*,
|
||||
user_id: str = "system",
|
||||
) -> bool:
|
||||
"""Добавляет Ca/P/сахара и т.д., если EAV уже есть, но без минералов."""
|
||||
if not component_id or nutrients_is_empty(component_id):
|
||||
return False
|
||||
component = Component.query.filter_by(id=component_id, is_deleted=False).first()
|
||||
if component is None:
|
||||
return False
|
||||
full = nutrients_full_dict(component_id)
|
||||
template = _payload_for_component(component)["nutrients"]
|
||||
allowed = set(_MINERAL_SUPPLEMENT)
|
||||
if _normalize_name(component.type) not in _ROUGHAGE_TYPES:
|
||||
allowed.update(_CARB_SUPPLEMENT)
|
||||
missing = {
|
||||
key: float(template[key])
|
||||
for key in allowed
|
||||
if key in template and read_from_mapping(full, (key,)) is None
|
||||
}
|
||||
if not missing:
|
||||
return False
|
||||
save_component_nutrients(component_id, missing, user_id=user_id, pin=True)
|
||||
return True
|
||||
|
||||
|
||||
def ensure_component_nutrients_if_empty(
|
||||
component_id: str | None,
|
||||
*,
|
||||
user_id: str = "system",
|
||||
) -> bool:
|
||||
"""Заполняет EAV демо-шаблоном, если у компонента нет nutrients. Возвращает True при записи."""
|
||||
if not component_id or not nutrients_is_empty(component_id):
|
||||
return False
|
||||
component = Component.query.filter_by(id=component_id, is_deleted=False).first()
|
||||
if component is None:
|
||||
return False
|
||||
_apply_demo_payload(component, _payload_for_component(component), user_id=user_id)
|
||||
return True
|
||||
|
||||
|
||||
def _marker_path() -> Path:
|
||||
return Path(__file__).resolve().parents[4] / "data" / _MARKER_NAME
|
||||
|
||||
|
||||
def _already_seeded() -> bool:
|
||||
return _marker_path().is_file()
|
||||
|
||||
|
||||
def _mark_seeded() -> None:
|
||||
path = _marker_path()
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text("1\n", encoding="utf-8")
|
||||
|
||||
|
||||
def seed_demo_component_nutrients_once(*, user_id: str = "system", force: bool = False) -> dict:
|
||||
"""Заполняет lab_component_nutrient_value у всех компонентов с пустой матрицей."""
|
||||
updated = 0
|
||||
skipped = 0
|
||||
for component in Component.query.filter_by(is_deleted=False).all():
|
||||
if not force and not nutrients_is_empty(component.id):
|
||||
skipped += 1
|
||||
continue
|
||||
if force and not nutrients_is_empty(component.id):
|
||||
skipped += 1
|
||||
continue
|
||||
_apply_demo_payload(component, _payload_for_component(component), user_id=user_id)
|
||||
updated += 1
|
||||
|
||||
if updated:
|
||||
db.session.commit()
|
||||
_mark_seeded()
|
||||
_log.info(
|
||||
"demo component nutrients seed user=%s updated=%s skipped=%s",
|
||||
user_id,
|
||||
updated,
|
||||
skipped,
|
||||
)
|
||||
return {"seeded": True, "updated": updated, "skipped": skipped}
|
||||
|
||||
return {"seeded": False, "reason": "nothing_to_update", "updated": 0, "skipped": skipped}
|
||||
@@ -0,0 +1,40 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from app.modules.zootech.wesp_bridge_db import db
|
||||
from app.modules.zootech.lab.db_guard import retry_locked
|
||||
from app.modules.zootech.lab.loaders.execution_loader import load_execution
|
||||
from app.modules.zootech.lab.models import LabRationLine, LabRecipeRation
|
||||
from app.modules.zootech.wesp_bridge_models import Component
|
||||
from app.modules.zootech.wesp_bridge_models import default_uuid
|
||||
|
||||
|
||||
@retry_locked
|
||||
def seed_from_execution(recipe_id: str, user_id: str = "system") -> dict:
|
||||
existing = LabRecipeRation.query.filter_by(recipe_id=recipe_id, is_deleted=False).first()
|
||||
if existing is not None:
|
||||
return {"recipeId": recipe_id, "seeded": False, "reason": "already_exists"}
|
||||
|
||||
execution = load_execution(recipe_id)
|
||||
header = LabRecipeRation(recipe_id=recipe_id, created_by=user_id, updated_by=user_id)
|
||||
db.session.add(header)
|
||||
|
||||
for idx, line in enumerate(execution.lines):
|
||||
comp = Component.query.get(line.component_id) if line.component_id else None
|
||||
daily_kg = line.daily_kg_total
|
||||
db.session.add(
|
||||
LabRationLine(
|
||||
id=default_uuid(),
|
||||
recipe_id=recipe_id,
|
||||
component_id=line.component_id,
|
||||
ingredient_name=line.name,
|
||||
row_index=idx,
|
||||
daily_kg=daily_kg,
|
||||
in_ration=True,
|
||||
in_compound=False,
|
||||
created_by=user_id,
|
||||
updated_by=user_id,
|
||||
)
|
||||
)
|
||||
header.seed_source = "execution"
|
||||
db.session.commit()
|
||||
return {"recipeId": recipe_id, "seeded": True, "lines": len(execution.lines)}
|
||||
@@ -0,0 +1,358 @@
|
||||
"""Тестовые рецепты LAB: полная матрица показателей + рацион «в нормах»."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from app.modules.zootech.wesp_bridge_db import db
|
||||
from app.modules.zootech.lab.commands.recalculate import recalculate_ration
|
||||
from app.modules.zootech.lab.models import LabAnimalProfile, LabRationLine, LabRecipeRation
|
||||
from app.modules.zootech.lab.services.component_nutrients import save_component_nutrients
|
||||
from app.modules.zootech.wesp_bridge_models import Component, Ingredient, Recipe
|
||||
from app.modules.zootech.wesp_bridge_models import default_uuid
|
||||
|
||||
_log = logging.getLogger(__name__)
|
||||
|
||||
PROFILE_KEY = "lab_math_dairy_01"
|
||||
HEADS = 10
|
||||
_MARKER = ".lab_math_ration_seeded"
|
||||
|
||||
RECIPE_MATRIX = "LAB тест — полная матрица"
|
||||
RECIPE_IN_NORMS = "LAB тест — в нормах"
|
||||
|
||||
# Реалистичные г/кг (и МДж для энергии) — покрывают расширенный каталог indicators.py
|
||||
_COMPONENTS: tuple[dict[str, Any], ...] = (
|
||||
{
|
||||
"name": "LAB тест — сено",
|
||||
"type": "Грубые корма",
|
||||
"dry_matter": 85.0,
|
||||
"price": 12.0,
|
||||
"nutrients": {
|
||||
"СВ": 850,
|
||||
"Осн.Корм": 850,
|
||||
"Сыр. Протеин": 78,
|
||||
"уСП": 62,
|
||||
"ОЭ-КРС": 5.9,
|
||||
"ЧЭЛ- КРС": 3.4,
|
||||
"Сырая клетч": 328,
|
||||
"НДК": 328,
|
||||
"КДК": 210,
|
||||
"Структур клетч": 265,
|
||||
"Сырой жир": 22,
|
||||
"Ca": 3.5,
|
||||
"P": 2.1,
|
||||
"Mg": 1.8,
|
||||
"Na": 0.5,
|
||||
"K": 18,
|
||||
"DCAB Форм": 120,
|
||||
"Сахар и Крохм": 120,
|
||||
"Сахар": 40,
|
||||
"Крахмал": 15,
|
||||
"Нераств. Крохмал": 5,
|
||||
"Каротин": 25,
|
||||
"Сырая зола": 65,
|
||||
"БЕР": 95,
|
||||
"Переварим Протеин": 55,
|
||||
"КРС Протеин": 68,
|
||||
"Переварим сырая клетч": 195,
|
||||
"Fe": 40,
|
||||
"Zn": 22,
|
||||
"Cu": 6,
|
||||
"Mn": 35,
|
||||
"Se": 0.04,
|
||||
"J": 0.02,
|
||||
"Вит А": 12000,
|
||||
"Вит D": 800,
|
||||
"Вит Е": 30,
|
||||
"Вит В1": 2.5,
|
||||
"Вит В2": 4.0,
|
||||
"Вит В6": 3.0,
|
||||
"Вит В12": 0.0,
|
||||
"Лизин": 2.8,
|
||||
"Метаб лизин": 1.2,
|
||||
"% уСП/кг СВ": 7.3,
|
||||
"Нер. СП / кг СВ": 11,
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "LAB тест — комбикорм",
|
||||
"type": "Концентрированные",
|
||||
"dry_matter": 88.0,
|
||||
"price": 22.0,
|
||||
"nutrients": {
|
||||
"СВ": 880,
|
||||
"Осн.Корм": 0,
|
||||
"Сыр. Протеин": 170,
|
||||
"уСП": 142,
|
||||
"ОЭ-КРС": 12.5,
|
||||
"ЧЭЛ- КРС": 7.8,
|
||||
"Сырая клетч": 95,
|
||||
"НДК": 95,
|
||||
"КДК": 42,
|
||||
"Структур клетч": 28,
|
||||
"Сырой жир": 38,
|
||||
"Ca": 8.0,
|
||||
"P": 5.5,
|
||||
"Mg": 3.2,
|
||||
"Na": 1.2,
|
||||
"K": 6.5,
|
||||
"DCAB Форм": 45,
|
||||
"Сахар и Крохм": 420,
|
||||
"Сахар": 80,
|
||||
"Крахмал": 280,
|
||||
"Нераств. Крохмал": 35,
|
||||
"Каротин": 5,
|
||||
"Сырая зола": 55,
|
||||
"БЕР": 520,
|
||||
"Переварим Протеин": 125,
|
||||
"КРС Протеин": 155,
|
||||
"Fe": 85,
|
||||
"Zn": 55,
|
||||
"Cu": 12,
|
||||
"Mn": 28,
|
||||
"Se": 0.12,
|
||||
"Вит А": 2500,
|
||||
"Вит D": 400,
|
||||
"Вит Е": 45,
|
||||
"Вит В1": 5.5,
|
||||
"Вит В2": 6.0,
|
||||
"Лизин": 8.5,
|
||||
"Метаб лизин": 4.2,
|
||||
"% уСП/кг СВ": 16.1,
|
||||
"Нер. СП / кг СВ": 28,
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "LAB тест — силос",
|
||||
"type": "Сочные корма",
|
||||
"dry_matter": 34.0,
|
||||
"price": 4.0,
|
||||
"nutrients": {
|
||||
"СВ": 340,
|
||||
"Осн.Корм": 0,
|
||||
"Сыр. Протеин": 32,
|
||||
"уСП": 28,
|
||||
"ОЭ-КРС": 6.2,
|
||||
"ЧЭЛ- КРС": 3.8,
|
||||
"Сырая клетч": 220,
|
||||
"НДК": 220,
|
||||
"КДК": 125,
|
||||
"Структур клетч": 45,
|
||||
"Сырой жир": 28,
|
||||
"Ca": 4.5,
|
||||
"P": 2.8,
|
||||
"Mg": 1.5,
|
||||
"Na": 0.3,
|
||||
"K": 12,
|
||||
"DCAB Форм": 95,
|
||||
"Сахар и Крохм": 180,
|
||||
"Сахар": 60,
|
||||
"Крахмал": 45,
|
||||
"Нераств. Крохмал": 12,
|
||||
"Каротин": 18,
|
||||
"Сырая зола": 38,
|
||||
"БЕР": 145,
|
||||
"Переварим Протеин": 22,
|
||||
"КРС Протеин": 28,
|
||||
"Fe": 25,
|
||||
"Zn": 18,
|
||||
"Cu": 4,
|
||||
"Mn": 15,
|
||||
"Вит А": 3500,
|
||||
"Вит Е": 18,
|
||||
"Лизин": 1.1,
|
||||
"% уСП/кг СВ": 8.2,
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
_RECIPE_SPECS: tuple[dict[str, Any], ...] = (
|
||||
{
|
||||
"name": RECIPE_MATRIX,
|
||||
"profile_key": PROFILE_KEY,
|
||||
"description": "перегруз для проверки красных отклонений",
|
||||
"lines": (
|
||||
("LAB тест — сено", 5.0),
|
||||
("LAB тест — комбикорм", 12.0),
|
||||
("LAB тест — силос", 8.0),
|
||||
),
|
||||
},
|
||||
{
|
||||
"name": RECIPE_IN_NORMS,
|
||||
"profile_key": PROFILE_KEY,
|
||||
"description": "поддержание 300 кг — большинство норм в зелёной зоне",
|
||||
"lines": (
|
||||
("LAB тест — сено", 4.5),
|
||||
("LAB тест — комбикорм", 1.0),
|
||||
("LAB тест — силос", 1.8),
|
||||
),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class LabMathRationStats:
|
||||
recipe_ids: list[str] = field(default_factory=list)
|
||||
created: int = 0
|
||||
updated: int = 0
|
||||
recalculated: int = 0
|
||||
errors: list[str] = field(default_factory=list)
|
||||
|
||||
@property
|
||||
def recipe_id(self) -> str | None:
|
||||
return self.recipe_ids[0] if self.recipe_ids else None
|
||||
|
||||
|
||||
def _marker_path() -> Path:
|
||||
return Path(__file__).resolve().parents[4] / "data" / _MARKER
|
||||
|
||||
|
||||
def _get_or_create_component(spec: dict[str, Any]) -> Component:
|
||||
comp = Component.query.filter_by(name=spec["name"], is_deleted=False).first()
|
||||
if comp is None:
|
||||
comp = Component(
|
||||
id=default_uuid(),
|
||||
name=spec["name"],
|
||||
type=spec["type"],
|
||||
dry_matter=float(spec["dry_matter"]),
|
||||
protein=0.0,
|
||||
energy=0.0,
|
||||
price=float(spec.get("price", 0)),
|
||||
created_by="lab-math-ration",
|
||||
updated_by="lab-math-ration",
|
||||
)
|
||||
db.session.add(comp)
|
||||
db.session.flush()
|
||||
else:
|
||||
comp.dry_matter = float(spec["dry_matter"])
|
||||
comp.price = float(spec.get("price", comp.price or 0))
|
||||
comp.updated_by = "lab-math-ration"
|
||||
nutrients = {k: float(v) for k, v in spec["nutrients"].items()}
|
||||
save_component_nutrients(comp.id, nutrients, user_id="lab-math-ration", pin=True)
|
||||
return comp
|
||||
|
||||
|
||||
def _seed_one_recipe(
|
||||
spec: dict[str, Any],
|
||||
*,
|
||||
by_name: dict[str, Component],
|
||||
profile: LabAnimalProfile,
|
||||
stats: LabMathRationStats,
|
||||
) -> str:
|
||||
recipe = Recipe.query.filter_by(name=spec["name"], is_deleted=False).first()
|
||||
if recipe is None:
|
||||
recipe = Recipe(
|
||||
id=default_uuid(),
|
||||
name=spec["name"],
|
||||
heads_per_trip=HEADS,
|
||||
ration_type="DAIRY",
|
||||
mixing_time=0,
|
||||
trip_percent=100.0,
|
||||
created_by="lab-math-ration",
|
||||
updated_by="lab-math-ration",
|
||||
)
|
||||
db.session.add(recipe)
|
||||
stats.created += 1
|
||||
db.session.flush()
|
||||
else:
|
||||
recipe.heads_per_trip = HEADS
|
||||
recipe.ration_type = "DAIRY"
|
||||
stats.updated += 1
|
||||
|
||||
for ing in Ingredient.query.filter_by(recipe_id=recipe.id, is_deleted=False).all():
|
||||
ing.soft_delete("lab-math-ration")
|
||||
for order, (cname, wph) in enumerate(spec["lines"]):
|
||||
comp = by_name[cname]
|
||||
db.session.add(
|
||||
Ingredient(
|
||||
id=default_uuid(),
|
||||
recipe_id=recipe.id,
|
||||
component_id=comp.id,
|
||||
name=comp.name,
|
||||
amount=wph * HEADS,
|
||||
weight_per_head=wph,
|
||||
dry_matter=comp.dry_matter,
|
||||
order=order,
|
||||
created_by="lab-math-ration",
|
||||
updated_by="lab-math-ration",
|
||||
)
|
||||
)
|
||||
|
||||
header = LabRecipeRation.query.filter_by(recipe_id=recipe.id, is_deleted=False).first()
|
||||
if header is None:
|
||||
header = LabRecipeRation(
|
||||
recipe_id=recipe.id,
|
||||
created_by="lab-math-ration",
|
||||
updated_by="lab-math-ration",
|
||||
)
|
||||
db.session.add(header)
|
||||
header.animal_profile_id = profile.id
|
||||
header.seed_source = "lab_math_test"
|
||||
|
||||
for line in LabRationLine.query.filter_by(recipe_id=recipe.id, is_deleted=False).all():
|
||||
line.soft_delete("lab-math-ration")
|
||||
for idx, (cname, wph) in enumerate(spec["lines"]):
|
||||
comp = by_name[cname]
|
||||
db.session.add(
|
||||
LabRationLine(
|
||||
id=default_uuid(),
|
||||
recipe_id=recipe.id,
|
||||
component_id=comp.id,
|
||||
ingredient_name=comp.name,
|
||||
row_index=idx,
|
||||
daily_kg=wph * HEADS,
|
||||
in_ration=True,
|
||||
in_compound=False,
|
||||
created_by="lab-math-ration",
|
||||
updated_by="lab-math-ration",
|
||||
)
|
||||
)
|
||||
return recipe.id
|
||||
|
||||
|
||||
def seed_lab_math_ration(*, force: bool = False, run_calc: bool = True) -> LabMathRationStats:
|
||||
stats = LabMathRationStats()
|
||||
if not force and _marker_path().is_file():
|
||||
for spec in _RECIPE_SPECS:
|
||||
existing = Recipe.query.filter_by(name=spec["name"], is_deleted=False).first()
|
||||
if existing:
|
||||
stats.recipe_ids.append(existing.id)
|
||||
if stats.recipe_ids:
|
||||
stats.updated = len(stats.recipe_ids)
|
||||
return stats
|
||||
|
||||
profile = LabAnimalProfile.query.filter_by(profile_key=PROFILE_KEY, is_deleted=False).first()
|
||||
if profile is None:
|
||||
stats.errors.append(
|
||||
f"Профиль {PROFILE_KEY} не найден — запустите scripts/seed_lab_math_profiles.py"
|
||||
)
|
||||
return stats
|
||||
|
||||
components = [_get_or_create_component(spec) for spec in _COMPONENTS]
|
||||
by_name = {c.name: c for c in components}
|
||||
|
||||
for spec in _RECIPE_SPECS:
|
||||
rid = _seed_one_recipe(spec, by_name=by_name, profile=profile, stats=stats)
|
||||
stats.recipe_ids.append(rid)
|
||||
|
||||
db.session.commit()
|
||||
|
||||
if run_calc:
|
||||
for rid in stats.recipe_ids:
|
||||
try:
|
||||
recalculate_ration(rid, "lab-math-ration")
|
||||
stats.recalculated += 1
|
||||
except Exception as exc:
|
||||
stats.errors.append(f"пересчёт {rid}: {exc}")
|
||||
|
||||
_marker_path().parent.mkdir(parents=True, exist_ok=True)
|
||||
_marker_path().write_text("\n".join(stats.recipe_ids) + "\n", encoding="utf-8")
|
||||
_log.info(
|
||||
"lab math rations: ids=%s matrix+in_norms heads=%s",
|
||||
stats.recipe_ids,
|
||||
HEADS,
|
||||
)
|
||||
return stats
|
||||
@@ -0,0 +1,90 @@
|
||||
"""5 профилей норм для проверки математики рациона (строки 1–5 из data/seed/norms)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import csv
|
||||
import logging
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
|
||||
from app.modules.zootech.wesp_bridge_db import db
|
||||
from app.modules.zootech.lab.commands.import_seed import _parse_min_max_groups, parse_norm_profile_row
|
||||
from app.modules.zootech.lab.seed_paths import norms_dir
|
||||
from app.modules.zootech.lab.models import LabAnimalProfile
|
||||
from app.modules.zootech.lab.services.profile_norms import save_norms_from_payload
|
||||
from app.modules.zootech.wesp_bridge_models import default_uuid
|
||||
|
||||
_log = logging.getLogger(__name__)
|
||||
|
||||
_MATH_SPECS: tuple[tuple[str, str, int, str], ...] = (
|
||||
("DAIRY", "Нормы Дойн.csv", 1, "lab_math_dairy_01"),
|
||||
("DAIRY", "Нормы Дойн.csv", 2, "lab_math_dairy_02"),
|
||||
("DAIRY", "Нормы Дойн.csv", 3, "lab_math_dairy_03"),
|
||||
("DAIRY", "Нормы Дойн.csv", 4, "lab_math_dairy_04"),
|
||||
("DAIRY", "Нормы Дойн.csv", 5, "lab_math_dairy_05"),
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class MathTestSeedStats:
|
||||
created: int = 0
|
||||
updated: int = 0
|
||||
errors: list[str] = field(default_factory=list)
|
||||
|
||||
|
||||
def _load_csv_row(csv_path: Path, external_no: int, ration_type: str) -> dict | None:
|
||||
with csv_path.open(encoding="utf-8") as f:
|
||||
rows = list(csv.reader(f))
|
||||
if len(rows) < 8:
|
||||
return None
|
||||
groups = _parse_min_max_groups(rows[2], rows[3])
|
||||
data_row = rows[5 + external_no]
|
||||
|
||||
eno = external_no
|
||||
return parse_norm_profile_row(
|
||||
data_row,
|
||||
groups,
|
||||
ration_type,
|
||||
unmapped=set(),
|
||||
profile_key_fn=lambda rt, _ext: f"lab_math_{rt.lower()}_{eno:02d}",
|
||||
)
|
||||
|
||||
|
||||
def seed_math_test_profiles(*, csv_dir: Path | None = None) -> MathTestSeedStats:
|
||||
stats = MathTestSeedStats()
|
||||
base = csv_dir or norms_dir()
|
||||
|
||||
for ration_type, filename, external_no, profile_key in _MATH_SPECS:
|
||||
parsed = _load_csv_row(base / filename, external_no, ration_type)
|
||||
if parsed is None:
|
||||
stats.errors.append(f"Строка {external_no} не найдена в {filename}")
|
||||
continue
|
||||
|
||||
profile = LabAnimalProfile.query.filter_by(profile_key=profile_key, is_deleted=False).first()
|
||||
if profile is None:
|
||||
profile = LabAnimalProfile(
|
||||
id=default_uuid(),
|
||||
profile_key=profile_key,
|
||||
created_by="lab-math-test-seed",
|
||||
)
|
||||
db.session.add(profile)
|
||||
stats.created += 1
|
||||
else:
|
||||
stats.updated += 1
|
||||
|
||||
profile.label = f"TEST математика #{external_no} — {parsed['label'][:48]}"
|
||||
profile.ration_type = ration_type
|
||||
profile.external_no = parsed["external_no"]
|
||||
parsed["indicators"]["rnb"] = {"min": -10.0, "max": 20.0}
|
||||
save_norms_from_payload(
|
||||
profile,
|
||||
{
|
||||
"massKg": parsed["mass_kg"],
|
||||
"externalNo": parsed["external_no"],
|
||||
"indicators": parsed["indicators"],
|
||||
},
|
||||
)
|
||||
|
||||
db.session.commit()
|
||||
_log.info("lab math test profiles: created=%s updated=%s", stats.created, stats.updated)
|
||||
return stats
|
||||
@@ -0,0 +1,49 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from app.modules.zootech.wesp_bridge_db import db
|
||||
from app.modules.zootech.lab.commands.write_audit import write_audit
|
||||
from app.modules.zootech.lab.db_guard import retry_locked
|
||||
from app.modules.zootech.lab.loaders.execution_loader import load_execution
|
||||
from app.modules.zootech.lab.models import LabRationLine, LabRecipeRation
|
||||
from app.modules.zootech.wesp_bridge_models import Component
|
||||
from app.modules.zootech.wesp_bridge_models import default_uuid
|
||||
|
||||
|
||||
@retry_locked
|
||||
def sync_from_execution(recipe_id: str, user_id: str = "system") -> dict:
|
||||
"""Обновить мастер из execution (обратный перенос /recipes → lab)."""
|
||||
execution = load_execution(recipe_id)
|
||||
header = LabRecipeRation.query.filter_by(recipe_id=recipe_id, is_deleted=False).first()
|
||||
if header is None:
|
||||
header = LabRecipeRation(recipe_id=recipe_id, created_by=user_id, updated_by=user_id)
|
||||
db.session.add(header)
|
||||
else:
|
||||
header.updated_by = user_id
|
||||
|
||||
existing = LabRationLine.query.filter_by(recipe_id=recipe_id, is_deleted=False).all()
|
||||
for line in existing:
|
||||
line.soft_delete(user_id)
|
||||
|
||||
created = 0
|
||||
for idx, line in enumerate(execution.lines):
|
||||
comp = Component.query.get(line.component_id) if line.component_id else None
|
||||
db.session.add(
|
||||
LabRationLine(
|
||||
id=default_uuid(),
|
||||
recipe_id=recipe_id,
|
||||
component_id=line.component_id,
|
||||
ingredient_name=line.name or (comp.name if comp else None),
|
||||
row_index=idx,
|
||||
daily_kg=line.daily_kg_total,
|
||||
in_ration=True,
|
||||
in_compound=False,
|
||||
created_by=user_id,
|
||||
updated_by=user_id,
|
||||
)
|
||||
)
|
||||
created += 1
|
||||
|
||||
header.seed_source = "synced_from"
|
||||
write_audit("SYNC_FROM_EXECUTION", "lab_recipe_ration", recipe_id, user_id)
|
||||
db.session.commit()
|
||||
return {"recipeId": recipe_id, "lines": created}
|
||||
@@ -0,0 +1,62 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from app.modules.zootech.wesp_bridge_db import db
|
||||
from app.modules.zootech.lab.db_guard import retry_locked
|
||||
from app.modules.zootech.lab.models import LabAnimalProfile
|
||||
from app.modules.zootech.lab.calc.nutrients import parse_num
|
||||
from app.modules.zootech.lab.calc.norms_resolver import normalize_norms_method
|
||||
from app.modules.zootech.lab.services.norms_params import save_norms_params
|
||||
from app.modules.zootech.lab.services.profile_norms import save_norms_from_payload, sync_racion_norms_to_profile
|
||||
from app.modules.zootech.wesp_bridge_models import default_uuid
|
||||
|
||||
|
||||
@retry_locked
|
||||
def upsert_animal_profile(profile_id: str | None, payload: dict[str, Any], user_id: str = "system") -> dict:
|
||||
pid = (profile_id or payload.get("id") or "").strip() or default_uuid()
|
||||
profile = LabAnimalProfile.query.filter_by(id=pid, is_deleted=False).first()
|
||||
is_new = profile is None
|
||||
if is_new:
|
||||
profile = LabAnimalProfile(id=pid, created_by=user_id)
|
||||
db.session.add(profile)
|
||||
|
||||
key = (payload.get("profileKey") or payload.get("profile_key") or "").strip()
|
||||
label = (payload.get("label") or "").strip()
|
||||
ration_type = (payload.get("rationType") or payload.get("ration_type") or "BEEF").strip().upper()
|
||||
if not key or not label:
|
||||
raise ValueError("profileKey и label обязательны")
|
||||
|
||||
profile.profile_key = key
|
||||
profile.label = label
|
||||
profile.ration_type = ration_type
|
||||
if "normsMethod" in payload or "norms_method" in payload:
|
||||
profile.norms_method = normalize_norms_method(
|
||||
payload.get("normsMethod", payload.get("norms_method"))
|
||||
)
|
||||
if "normsParams" in payload or "norms_params" in payload:
|
||||
raw_params = payload.get("normsParams", payload.get("norms_params"))
|
||||
save_norms_params(profile, raw_params if isinstance(raw_params, dict) else None)
|
||||
if "milkYieldKg" in payload or "milk_yield_kg" in payload:
|
||||
profile.milk_yield_kg = parse_num(payload.get("milkYieldKg", payload.get("milk_yield_kg")))
|
||||
|
||||
norms_payload: dict[str, Any] = {}
|
||||
if "normsData" in payload or "norms_data" in payload:
|
||||
raw = payload.get("normsData") if "normsData" in payload else payload.get("norms_data")
|
||||
if isinstance(raw, dict):
|
||||
norms_payload = dict(raw)
|
||||
if payload.get("massKg") is not None or payload.get("mass_kg") is not None:
|
||||
norms_payload["massKg"] = payload.get("massKg", payload.get("mass_kg"))
|
||||
if "milkYieldKg" in payload or "milk_yield_kg" in payload:
|
||||
norms_payload["milkYieldKg"] = payload.get("milkYieldKg", payload.get("milk_yield_kg"))
|
||||
if payload.get("externalNo") is not None or payload.get("external_no") is not None:
|
||||
norms_payload["externalNo"] = payload.get("externalNo", payload.get("external_no"))
|
||||
if isinstance(payload.get("indicators"), dict):
|
||||
norms_payload["indicators"] = payload["indicators"]
|
||||
if norms_payload:
|
||||
save_norms_from_payload(profile, norms_payload)
|
||||
|
||||
profile.updated_by = user_id
|
||||
sync_racion_norms_to_profile(profile)
|
||||
db.session.commit()
|
||||
return {"id": profile.id, "profileKey": profile.profile_key, "label": profile.label}
|
||||
@@ -0,0 +1,65 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from app.modules.zootech.wesp_bridge_db import db
|
||||
from app.modules.zootech.lab.db_guard import retry_locked
|
||||
from app.modules.zootech.lab.models import LabRationLine, LabRecipeRation
|
||||
from app.modules.zootech.wesp_bridge_models import Recipe
|
||||
from app.modules.zootech.wesp_bridge_models import default_uuid
|
||||
|
||||
|
||||
@retry_locked
|
||||
def upsert_ration(recipe_id: str, payload: dict[str, Any], user_id: str = "system") -> dict[str, Any]:
|
||||
recipe = Recipe.query.filter_by(id=recipe_id, is_deleted=False).first()
|
||||
if recipe is None:
|
||||
raise LookupError("Рецепт не найден")
|
||||
|
||||
header = LabRecipeRation.query.filter_by(recipe_id=recipe_id).first()
|
||||
if header is None:
|
||||
header = LabRecipeRation(recipe_id=recipe_id, created_by=user_id, updated_by=user_id)
|
||||
db.session.add(header)
|
||||
header.animal_profile_id = payload.get("animalProfileId") or payload.get("animal_profile_id")
|
||||
if "params" in payload:
|
||||
params = payload.get("params") or {}
|
||||
if isinstance(params, dict):
|
||||
seeded = params.get("seeded_from") or params.get("synced_from")
|
||||
if seeded:
|
||||
header.seed_source = str(seeded)
|
||||
if "rationType" in payload or "ration_type" in payload:
|
||||
recipe.ration_type = (payload.get("rationType") or payload.get("ration_type") or "").upper() or None
|
||||
header.updated_by = user_id
|
||||
|
||||
incoming = payload.get("lines") or []
|
||||
existing = {
|
||||
str(l.id): l
|
||||
for l in LabRationLine.query.filter_by(recipe_id=recipe_id, is_deleted=False).all()
|
||||
}
|
||||
seen_ids: set[str] = set()
|
||||
for idx, row in enumerate(incoming):
|
||||
line_id = str(row.get("id") or "")
|
||||
line = existing.get(line_id) if line_id else None
|
||||
if line is None:
|
||||
line = LabRationLine(
|
||||
id=default_uuid(),
|
||||
recipe_id=recipe_id,
|
||||
created_by=user_id,
|
||||
updated_by=user_id,
|
||||
)
|
||||
db.session.add(line)
|
||||
line.row_index = int(row.get("rowIndex", row.get("row_index", idx)))
|
||||
line.component_id = row.get("componentId") or row.get("component_id")
|
||||
line.ingredient_name = row.get("ingredientName") or row.get("ingredient_name")
|
||||
line.daily_kg = row.get("dailyKg", row.get("daily_kg"))
|
||||
line.in_ration = bool(row.get("inRation", row.get("in_ration", True)))
|
||||
line.in_compound = bool(row.get("inCompound", row.get("in_compound", False)))
|
||||
line.updated_by = user_id
|
||||
if line.id:
|
||||
seen_ids.add(str(line.id))
|
||||
|
||||
for lid, line in existing.items():
|
||||
if lid not in seen_ids:
|
||||
line.soft_delete(user_id)
|
||||
|
||||
db.session.commit()
|
||||
return {"recipeId": recipe_id, "ok": True}
|
||||
@@ -0,0 +1,43 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
_log = logging.getLogger("app.lab.audit")
|
||||
|
||||
|
||||
def write_audit(
|
||||
action: str,
|
||||
entity_type: str,
|
||||
entity_id: str | None,
|
||||
user_id: str | None = None,
|
||||
metadata: dict[str, Any] | None = None,
|
||||
) -> None:
|
||||
_log.info(
|
||||
"lab action=%s entity=%s/%s user=%s metadata=%s",
|
||||
action,
|
||||
entity_type,
|
||||
entity_id,
|
||||
user_id or "system",
|
||||
json.dumps(metadata or {}, ensure_ascii=False),
|
||||
)
|
||||
|
||||
|
||||
def write_calculation_run(
|
||||
recipe_id: str,
|
||||
status: str,
|
||||
kpi_results: dict[str, Any],
|
||||
*,
|
||||
duration_ms: int | None = None,
|
||||
error_message: str | None = None,
|
||||
) -> None:
|
||||
_log.info(
|
||||
"lab calculation recipe=%s status=%s duration_ms=%s engine=%s error=%s kpi=%s",
|
||||
recipe_id,
|
||||
status,
|
||||
duration_ms,
|
||||
"native-1",
|
||||
error_message or "",
|
||||
json.dumps(kpi_results, ensure_ascii=False),
|
||||
)
|
||||
@@ -0,0 +1,28 @@
|
||||
"""Lab ration calc constants (ported from tab @neoton/shared)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from app.modules.zootech.lab.indicators import RATION_ALL_INDICATORS, RATION_QUALITY_INDICATORS
|
||||
|
||||
RATION_TOTAL_KEYS = {
|
||||
"DAIRY": [
|
||||
{"key": "total_kg", "label": "Итого кг/день"},
|
||||
{"key": "ration_pct_sum", "label": "Сумма % в рационе"},
|
||||
],
|
||||
"BEEF": [
|
||||
{"key": "total_kg", "label": "Итого кг/день"},
|
||||
{"key": "ration_kg", "label": "Сумма «из рациона», кг"},
|
||||
{"key": "ration_pct_sum", "label": "Сумма % в рационе"},
|
||||
{"key": "cost_total", "label": "Стоимость рациона (сумма)"},
|
||||
],
|
||||
}
|
||||
|
||||
NORM_COLUMN_ALIASES = {
|
||||
"dry_matter": ["Сухое Вещество", "Сухое вещество"],
|
||||
"crude_protein": ["Сыр. Протеин"],
|
||||
"usp": ["уСП"],
|
||||
"oe": ["ОЭ-КРС", " ОЭ-КРС"],
|
||||
"nel": ["ЧЭЛ- КРС", " ЧЭЛ- КРС"],
|
||||
}
|
||||
|
||||
DIFF_TOLERANCE_KG = 0.05
|
||||
@@ -0,0 +1,28 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from functools import wraps
|
||||
from typing import Any, Callable, TypeVar
|
||||
|
||||
from sqlalchemy.exc import OperationalError
|
||||
|
||||
F = TypeVar("F", bound=Callable[..., Any])
|
||||
|
||||
|
||||
def retry_locked(fn: F, *, attempts: int = 3, delay: float = 0.15) -> F:
|
||||
@wraps(fn)
|
||||
def wrapper(*args: Any, **kwargs: Any) -> Any:
|
||||
last_exc: Exception | None = None
|
||||
for i in range(attempts):
|
||||
try:
|
||||
return fn(*args, **kwargs)
|
||||
except OperationalError as exc:
|
||||
if "locked" not in str(exc).lower():
|
||||
raise
|
||||
last_exc = exc
|
||||
time.sleep(delay * (i + 1))
|
||||
if last_exc:
|
||||
raise last_exc
|
||||
return None
|
||||
|
||||
return wrapper # type: ignore[return-value]
|
||||
@@ -0,0 +1,3 @@
|
||||
from .ration import ExecutionSnapshot, RationSnapshot
|
||||
|
||||
__all__ = ["RationSnapshot", "ExecutionSnapshot"]
|
||||
@@ -0,0 +1,52 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RationLineSnapshot:
|
||||
id: str | None
|
||||
component_id: str | None
|
||||
ingredient_name: str | None
|
||||
row_index: int
|
||||
daily_kg: float | None
|
||||
in_ration: bool
|
||||
in_compound: bool
|
||||
dry_matter: float | None
|
||||
price_per_kg: float | None
|
||||
nutrients: dict[str, Any]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RationSnapshot:
|
||||
recipe_id: str
|
||||
recipe_name: str
|
||||
ration_type: str
|
||||
heads_per_trip: int
|
||||
animal_profile_id: str | None
|
||||
params: dict[str, Any]
|
||||
lines: tuple[RationLineSnapshot, ...] = field(default_factory=tuple)
|
||||
norms: dict[str, dict[str, float | None]] = field(default_factory=dict)
|
||||
ration_results: dict[str, Any] = field(default_factory=dict)
|
||||
compound_results: dict[str, Any] = field(default_factory=dict)
|
||||
calculated_at: str | None = None
|
||||
exists: bool = True
|
||||
norms_profile_key: str | None = None
|
||||
legacy_norms_remapped: bool = False
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ExecutionLineSnapshot:
|
||||
ingredient_id: str
|
||||
component_id: str | None
|
||||
name: str
|
||||
weight_per_head: float
|
||||
daily_kg_total: float
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ExecutionSnapshot:
|
||||
recipe_id: str
|
||||
heads_per_trip: int
|
||||
lines: tuple[ExecutionLineSnapshot, ...] = field(default_factory=tuple)
|
||||
@@ -0,0 +1 @@
|
||||
"""Offline ETL from tab reference PostgreSQL."""
|
||||
@@ -0,0 +1,88 @@
|
||||
"""AgroStar PDF (печатная форма анализа) → AgrostarParseResult."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
from app.modules.zootech.lab.etl.agrostar_tabular import (
|
||||
parse_tabular_triplet_lines,
|
||||
rows_to_label_values,
|
||||
sample_from_agro_fields,
|
||||
tabular_rows_to_agro_fields,
|
||||
)
|
||||
from app.modules.zootech.lab.etl.agrostar_xml_import import AgrostarParseResult
|
||||
|
||||
|
||||
def _pdf_text(data: bytes) -> str:
|
||||
try:
|
||||
import fitz # pymupdf
|
||||
except ImportError as exc:
|
||||
raise RuntimeError("Для PDF нужен pymupdf (pip install pymupdf)") from exc
|
||||
doc = fitz.open(stream=data, filetype="pdf")
|
||||
parts = [doc[i].get_text() for i in range(doc.page_count)]
|
||||
doc.close()
|
||||
return "\n".join(parts)
|
||||
|
||||
|
||||
def _line_after(lines: list[str], marker: str) -> str:
|
||||
for i, line in enumerate(lines):
|
||||
if line == marker and i + 1 < len(lines):
|
||||
return lines[i + 1]
|
||||
return ""
|
||||
|
||||
|
||||
def _is_agrostar_pdf(text: str) -> bool:
|
||||
t = text or ""
|
||||
return "ИНФОРМАЦИЯ ОБ ОБРАЗЦЕ" in t or "РЕЗУЛЬТАТЫ АНАЛИЗА" in t
|
||||
|
||||
|
||||
def parse_agrostar_pdf(data: bytes) -> AgrostarParseResult:
|
||||
result = AgrostarParseResult(lab_name="АгроСтар")
|
||||
try:
|
||||
text = _pdf_text(data)
|
||||
except RuntimeError as exc:
|
||||
result.errors.append(str(exc))
|
||||
return result
|
||||
|
||||
if not _is_agrostar_pdf(text):
|
||||
result.errors.append("Не похоже на отчёт AgroStar (PDF)")
|
||||
return result
|
||||
|
||||
lines = [ln.strip() for ln in text.splitlines() if ln.strip()]
|
||||
sample_no = _line_after(lines, "Образец №")
|
||||
farm_name = _line_after(lines, "Клиент")
|
||||
date_printed = _line_after(lines, "Дата анализа")
|
||||
desc = _line_after(lines, "Описание")
|
||||
|
||||
label_parts: list[str] = []
|
||||
if "Образец" in lines:
|
||||
idx = lines.index("Образец")
|
||||
if idx + 1 < len(lines) and lines[idx + 1] != "Описание":
|
||||
label_parts.append(lines[idx + 1])
|
||||
if idx + 2 < len(lines) and lines[idx + 2] not in ("Описание", "Клиент"):
|
||||
label_parts.append(lines[idx + 2])
|
||||
|
||||
triplets = parse_tabular_triplet_lines(lines)
|
||||
agro_fields = tabular_rows_to_agro_fields(rows_to_label_values(triplets))
|
||||
if not agro_fields:
|
||||
result.errors.append("В PDF не найдены показатели анализа")
|
||||
return result
|
||||
|
||||
sample = sample_from_agro_fields(
|
||||
sample_no=sample_no,
|
||||
farm_name=farm_name,
|
||||
date_printed=date_printed,
|
||||
desc_1=desc or " ".join(label_parts),
|
||||
agro_fields=agro_fields,
|
||||
)
|
||||
result.samples.append(sample)
|
||||
return result
|
||||
|
||||
|
||||
def looks_like_agrostar_pdf(data: bytes) -> bool:
|
||||
try:
|
||||
text = _pdf_text(data)
|
||||
except RuntimeError:
|
||||
return False
|
||||
return _is_agrostar_pdf(text)
|
||||
@@ -0,0 +1,129 @@
|
||||
"""AgroStar табличные форматы (PDF, xlsx) → те же AgrostarSample, что и XML."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
from app.modules.zootech.lab.etl.agrostar_xml_import import (
|
||||
AgrostarParseResult,
|
||||
AgrostarSample,
|
||||
_parse_float,
|
||||
_pct_dm_to_g_per_kg_sv,
|
||||
finalize_agrostar_sample,
|
||||
)
|
||||
|
||||
# Подпись в отчёте → ключ AgroStar XML (дальше — общий _AGROSTAR_MAP)
|
||||
_TABULAR_LABEL_TO_AGRO: dict[str, str] = {
|
||||
"Сухое вещество (DM)": "DM",
|
||||
"Сырой протеин (Crude Protein)": "CP",
|
||||
"КДК (ADF)": "ADF",
|
||||
"НДК (aNDF)": "NDF",
|
||||
"НДК по орг. веществу (aNDFom)": "aNDFom",
|
||||
"Сырой жир (Fat EE)": "Fat_EE",
|
||||
"Сырая зола (Ash)": "Ash",
|
||||
"Кальций (Ca)": "Ca",
|
||||
"Фосфор (P)": "P",
|
||||
"Магний (Mg)": "Mg",
|
||||
"Калий (K)": "K",
|
||||
"Сера (S)": "S",
|
||||
"Хлор (Cl)": "Cl",
|
||||
"Водорастворимый сахар (WSC)": "Sugar_WSC",
|
||||
"Cпирторастворимый сахар (ESC)": "Sugar_ESC",
|
||||
"Крахмал (Starch)": "Starch",
|
||||
"Безволокнистые углеводы (NFC)": "NFC",
|
||||
"Переваримые питательные вещества (TDN)": "TDN",
|
||||
"НДК протеин (ND-ICP)": "NDICP_CP",
|
||||
"Лизин": "Lys",
|
||||
"Метионин": "Met",
|
||||
"Лейцин": "Leu",
|
||||
"Изолейцин": "Ile",
|
||||
"Валин": "Val",
|
||||
"Усваиваемость НДК 30 ч (NDFD30)": "NDFDom_IV_30hr",
|
||||
}
|
||||
|
||||
_SECTION_HEADERS = frozenset(
|
||||
{
|
||||
"Протеин:",
|
||||
"Клетчатка:",
|
||||
"Сахара и крахмал:",
|
||||
"Жир и жирные кислоты:",
|
||||
"Минералы:",
|
||||
"Энергия:",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def map_tabular_label(label: str) -> str | None:
|
||||
text = (label or "").strip()
|
||||
if not text or text in _SECTION_HEADERS:
|
||||
return None
|
||||
if text in _TABULAR_LABEL_TO_AGRO:
|
||||
return _TABULAR_LABEL_TO_AGRO[text]
|
||||
lowered = text.lower()
|
||||
for key, ag_key in sorted(_TABULAR_LABEL_TO_AGRO.items(), key=lambda item: len(item[0]), reverse=True):
|
||||
if key.lower() in lowered:
|
||||
return ag_key
|
||||
return None
|
||||
|
||||
|
||||
def tabular_rows_to_agro_fields(rows: list[tuple[str, str]]) -> dict[str, float]:
|
||||
"""[(label, raw_value), ...] → raw_fields AgroStar."""
|
||||
out: dict[str, float] = {}
|
||||
for label, raw in rows:
|
||||
ag_key = map_tabular_label(label)
|
||||
if not ag_key:
|
||||
continue
|
||||
val = _parse_float(raw)
|
||||
if val is not None:
|
||||
out[ag_key] = val
|
||||
return out
|
||||
|
||||
|
||||
def sample_from_agro_fields(
|
||||
*,
|
||||
sample_no: str,
|
||||
farm_name: str,
|
||||
farm_id: str = "",
|
||||
date_printed: str = "",
|
||||
feed_type: str = "Mixed haylage",
|
||||
desc_1: str = "",
|
||||
desc_2: str = "",
|
||||
desc_3: str = "",
|
||||
agro_fields: dict[str, float],
|
||||
) -> AgrostarSample:
|
||||
sample = AgrostarSample(
|
||||
sample_no=sample_no,
|
||||
farm_name=farm_name,
|
||||
farm_id=farm_id,
|
||||
date_printed=date_printed,
|
||||
feed_type=feed_type,
|
||||
desc_1=desc_1,
|
||||
desc_2=desc_2,
|
||||
desc_3=desc_3,
|
||||
dry_matter_pct=agro_fields.get("DM"),
|
||||
)
|
||||
sample.raw_fields = {k: v for k, v in agro_fields.items() if k != "DM"}
|
||||
return finalize_agrostar_sample(sample)
|
||||
|
||||
|
||||
def parse_tabular_triplet_lines(lines: list[str]) -> list[tuple[str, str, str]]:
|
||||
"""Строки PDF: name, unit (%DM|%CP|%), value."""
|
||||
rows: list[tuple[str, str, str]] = []
|
||||
i = 0
|
||||
units = frozenset({"%DM", "%CP", "%"})
|
||||
while i < len(lines):
|
||||
line = lines[i]
|
||||
if line in units and i >= 1:
|
||||
name = lines[i - 1]
|
||||
val = lines[i + 1] if i + 1 < len(lines) else ""
|
||||
if val and (val[0].isdigit() or val.startswith("<")):
|
||||
rows.append((name, line, val))
|
||||
i += 2
|
||||
continue
|
||||
i += 1
|
||||
return rows
|
||||
|
||||
|
||||
def rows_to_label_values(triplets: list[tuple[str, str, str]]) -> list[tuple[str, str]]:
|
||||
return [(name, val) for name, _unit, val in triplets]
|
||||
@@ -0,0 +1,72 @@
|
||||
"""AgroStar xlsx (сводка проб) → AgrostarParseResult."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
from typing import Any
|
||||
|
||||
from openpyxl import load_workbook
|
||||
|
||||
from app.modules.zootech.lab.etl.agrostar_tabular import map_tabular_label, sample_from_agro_fields, tabular_rows_to_agro_fields
|
||||
from app.modules.zootech.lab.etl.agrostar_xml_import import AgrostarParseResult, _parse_float
|
||||
|
||||
|
||||
def parse_agrostar_xlsx(data: bytes) -> AgrostarParseResult:
|
||||
result = AgrostarParseResult(lab_name="АгроСтар")
|
||||
try:
|
||||
wb = load_workbook(io.BytesIO(data), data_only=True, read_only=False)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
result.errors.append(f"Не удалось открыть xlsx: {exc}")
|
||||
return result
|
||||
|
||||
ws = wb.active
|
||||
if ws.max_row < 4 or ws.max_column < 2:
|
||||
result.errors.append("Пустой или неузнаваемый xlsx")
|
||||
return result
|
||||
|
||||
sample_nos: list[str] = []
|
||||
farms: list[str] = []
|
||||
labels: list[str] = []
|
||||
for col in range(2, ws.max_column + 1):
|
||||
no = ws.cell(1, col).value
|
||||
if no is None:
|
||||
continue
|
||||
sample_nos.append(str(no).strip())
|
||||
farms.append(str(ws.cell(2, col).value or "").strip())
|
||||
labels.append(str(ws.cell(3, col).value or "").strip())
|
||||
|
||||
if not sample_nos:
|
||||
result.errors.append("В xlsx нет колонок с пробами")
|
||||
return result
|
||||
|
||||
row_values: list[tuple[str, list[str]]] = []
|
||||
for row in range(4, ws.max_row + 1):
|
||||
label = ws.cell(row, 1).value
|
||||
if label is None:
|
||||
continue
|
||||
label_str = str(label).strip()
|
||||
if not label_str or not map_tabular_label(label_str):
|
||||
continue
|
||||
vals = []
|
||||
for col in range(2, 2 + len(sample_nos)):
|
||||
raw = ws.cell(row, col).value
|
||||
vals.append("" if raw is None else str(raw))
|
||||
row_values.append((label_str, vals))
|
||||
|
||||
for idx, sample_no in enumerate(sample_nos):
|
||||
pairs = [(label, vals[idx]) for label, vals in row_values if idx < len(vals)]
|
||||
agro_fields = tabular_rows_to_agro_fields(pairs)
|
||||
if "DM" not in agro_fields:
|
||||
result.errors.append(f"Проба {sample_no}: нет СВ")
|
||||
continue
|
||||
sample = sample_from_agro_fields(
|
||||
sample_no=sample_no,
|
||||
farm_name=farms[idx] if idx < len(farms) else "",
|
||||
desc_1=labels[idx] if idx < len(labels) else sample_no,
|
||||
agro_fields=agro_fields,
|
||||
)
|
||||
result.samples.append(sample)
|
||||
|
||||
if not result.samples and not result.errors:
|
||||
result.errors.append("Не удалось разобрать пробы из xlsx")
|
||||
return result
|
||||
@@ -0,0 +1,787 @@
|
||||
"""Импорт лабораторных анализов AgroStar (Standard_XML_Data) → zootech nutrients WESP."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import xml.etree.ElementTree as ET
|
||||
from dataclasses import dataclass, field
|
||||
from difflib import SequenceMatcher
|
||||
from typing import Any
|
||||
|
||||
from app.modules.zootech.wesp_bridge_db import db
|
||||
from app.modules.zootech.lab.calc.feed_groups import classify_feed_group
|
||||
from app.modules.zootech.lab.services.component_nutrients import save_component_nutrients
|
||||
from app.modules.zootech.wesp_bridge_models import Component
|
||||
|
||||
# AgroStar Name → (WESP заголовок «База сырья», kind)
|
||||
# pct_dm: %DM → г/кг СВ (×10); pct: процент как есть; extra: дублировать в доп. ключи
|
||||
_AGROSTAR_MAP: tuple[tuple[str, str, str], ...] = (
|
||||
("CP", "Сыр. Протеин", "pct_dm"),
|
||||
("NDF", "Сырая клетч", "pct_dm"),
|
||||
("ADF", "КДК", "pct_dm"),
|
||||
("Fat_EE", "Сырой жир", "pct_dm"),
|
||||
("Ash", "Сырая зола", "pct_dm"),
|
||||
("Ca", "Ca", "pct_dm"),
|
||||
("P", "P", "pct_dm"),
|
||||
("Mg", "Mg", "pct_dm"),
|
||||
("K", "K", "pct_dm"),
|
||||
("S", "S", "pct_dm"),
|
||||
("Cl", "CL", "pct_dm"),
|
||||
("Starch", "Крахмал", "pct_dm"),
|
||||
("Sugar_WSC", "Сахар", "pct_dm"),
|
||||
("NFC", "NFC", "pct_dm"),
|
||||
("Lys", "Лизин", "pct_dm"),
|
||||
("Met", "Метионин", "pct_dm"),
|
||||
("Leu", "Лейцин", "pct_dm"),
|
||||
("Ile", "Изолейцин", "pct_dm"),
|
||||
("Val", "Валин", "pct_dm"),
|
||||
("NDICP_CP", "% нераствор протеин", "pct"),
|
||||
("TDN", "ВРХ Орг Вещ", "pct"),
|
||||
)
|
||||
|
||||
_AGROSTAR_MAPPED_FIELDS = frozenset(key for key, _, _ in _AGROSTAR_MAP) | frozenset({"DM", "Sugar_ESC", "aNDFom"})
|
||||
|
||||
_UNSUPPORTED_AGROSTAR: dict[str, str] = {
|
||||
"NDFDom_IV_30hr": "нет поля для переваримости NDF",
|
||||
"NDFDom_IV_12hr": "нет поля для переваримости NDF",
|
||||
"NDFDom_IV_120hr": "нет поля для переваримости NDF",
|
||||
"NDFDom_IV_240hr": "нет поля для переваримости NDF",
|
||||
}
|
||||
|
||||
# Подписи AgroStar-полей, которые не пишем в WESP (для блока «Не попадёт»)
|
||||
_AGROSTAR_FIELD_RU: dict[str, str] = {
|
||||
"Moisture": "Влажность",
|
||||
"pH": "pH",
|
||||
"SP_CP": "Растворимый протеин",
|
||||
"SP": "Растворимый протеин (абс.)",
|
||||
"ADICP": "КДК-протеин (AD-ICP)",
|
||||
"ADICP_CP": "КДК-протеин, %CP",
|
||||
"NDICP": "НДК-протеин (абс.)",
|
||||
"NH3CPE": "Небелковый азот (NH₃-CP)",
|
||||
"Lignin": "Лигнин",
|
||||
"Lignin_NDF": "Лигнин, %NDF",
|
||||
"TFA": "Жирные кислоты, всего",
|
||||
"RFV": "RFV (относит. корм. ценность)",
|
||||
"RFQ": "RFQ (относит. качество)",
|
||||
"uNDFom_IV_12hr": "Нерасщепляемая NDF 12 ч",
|
||||
"uNDFom_IV_30hr": "Нерасщепляемая NDF 30 ч",
|
||||
"uNDFom_IV_120hr": "Нерасщепляемая NDF 120 ч",
|
||||
"uNDFom_IV_240hr": "Нерасщепляемая NDF 240 ч",
|
||||
"NDFDom_IV_12hr": "Переваримость NDF 12 ч",
|
||||
"NDFDom_IV_30hr": "Переваримость NDF 30 ч",
|
||||
"NDFDom_IV_120hr": "Переваримость NDF 120 ч",
|
||||
"NDFDom_IV_240hr": "Переваримость NDF 240 ч",
|
||||
"NELMcallb": "NEL (Mcalf/lb)",
|
||||
"NELMcalkg": "NEL (Mcal/kg)",
|
||||
"NELMJkg": "NEL (MJ/kg)",
|
||||
"NEMMcallb": "NEM (Mcal/lb)",
|
||||
"NEMMcalkg": "NEM (Mcal/kg)",
|
||||
"NEMMJkg": "NEM (MJ/kg)",
|
||||
"NEGMcallb": "NEG (Mcal/lb)",
|
||||
"NEGMcalkg": "NEG (Mcal/kg)",
|
||||
"NEGMJkg": "NEG (MJ/kg)",
|
||||
"MilktonH": "Молоко/тонна (Holstein)",
|
||||
"Horse_DE_Mcallb": "DE лошади (Mcal/lb)",
|
||||
"Acetic": "Уксусная кислота",
|
||||
"Propionic": "Пропионовая кислота",
|
||||
"Butyric": "Масляная кислота",
|
||||
"Lactic": "Молочная кислота",
|
||||
"Total_acid": "Органические кислоты, всего",
|
||||
"C160": "C16:0",
|
||||
"C180": "C18:0",
|
||||
"C181": "C18:1",
|
||||
"C182": "C18:2",
|
||||
"C183": "C18:3",
|
||||
"C160_TFA": "C16:0, %TFA",
|
||||
"C180_TFA": "C18:0, %TFA",
|
||||
"C181_TFA": "C18:1, %TFA",
|
||||
"C182_TFA": "C18:2, %TFA",
|
||||
"C183_TFA": "C18:3, %TFA",
|
||||
"His": "Гистидин",
|
||||
"TAA": "Сумма аминокислот",
|
||||
"Lys_CP": "Лизин, %CP",
|
||||
"Met_CP": "Метионин, %CP",
|
||||
"Leu_CP": "Лейцин, %CP",
|
||||
"Ile_CP": "Изолейцин, %CP",
|
||||
"Val_CP": "Валин, %CP",
|
||||
"His_CP": "Гистидин, %CP",
|
||||
"TAA_CP": "Сумма аминокислот, %CP",
|
||||
}
|
||||
|
||||
_UNSUPPORTED_GROUP_ORDER: tuple[str, ...] = (
|
||||
"переваримость NDF",
|
||||
"энергия",
|
||||
"кислоты силоса",
|
||||
"жирные кислоты",
|
||||
"дробный протеин",
|
||||
"аминокислоты (%CP)",
|
||||
"клетчатка и RF",
|
||||
"качество силоса",
|
||||
"прочее",
|
||||
)
|
||||
|
||||
|
||||
def _unsupported_group_key(ag_key: str) -> str:
|
||||
if ag_key in {"Moisture", "pH"}:
|
||||
return "качество силоса"
|
||||
if ag_key.startswith(("NDFDom", "uNDFom")):
|
||||
return "переваримость NDF"
|
||||
if ag_key.startswith(("NEL", "NEM", "NEG", "Milk", "Horse")):
|
||||
return "энергия"
|
||||
if ag_key in {"Acetic", "Propionic", "Butyric", "Lactic", "Total_acid"}:
|
||||
return "кислоты силоса"
|
||||
if ag_key.startswith("C1") or ag_key == "TFA":
|
||||
return "жирные кислоты"
|
||||
if ag_key in {"SP_CP", "SP", "ADICP", "ADICP_CP", "NDICP", "NH3CPE"}:
|
||||
return "дробный протеин"
|
||||
if ag_key.endswith("_CP") or ag_key in {"His", "TAA"}:
|
||||
return "аминокислоты (%CP)"
|
||||
if ag_key.startswith(("Lignin", "RF")):
|
||||
return "клетчатка и RF"
|
||||
return "прочее"
|
||||
|
||||
|
||||
def _summarize_unsupported(
|
||||
unsupported: list[dict[str, str]],
|
||||
*,
|
||||
nutrient_count: int,
|
||||
total_in_report: int,
|
||||
) -> tuple[list[dict[str, int | str]], str]:
|
||||
counts: dict[str, int] = {}
|
||||
for row in unsupported:
|
||||
group = _unsupported_group_key(row["agroKey"])
|
||||
counts[group] = counts.get(group, 0) + 1
|
||||
|
||||
groups: list[dict[str, int | str]] = []
|
||||
for label in _UNSUPPORTED_GROUP_ORDER:
|
||||
count = counts.pop(label, 0)
|
||||
if count:
|
||||
groups.append({"label": label, "count": count})
|
||||
for label, count in sorted(counts.items()):
|
||||
groups.append({"label": label, "count": count})
|
||||
|
||||
skip = len(unsupported)
|
||||
if skip == 0:
|
||||
return groups, ""
|
||||
|
||||
parts = [f"{g['label']} ({g['count']})" for g in groups]
|
||||
group_text = ", ".join(parts)
|
||||
stored = nutrient_count + 0 # nutrients without DM line in count; DM stored separately
|
||||
return (
|
||||
groups,
|
||||
f"В отчёте {total_in_report} показателей — запишем СВ и {stored} в карточку реагента. "
|
||||
f"Ещё {skip} останутся только в AgroStar: {group_text}.",
|
||||
)
|
||||
|
||||
# WESP nutrient key → AgroStar raw field(s), первый найденный — для колонки «В документе»
|
||||
_PREVIEW_AGRO_SOURCE: dict[str, tuple[str, ...]] = {
|
||||
"Сыр. Протеин": ("CP",),
|
||||
"% нераствор протеин": ("NDICP_CP",),
|
||||
"Сырая клетч": ("NDF",),
|
||||
"КДК": ("ADF",),
|
||||
"Структур. клетч": ("aNDFom", "ADF"),
|
||||
"Сырой жир": ("Fat_EE",),
|
||||
"Сырая зола": ("Ash",),
|
||||
"NFC": ("NFC",),
|
||||
"Сахар": ("Sugar_WSC", "Sugar_ESC"),
|
||||
"Крахмал": ("Starch",),
|
||||
"Ca": ("Ca",),
|
||||
"P": ("P",),
|
||||
"Mg": ("Mg",),
|
||||
"K": ("K",),
|
||||
"S": ("S",),
|
||||
"CL": ("Cl",),
|
||||
"ВРХ Орг Вещ": ("TDN",),
|
||||
"Лизин": ("Lys",),
|
||||
"Метионин": ("Met",),
|
||||
"Лейцин": ("Leu",),
|
||||
"Изолейцин": ("Ile",),
|
||||
"Валин": ("Val",),
|
||||
}
|
||||
|
||||
_AGRO_SOURCE_UNIT: dict[str, str] = {
|
||||
"CP": "%DM",
|
||||
"NDF": "%DM",
|
||||
"ADF": "%DM",
|
||||
"aNDFom": "%DM",
|
||||
"Fat_EE": "%DM",
|
||||
"Ash": "%DM",
|
||||
"Ca": "%DM",
|
||||
"P": "%DM",
|
||||
"Mg": "%DM",
|
||||
"K": "%DM",
|
||||
"S": "%DM",
|
||||
"Cl": "%DM",
|
||||
"Starch": "%DM",
|
||||
"Sugar_WSC": "%DM",
|
||||
"Sugar_ESC": "%DM",
|
||||
"NFC": "%DM",
|
||||
"TDN": "%DM",
|
||||
"Lys": "%DM",
|
||||
"Met": "%DM",
|
||||
"Leu": "%DM",
|
||||
"Ile": "%DM",
|
||||
"Val": "%DM",
|
||||
"NDICP_CP": "%CP",
|
||||
}
|
||||
|
||||
_META_FIELDS = frozenset({"Sample_No", "Name", "Farm_ID", "Lot_name", "Date_Printed", "Type", "Desc_1", "Desc_2", "Desc_3", "Product_code"})
|
||||
|
||||
_FEED_TYPE_RU: dict[str, str] = {
|
||||
"Mixed haylage": "Смешанный сенаж",
|
||||
"Haylage": "Сенаж",
|
||||
"Corn silage": "Кукурузный силос",
|
||||
"Grass silage": "Травяной силос",
|
||||
}
|
||||
|
||||
_AGROSTAR_FEED_TO_COMPONENT_TYPE: dict[str, str] = {
|
||||
"Mixed haylage": "Сочные корма",
|
||||
"Haylage": "Сочные корма",
|
||||
"Corn silage": "Сочные корма",
|
||||
"Grass silage": "Сочные корма",
|
||||
}
|
||||
|
||||
_FEED_GROUP_TO_COMPONENT_TYPE: dict[str, str] = {
|
||||
"rough": "Грубые корма",
|
||||
"succulent": "Сочные корма",
|
||||
"concentrate": "Концентрированные",
|
||||
"other": "Добавки",
|
||||
}
|
||||
|
||||
_WARNING_RU: dict[str, str] = {
|
||||
"dm_missing": "В файле нет сухого вещества (DM)",
|
||||
"omd_missing": "Нет данных для ВРХ — при расчёте подставится дефолт WESP",
|
||||
"omd_from_tdn": "ВРХ орг. вещ. оценили по TDN, не прямой OMD из лаборатории",
|
||||
}
|
||||
|
||||
# Порядок показа в «понюхали»: (ключ WESP, подпись, единица)
|
||||
_PREVIEW_WRITE_ORDER: tuple[tuple[str, str, str], ...] = (
|
||||
("__dry_matter__", "Сухое вещество (в карточку компонента)", "%"),
|
||||
("Сыр. Протеин", "Сырой протеин", "г/кг СВ"),
|
||||
("% нераствор протеин", "Нерастворимый протеин, %", "%"),
|
||||
("Сырая клетч", "Сырая клетчатка (NDF)", "г/кг СВ"),
|
||||
("КДК", "Кислая детергентная клетчатка (ADF)", "г/кг СВ"),
|
||||
("Структур. клетч", "Структурная клетчатка", "г/кг СВ"),
|
||||
("Сырой жир", "Сырой жир", "г/кг СВ"),
|
||||
("Сырая зола", "Сырая зола", "г/кг СВ"),
|
||||
("NFC", "Безволокнистые углеводы (NFC)", "г/кг СВ"),
|
||||
("Сахар", "Сахар (WSC/ESC)", "г/кг СВ"),
|
||||
("Крахмал", "Крахмал", "г/кг СВ"),
|
||||
("Ca", "Кальций", "г/кг СВ"),
|
||||
("P", "Фосфор", "г/кг СВ"),
|
||||
("Mg", "Магний", "г/кг СВ"),
|
||||
("K", "Калий", "г/кг СВ"),
|
||||
("S", "Сера", "г/кг СВ"),
|
||||
("CL", "Хлор", "г/кг СВ"),
|
||||
("ВРХ Орг Вещ", "Переваримость ОВ (из TDN)", "%"),
|
||||
("Лизин", "Лизин", "г/кг СВ"),
|
||||
("Метионин", "Метионин", "г/кг СВ"),
|
||||
("Лейцин", "Лейцин", "г/кг СВ"),
|
||||
("Изолейцин", "Изолейцин", "г/кг СВ"),
|
||||
("Валин", "Валин", "г/кг СВ"),
|
||||
)
|
||||
|
||||
|
||||
def _parse_float(value: str | None) -> float | None:
|
||||
if value is None:
|
||||
return None
|
||||
text = str(value).strip().replace(",", ".")
|
||||
if not text or text.startswith("<"):
|
||||
return None
|
||||
try:
|
||||
n = float(text)
|
||||
except ValueError:
|
||||
return None
|
||||
return None if n != n else n
|
||||
|
||||
|
||||
def _normalize_label(value: str) -> str:
|
||||
return re.sub(r"\s+", " ", (value or "").strip().lower())
|
||||
|
||||
|
||||
def _pct_dm_to_g_per_kg_sv(pct_dm: float) -> float:
|
||||
return round(pct_dm * 10.0, 4)
|
||||
|
||||
|
||||
@dataclass
|
||||
class AgrostarSample:
|
||||
sample_no: str
|
||||
farm_name: str
|
||||
farm_id: str
|
||||
date_printed: str
|
||||
feed_type: str
|
||||
desc_1: str
|
||||
desc_2: str
|
||||
desc_3: str
|
||||
dry_matter_pct: float | None
|
||||
raw_fields: dict[str, float] = field(default_factory=dict)
|
||||
nutrients: dict[str, float] = field(default_factory=dict)
|
||||
warnings: list[str] = field(default_factory=list)
|
||||
|
||||
@property
|
||||
def label(self) -> str:
|
||||
parts = [p for p in (self.desc_1, self.desc_2, self.desc_3) if p]
|
||||
return parts[0] if parts else self.sample_no
|
||||
|
||||
def to_api_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"sampleNo": self.sample_no,
|
||||
"farmName": self.farm_name,
|
||||
"farmId": self.farm_id,
|
||||
"datePrinted": self.date_printed,
|
||||
"feedType": self.feed_type,
|
||||
"desc1": self.desc_1,
|
||||
"desc2": self.desc_2,
|
||||
"desc3": self.desc_3,
|
||||
"label": self.label,
|
||||
"dryMatterPct": self.dry_matter_pct,
|
||||
"nutrients": self.nutrients,
|
||||
"warnings": self.warnings,
|
||||
"rawFieldCount": len(self.raw_fields),
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class AgrostarParseResult:
|
||||
lab_name: str
|
||||
samples: list[AgrostarSample] = field(default_factory=list)
|
||||
errors: list[str] = field(default_factory=list)
|
||||
|
||||
def to_api_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"labName": self.lab_name,
|
||||
"samples": [s.to_api_dict() for s in self.samples],
|
||||
"errors": self.errors,
|
||||
"sampleCount": len(self.samples),
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class ComponentMatch:
|
||||
component_id: str
|
||||
name: str
|
||||
score: float
|
||||
|
||||
def to_api_dict(self) -> dict[str, Any]:
|
||||
return {"componentId": self.component_id, "name": self.name, "score": round(self.score, 3)}
|
||||
|
||||
|
||||
@dataclass
|
||||
class ApplyAssignmentResult:
|
||||
sample_no: str
|
||||
component_id: str | None
|
||||
component_name: str | None
|
||||
dry_matter_pct: float | None
|
||||
nutrient_count: int
|
||||
warnings: list[str]
|
||||
skipped: bool = False
|
||||
reason: str | None = None
|
||||
|
||||
def to_api_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"sampleNo": self.sample_no,
|
||||
"componentId": self.component_id,
|
||||
"componentName": self.component_name,
|
||||
"dryMatterPct": self.dry_matter_pct,
|
||||
"nutrientCount": self.nutrient_count,
|
||||
"warnings": self.warnings,
|
||||
"skipped": self.skipped,
|
||||
"reason": self.reason,
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class ApplyResult:
|
||||
applied: int = 0
|
||||
skipped: int = 0
|
||||
results: list[ApplyAssignmentResult] = field(default_factory=list)
|
||||
dry_run: bool = False
|
||||
|
||||
def to_api_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"applied": self.applied,
|
||||
"skipped": self.skipped,
|
||||
"dryRun": self.dry_run,
|
||||
"results": [r.to_api_dict() for r in self.results],
|
||||
}
|
||||
|
||||
|
||||
def _apply_nutrients_from_raw(sample: AgrostarSample) -> AgrostarSample:
|
||||
nutrients: dict[str, float] = {}
|
||||
for ag_key, wesp_key, kind in _AGROSTAR_MAP:
|
||||
val = sample.raw_fields.get(ag_key)
|
||||
if val is None:
|
||||
continue
|
||||
if kind == "pct_dm":
|
||||
nutrients[wesp_key] = _pct_dm_to_g_per_kg_sv(val)
|
||||
elif kind == "pct":
|
||||
nutrients[wesp_key] = round(val, 4)
|
||||
|
||||
if "Sugar_ESC" in sample.raw_fields and "Sugar_WSC" not in sample.raw_fields:
|
||||
esc = sample.raw_fields["Sugar_ESC"]
|
||||
nutrients["Сахар"] = _pct_dm_to_g_per_kg_sv(esc)
|
||||
|
||||
struct_raw = sample.raw_fields.get("aNDFom")
|
||||
if struct_raw is None:
|
||||
struct_raw = sample.raw_fields.get("ADF")
|
||||
if struct_raw is not None:
|
||||
nutrients["Структур. клетч"] = _pct_dm_to_g_per_kg_sv(struct_raw)
|
||||
|
||||
if "Структур. клетч" in nutrients:
|
||||
nutrients["Структур клетч"] = nutrients["Структур. клетч"]
|
||||
|
||||
sample.nutrients = nutrients
|
||||
|
||||
if sample.dry_matter_pct is None:
|
||||
sample.warnings.append("dm_missing")
|
||||
if "ВРХ Орг Вещ" not in nutrients and "TDN" not in sample.raw_fields:
|
||||
sample.warnings.append("omd_missing")
|
||||
elif "TDN" in sample.raw_fields:
|
||||
sample.warnings.append("omd_from_tdn")
|
||||
|
||||
ndfd30 = sample.raw_fields.get("NDFDom_IV_30hr")
|
||||
if ndfd30 is not None:
|
||||
sample.warnings.append(f"ndfd30={ndfd30}")
|
||||
|
||||
return sample
|
||||
|
||||
|
||||
def finalize_agrostar_sample(sample: AgrostarSample) -> AgrostarSample:
|
||||
"""Общий финал: raw_fields → nutrients + warnings (XML/PDF/xlsx)."""
|
||||
return _apply_nutrients_from_raw(sample)
|
||||
|
||||
|
||||
def _read_sample_block(block: ET.Element) -> AgrostarSample:
|
||||
fields: dict[str, str] = {}
|
||||
for child in block:
|
||||
name = child.get("Name") or child.tag
|
||||
fields[name] = child.get("Value") or ""
|
||||
|
||||
sample = AgrostarSample(
|
||||
sample_no=fields.get("Sample_No", ""),
|
||||
farm_name=fields.get("Name", ""),
|
||||
farm_id=fields.get("Farm_ID", ""),
|
||||
date_printed=fields.get("Date_Printed", ""),
|
||||
feed_type=fields.get("Type", ""),
|
||||
desc_1=fields.get("Desc_1", ""),
|
||||
desc_2=fields.get("Desc_2", ""),
|
||||
desc_3=fields.get("Desc_3", ""),
|
||||
dry_matter_pct=_parse_float(fields.get("DM")),
|
||||
)
|
||||
|
||||
for key, raw in fields.items():
|
||||
if key in _META_FIELDS or key == "DM":
|
||||
continue
|
||||
val = _parse_float(raw)
|
||||
if val is not None:
|
||||
sample.raw_fields[key] = val
|
||||
|
||||
return finalize_agrostar_sample(sample)
|
||||
|
||||
|
||||
def parse_agrostar_xml(text: str) -> AgrostarParseResult:
|
||||
result = AgrostarParseResult(lab_name="")
|
||||
if not (text or "").strip():
|
||||
result.errors.append("Пустой файл")
|
||||
return result
|
||||
try:
|
||||
root = ET.fromstring(text)
|
||||
except ET.ParseError as exc:
|
||||
result.errors.append(f"Некорректный XML: {exc}")
|
||||
return result
|
||||
|
||||
if root.tag != "Standard_XML_Data":
|
||||
result.errors.append(f"Ожидался Standard_XML_Data, получен {root.tag}")
|
||||
|
||||
for child in root:
|
||||
tag = child.tag
|
||||
if tag == "Lab_Name":
|
||||
result.lab_name = child.get("Value") or ""
|
||||
elif tag == "Sample_Data":
|
||||
try:
|
||||
result.samples.append(_read_sample_block(child))
|
||||
except Exception as exc: # noqa: BLE001 — собрать все пробы
|
||||
result.errors.append(f"Ошибка пробы: {exc}")
|
||||
|
||||
if not result.samples and not result.errors:
|
||||
result.errors.append("В файле нет блоков Sample_Data")
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def _match_score(label: str, component_name: str) -> float:
|
||||
a = _normalize_label(label)
|
||||
b = _normalize_label(component_name)
|
||||
if not a or not b:
|
||||
return 0.0
|
||||
if a in b or b in a:
|
||||
return 0.95
|
||||
# ключевые токены: яма, номер, силос
|
||||
tokens_a = set(re.findall(r"[a-zа-яё0-9]+", a, re.I))
|
||||
tokens_b = set(re.findall(r"[a-zа-яё0-9]+", b, re.I))
|
||||
if tokens_a and tokens_b:
|
||||
overlap = len(tokens_a & tokens_b) / max(len(tokens_a), len(tokens_b))
|
||||
if overlap >= 0.4:
|
||||
return 0.5 + overlap * 0.4
|
||||
return SequenceMatcher(None, a, b).ratio()
|
||||
|
||||
|
||||
def suggest_component_matches(
|
||||
label: str,
|
||||
*,
|
||||
limit: int = 8,
|
||||
min_score: float = 0.25,
|
||||
) -> list[ComponentMatch]:
|
||||
components = (
|
||||
Component.query.filter(Component.is_deleted.is_(False), Component.is_active.is_(True))
|
||||
.order_by(Component.name)
|
||||
.all()
|
||||
)
|
||||
scored: list[ComponentMatch] = []
|
||||
for comp in components:
|
||||
score = _match_score(label, comp.name or "")
|
||||
if score >= min_score:
|
||||
scored.append(ComponentMatch(comp.id, comp.name or "", score))
|
||||
scored.sort(key=lambda m: (-m.score, m.name.lower()))
|
||||
return scored[:limit]
|
||||
|
||||
|
||||
def _fmt_preview_num(value: float) -> str:
|
||||
rounded = round(value, 2)
|
||||
if abs(rounded - round(rounded)) < 0.01:
|
||||
return f"{round(rounded):g}"
|
||||
text = f"{rounded:.2f}".rstrip("0").rstrip(".")
|
||||
return text or "0"
|
||||
|
||||
|
||||
def _preview_source_for_key(sample: AgrostarSample, wesp_key: str) -> tuple[float | None, str]:
|
||||
for ag_key in _PREVIEW_AGRO_SOURCE.get(wesp_key, ()):
|
||||
val = sample.raw_fields.get(ag_key)
|
||||
if val is not None:
|
||||
return val, _AGRO_SOURCE_UNIT.get(ag_key, "%DM")
|
||||
return None, ""
|
||||
|
||||
|
||||
def suggest_canonical_feed_type(sample: AgrostarSample) -> str:
|
||||
"""Канонический component.type для POST /api/components."""
|
||||
mapped = _AGROSTAR_FEED_TO_COMPONENT_TYPE.get(sample.feed_type)
|
||||
if mapped:
|
||||
return mapped
|
||||
stub = Component(name=sample.label, type="")
|
||||
group = classify_feed_group(stub)
|
||||
return _FEED_GROUP_TO_COMPONENT_TYPE.get(group, "Сочные корма")
|
||||
|
||||
|
||||
def _warning_to_text(code: str) -> str:
|
||||
if code in _WARNING_RU:
|
||||
return _WARNING_RU[code]
|
||||
if code.startswith("ndfd30="):
|
||||
val = code.split("=", 1)[1]
|
||||
return _UNSUPPORTED_AGROSTAR.get("NDFDom_IV_30hr", f"ndfd30={val}")
|
||||
return code
|
||||
|
||||
|
||||
def build_storage_report(sample: AgrostarSample) -> dict[str, Any]:
|
||||
"""Сводка: что сохранится в WESP и что из AgroStar не мапится."""
|
||||
unsupported: list[dict[str, str]] = []
|
||||
for ag_key, val in sample.raw_fields.items():
|
||||
if ag_key in _AGROSTAR_MAPPED_FIELDS:
|
||||
continue
|
||||
label = _AGROSTAR_FIELD_RU.get(ag_key, ag_key)
|
||||
reason = _UNSUPPORTED_AGROSTAR.get(ag_key, "нет поля в WESP")
|
||||
unsupported.append(
|
||||
{
|
||||
"agroKey": ag_key,
|
||||
"label": label,
|
||||
"value": _fmt_preview_num(val),
|
||||
"reason": reason,
|
||||
}
|
||||
)
|
||||
unsupported.sort(key=lambda row: row["label"].lower())
|
||||
|
||||
nutrient_keys = set(sample.nutrients)
|
||||
stored_count = len(nutrient_keys) + (1 if sample.dry_matter_pct is not None else 0)
|
||||
total_in_report = len(sample.raw_fields) + (1 if sample.dry_matter_pct is not None else 0)
|
||||
groups, unsupported_message = _summarize_unsupported(
|
||||
unsupported,
|
||||
nutrient_count=len(nutrient_keys),
|
||||
total_in_report=total_in_report,
|
||||
)
|
||||
return {
|
||||
"storedCount": stored_count,
|
||||
"nutrientCount": len(nutrient_keys),
|
||||
"unsupportedCount": len(unsupported),
|
||||
"unsupportedGroups": groups,
|
||||
"unsupportedMessage": unsupported_message,
|
||||
"unsupported": unsupported,
|
||||
}
|
||||
|
||||
|
||||
def build_sample_preview(sample: AgrostarSample) -> dict[str, Any]:
|
||||
"""Человекочитаемый разбор пробы для UI «Сначала понюхаем»."""
|
||||
feed_ru = _FEED_TYPE_RU.get(sample.feed_type, sample.feed_type or "—")
|
||||
desc_parts = [p for p in (sample.desc_1, sample.desc_2, sample.desc_3) if p]
|
||||
description = " · ".join(desc_parts) if desc_parts else sample.label
|
||||
|
||||
meta: list[dict[str, str]] = [
|
||||
{"label": "№ пробы", "value": sample.sample_no or "—"},
|
||||
{"label": "Хозяйство", "value": sample.farm_name or "—"},
|
||||
{"label": "Код хозяйства", "value": sample.farm_id or "—"},
|
||||
{"label": "Дата отчёта", "value": sample.date_printed or "—"},
|
||||
{"label": "Тип корма", "value": feed_ru},
|
||||
{"label": "Описание", "value": description},
|
||||
]
|
||||
|
||||
will_write: list[dict[str, str]] = []
|
||||
if sample.dry_matter_pct is not None:
|
||||
will_write.append(
|
||||
{
|
||||
"label": "Сухое вещество (поле компонента)",
|
||||
"value": _fmt_preview_num(sample.dry_matter_pct),
|
||||
"unit": "%",
|
||||
"sourceValue": _fmt_preview_num(sample.dry_matter_pct),
|
||||
"sourceUnit": "%",
|
||||
}
|
||||
)
|
||||
for key, label, unit in _PREVIEW_WRITE_ORDER:
|
||||
if key == "__dry_matter__":
|
||||
continue
|
||||
val = sample.nutrients.get(key)
|
||||
if val is None:
|
||||
continue
|
||||
row: dict[str, str] = {"label": label, "value": _fmt_preview_num(val), "unit": unit}
|
||||
source_val, source_unit = _preview_source_for_key(sample, key)
|
||||
if source_val is not None:
|
||||
row["sourceValue"] = _fmt_preview_num(source_val)
|
||||
row["sourceUnit"] = source_unit
|
||||
will_write.append(row)
|
||||
|
||||
notes = [_warning_to_text(w) for w in sample.warnings]
|
||||
storage = build_storage_report(sample)
|
||||
|
||||
return {
|
||||
"title": sample.label,
|
||||
"feedTypeRu": feed_ru,
|
||||
"suggestedName": sample.label,
|
||||
"suggestedType": suggest_canonical_feed_type(sample),
|
||||
"meta": meta,
|
||||
"willWrite": will_write,
|
||||
"notes": notes,
|
||||
"recognizedCount": len(will_write),
|
||||
"storage": storage,
|
||||
}
|
||||
|
||||
|
||||
def enrich_parse_with_matches(parse: AgrostarParseResult) -> dict[str, Any]:
|
||||
body = parse.to_api_dict()
|
||||
for i, sample in enumerate(parse.samples):
|
||||
matches = suggest_component_matches(sample.label)
|
||||
body["samples"][i]["suggestedComponents"] = [m.to_api_dict() for m in matches]
|
||||
body["samples"][i]["preview"] = build_sample_preview(sample)
|
||||
return body
|
||||
|
||||
|
||||
def apply_agrostar_import(
|
||||
assignments: list[dict[str, Any]],
|
||||
*,
|
||||
user_id: str = "agrostar-import",
|
||||
dry_run: bool = False,
|
||||
) -> ApplyResult:
|
||||
"""assignments: [{sampleNo, componentId}] — данные проб из предыдущего parse или повторный parse."""
|
||||
result = ApplyResult(dry_run=dry_run)
|
||||
by_sample: dict[str, dict[str, Any]] = {
|
||||
str(a.get("sampleNo") or a.get("sample_no") or ""): a for a in assignments
|
||||
}
|
||||
|
||||
for sample_no, row in by_sample.items():
|
||||
if not sample_no:
|
||||
result.skipped += 1
|
||||
result.results.append(
|
||||
ApplyAssignmentResult(
|
||||
sample_no="",
|
||||
component_id=None,
|
||||
component_name=None,
|
||||
dry_matter_pct=None,
|
||||
nutrient_count=0,
|
||||
warnings=[],
|
||||
skipped=True,
|
||||
reason="missing_sample_no",
|
||||
)
|
||||
)
|
||||
continue
|
||||
|
||||
component_id = row.get("componentId") or row.get("component_id")
|
||||
nutrients = row.get("nutrients") or {}
|
||||
dry_matter = row.get("dryMatterPct") or row.get("dry_matter_pct")
|
||||
|
||||
if not component_id:
|
||||
result.skipped += 1
|
||||
result.results.append(
|
||||
ApplyAssignmentResult(
|
||||
sample_no=sample_no,
|
||||
component_id=None,
|
||||
component_name=None,
|
||||
dry_matter_pct=dry_matter,
|
||||
nutrient_count=len(nutrients),
|
||||
warnings=list(row.get("warnings") or []),
|
||||
skipped=True,
|
||||
reason="no_component",
|
||||
)
|
||||
)
|
||||
continue
|
||||
|
||||
comp = Component.query.filter_by(id=component_id, is_deleted=False).first()
|
||||
if comp is None:
|
||||
result.skipped += 1
|
||||
result.results.append(
|
||||
ApplyAssignmentResult(
|
||||
sample_no=sample_no,
|
||||
component_id=component_id,
|
||||
component_name=None,
|
||||
dry_matter_pct=dry_matter,
|
||||
nutrient_count=len(nutrients),
|
||||
warnings=[],
|
||||
skipped=True,
|
||||
reason="component_not_found",
|
||||
)
|
||||
)
|
||||
continue
|
||||
|
||||
warnings = list(row.get("warnings") or [])
|
||||
if dry_run:
|
||||
result.applied += 1
|
||||
result.results.append(
|
||||
ApplyAssignmentResult(
|
||||
sample_no=sample_no,
|
||||
component_id=comp.id,
|
||||
component_name=comp.name,
|
||||
dry_matter_pct=dry_matter,
|
||||
nutrient_count=len(nutrients),
|
||||
warnings=warnings,
|
||||
)
|
||||
)
|
||||
continue
|
||||
|
||||
if dry_matter is not None:
|
||||
try:
|
||||
comp.dry_matter = float(dry_matter)
|
||||
except (TypeError, ValueError):
|
||||
warnings.append("invalid_dry_matter")
|
||||
|
||||
save_component_nutrients(comp.id, nutrients, user_id=user_id)
|
||||
comp.updated_by = user_id
|
||||
db.session.commit()
|
||||
|
||||
result.applied += 1
|
||||
result.results.append(
|
||||
ApplyAssignmentResult(
|
||||
sample_no=sample_no,
|
||||
component_id=comp.id,
|
||||
component_name=comp.name,
|
||||
dry_matter_pct=comp.dry_matter,
|
||||
nutrient_count=len(nutrients),
|
||||
warnings=warnings,
|
||||
)
|
||||
)
|
||||
|
||||
return result
|
||||
@@ -0,0 +1,243 @@
|
||||
"""Единый роутер импорта lab: XML / PDF / xlsx → preview API."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
from app.modules.zootech.lab.etl.agrostar_pdf_import import looks_like_agrostar_pdf, parse_agrostar_pdf
|
||||
from app.modules.zootech.lab.etl.agrostar_xlsx_import import parse_agrostar_xlsx
|
||||
from app.modules.zootech.lab.etl.agrostar_xml_import import AgrostarParseResult, enrich_parse_with_matches, parse_agrostar_xml
|
||||
from app.modules.zootech.lab.etl.plinor_pdf_import import (
|
||||
enrich_plinor_composition,
|
||||
is_plinor_sos,
|
||||
is_plinor_zoo,
|
||||
parse_plinor_sos,
|
||||
parse_plinor_zoo,
|
||||
)
|
||||
|
||||
SOURCE_LABELS: dict[str, str] = {
|
||||
"agrostar_xml": "AgroStar XML",
|
||||
"agrostar_pdf": "AgroStar PDF",
|
||||
"agrostar_xlsx": "AgroStar Excel",
|
||||
"plinor_sos": "ПЛИНОР — состав рациона",
|
||||
"plinor_zoo": "ПЛИНОР — показатели рациона",
|
||||
}
|
||||
|
||||
_PDF_MAGIC = b"%PDF"
|
||||
_XLSX_MAGIC = b"PK\x03\x04"
|
||||
_XML_MARKERS = (b"Standard_XML_Data", b"<?xml", b"<Standard_XML_Data")
|
||||
|
||||
|
||||
def _filename_hint_plinor_zoo(name: str) -> bool:
|
||||
n = (name or "").lower()
|
||||
return "зоо" in n or re.search(r"(^|[^a-z])zoo([^a-z]|$)", n) is not None
|
||||
|
||||
|
||||
def _filename_hint_plinor_sos(name: str) -> bool:
|
||||
n = (name or "").lower()
|
||||
return "сос" in n or re.search(r"(^|[^a-z])sos([^a-z]|$)", n) is not None
|
||||
|
||||
|
||||
def _looks_like_xml_agrostar(data: bytes) -> bool:
|
||||
head = data[:16384]
|
||||
return any(marker in head for marker in _XML_MARKERS)
|
||||
|
||||
|
||||
def _looks_like_xlsx(data: bytes) -> bool:
|
||||
return data.startswith(_XLSX_MAGIC)
|
||||
|
||||
|
||||
def _looks_like_pdf(data: bytes) -> bool:
|
||||
return data.startswith(_PDF_MAGIC)
|
||||
|
||||
|
||||
def _pdf_import_available() -> bool:
|
||||
try:
|
||||
import fitz # noqa: F401
|
||||
except ImportError:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def detect_source_format(filename: str, data: bytes) -> str:
|
||||
"""Определение формата по содержимому и имени (расширение — запасной вариант)."""
|
||||
name = (filename or "").lower()
|
||||
|
||||
if _looks_like_pdf(data):
|
||||
return _detect_pdf_format(data, name)
|
||||
if _looks_like_xlsx(data):
|
||||
return "agrostar_xlsx"
|
||||
if _looks_like_xml_agrostar(data):
|
||||
return "agrostar_xml"
|
||||
|
||||
if name.endswith(".xml"):
|
||||
return "agrostar_xml"
|
||||
if name.endswith(".xlsx") or name.endswith(".xls"):
|
||||
return "agrostar_xlsx"
|
||||
if name.endswith(".pdf"):
|
||||
return _detect_pdf_format(data, name)
|
||||
return "unknown"
|
||||
|
||||
|
||||
def _detect_pdf_format(data: bytes, name: str) -> str:
|
||||
head = data[:8192]
|
||||
if b"Standard_XML_Data" in head:
|
||||
return "agrostar_xml"
|
||||
if looks_like_agrostar_pdf(data):
|
||||
return "agrostar_pdf"
|
||||
|
||||
pdf_text = ""
|
||||
if _pdf_import_available():
|
||||
try:
|
||||
pdf_text = _peek_pdf_text(data)
|
||||
except Exception: # noqa: BLE001
|
||||
pdf_text = ""
|
||||
|
||||
if pdf_text:
|
||||
if is_plinor_zoo(pdf_text):
|
||||
return "plinor_zoo"
|
||||
if is_plinor_sos(pdf_text):
|
||||
return "plinor_sos"
|
||||
if looks_like_agrostar_pdf(data):
|
||||
return "agrostar_pdf"
|
||||
|
||||
if _filename_hint_plinor_zoo(name):
|
||||
return "plinor_zoo"
|
||||
if _filename_hint_plinor_sos(name):
|
||||
return "plinor_sos"
|
||||
return "unknown_pdf"
|
||||
|
||||
|
||||
def _peek_pdf_text(data: bytes) -> str:
|
||||
try:
|
||||
import fitz
|
||||
except ImportError as exc:
|
||||
raise RuntimeError("Для PDF нужен pymupdf (pip install pymupdf)") from exc
|
||||
doc = fitz.open(stream=data, filetype="pdf")
|
||||
text = doc[0].get_text() if doc.page_count else ""
|
||||
doc.close()
|
||||
return text
|
||||
|
||||
|
||||
def parse_lab_import(filename: str, data: bytes) -> dict[str, Any]:
|
||||
"""Разбор файла → единый JSON для /lab комбайна."""
|
||||
if not data:
|
||||
return {"error": True, "message": "Пустой файл", "kind": "error"}
|
||||
|
||||
fmt = detect_source_format(filename, data)
|
||||
source_label = SOURCE_LABELS.get(fmt, "Неизвестный формат")
|
||||
|
||||
if fmt == "agrostar_xml":
|
||||
text = _decode_text(data)
|
||||
parsed = parse_agrostar_xml(text)
|
||||
if parsed.errors and not parsed.samples:
|
||||
return {
|
||||
"error": True,
|
||||
"message": "; ".join(parsed.errors),
|
||||
"kind": "error",
|
||||
"sourceFormat": fmt,
|
||||
**parsed.to_api_dict(),
|
||||
}
|
||||
body = enrich_parse_with_matches(parsed)
|
||||
body["kind"] = "lab_samples"
|
||||
body["sourceFormat"] = fmt
|
||||
body["sourceLabel"] = source_label
|
||||
body["fileName"] = filename
|
||||
if parsed.errors:
|
||||
body["warnings"] = parsed.errors
|
||||
return body
|
||||
|
||||
if fmt == "agrostar_pdf":
|
||||
parsed = parse_agrostar_pdf(data)
|
||||
return _lab_samples_response(parsed, fmt, source_label, filename)
|
||||
|
||||
if fmt == "agrostar_xlsx":
|
||||
parsed = parse_agrostar_xlsx(data)
|
||||
return _lab_samples_response(parsed, fmt, source_label, filename)
|
||||
|
||||
if fmt == "plinor_sos":
|
||||
parsed = parse_plinor_sos(data)
|
||||
if parsed.errors and not parsed.feed_lines:
|
||||
return {
|
||||
"error": True,
|
||||
"message": "; ".join(parsed.errors),
|
||||
"kind": "error",
|
||||
"sourceFormat": fmt,
|
||||
}
|
||||
body = enrich_plinor_composition(parsed.to_api_dict())
|
||||
body["kind"] = "ration_composition"
|
||||
body["sourceFormat"] = fmt
|
||||
body["sourceLabel"] = source_label
|
||||
body["fileName"] = filename
|
||||
if parsed.errors:
|
||||
body["warnings"] = parsed.errors
|
||||
return body
|
||||
|
||||
if fmt == "plinor_zoo":
|
||||
parsed = parse_plinor_zoo(data)
|
||||
if parsed.errors and not parsed.indicators:
|
||||
return {
|
||||
"error": True,
|
||||
"message": "; ".join(parsed.errors),
|
||||
"kind": "error",
|
||||
"sourceFormat": fmt,
|
||||
}
|
||||
body = parsed.to_api_dict()
|
||||
body["kind"] = "ration_indicators"
|
||||
body["sourceFormat"] = fmt
|
||||
body["sourceLabel"] = source_label
|
||||
body["fileName"] = filename
|
||||
if parsed.errors:
|
||||
body["warnings"] = parsed.errors
|
||||
return body
|
||||
|
||||
if fmt == "unknown_pdf" and _looks_like_pdf(data) and not _pdf_import_available():
|
||||
return {
|
||||
"error": True,
|
||||
"message": "Для PDF нужен pymupdf — выполните: pip install pymupdf",
|
||||
"kind": "error",
|
||||
"sourceFormat": fmt,
|
||||
"fileName": filename,
|
||||
}
|
||||
|
||||
return {
|
||||
"error": True,
|
||||
"message": "Формат не распознан. Поддерживаются: AgroStar xml/pdf/xlsx, ПЛИНОР сос/зоо pdf",
|
||||
"kind": "error",
|
||||
"sourceFormat": fmt,
|
||||
"fileName": filename,
|
||||
}
|
||||
|
||||
|
||||
def _lab_samples_response(
|
||||
parsed: AgrostarParseResult,
|
||||
fmt: str,
|
||||
source_label: str,
|
||||
filename: str,
|
||||
) -> dict[str, Any]:
|
||||
if parsed.errors and not parsed.samples:
|
||||
return {
|
||||
"error": True,
|
||||
"message": "; ".join(parsed.errors),
|
||||
"kind": "error",
|
||||
"sourceFormat": fmt,
|
||||
**parsed.to_api_dict(),
|
||||
}
|
||||
body = enrich_parse_with_matches(parsed)
|
||||
body["kind"] = "lab_samples"
|
||||
body["sourceFormat"] = fmt
|
||||
body["sourceLabel"] = source_label
|
||||
body["fileName"] = filename
|
||||
if parsed.errors:
|
||||
body["warnings"] = parsed.errors
|
||||
return body
|
||||
|
||||
|
||||
def _decode_text(data: bytes) -> str:
|
||||
for encoding in ("utf-8", "utf-8-sig", "cp1251"):
|
||||
try:
|
||||
return data.decode(encoding)
|
||||
except UnicodeDecodeError:
|
||||
continue
|
||||
return data.decode("utf-8", errors="replace")
|
||||
@@ -0,0 +1,215 @@
|
||||
"""ПЛИНОР PDF (сос / зоо) → состав рациона или показатели."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
from app.modules.zootech.lab.etl.agrostar_xml_import import _parse_float, suggest_component_matches
|
||||
|
||||
|
||||
def _pdf_text(data: bytes) -> str:
|
||||
try:
|
||||
import fitz
|
||||
except ImportError as exc:
|
||||
raise RuntimeError("Для PDF нужен pymupdf (pip install pymupdf)") from exc
|
||||
doc = fitz.open(stream=data, filetype="pdf")
|
||||
parts = [doc[i].get_text() for i in range(doc.page_count)]
|
||||
doc.close()
|
||||
return "\n".join(parts)
|
||||
|
||||
|
||||
def _meta_from_lines(lines: list[str]) -> dict[str, str]:
|
||||
meta: dict[str, str] = {}
|
||||
for line in lines:
|
||||
for key, prefix in (
|
||||
("group", "Группа:"),
|
||||
("farm", "Хозяйство:"),
|
||||
("region", "Район:"),
|
||||
("rationDate", "Дата рациона:"),
|
||||
("calcDate", "Дата расчетов:"),
|
||||
):
|
||||
if line.startswith(prefix):
|
||||
meta[key] = line.split(":", 1)[1].strip()
|
||||
if line.startswith("Суточный удой"):
|
||||
m = re.search(r"(\d+(?:[,\.]\d+)?)", line)
|
||||
if m:
|
||||
meta["milkYieldKg"] = m.group(1).replace(",", ".")
|
||||
for i, line in enumerate(lines):
|
||||
if line.startswith("Цена (руб.)") and i + 1 < len(lines):
|
||||
meta["totalCostRub"] = lines[i + 1].replace(",", ".")
|
||||
if line == "Масса (кг)" and i + 1 < len(lines):
|
||||
meta["totalMassKg"] = lines[i + 1].replace(",", ".")
|
||||
return meta
|
||||
|
||||
|
||||
@dataclass
|
||||
class PlinorFeedLine:
|
||||
feed_name: str
|
||||
daily_kg: float
|
||||
cost_rub: float
|
||||
|
||||
def to_api_dict(self) -> dict[str, Any]:
|
||||
return {"feedName": self.feed_name, "dailyKg": self.daily_kg, "costRub": self.cost_rub}
|
||||
|
||||
|
||||
@dataclass
|
||||
class PlinorIndicatorRow:
|
||||
name: str
|
||||
norm: float | None
|
||||
current: float | None
|
||||
|
||||
def to_api_dict(self) -> dict[str, Any]:
|
||||
return {"name": self.name, "norm": self.norm, "current": self.current}
|
||||
|
||||
|
||||
@dataclass
|
||||
class PlinorCompositionResult:
|
||||
meta: dict[str, str] = field(default_factory=dict)
|
||||
feed_lines: list[PlinorFeedLine] = field(default_factory=list)
|
||||
errors: list[str] = field(default_factory=list)
|
||||
|
||||
def to_api_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"meta": self.meta,
|
||||
"feedLines": [line.to_api_dict() for line in self.feed_lines],
|
||||
"feedCount": len(self.feed_lines),
|
||||
"errors": self.errors,
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class PlinorIndicatorsResult:
|
||||
meta: dict[str, str] = field(default_factory=dict)
|
||||
indicators: list[PlinorIndicatorRow] = field(default_factory=list)
|
||||
errors: list[str] = field(default_factory=list)
|
||||
|
||||
def to_api_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"meta": self.meta,
|
||||
"indicators": [row.to_api_dict() for row in self.indicators],
|
||||
"indicatorCount": len(self.indicators),
|
||||
"errors": self.errors,
|
||||
}
|
||||
|
||||
|
||||
def _is_plinor(text: str) -> bool:
|
||||
return "ПЛИНОР" in (text or "") or 'ИАС "РАЦИОНЫ"' in (text or "")
|
||||
|
||||
|
||||
def is_plinor_sos(text: str) -> bool:
|
||||
if not _is_plinor(text):
|
||||
return False
|
||||
if "Состав рациона" in text:
|
||||
return True
|
||||
return bool(re.search(r"Таблица 1\.1(?!0)", text))
|
||||
|
||||
|
||||
def is_plinor_zoo(text: str) -> bool:
|
||||
if not _is_plinor(text):
|
||||
return False
|
||||
if "Зоотехнические показатели" in text:
|
||||
return True
|
||||
return "Таблица 1.10" in text
|
||||
|
||||
|
||||
def parse_plinor_sos(data: bytes) -> PlinorCompositionResult:
|
||||
result = PlinorCompositionResult()
|
||||
try:
|
||||
text = _pdf_text(data)
|
||||
except RuntimeError as exc:
|
||||
result.errors.append(str(exc))
|
||||
return result
|
||||
if not is_plinor_sos(text):
|
||||
result.errors.append("Не похоже на ПЛИНОР «Состав рациона» (табл. 1.1)")
|
||||
return result
|
||||
|
||||
lines = [ln.strip() for ln in text.splitlines() if ln.strip()]
|
||||
result.meta = _meta_from_lines(lines)
|
||||
|
||||
feeds: list[PlinorFeedLine] = []
|
||||
i = 0
|
||||
while i < len(lines):
|
||||
if lines[i] == "кг" and i >= 1 and i + 2 < len(lines):
|
||||
name = lines[i - 1]
|
||||
amt = _parse_float(lines[i + 1])
|
||||
cost = _parse_float(lines[i + 2])
|
||||
if (
|
||||
amt is not None
|
||||
and cost is not None
|
||||
and name not in ("Дача", "изм.", "Корма")
|
||||
and "кг" not in name.lower()
|
||||
):
|
||||
feeds.append(PlinorFeedLine(name, amt, cost))
|
||||
i += 3
|
||||
continue
|
||||
i += 1
|
||||
|
||||
if not feeds:
|
||||
result.errors.append("Не найдены строки кормов")
|
||||
result.feed_lines = feeds
|
||||
return result
|
||||
|
||||
|
||||
def parse_plinor_zoo(data: bytes) -> PlinorIndicatorsResult:
|
||||
result = PlinorIndicatorsResult()
|
||||
try:
|
||||
text = _pdf_text(data)
|
||||
except RuntimeError as exc:
|
||||
result.errors.append(str(exc))
|
||||
return result
|
||||
if not is_plinor_zoo(text):
|
||||
result.errors.append("Не похоже на ПЛИНОР «Зоотехнические показатели» (табл. 1.10)")
|
||||
return result
|
||||
|
||||
lines = [ln.strip() for ln in text.splitlines() if ln.strip()]
|
||||
result.meta = _meta_from_lines(lines)
|
||||
|
||||
skip_prefixes = (
|
||||
"Район:",
|
||||
"Хозяйство:",
|
||||
"Ферма:",
|
||||
"Двор:",
|
||||
"Подразделение:",
|
||||
"Группа:",
|
||||
"Суточный",
|
||||
"Стадия",
|
||||
"Живая",
|
||||
"Система",
|
||||
"Конц.",
|
||||
"Кр.опт.",
|
||||
"Дата",
|
||||
)
|
||||
skip_exact = frozenset(
|
||||
{"1", "2", "3", "По польз.", "норме", "Текущий рацион", "Наименование", "Значение"}
|
||||
)
|
||||
|
||||
rows: list[PlinorIndicatorRow] = []
|
||||
i = 0
|
||||
while i < len(lines):
|
||||
line = lines[i]
|
||||
if line in skip_exact or any(line.startswith(p) for p in skip_prefixes):
|
||||
i += 1
|
||||
continue
|
||||
if i + 2 < len(lines) and len(line) > 8:
|
||||
norm = _parse_float(lines[i + 1])
|
||||
cur = _parse_float(lines[i + 2])
|
||||
if norm is not None and cur is not None:
|
||||
rows.append(PlinorIndicatorRow(line, norm, cur))
|
||||
i += 3
|
||||
continue
|
||||
i += 1
|
||||
|
||||
if not rows:
|
||||
result.errors.append("Не найдены показатели рациона")
|
||||
result.indicators = rows
|
||||
return result
|
||||
|
||||
|
||||
def enrich_plinor_composition(body: dict[str, Any]) -> dict[str, Any]:
|
||||
for i, line in enumerate(body.get("feedLines") or []):
|
||||
name = line.get("feedName") or ""
|
||||
matches = suggest_component_matches(name, limit=5)
|
||||
body["feedLines"][i]["suggestedComponents"] = [m.to_api_dict() for m in matches]
|
||||
return body
|
||||
@@ -0,0 +1,265 @@
|
||||
"""Import tab reference PostgreSQL → WESP SQLite (offline admin ETL)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
import psycopg2
|
||||
from psycopg2.extras import RealDictCursor
|
||||
|
||||
from app.modules.zootech.wesp_bridge_db import db
|
||||
from app.modules.zootech.lab.models import LabAnimalProfile
|
||||
from app.modules.zootech.wesp_bridge_models import Component, Recipe, WESP_SUPPRESS_SYNC_ENQUEUE
|
||||
from app.modules.zootech.lab.services.profile_norms import import_norms_from_legacy_text, save_norms_from_payload
|
||||
|
||||
|
||||
DEFAULT_PG_URL = "postgresql://neoton:neoton_secret@localhost:5432/neoton"
|
||||
|
||||
|
||||
@dataclass
|
||||
class ImportStats:
|
||||
components_enriched: int = 0
|
||||
components_unmatched: int = 0
|
||||
tab_components_purged: int = 0
|
||||
animal_profiles: int = 0
|
||||
recipe_links: int = 0
|
||||
errors: list[str] = field(default_factory=list)
|
||||
|
||||
|
||||
def _pg_url() -> str:
|
||||
return os.environ.get("TAB_REFERENCE_DATABASE_URL", DEFAULT_PG_URL).strip()
|
||||
|
||||
|
||||
def _json_text(value: Any) -> str:
|
||||
if value is None:
|
||||
return "{}"
|
||||
if isinstance(value, str):
|
||||
return value if value else "{}"
|
||||
return json.dumps(value, ensure_ascii=False)
|
||||
|
||||
|
||||
def _dt(value: Any) -> datetime | None:
|
||||
if value is None:
|
||||
return None
|
||||
if isinstance(value, datetime):
|
||||
return value
|
||||
return None
|
||||
|
||||
|
||||
def _fetch_all(conn, sql: str) -> list[dict[str, Any]]:
|
||||
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||
cur.execute(sql)
|
||||
return list(cur.fetchall())
|
||||
|
||||
|
||||
def _normalize_name(name: str) -> str:
|
||||
return " ".join((name or "").lower().split())
|
||||
|
||||
|
||||
def _find_wesp_component(row: dict[str, Any]) -> Component | None:
|
||||
"""Match tab feed_ingredient → existing WESP component (never create)."""
|
||||
external_no = row.get("external_no")
|
||||
name = (row.get("name") or "").strip()
|
||||
norm = _normalize_name(name)
|
||||
|
||||
if external_no is not None:
|
||||
hit = Component.query.filter_by(external_no=external_no, is_deleted=False).first()
|
||||
if hit is not None:
|
||||
return hit
|
||||
|
||||
if name:
|
||||
hit = Component.query.filter(
|
||||
Component.name == name, Component.is_deleted.is_(False)
|
||||
).first()
|
||||
if hit is not None:
|
||||
return hit
|
||||
|
||||
if norm:
|
||||
for comp in Component.query.filter(Component.is_deleted.is_(False)).all():
|
||||
if _normalize_name(comp.name) == norm:
|
||||
return comp
|
||||
if len(name) >= 12:
|
||||
prefix = name[:20].lower()
|
||||
for comp in Component.query.filter(Component.is_deleted.is_(False)).all():
|
||||
if prefix in (comp.name or "").lower():
|
||||
return comp
|
||||
return None
|
||||
|
||||
|
||||
def _enrich_wesp_component(component: Component, row: dict[str, Any]) -> None:
|
||||
"""Copy zootech nutrients from tab; WESP id/name/dry_matter/price stay canonical."""
|
||||
from app.modules.zootech.lab.services.component_nutrients import save_component_nutrients
|
||||
|
||||
raw = row.get("nutrients")
|
||||
if isinstance(raw, str):
|
||||
try:
|
||||
raw = json.loads(raw)
|
||||
except json.JSONDecodeError:
|
||||
raw = {}
|
||||
if not isinstance(raw, dict):
|
||||
raw = {}
|
||||
save_component_nutrients(component.id, raw, user_id="reference-db-import")
|
||||
if row.get("external_no") is not None:
|
||||
component.external_no = row.get("external_no")
|
||||
component.updated_by = "tab-enrich"
|
||||
|
||||
|
||||
def _purge_tab_imported_components(stats: ImportStats) -> None:
|
||||
"""Remove components created from tab feed_ingredients (not used in WESP calc)."""
|
||||
tab_ids = [
|
||||
c.id
|
||||
for c in Component.query.filter(
|
||||
Component.created_by == "tab-import", Component.is_deleted.is_(False)
|
||||
).all()
|
||||
]
|
||||
if not tab_ids:
|
||||
return
|
||||
for comp in Component.query.filter(Component.id.in_(tab_ids)).all():
|
||||
comp.soft_delete("tab-import-cleanup")
|
||||
stats.tab_components_purged += 1
|
||||
|
||||
|
||||
def _import_feed_ingredients(conn, stats: ImportStats) -> None:
|
||||
"""Map tab feed_ingredient → WESP component; enrich nutrients only."""
|
||||
rows = _fetch_all(
|
||||
conn,
|
||||
"""
|
||||
SELECT id, external_no, name, price_per_kg, dry_matter, nutrients, row_index
|
||||
FROM feed_ingredients
|
||||
ORDER BY row_index NULLS LAST, external_no NULLS LAST
|
||||
""",
|
||||
)
|
||||
for row in rows:
|
||||
component = _find_wesp_component(row)
|
||||
if component is None:
|
||||
stats.components_unmatched += 1
|
||||
continue
|
||||
_enrich_wesp_component(component, row)
|
||||
stats.components_enriched += 1
|
||||
|
||||
|
||||
def build_feed_ingredient_mapping(rows: list[dict[str, Any]], stats: ImportStats | None = None) -> dict[str, str | None]:
|
||||
st = stats or ImportStats()
|
||||
mapping: dict[str, str | None] = {}
|
||||
for row in rows:
|
||||
tab_id = str(row.get("id") or "")
|
||||
component = _find_wesp_component(row)
|
||||
if component is None:
|
||||
st.components_unmatched += 1
|
||||
mapping[tab_id] = None
|
||||
continue
|
||||
_enrich_wesp_component(component, row)
|
||||
mapping[tab_id] = component.id
|
||||
st.components_enriched += 1
|
||||
return mapping
|
||||
|
||||
|
||||
def apply_animal_profile_rows(rows: list[dict[str, Any]], stats: ImportStats | None = None) -> ImportStats:
|
||||
"""Импорт профилей из списка строк (PG-формат или fixtures JSON)."""
|
||||
st = stats or ImportStats()
|
||||
for row in rows:
|
||||
_upsert_animal_profile_row(row, st)
|
||||
return st
|
||||
|
||||
|
||||
def _upsert_animal_profile_row(row: dict[str, Any], stats: ImportStats) -> None:
|
||||
profile = LabAnimalProfile.query.get(row["id"])
|
||||
if profile is None:
|
||||
profile = LabAnimalProfile(id=row["id"])
|
||||
db.session.add(profile)
|
||||
profile.profile_key = row["key"]
|
||||
profile.label = row["label"]
|
||||
profile.ration_type = str(row["type"])
|
||||
norms_raw = row.get("norms_data")
|
||||
if isinstance(norms_raw, dict):
|
||||
save_norms_from_payload(profile, norms_raw)
|
||||
else:
|
||||
import_norms_from_legacy_text(profile, norms_raw)
|
||||
profile.created_at = _dt(row.get("created_at")) or profile.created_at
|
||||
profile.updated_at = _dt(row.get("updated_at")) or profile.updated_at
|
||||
profile.created_by = "tab-import"
|
||||
profile.updated_by = "tab-import"
|
||||
stats.animal_profiles += 1
|
||||
|
||||
|
||||
def apply_feed_ingredient_rows(rows: list[dict[str, Any]], stats: ImportStats | None = None) -> ImportStats:
|
||||
"""Обогащение WESP component из строк feed_ingredients (без PG)."""
|
||||
st = stats or ImportStats()
|
||||
for row in rows:
|
||||
component = _find_wesp_component(row)
|
||||
if component is None:
|
||||
st.components_unmatched += 1
|
||||
continue
|
||||
_enrich_wesp_component(component, row)
|
||||
st.components_enriched += 1
|
||||
return st
|
||||
|
||||
|
||||
def _import_animal_profiles(conn, stats: ImportStats) -> None:
|
||||
rows = _fetch_all(
|
||||
conn,
|
||||
"""
|
||||
SELECT id, key, label, type, norms_data, created_at, updated_at
|
||||
FROM animal_profiles
|
||||
ORDER BY key
|
||||
""",
|
||||
)
|
||||
apply_animal_profile_rows(rows, stats)
|
||||
|
||||
|
||||
def _recipe_by_name(name: str) -> Recipe | None:
|
||||
if not name:
|
||||
return None
|
||||
return Recipe.query.filter(Recipe.name == name, Recipe.is_deleted.is_(False)).first()
|
||||
|
||||
|
||||
def _link_recipes_from_tab_projects(conn, stats: ImportStats) -> None:
|
||||
"""Только ration_type на recipe по имени — без staging-таблиц."""
|
||||
rows = _fetch_all(
|
||||
conn,
|
||||
"""
|
||||
SELECT name, type
|
||||
FROM ration_projects
|
||||
ORDER BY updated_at DESC NULLS LAST
|
||||
""",
|
||||
)
|
||||
seen: set[str] = set()
|
||||
for row in rows:
|
||||
name = (row.get("name") or "").strip()
|
||||
if not name or name in seen:
|
||||
continue
|
||||
linked = _recipe_by_name(name)
|
||||
if linked is None:
|
||||
continue
|
||||
if row.get("type"):
|
||||
linked.ration_type = str(row["type"])
|
||||
seen.add(name)
|
||||
stats.recipe_links += 1
|
||||
|
||||
|
||||
def import_from_reference_db(pg_url: str | None = None) -> ImportStats:
|
||||
"""Full dump from tab PostgreSQL into WESP SQLite."""
|
||||
stats = ImportStats()
|
||||
url = pg_url or _pg_url()
|
||||
conn = psycopg2.connect(url)
|
||||
db.session.info[WESP_SUPPRESS_SYNC_ENQUEUE] = True
|
||||
try:
|
||||
_purge_tab_imported_components(stats)
|
||||
db.session.flush()
|
||||
_import_feed_ingredients(conn, stats)
|
||||
db.session.flush()
|
||||
_import_animal_profiles(conn, stats)
|
||||
_link_recipes_from_tab_projects(conn, stats)
|
||||
db.session.commit()
|
||||
except Exception as exc:
|
||||
db.session.rollback()
|
||||
stats.errors.append(str(exc))
|
||||
raise
|
||||
finally:
|
||||
db.session.info.pop(WESP_SUPPRESS_SYNC_ENQUEUE, None)
|
||||
conn.close()
|
||||
return stats
|
||||
@@ -0,0 +1,169 @@
|
||||
"""Все показатели рациона для расчёта и сравнения с нормами профиля."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
|
||||
def _ind(
|
||||
key: str,
|
||||
label: str,
|
||||
unit: str,
|
||||
nutrient_keys: list[str] | None = None,
|
||||
*,
|
||||
aggregation: str = "daily_total",
|
||||
derived: str | None = None,
|
||||
**extra: Any,
|
||||
) -> dict[str, Any]:
|
||||
row: dict[str, Any] = {
|
||||
"key": key,
|
||||
"label": label,
|
||||
"unit": unit,
|
||||
"nutrient_keys": nutrient_keys or [],
|
||||
"aggregation": aggregation,
|
||||
}
|
||||
if derived:
|
||||
row["derived"] = derived
|
||||
row.update(extra)
|
||||
return row
|
||||
|
||||
|
||||
# Порядок: базовые → расширенные zootech → производные
|
||||
RATION_ALL_INDICATORS: list[dict[str, Any]] = [
|
||||
_ind("dry_matter", "Сухое вещество", "г", ["СВ"]),
|
||||
_ind("dm_main", "СВ — основной корм", "г", ["Осн.Корм", "СВ Основной корм"]),
|
||||
_ind("oe", "ОЭ — КРС / Дойн", "MJ", ["ОЭ-КРС", " ОЭ-КРС", "OЭ КРС форм"]),
|
||||
_ind("nel", "ЧЭЛ — КРС / Дойн", "MJ", ["ЧЭЛ- КРС", " ЧЭЛ- КРС", "ЧЭЛ - КРС Форм"]),
|
||||
_ind("nel_per_kg_dm", "ЧЭЛ/кг СВ", "MJ", derived="nel_per_kg_dm"),
|
||||
_ind("crude_protein", "Сырой протеин", "г", ["Сыр. Протеин"]),
|
||||
_ind("rup_per_kg_dm", "Нер. СП / кг СВ", "г", ["Нер. СП / кг СВ"], aggregation="weighted_avg"),
|
||||
_ind("insoluble_protein", "Нерастворимый протеин", "г", ["Нераствор протеин ", "Нерастворим прот ", "Нерастворим прот"]),
|
||||
_ind("insoluble_protein_pct", "% нераств. протеин", "%", ["% нераств. протеин", "% нераствор протеин"], aggregation="weighted_avg"),
|
||||
_ind("usp", "уСП", "г", ["уСП", "уСП формул"]),
|
||||
_ind("usp_pct_dm", "% уСП/кг СВ", "%", ["% уСП/кг СВ", "% уСП/кг СР"], aggregation="weighted_avg"),
|
||||
_ind("usp_per_kg_dm", "уСП/кг СВ", "г", derived="g_per_kg_dm", from_key="usp"),
|
||||
_ind("usp_in_om", "уСП в ОВ", "г", ["уСП в ОР", "уСП в ОВ"]),
|
||||
_ind("usp_low_fat", "уСП <7% СЖ", "г", ["уСП<7%CЖ"], aggregation="weighted_avg"),
|
||||
_ind("usp_high_fat", "уСП >7% СЖ", "г", ["уСП>7%CЖ"], aggregation="weighted_avg"),
|
||||
_ind("ndf", "Сырая клетчатка", "г", ["Сырая клетч", "Сырая клетчатка", "НДК"]),
|
||||
_ind("ndf_total", "НДК общ", "г", ["НДК Общ", "НДК"]),
|
||||
_ind("ndf_main_feed", "НДК осн. корм", "г", ["НДК Осн. Корм"]),
|
||||
_ind("adf_total", "КДК общ", "г", ["КДК общ", "КДК"]),
|
||||
_ind("structural_fiber", "Структур. клетчатка", "г", ["Структур клетч", "Структур. клетчатка"]),
|
||||
_ind("structural_fiber_pct", "% стр. клетчатки", "%", ["% стр Сыр. Клетчатк.", "% стр Сыр. Клетч."], aggregation="weighted_avg"),
|
||||
_ind("crude_fat", "Сырой жир", "г", ["Сырой жир"]),
|
||||
_ind("rnb", "RNB", "г", ["RNB", "БРА", " БРА "], derived="rnb"),
|
||||
_ind("calcium", "Ca", "г", ["Ca"]),
|
||||
_ind("phosphorus", "P", "г", ["P"]),
|
||||
_ind("magnesium", "Mg", "г", ["Mg"]),
|
||||
_ind("sodium", "Na", "г", ["Na"]),
|
||||
_ind("potassium", "K", "г", ["K"]),
|
||||
_ind("dcab", "DCAB", "мэкв", ["DCAB Форм", "DCAB"]),
|
||||
_ind("sugar_starch", "Сахар и крахмал", "г", ["Сахар и Крохм"]),
|
||||
_ind("sugar_digestible_starch", "Сахар и усв. крахмал", "г", ["Сахар и усв. крох"]),
|
||||
_ind("insoluble_starch", "Нераств. крахмал", "г", ["Нераств. Крохмал", "Нераств Крохм", "Нераств крахмал"]),
|
||||
_ind("insoluble_starch_alt", "Нераств. крахмал (2)", "г", ["Нераств крохмал"]),
|
||||
_ind("insoluble_starch_pct", "% нераств. крахмала", "%", ["% Нераств крохм"], aggregation="weighted_avg"),
|
||||
_ind("sugar", "Сахар", "г", ["Сахар"]),
|
||||
_ind("starch", "Крахмал", "г", ["Крахмал"]),
|
||||
_ind("starch_pct_dm", "% крахмала СВ", "%", ["% Крохмал. СВ", "доля крохмала"], aggregation="weighted_avg"),
|
||||
_ind("carotene", "Каротин", "мг", ["Каротин"]),
|
||||
_ind("beta_carotene", "β-каротин", "мг", ["b -Каротин"]),
|
||||
_ind("linoleic_acid", "Линолевая к-та", "г", ["Линолевая к-та"]),
|
||||
_ind("linolenic_acid", "Линоленовая к-та", "г", ["Линоленовая ки-та"]),
|
||||
_ind("butyric_acid", "Масляная к-та", "г", ["Масляная ки-та"]),
|
||||
_ind("arachidonic_acid", "Арахидоновая к-та", "г", ["Арахидоновая ки-та"]),
|
||||
_ind("polyenoic_acid", "Полиэновая к-та", "г", ["Полиэновая ки-та"]),
|
||||
_ind("urea", "Мочевина", "г", ["Мочевина"]),
|
||||
_ind("crude_ash", "Сырая зола", "г", ["Сырая зола"]),
|
||||
_ind("nfe", "БЭВ", "г", ["БЕР", "БЭВ"]),
|
||||
_ind("tdn_cattle", "КРС орг. вещество", "г", ["КРС Орг Вещ", "ВРХ Орг Вещ"]),
|
||||
_ind("digestible_organic_matter", "Перевар. орг. вещество", "г", ["Перевар Орг Вещ", "Переварим Орг Вещ"]),
|
||||
_ind("cp_cattle", "КРС протеин", "г", ["КРС Протеин"]),
|
||||
_ind("digestible_protein", "Перевар. протеин", "г", ["Перевар Протеин", "Переварим Протеин"]),
|
||||
_ind("fat_cattle", "КРС сырой жир", "г", ["КРС Сырой жир", "КРС Сирий жир", "КРС Сирой жир"]),
|
||||
_ind("digestible_fat", "Перевар. сырой жир", "г", ["Перевар Сырой жир", "Перевар Сирой жир", "Переварим Сырой жир"]),
|
||||
_ind("fiber_cattle", "КРС сырая клетчатка", "г", ["КРС Сырая клетч"]),
|
||||
_ind("digestible_fiber", "Перевар. сырая клетчатка", "г", ["Перевар сыр клетч", "Переварим сырая клетч"]),
|
||||
_ind("nfe_cattle", "КРС БЭВ", "г", ["КРС БЭВ"]),
|
||||
_ind("digestible_nfe", "Перевар. БЭВ", "г", ["Перевар БЭВ", "Переварим БЭВ"]),
|
||||
_ind("feed_value", "ВЕ", "", ["ВЕ"], aggregation="weighted_avg"),
|
||||
_ind("fat_per_kg_dm", "СЖ/кг СВ", "г", derived="g_per_kg_dm", from_key="crude_fat"),
|
||||
_ind("nsp_per_kg_dm", "НСП/кг СВ", "г", ["НСП/кг СВ"], aggregation="weighted_avg"),
|
||||
_ind("cp_per_kg_dm", "СП/кг СВ", "г", derived="g_per_kg_dm", from_key="crude_protein"),
|
||||
_ind("dom_per_kg_dm", "пОВ/кг СВ", "г", derived="g_per_kg_dm", from_key="digestible_organic_matter"),
|
||||
_ind(
|
||||
"digestible_fat_per_kg_dm",
|
||||
"пСЖ/кг СВ",
|
||||
"г",
|
||||
derived="g_per_kg_dm",
|
||||
from_key="digestible_fat",
|
||||
),
|
||||
_ind("fiber_pct_per_kg_dm", "%СК/кг СВ", "%", derived="pct_of_dm", from_key="ndf"),
|
||||
_ind("fat_pct_per_kg_dm", "%-СЖ/кг СВ", "%", derived="pct_of_dm", from_key="crude_fat"),
|
||||
_ind("usp_pct_per_kg_dm", "%-уСП / кг СВ", "%", derived="pct_of_dm", from_key="usp"),
|
||||
_ind("ca_pct_per_kg_dm", "%-Ca / кг СВ", "%", derived="pct_of_dm", from_key="calcium"),
|
||||
_ind("p_pct_per_kg_dm", "%-P / кг СВ", "%", derived="pct_of_dm", from_key="phosphorus"),
|
||||
_ind("k_pct_per_kg_dm", "%-К / кг СВ", "%", derived="pct_of_dm", from_key="potassium"),
|
||||
_ind("na_pct_per_kg_dm", "%-Na / кг СВ", "%", derived="pct_of_dm", from_key="sodium"),
|
||||
_ind("mg_pct_per_kg_dm", "%-Mg / кг СВ", "%", derived="pct_of_dm", from_key="magnesium"),
|
||||
_ind("ndf_pct_dm", "НДК % от СВ", "%", derived="pct_of_dm", from_key="ndf"),
|
||||
_ind("ndf_main_pct_dm", "НДК-ОК % от СВ", "%", derived="pct_of_dm", from_key="ndf_main_feed"),
|
||||
_ind("adf_pct_dm", "КДК % от СВ", "%", derived="pct_of_dm", from_key="adf_total"),
|
||||
_ind("nfc_pct_dm", "НКВ % от СВ", "%", derived="pct_of_dm", from_key="nfe"),
|
||||
_ind("sw_per_kg_dm", "SW / кг СВ", "", ["SW / кг СВ ()"], aggregation="weighted_avg"),
|
||||
_ind("metab_oet", "Метаб. ОЕТ", "г", ["Метаб ОЕТ", "OEB"]),
|
||||
_ind("metab_lysine", "Метаб. лизин", "г", ["Метаб лизин", "Лизин"]),
|
||||
_ind("metab_threonine", "Метаб. треонин", "г", ["Метабол Треон", "Треонин"]),
|
||||
_ind("metab_leucine", "Метаб. лейцин", "г", ["Метаб Лейц", "Метабол Лейцин", "Лейцин"]),
|
||||
_ind("metab_isoleucine", "Метаб. изолейцин", "г", ["Метаб Изол", "Метабол Изолейц", "Изолейцин"]),
|
||||
_ind("metab_valine", "Метаб. валин", "г", ["Метаб Вал", "Метабол Валин", "Валин"]),
|
||||
_ind("vitamin_a", "Витамин А", "МЕ", ["Вит А"]),
|
||||
_ind("vitamin_d", "Витамин D", "МЕ", ["Вит D"]),
|
||||
_ind("vitamin_e", "Витамин Е", "мг", ["Вит Е"]),
|
||||
_ind("vitamin_b1", "Витамин В1", "мг", ["Вит В1"]),
|
||||
_ind("vitamin_b2", "Витамин В2", "мг", ["Вит В2"]),
|
||||
_ind("vitamin_b6", "Витамин В6", "мг", ["Вит В6"]),
|
||||
_ind("vitamin_b12", "Витамин В12", "мкг", ["Вит В12"]),
|
||||
_ind("calcium_pantothenate", "Пантотенат кальция", "мг", ["Пант Кальц", "Пантеонат Кальция"]),
|
||||
_ind("niacin", "Никотиновая к-та", "мг", ["Никот Ки-та", "Никотиновая кислота"]),
|
||||
_ind("folic_acid", "Фолиевая к-та", "мг", ["Фол ки-та", "Фолиева кислота"]),
|
||||
_ind("choline", "Холин", "мг", ["Холин"]),
|
||||
_ind("biotin", "Биотин", "мкг", ["Биотин"]),
|
||||
_ind("iron", "Fe", "мг", ["Fe"]),
|
||||
_ind("zinc", "Zn", "мг", ["Zn"]),
|
||||
_ind("copper", "Cu", "мг", ["Cu"]),
|
||||
_ind("cobalt", "Co", "мг", ["Co"]),
|
||||
_ind("manganese", "Mn", "мг", ["Mn"]),
|
||||
_ind("selenium", "Se", "мг", ["Se"]),
|
||||
_ind("iodine", "J", "мг", ["J"]),
|
||||
_ind("oet", "OET", "", ["OEB", "OET"]),
|
||||
_ind("synthesis_mj", "Синтез", "МДж", ["Синтез Мдж"]),
|
||||
_ind("ca_p_ratio", "Са:P", "", derived="ratio", ratio_num="calcium", ratio_den="phosphorus"),
|
||||
_ind("k_na_ratio", "K:Na", "", derived="ratio", ratio_num="potassium", ratio_den="sodium"),
|
||||
_ind("ration_dm_pct_bw", "Рацион % СВ от веса", "%", derived="dm_pct_bw"),
|
||||
_ind("ration_pct_bw", "Рацион % от веса", "%", derived="ration_pct_bw"),
|
||||
# Дубликаты ключей норм (укр. заголовки) — тот же расчёт
|
||||
_ind("ndf_pct_dm_uk", "НДК % від СВ", "%", derived="alias", alias_of="ndf_pct_dm"),
|
||||
_ind("ndf_main_pct_dm_uk", "НДК-ОК % від СВ", "%", derived="alias", alias_of="ndf_main_pct_dm"),
|
||||
_ind("adf_pct_dm_uk", "КДК % від СВ", "%", derived="alias", alias_of="adf_pct_dm"),
|
||||
_ind("nfc_pct_dm_uk", "НКВ % від СВ", "%", derived="alias", alias_of="nfc_pct_dm"),
|
||||
]
|
||||
|
||||
# Обратная совместимость
|
||||
RATION_QUALITY_INDICATORS = RATION_ALL_INDICATORS
|
||||
|
||||
_INDICATOR_BY_KEY = {d["key"]: d for d in RATION_ALL_INDICATORS}
|
||||
_LABEL_TO_KEY = {d["label"]: d["key"] for d in RATION_ALL_INDICATORS}
|
||||
|
||||
|
||||
def indicator_by_key(key: str) -> dict[str, Any] | None:
|
||||
return _INDICATOR_BY_KEY.get(key)
|
||||
|
||||
|
||||
def label_to_indicator_key(label: str) -> str | None:
|
||||
return _LABEL_TO_KEY.get(label)
|
||||
|
||||
|
||||
def calc_indicator_keys() -> frozenset[str]:
|
||||
return frozenset(_INDICATOR_BY_KEY)
|
||||
@@ -0,0 +1,5 @@
|
||||
from .execution_loader import load_execution
|
||||
from .profile_loader import list_animal_profiles, load_animal_profile
|
||||
from .ration_loader import load_ration
|
||||
|
||||
__all__ = ["load_ration", "load_execution", "list_animal_profiles", "load_animal_profile"]
|
||||
@@ -0,0 +1,17 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
|
||||
def parse_json_text(value: Any, default: Any = None) -> Any:
|
||||
if default is None:
|
||||
default = {}
|
||||
if value is None or value == "":
|
||||
return default
|
||||
if isinstance(value, dict):
|
||||
return value
|
||||
try:
|
||||
return json.loads(value)
|
||||
except (TypeError, json.JSONDecodeError):
|
||||
return default
|
||||
@@ -0,0 +1,29 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from app.modules.zootech.lab.dto.ration import ExecutionLineSnapshot, ExecutionSnapshot
|
||||
from app.modules.zootech.wesp_bridge_models import Ingredient, Recipe
|
||||
|
||||
|
||||
def load_execution(recipe_id: str) -> ExecutionSnapshot:
|
||||
recipe = Recipe.query.filter_by(id=recipe_id, is_deleted=False).first()
|
||||
if recipe is None:
|
||||
raise LookupError("Рецепт не найден")
|
||||
heads = int(recipe.heads_per_trip or 1)
|
||||
ingredients = (
|
||||
Ingredient.query.filter_by(recipe_id=recipe_id, is_deleted=False)
|
||||
.order_by(Ingredient.order)
|
||||
.all()
|
||||
)
|
||||
lines = []
|
||||
for ing in ingredients:
|
||||
wph = float(ing.weight_per_head or 0)
|
||||
lines.append(
|
||||
ExecutionLineSnapshot(
|
||||
ingredient_id=ing.id,
|
||||
component_id=ing.component_id,
|
||||
name=ing.name,
|
||||
weight_per_head=wph,
|
||||
daily_kg_total=wph * heads,
|
||||
)
|
||||
)
|
||||
return ExecutionSnapshot(recipe_id=recipe_id, heads_per_trip=heads, lines=tuple(lines))
|
||||
@@ -0,0 +1,58 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from app.modules.zootech.lab.models import LabAnimalProfile
|
||||
from app.modules.zootech.lab.services.profile_norms import load_norms_api
|
||||
|
||||
|
||||
def list_animal_profiles(ration_type: str | None = None) -> list[dict]:
|
||||
q = LabAnimalProfile.query.filter_by(is_deleted=False)
|
||||
if ration_type:
|
||||
q = q.filter_by(ration_type=ration_type.upper())
|
||||
rows = q.order_by(LabAnimalProfile.profile_key).all()
|
||||
return [_profile_dict(p, include_resolved=False) for p in rows]
|
||||
|
||||
|
||||
def load_animal_profile(profile_id: str) -> dict:
|
||||
profile = LabAnimalProfile.query.filter_by(id=profile_id, is_deleted=False).first()
|
||||
if profile is None:
|
||||
raise LookupError("Профиль не найден")
|
||||
return _profile_dict(profile, resolve_norms=True, include_resolved=True)
|
||||
|
||||
|
||||
def _profile_dict(
|
||||
profile: LabAnimalProfile,
|
||||
*,
|
||||
resolve_norms: bool = False,
|
||||
include_resolved: bool = False,
|
||||
) -> dict:
|
||||
norms_api = load_norms_api(profile, resolve_dynamic=include_resolved) if resolve_norms else {}
|
||||
if not resolve_norms:
|
||||
from app.modules.zootech.lab.services.profile_norms import load_norms_dict
|
||||
|
||||
indicators = load_norms_dict(profile.id)
|
||||
norms_api = {"indicators": indicators}
|
||||
from app.modules.zootech.lab.services.norms_params import norms_params_api
|
||||
|
||||
out: dict = {
|
||||
"id": profile.id,
|
||||
"profileKey": profile.profile_key,
|
||||
"label": profile.label,
|
||||
"rationType": profile.ration_type,
|
||||
"massKg": profile.mass_kg,
|
||||
"milkYieldKg": profile.milk_yield_kg,
|
||||
"externalNo": profile.external_no,
|
||||
"normsMethod": profile.norms_method or "wesp",
|
||||
"normsParams": norms_params_api(profile),
|
||||
"normsData": norms_api,
|
||||
"norms": norms_api.get("indicators") or {},
|
||||
"normsProfileKey": profile.profile_key,
|
||||
"legacyNormsRemapped": False,
|
||||
}
|
||||
if include_resolved:
|
||||
out["resolvedNorms"] = norms_api.get("resolvedIndicators") or {}
|
||||
out["dynamicNorms"] = norms_api.get("dynamicNorms") or {}
|
||||
if norms_api.get("coverage"):
|
||||
out["coverage"] = norms_api["coverage"]
|
||||
if norms_api.get("normsMeta"):
|
||||
out["normsMeta"] = norms_api["normsMeta"]
|
||||
return out
|
||||
@@ -0,0 +1,125 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from app.modules.zootech.lab.calc.nutrients import norm_diff
|
||||
from app.modules.zootech.lab.indicators import label_to_indicator_key
|
||||
from app.modules.zootech.lab.dto.ration import RationLineSnapshot, RationSnapshot
|
||||
from app.modules.zootech.lab.models import LabAnimalProfile, LabRationLine, LabRecipeRation
|
||||
from app.modules.zootech.lab.services.component_nutrients import nutrients_calc_dict
|
||||
from app.modules.zootech.lab.services.profile_norms import resolve_norms_for_profile
|
||||
from app.modules.zootech.lab.services.ration_calc_store import (
|
||||
load_compound_results,
|
||||
load_params,
|
||||
load_ration_results,
|
||||
)
|
||||
from app.modules.zootech.wesp_bridge_models import Component, Recipe
|
||||
|
||||
|
||||
def _refresh_indicator_norms(
|
||||
indicators: list[dict],
|
||||
norms: dict[str, dict[str, float | None]],
|
||||
) -> list[dict]:
|
||||
"""min/max/diff в сохранённом расчёте — по текущему профилю норм."""
|
||||
if not indicators or not norms:
|
||||
return indicators
|
||||
out: list[dict] = []
|
||||
for row in indicators:
|
||||
item = dict(row)
|
||||
key = item.get("key") or label_to_indicator_key(str(item.get("label") or ""))
|
||||
if key and key in norms:
|
||||
bounds = norms[key]
|
||||
min_v = bounds.get("min")
|
||||
max_v = bounds.get("max")
|
||||
item["min"] = min_v
|
||||
item["max"] = max_v
|
||||
item["diff"] = norm_diff(item.get("content"), min_v, max_v)
|
||||
out.append(item)
|
||||
return out
|
||||
|
||||
|
||||
def load_ration(recipe_id: str) -> RationSnapshot:
|
||||
recipe = Recipe.query.filter_by(id=recipe_id, is_deleted=False).first()
|
||||
if recipe is None:
|
||||
raise LookupError("Рецепт не найден")
|
||||
header = LabRecipeRation.query.filter_by(recipe_id=recipe_id, is_deleted=False).first()
|
||||
ration_type = recipe.ration_type or "BEEF"
|
||||
norms: dict = {}
|
||||
params: dict = {}
|
||||
ration_results: dict = {}
|
||||
compound_results: dict = {}
|
||||
calculated_at = None
|
||||
animal_profile_id = None
|
||||
norms_profile_key = None
|
||||
if header:
|
||||
animal_profile_id = header.animal_profile_id
|
||||
params = load_params(recipe_id, header)
|
||||
ration_results = load_ration_results(recipe_id, header)
|
||||
compound_results = load_compound_results(recipe_id)
|
||||
calculated_at = header.calculated_at.isoformat() if header.calculated_at else None
|
||||
if header.animal_profile_id:
|
||||
profile = LabAnimalProfile.query.filter_by(
|
||||
id=header.animal_profile_id, is_deleted=False
|
||||
).first()
|
||||
if profile:
|
||||
norms_profile_key = profile.profile_key
|
||||
norms = resolve_norms_for_profile(profile)
|
||||
if ration_results.get("indicators") and norms:
|
||||
ration_results = {
|
||||
**ration_results,
|
||||
"indicators": _refresh_indicator_norms(ration_results["indicators"], norms),
|
||||
}
|
||||
lines_q = (
|
||||
LabRationLine.query.filter_by(recipe_id=recipe_id, is_deleted=False)
|
||||
.order_by(LabRationLine.row_index)
|
||||
.all()
|
||||
)
|
||||
line_snaps = []
|
||||
for line in lines_q:
|
||||
comp = Component.query.get(line.component_id) if line.component_id else None
|
||||
nutrients = nutrients_calc_dict(line.component_id) if line.component_id else {}
|
||||
dry_matter_pct = comp.dry_matter if comp else None
|
||||
line_snaps.append(
|
||||
RationLineSnapshot(
|
||||
id=line.id,
|
||||
component_id=line.component_id,
|
||||
ingredient_name=line.ingredient_name or (comp.name if comp else None),
|
||||
row_index=line.row_index,
|
||||
daily_kg=line.daily_kg,
|
||||
in_ration=bool(line.in_ration),
|
||||
in_compound=bool(line.in_compound),
|
||||
dry_matter=dry_matter_pct,
|
||||
price_per_kg=comp.price if comp else None,
|
||||
nutrients=nutrients,
|
||||
)
|
||||
)
|
||||
return RationSnapshot(
|
||||
recipe_id=recipe_id,
|
||||
recipe_name=recipe.name,
|
||||
ration_type=ration_type,
|
||||
heads_per_trip=int(recipe.heads_per_trip or 1),
|
||||
animal_profile_id=animal_profile_id,
|
||||
params=params,
|
||||
lines=tuple(line_snaps),
|
||||
norms=norms,
|
||||
ration_results=ration_results,
|
||||
compound_results=compound_results,
|
||||
calculated_at=calculated_at,
|
||||
exists=header is not None,
|
||||
norms_profile_key=norms_profile_key,
|
||||
legacy_norms_remapped=False,
|
||||
)
|
||||
|
||||
|
||||
def ration_to_calc_lines(snapshot: RationSnapshot) -> list[dict]:
|
||||
return [
|
||||
{
|
||||
"daily_kg": line.daily_kg,
|
||||
"in_ration": line.in_ration,
|
||||
"in_compound": line.in_compound,
|
||||
"ingredient_name": line.ingredient_name,
|
||||
"price_per_kg": line.price_per_kg,
|
||||
"dry_matter": line.dry_matter,
|
||||
"nutrients": line.nutrients,
|
||||
"component_id": line.component_id,
|
||||
}
|
||||
for line in snapshot.lines
|
||||
]
|
||||
@@ -0,0 +1,17 @@
|
||||
"""Re-export WESP lab model aliases for ported lab package."""
|
||||
|
||||
from app.modules.zootech.wesp_bridge_models import ( # noqa: F401
|
||||
LabAnimalProfile,
|
||||
LabComponentNutrientValue,
|
||||
LabProfileNorm,
|
||||
LabRacionNormyInfo,
|
||||
LabRacionNormyMoskwa,
|
||||
LabRacionNormyMoskwaMeta,
|
||||
LabRacionNormyPiter,
|
||||
LabRacionNormyPiterMeta,
|
||||
LabRationCalcIndicator,
|
||||
LabRationCalcTotal,
|
||||
LabRationCompoundLine,
|
||||
LabRationLine,
|
||||
LabRecipeRation,
|
||||
)
|
||||
@@ -0,0 +1,290 @@
|
||||
"""Каталог показателей норм профиля стада."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import unicodedata
|
||||
|
||||
from app.modules.zootech.lab.indicators import RATION_ALL_INDICATORS, calc_indicator_keys
|
||||
|
||||
_CALC_META = {d["key"]: d for d in RATION_ALL_INDICATORS}
|
||||
|
||||
# Заголовок колонки CSV «Нормы Дойн/КРС» (после нормализации) → indicator_key в lab_profile_norm
|
||||
NORM_HEADER_TO_KEY: dict[str, str] = {
|
||||
"Сухое Вещество": "dry_matter",
|
||||
"Сыр. Протеин": "crude_protein",
|
||||
"уСП": "usp",
|
||||
"RNB": "rnb",
|
||||
"БРА": "rnb",
|
||||
"ЧЭЛ- КРС": "nel",
|
||||
"ОЭ-КРС": "oe",
|
||||
"Сырая клетч": "ndf",
|
||||
"Сырая клетчат": "ndf",
|
||||
"Сырая клетчатка": "ndf",
|
||||
"Структур клетчатк": "structural_fiber",
|
||||
"Сырой жир": "crude_fat",
|
||||
"Сирой жир": "crude_fat",
|
||||
"Ca": "calcium",
|
||||
"P": "phosphorus",
|
||||
"Mg": "magnesium",
|
||||
"Na": "sodium",
|
||||
"DCAB": "dcab",
|
||||
"Сахар и Крохм": "sugar_starch",
|
||||
"Нераств. Крохмал": "insoluble_starch",
|
||||
"Нерозч. Крохмал": "insoluble_starch",
|
||||
"Сахар": "sugar",
|
||||
"Крохмал": "starch",
|
||||
"Каротин": "carotene",
|
||||
"b -Каротин": "beta_carotene",
|
||||
"Линолевая к-та": "linoleic_acid",
|
||||
"Линоленовая ки-та": "linolenic_acid",
|
||||
"Масляная ки-та": "butyric_acid",
|
||||
"Мочевина": "urea",
|
||||
"СВ Основной корм": "dm_main",
|
||||
"КРС Орг Вещ": "tdn_cattle",
|
||||
"Перевар Орг Вещ": "digestible_organic_matter",
|
||||
"КРС Протеин": "cp_cattle",
|
||||
"Перевар Протеин": "digestible_protein",
|
||||
"КРС Сирий жир": "fat_cattle",
|
||||
"КРС Сирой жир": "fat_cattle",
|
||||
"Перевар Сырой жир": "digestible_fat",
|
||||
"Перевар Сирой жир": "digestible_fat",
|
||||
"КРС Сырая клетч": "fiber_cattle",
|
||||
"Перевар сыр клетч": "digestible_fiber",
|
||||
"КРС БЭВ": "nfe_cattle",
|
||||
"Перевар БЭВ": "digestible_nfe",
|
||||
"ВЕ": "feed_value",
|
||||
"НДК Общ": "ndf_total",
|
||||
"НДК Осн. Корм": "ndf_main_feed",
|
||||
"КДК общ": "adf_total",
|
||||
"% нераств. протеин": "insoluble_protein_pct",
|
||||
"Нераствор протеин": "insoluble_protein",
|
||||
"СЖ/кг СВ": "fat_per_kg_dm",
|
||||
"НСП/кг СВ": "nsp_per_kg_dm",
|
||||
"СП/кг СВ": "cp_per_kg_dm",
|
||||
"пОВ/кг СВ": "dom_per_kg_dm",
|
||||
"пСЖ/кг СВ": "digestible_fat_per_kg_dm",
|
||||
"уСП<7%CЖ": "usp_low_fat",
|
||||
"уСП>7%CЖ": "usp_high_fat",
|
||||
"уСП/кг СВ": "usp_per_kg_dm",
|
||||
"уСП в ОВ": "usp_in_om",
|
||||
"Нераств крохмал": "insoluble_starch_alt",
|
||||
"Метаб ОЕТ": "metab_oet",
|
||||
"Метаб лизин": "metab_lysine",
|
||||
"Метабол Треон": "metab_threonine",
|
||||
"Метаб Лейц": "metab_leucine",
|
||||
"Метаб Изол": "metab_isoleucine",
|
||||
"Метаб Вал": "metab_valine",
|
||||
"НДК % від СВ ()": "ndf_pct_dm_uk",
|
||||
"НДК % от СВ ()": "ndf_pct_dm",
|
||||
"НДК-ОК % від СВ ()": "ndf_main_pct_dm_uk",
|
||||
"НДК-ОК % от СВ ()": "ndf_main_pct_dm",
|
||||
"КДК % від СВ ()": "adf_pct_dm_uk",
|
||||
"КДК % от СВ ()": "adf_pct_dm",
|
||||
"НКВ % від СВ ()": "nfc_pct_dm_uk",
|
||||
"НКВ % от СВ ()": "nfc_pct_dm",
|
||||
"SW / кг СВ ()": "sw_per_kg_dm",
|
||||
"ЧЕЛ/кг СВ (МДж)": "nel_per_kg_dm",
|
||||
"Нер. СП / кг СВ": "rup_per_kg_dm",
|
||||
"%СК/кг СВ ()": "fiber_pct_per_kg_dm",
|
||||
"%-СЖ/кг СВ ()": "fat_pct_per_kg_dm",
|
||||
"%-уСП / кг СВ ()": "usp_pct_per_kg_dm",
|
||||
"% уСП/кг СВ": "usp_pct_dm",
|
||||
"%-Ca / кг СВ ()": "ca_pct_per_kg_dm",
|
||||
"%-P / кг СВ ()": "p_pct_per_kg_dm",
|
||||
"%-К / кг СВ ()": "k_pct_per_kg_dm",
|
||||
"%-Na / кг СВ ()": "na_pct_per_kg_dm",
|
||||
"%-Mg / кг СВ ()": "mg_pct_per_kg_dm",
|
||||
"% стр Сыр. Клетчатк.": "structural_fiber_pct",
|
||||
"% стр Сыр. Клетч.": "structural_fiber_pct",
|
||||
"% Крохмал. СВ": "starch_pct_dm",
|
||||
"% Нераств крохм": "insoluble_starch_pct",
|
||||
"Са:P": "ca_p_ratio",
|
||||
"K:Na": "k_na_ratio",
|
||||
"Рацион % СВ от Веса КРС": "ration_dm_pct_bw",
|
||||
"Рацион % от Веса КРС": "ration_pct_bw",
|
||||
"Витамин А": "vitamin_a",
|
||||
"Витамин D": "vitamin_d",
|
||||
"Витамин Е": "vitamin_e",
|
||||
"Витамин В1": "vitamin_b1",
|
||||
"Витамин В2": "vitamin_b2",
|
||||
"Витамин В6": "vitamin_b6",
|
||||
"Витамин В12": "vitamin_b12",
|
||||
"Пантеонат Кальция": "calcium_pantothenate",
|
||||
"Пантеонат Кальцию": "calcium_pantothenate",
|
||||
"Никотиновая кислота": "niacin",
|
||||
"Фолиева кислота": "folic_acid",
|
||||
"Фолиевач кислота": "folic_acid",
|
||||
"Холин": "choline",
|
||||
"Биотин": "biotin",
|
||||
"Fe": "iron",
|
||||
"Zn": "zinc",
|
||||
"Cu": "copper",
|
||||
"Co": "cobalt",
|
||||
"Mn": "manganese",
|
||||
"Se": "selenium",
|
||||
"J": "iodine",
|
||||
"K": "potassium",
|
||||
"Сахар и усв. крох": "sugar_digestible_starch",
|
||||
"Сырая зола": "crude_ash",
|
||||
"Сірая зола": "crude_ash",
|
||||
"БЭВ": "nfe",
|
||||
"БЄВ": "nfe",
|
||||
"Арахидоновая ки-та": "arachidonic_acid",
|
||||
"Полиэновая ки-та": "polyenoic_acid",
|
||||
"OET": "oet",
|
||||
"Синтез Мдж": "synthesis_mj",
|
||||
}
|
||||
|
||||
# Метаданные для UI (label/unit) — показатели вне расчёта рациона
|
||||
_EXTRA_NORM_META: dict[str, dict[str, str]] = {
|
||||
"beta_carotene": {"label": "β-каротин", "unit": "мг"},
|
||||
"linoleic_acid": {"label": "Линолевая к-та", "unit": "г"},
|
||||
"linolenic_acid": {"label": "Линоленовая к-та", "unit": "г"},
|
||||
"butyric_acid": {"label": "Масляная к-та", "unit": "г"},
|
||||
"urea": {"label": "Мочевина", "unit": "г"},
|
||||
"tdn_cattle": {"label": "КРС орг. вещество", "unit": "г"},
|
||||
"digestible_organic_matter": {"label": "Перевар. орг. вещество", "unit": "г"},
|
||||
"cp_cattle": {"label": "КРС протеин", "unit": "г"},
|
||||
"digestible_protein": {"label": "Перевар. протеин", "unit": "г"},
|
||||
"fat_cattle": {"label": "КРС сырой жир", "unit": "г"},
|
||||
"digestible_fat": {"label": "Перевар. сырой жир", "unit": "г"},
|
||||
"fiber_cattle": {"label": "КРС сырая клетчатка", "unit": "г"},
|
||||
"digestible_fiber": {"label": "Перевар. сырая клетчатка", "unit": "г"},
|
||||
"nfe_cattle": {"label": "КРС БЭВ", "unit": "г"},
|
||||
"digestible_nfe": {"label": "Перевар. БЭВ", "unit": "г"},
|
||||
"feed_value": {"label": "ВЕ", "unit": ""},
|
||||
"ndf_total": {"label": "НДК общ", "unit": "г"},
|
||||
"ndf_main_feed": {"label": "НДК осн. корм", "unit": "г"},
|
||||
"adf_total": {"label": "КДК общ", "unit": "г"},
|
||||
"insoluble_protein_pct": {"label": "% нераств. протеин", "unit": "%"},
|
||||
"fat_per_kg_dm": {"label": "СЖ/кг СВ", "unit": "г"},
|
||||
"nsp_per_kg_dm": {"label": "НСП/кг СВ", "unit": "г"},
|
||||
"cp_per_kg_dm": {"label": "СП/кг СВ", "unit": "г"},
|
||||
"dom_per_kg_dm": {"label": "пОВ/кг СВ", "unit": "г"},
|
||||
"digestible_fat_per_kg_dm": {"label": "пСЖ/кг СВ", "unit": "г"},
|
||||
"usp_low_fat": {"label": "уСП <7% СЖ", "unit": "г"},
|
||||
"usp_high_fat": {"label": "уСП >7% СЖ", "unit": "г"},
|
||||
"usp_per_kg_dm": {"label": "уСП/кг СВ", "unit": "г"},
|
||||
"usp_in_om": {"label": "уСП в ОВ", "unit": "г"},
|
||||
"insoluble_starch_alt": {"label": "Нераств. крахмал (2)", "unit": "г"},
|
||||
"metab_oet": {"label": "Метаб. ОЕТ", "unit": "г"},
|
||||
"metab_lysine": {"label": "Метаб. лизин", "unit": "г"},
|
||||
"metab_threonine": {"label": "Метаб. треонин", "unit": "г"},
|
||||
"metab_leucine": {"label": "Метаб. лейцин", "unit": "г"},
|
||||
"metab_isoleucine": {"label": "Метаб. изолейцин", "unit": "г"},
|
||||
"metab_valine": {"label": "Метаб. валин", "unit": "г"},
|
||||
"ndf_pct_dm": {"label": "НДК % от СВ", "unit": "%"},
|
||||
"ndf_pct_dm_uk": {"label": "НДК % від СВ", "unit": "%"},
|
||||
"ndf_main_pct_dm": {"label": "НДК-ОК % от СВ", "unit": "%"},
|
||||
"ndf_main_pct_dm_uk": {"label": "НДК-ОК % від СВ", "unit": "%"},
|
||||
"adf_pct_dm": {"label": "КДК % от СВ", "unit": "%"},
|
||||
"adf_pct_dm_uk": {"label": "КДК % від СВ", "unit": "%"},
|
||||
"nfc_pct_dm": {"label": "НКВ % от СВ", "unit": "%"},
|
||||
"nfc_pct_dm_uk": {"label": "НКВ % від СВ", "unit": "%"},
|
||||
"sw_per_kg_dm": {"label": "SW / кг СВ", "unit": ""},
|
||||
"fiber_pct_per_kg_dm": {"label": "%СК/кг СВ", "unit": "%"},
|
||||
"fat_pct_per_kg_dm": {"label": "%-СЖ/кг СВ", "unit": "%"},
|
||||
"usp_pct_per_kg_dm": {"label": "%-уСП / кг СВ", "unit": "%"},
|
||||
"ca_pct_per_kg_dm": {"label": "%-Ca / кг СВ", "unit": "%"},
|
||||
"p_pct_per_kg_dm": {"label": "%-P / кг СВ", "unit": "%"},
|
||||
"k_pct_per_kg_dm": {"label": "%-К / кг СВ", "unit": "%"},
|
||||
"na_pct_per_kg_dm": {"label": "%-Na / кг СВ", "unit": "%"},
|
||||
"mg_pct_per_kg_dm": {"label": "%-Mg / кг СВ", "unit": "%"},
|
||||
"structural_fiber_pct": {"label": "% стр. клетчатки", "unit": "%"},
|
||||
"starch_pct_dm": {"label": "% крахмала СВ", "unit": "%"},
|
||||
"insoluble_starch_pct": {"label": "% нераств. крахмала", "unit": "%"},
|
||||
"ca_p_ratio": {"label": "Са:P", "unit": ""},
|
||||
"k_na_ratio": {"label": "K:Na", "unit": ""},
|
||||
"ration_dm_pct_bw": {"label": "Рацион % СВ от веса", "unit": "%"},
|
||||
"ration_pct_bw": {"label": "Рацион % от веса", "unit": "%"},
|
||||
"vitamin_a": {"label": "Витамин А", "unit": "МЕ"},
|
||||
"vitamin_d": {"label": "Витамин D", "unit": "МЕ"},
|
||||
"vitamin_e": {"label": "Витамин Е", "unit": "мг"},
|
||||
"vitamin_b1": {"label": "Витамин В1", "unit": "мг"},
|
||||
"vitamin_b2": {"label": "Витамин В2", "unit": "мг"},
|
||||
"vitamin_b6": {"label": "Витамин В6", "unit": "мг"},
|
||||
"vitamin_b12": {"label": "Витамин В12", "unit": "мкг"},
|
||||
"calcium_pantothenate": {"label": "Пантотенат кальция", "unit": "мг"},
|
||||
"niacin": {"label": "Никотиновая к-та", "unit": "мг"},
|
||||
"folic_acid": {"label": "Фолиевая к-та", "unit": "мг"},
|
||||
"choline": {"label": "Холин", "unit": "мг"},
|
||||
"biotin": {"label": "Биотин", "unit": "мкг"},
|
||||
"iron": {"label": "Fe", "unit": "мг"},
|
||||
"zinc": {"label": "Zn", "unit": "мг"},
|
||||
"copper": {"label": "Cu", "unit": "мг"},
|
||||
"cobalt": {"label": "Co", "unit": "мг"},
|
||||
"manganese": {"label": "Mn", "unit": "мг"},
|
||||
"selenium": {"label": "Se", "unit": "мг"},
|
||||
"iodine": {"label": "J", "unit": "мг"},
|
||||
"potassium": {"label": "K", "unit": "г"},
|
||||
"sugar_digestible_starch": {"label": "Сахар и усв. крахмал", "unit": "г"},
|
||||
"crude_ash": {"label": "Сырая зола", "unit": "г"},
|
||||
"nfe": {"label": "БЭВ", "unit": "г"},
|
||||
"arachidonic_acid": {"label": "Арахидоновая к-та", "unit": "г"},
|
||||
"polyenoic_acid": {"label": "Полиэновая к-та", "unit": "г"},
|
||||
"oet": {"label": "OET", "unit": ""},
|
||||
"synthesis_mj": {"label": "Синтез", "unit": "МДж"},
|
||||
}
|
||||
|
||||
_CALC_KEYS = calc_indicator_keys()
|
||||
|
||||
|
||||
def norm_header(value: str) -> str:
|
||||
return " ".join((value or "").split()).strip()
|
||||
|
||||
|
||||
def norm_title_to_key(title: str) -> str | None:
|
||||
"""Заголовок колонки CSV → indicator_key (или None)."""
|
||||
key = NORM_HEADER_TO_KEY.get(norm_header(title))
|
||||
if key:
|
||||
return key
|
||||
return _fallback_key(title)
|
||||
|
||||
|
||||
def _fallback_key(title: str) -> str | None:
|
||||
n = norm_header(title)
|
||||
if not n:
|
||||
return None
|
||||
low = n.lower().replace("і", "и").replace("є", "е")
|
||||
for header, key in NORM_HEADER_TO_KEY.items():
|
||||
if header.lower() == low:
|
||||
return key
|
||||
slug = re.sub(r"[^a-z0-9]+", "_", unicodedata.normalize("NFKD", low).encode("ascii", "ignore").decode())
|
||||
slug = slug.strip("_")[:48]
|
||||
return f"norm_{slug}" if slug else None
|
||||
|
||||
|
||||
def norm_indicator_meta(key: str) -> dict[str, str | bool]:
|
||||
calc = _CALC_META.get(key)
|
||||
if calc:
|
||||
return {
|
||||
"key": key,
|
||||
"label": str(calc["label"]),
|
||||
"unit": str(calc.get("unit") or ""),
|
||||
"inCalc": True,
|
||||
}
|
||||
extra = _EXTRA_NORM_META.get(key, {})
|
||||
return {
|
||||
"key": key,
|
||||
"label": extra.get("label", key),
|
||||
"unit": extra.get("unit", ""),
|
||||
"inCalc": key in _CALC_KEYS,
|
||||
}
|
||||
|
||||
|
||||
def list_norm_indicators() -> list[dict[str, str | bool]]:
|
||||
keys: list[str] = []
|
||||
seen: set[str] = set()
|
||||
for key in NORM_HEADER_TO_KEY.values():
|
||||
if key not in seen:
|
||||
seen.add(key)
|
||||
keys.append(key)
|
||||
for key in sorted(_EXTRA_NORM_META):
|
||||
if key not in seen:
|
||||
keys.append(key)
|
||||
return [norm_indicator_meta(k) for k in keys]
|
||||
|
||||
|
||||
def calc_norm_keys() -> frozenset[str]:
|
||||
return _CALC_KEYS
|
||||
@@ -0,0 +1,28 @@
|
||||
"""Канонические indicator_key для нутриентов компонента (без fuzzy-match)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from app.modules.zootech.lab.norm_catalog import NORM_HEADER_TO_KEY
|
||||
|
||||
|
||||
def augment_with_indicator_keys(data: dict[str, Any] | None) -> dict[str, float]:
|
||||
"""Дублирует значения под slug indicator_key."""
|
||||
if not data:
|
||||
return {}
|
||||
out: dict[str, float] = {}
|
||||
for raw_key, raw_val in data.items():
|
||||
try:
|
||||
out[str(raw_key)] = float(raw_val)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
for header, indicator_key in NORM_HEADER_TO_KEY.items():
|
||||
if header in out and indicator_key not in out:
|
||||
out[indicator_key] = out[header]
|
||||
return out
|
||||
|
||||
|
||||
def canonicalize_for_storage(data: dict[str, Any] | None) -> dict[str, float]:
|
||||
"""При записи в EAV: slug indicator_key + исходный заголовок."""
|
||||
return augment_with_indicator_keys(data)
|
||||
@@ -0,0 +1,163 @@
|
||||
"""Канонические поля zootech-показателей компонента (parity tab INGREDIENT_NUTRIENT_EDIT_ROWS)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
# column_name -> ключи в API/calc (первый — канон для UI)
|
||||
NUTRIENT_FIELD_SPECS: tuple[tuple[str, tuple[str, ...]], ...] = (
|
||||
("crude_protein", ("Сыр. Протеин",)),
|
||||
("usp", ("уСП",)),
|
||||
("rnb", ("RNB", "БРА", " БРА ")),
|
||||
("nel_cattle", ("ЧЭЛ- КРС", " ЧЭЛ- КРС", "ЧЭЛ - КРС Форм")),
|
||||
("oe_cattle", ("ОЭ-КРС", " ОЭ-КРС", "OЭ КРС форм")),
|
||||
("ndf", ("Сырая клетчатка", "Сырая клетч")),
|
||||
("structural_fiber", ("Структур. клетчатка", "Структур клетч")),
|
||||
("crude_fat", ("Сырой жир",)),
|
||||
)
|
||||
|
||||
|
||||
def _normalize_key(value: str) -> str:
|
||||
return " ".join((value or "").split()).strip().lower()
|
||||
|
||||
|
||||
def _parse_num(value: Any) -> float | None:
|
||||
if value is None or value == "":
|
||||
return None
|
||||
try:
|
||||
n = float(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
return None if n != n else n
|
||||
|
||||
|
||||
def dry_matter_g_per_kg(dry_matter_pct: float | None) -> float | None:
|
||||
"""СВ г/кг из канонического component.dry_matter (%): 88.7% → 887 г/кг."""
|
||||
dm = _parse_num(dry_matter_pct)
|
||||
if dm is None:
|
||||
return None
|
||||
if 0 < dm <= 100:
|
||||
return dm * 10.0
|
||||
if dm > 100:
|
||||
return dm
|
||||
return None
|
||||
|
||||
|
||||
def resolve_sv_g_per_kg(
|
||||
nutrients: dict[str, Any] | None,
|
||||
dry_matter_pct: float | None,
|
||||
) -> float | None:
|
||||
"""Deprecated alias: СВ только из component.dry_matter (%). nutrients игнорируется."""
|
||||
return dry_matter_g_per_kg(dry_matter_pct)
|
||||
|
||||
|
||||
def read_from_mapping(data: dict[str, Any] | None, keys: tuple[str, ...]) -> float | None:
|
||||
if not data:
|
||||
return None
|
||||
for search in keys:
|
||||
target = _normalize_key(search)
|
||||
for k, v in data.items():
|
||||
if _normalize_key(str(k)) != target:
|
||||
continue
|
||||
n = _parse_num(v)
|
||||
if n is not None:
|
||||
return n
|
||||
return None
|
||||
|
||||
|
||||
def api_dict_to_column_values(data: dict[str, Any] | None) -> dict[str, float | None]:
|
||||
payload = data or {}
|
||||
out: dict[str, float | None] = {}
|
||||
for col, keys in NUTRIENT_FIELD_SPECS:
|
||||
out[col] = read_from_mapping(payload, keys)
|
||||
return out
|
||||
|
||||
|
||||
def derived_to_column_values(data: dict[str, Any] | None) -> dict[str, float | None]:
|
||||
return api_dict_to_column_values(data)
|
||||
|
||||
|
||||
def column_values_to_mapping(values: dict[str, float | None]) -> dict[str, float]:
|
||||
out: dict[str, float] = {}
|
||||
for col, keys in NUTRIENT_FIELD_SPECS:
|
||||
val = values.get(col)
|
||||
n = _parse_num(val)
|
||||
if n is not None:
|
||||
out[keys[0]] = n
|
||||
return out
|
||||
|
||||
|
||||
def mapping_to_calc_dict(data: dict[str, Any] | None) -> dict[str, float]:
|
||||
if not data:
|
||||
return {}
|
||||
from app.modules.zootech.lab.indicators import RATION_ALL_INDICATORS
|
||||
|
||||
result: dict[str, float] = {}
|
||||
for _col, keys in NUTRIENT_FIELD_SPECS:
|
||||
val = read_from_mapping(data, keys)
|
||||
if val is None:
|
||||
continue
|
||||
for key in keys:
|
||||
result[key] = val
|
||||
seen: set[str] = set()
|
||||
for defn in RATION_ALL_INDICATORS:
|
||||
for key in defn.get("nutrient_keys") or []:
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
val = read_from_mapping(data, (key,))
|
||||
if val is not None:
|
||||
result[key] = val
|
||||
return result
|
||||
|
||||
|
||||
def row_to_calc_dict(row: Any | None) -> dict[str, float]:
|
||||
if row is None:
|
||||
return {}
|
||||
result: dict[str, float] = {}
|
||||
for col, keys in NUTRIENT_FIELD_SPECS:
|
||||
val = getattr(row, col, None)
|
||||
if val is None:
|
||||
continue
|
||||
n = _parse_num(val)
|
||||
if n is None:
|
||||
continue
|
||||
for key in keys:
|
||||
result[key] = n
|
||||
return result
|
||||
|
||||
|
||||
def row_to_api_dict(row: Any | None) -> dict[str, float]:
|
||||
"""API nutrients — канонические ключи tab."""
|
||||
if row is None:
|
||||
return {}
|
||||
out: dict[str, float] = {}
|
||||
for col, keys in NUTRIENT_FIELD_SPECS:
|
||||
val = getattr(row, col, None)
|
||||
n = _parse_num(val)
|
||||
if n is not None:
|
||||
out[keys[0]] = n
|
||||
return out
|
||||
|
||||
|
||||
def row_is_empty(row: Any | None) -> bool:
|
||||
if row is None:
|
||||
return True
|
||||
for col, _keys in NUTRIENT_FIELD_SPECS:
|
||||
if _parse_num(getattr(row, col, None)) is not None:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def legacy_json_to_column_values(raw: str | None) -> dict[str, float | None]:
|
||||
if not raw or not str(raw).strip() or str(raw).strip() == "{}":
|
||||
return {col: None for col, _ in NUTRIENT_FIELD_SPECS}
|
||||
import json
|
||||
|
||||
try:
|
||||
data = json.loads(raw)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
data = {}
|
||||
if not isinstance(data, dict):
|
||||
data = {}
|
||||
return api_dict_to_column_values(data)
|
||||
@@ -0,0 +1,9 @@
|
||||
"""Справочные zootech-профили (norm_*) vs пользовательские стандарты."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
_REFERENCE_PREFIX = "norm_"
|
||||
|
||||
|
||||
def is_reference_profile(profile_key: str | None) -> bool:
|
||||
return bool(profile_key) and profile_key.startswith(_REFERENCE_PREFIX)
|
||||
@@ -0,0 +1,23 @@
|
||||
"""Пути к seed-данным WESP (data/seed/)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
_API_ROOT = Path(__file__).resolve().parents[4]
|
||||
_REPO_ROOT = _API_ROOT.parent.parent
|
||||
|
||||
|
||||
def seed_dir() -> Path:
|
||||
local = _API_ROOT / "data" / "seed"
|
||||
if local.exists():
|
||||
return local
|
||||
return _REPO_ROOT / "WESP_REL" / "data" / "seed"
|
||||
|
||||
|
||||
def norms_dir() -> Path:
|
||||
return seed_dir() / "norms"
|
||||
|
||||
|
||||
def nutrients_dir() -> Path:
|
||||
return seed_dir() / "nutrients"
|
||||
@@ -0,0 +1,3 @@
|
||||
from .api import ration_to_api
|
||||
|
||||
__all__ = ["ration_to_api"]
|
||||
@@ -0,0 +1,52 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from app.modules.zootech.lab.dto.ration import RationSnapshot
|
||||
|
||||
|
||||
def ration_to_api(snapshot: RationSnapshot) -> dict[str, Any]:
|
||||
return {
|
||||
"recipeId": snapshot.recipe_id,
|
||||
"recipeName": snapshot.recipe_name,
|
||||
"rationType": snapshot.ration_type,
|
||||
"headsPerTrip": snapshot.heads_per_trip,
|
||||
"exists": snapshot.exists,
|
||||
"animalProfileId": snapshot.animal_profile_id,
|
||||
"params": snapshot.params,
|
||||
"norms": snapshot.norms,
|
||||
"normsProfileKey": snapshot.norms_profile_key,
|
||||
"legacyNormsRemapped": snapshot.legacy_norms_remapped,
|
||||
"rationResults": snapshot.ration_results,
|
||||
"compoundResults": snapshot.compound_results,
|
||||
"calculatedAt": snapshot.calculated_at,
|
||||
"lines": [
|
||||
{
|
||||
"id": line.id,
|
||||
"componentId": line.component_id,
|
||||
"ingredientName": line.ingredient_name,
|
||||
"rowIndex": line.row_index,
|
||||
"dailyKg": line.daily_kg,
|
||||
"inRation": line.in_ration,
|
||||
"inCompound": line.in_compound,
|
||||
"dryMatter": line.dry_matter,
|
||||
"pricePerKg": line.price_per_kg,
|
||||
}
|
||||
for line in snapshot.lines
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def diff_to_api(diff: dict[str, Any]) -> dict[str, Any]:
|
||||
return {
|
||||
"hasChanges": diff.get("has_changes", False),
|
||||
"lines": [
|
||||
{
|
||||
"componentId": row.get("component_id"),
|
||||
"masterKg": row.get("master_kg"),
|
||||
"executionKg": row.get("execution_kg"),
|
||||
"reasons": row.get("reasons", []),
|
||||
}
|
||||
for row in diff.get("lines", [])
|
||||
],
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
from app.modules.zootech.wesp_bridge_db import db
|
||||
from app.modules.zootech.lab.calc.feed_groups import classify_feed_group
|
||||
from app.modules.zootech.lab.calc.gfe_policies import DeriveContext
|
||||
from app.modules.zootech.lab.calc.ingredient_catalog import DERIVED_HEADERS
|
||||
from app.modules.zootech.lab.calc.ingredient_derive import derive_ingredient_nutrients
|
||||
from app.modules.zootech.lab.models import LabComponentNutrientValue
|
||||
from app.modules.zootech.lab.nutrient_keys import augment_with_indicator_keys, canonicalize_for_storage
|
||||
from app.modules.zootech.lab.nutrient_schema import (
|
||||
dry_matter_g_per_kg,
|
||||
mapping_to_calc_dict,
|
||||
read_from_mapping,
|
||||
)
|
||||
from app.modules.zootech.wesp_bridge_models import Component
|
||||
|
||||
_DERIVE_INPUT_KEYS = ("СВ", "Сыр. Протеин", "Сырая клетч", "Сырой жир")
|
||||
_EAV_SKIP_KEYS = frozenset({"СВ"})
|
||||
_MAIN_FEED_KEYS = ("Осн.Корм", "СВ Основной корм")
|
||||
|
||||
|
||||
def derive_context_for_component(
|
||||
component_id: str | None,
|
||||
merged: dict[str, Any] | None = None,
|
||||
) -> DeriveContext:
|
||||
"""Контекст derive: тип корма + признак основного корма."""
|
||||
main_feed = read_from_mapping(merged or {}, _MAIN_FEED_KEYS)
|
||||
is_main = main_feed is not None and float(main_feed) > 0
|
||||
feed_group: str = "unknown"
|
||||
if component_id:
|
||||
comp = Component.query.filter_by(id=component_id, is_deleted=False).first()
|
||||
if comp is not None:
|
||||
feed_group = classify_feed_group(comp)
|
||||
return DeriveContext(feed_group=feed_group, is_main_feed=is_main) # type: ignore[arg-type]
|
||||
|
||||
|
||||
def _component_dry_matter_pct(component_id: str | None) -> float | None:
|
||||
if not component_id:
|
||||
return None
|
||||
comp = Component.query.get(component_id)
|
||||
return comp.dry_matter if comp else None
|
||||
|
||||
|
||||
def _inject_sv_for_derive(merged: dict[str, Any], dry_matter_pct: float | None) -> dict[str, Any]:
|
||||
out = dict(merged)
|
||||
sv = dry_matter_g_per_kg(dry_matter_pct)
|
||||
if sv is not None:
|
||||
out["СВ"] = sv
|
||||
return out
|
||||
|
||||
|
||||
def _should_derive(merged: dict[str, Any]) -> bool:
|
||||
"""Derive при полном базовом вводе (СВ из component.dry_matter + 3 показателя)."""
|
||||
return all(read_from_mapping(merged, (k,)) is not None for k in _DERIVE_INPUT_KEYS)
|
||||
|
||||
|
||||
def nutrients_full_dict(component_id: str | None) -> dict[str, float]:
|
||||
if not component_id:
|
||||
return {}
|
||||
rows = LabComponentNutrientValue.query.filter_by(component_id=component_id).all()
|
||||
return {
|
||||
row.nutrient_key: float(row.value)
|
||||
for row in rows
|
||||
if row.value is not None and row.nutrient_key not in _EAV_SKIP_KEYS
|
||||
}
|
||||
|
||||
|
||||
def nutrients_api_dict(component_id: str | None) -> dict[str, float]:
|
||||
return nutrients_full_dict(component_id)
|
||||
|
||||
|
||||
def _fill_missing_derived(
|
||||
merged: dict[str, float],
|
||||
*,
|
||||
component_id: str | None,
|
||||
) -> dict[str, float]:
|
||||
"""Добавляет derived-поля (пОВ, переваримые фракции), не перезаписывая введённые лаб. значения."""
|
||||
if not _should_derive(merged):
|
||||
return merged
|
||||
ctx = derive_context_for_component(component_id, merged)
|
||||
derived = derive_ingredient_nutrients(merged, context=ctx)
|
||||
out = dict(merged)
|
||||
for header in DERIVED_HEADERS:
|
||||
if read_from_mapping(out, (header,)) is not None:
|
||||
continue
|
||||
val = read_from_mapping(derived, (header,))
|
||||
if val is not None:
|
||||
out[header] = float(val)
|
||||
return out
|
||||
|
||||
|
||||
def _repair_for_ration_calc(
|
||||
full: dict[str, float],
|
||||
dry_matter_pct: float | None,
|
||||
component_id: str | None = None,
|
||||
) -> dict[str, float]:
|
||||
merged = _inject_sv_for_derive(full, dry_matter_pct)
|
||||
oe = read_from_mapping(merged, ("ОЭ-КРС", " ОЭ-КРС"))
|
||||
nel = read_from_mapping(merged, ("ЧЭЛ- КРС", " ЧЭЛ- КРС"))
|
||||
energy_bad = (oe is not None and oe < 0) or (nel is not None and nel < 0)
|
||||
if energy_bad:
|
||||
minimal = {key: read_from_mapping(merged, (key,)) for key in _DERIVE_INPUT_KEYS}
|
||||
if all(v is not None for v in minimal.values()):
|
||||
ctx = derive_context_for_component(component_id, merged)
|
||||
merged.update(derive_ingredient_nutrients(minimal, context=ctx))
|
||||
else:
|
||||
merged = _fill_missing_derived(merged, component_id=component_id)
|
||||
return merged
|
||||
|
||||
|
||||
def _calc_dict_from_full(
|
||||
full: dict[str, float],
|
||||
dry_matter_pct: float | None,
|
||||
component_id: str | None,
|
||||
) -> dict[str, float]:
|
||||
repaired = _repair_for_ration_calc(full, dry_matter_pct, component_id)
|
||||
return mapping_to_calc_dict(augment_with_indicator_keys(repaired))
|
||||
|
||||
|
||||
def nutrients_calc_dict(component_id: str | None) -> dict[str, float]:
|
||||
full = nutrients_full_dict(component_id)
|
||||
dry_matter_pct = _component_dry_matter_pct(component_id)
|
||||
return _calc_dict_from_full(full, dry_matter_pct, component_id)
|
||||
|
||||
|
||||
def nutrients_calc_dict_batch(component_ids: list[str]) -> dict[str, dict[str, float]]:
|
||||
"""Batch-load EAV + dry_matter for formulate (one SQL round-trip per table)."""
|
||||
unique = list(dict.fromkeys(cid for cid in component_ids if cid))
|
||||
if not unique:
|
||||
return {}
|
||||
|
||||
eav_by_id: dict[str, dict[str, float]] = {cid: {} for cid in unique}
|
||||
rows = LabComponentNutrientValue.query.filter(
|
||||
LabComponentNutrientValue.component_id.in_(unique),
|
||||
).all()
|
||||
for row in rows:
|
||||
if row.value is None or row.nutrient_key in _EAV_SKIP_KEYS:
|
||||
continue
|
||||
eav_by_id.setdefault(row.component_id, {})[row.nutrient_key] = float(row.value)
|
||||
|
||||
dm_by_id: dict[str, float | None] = {cid: None for cid in unique}
|
||||
for comp in Component.query.filter(
|
||||
Component.id.in_(unique),
|
||||
Component.is_deleted.is_(False),
|
||||
).all():
|
||||
dm_by_id[comp.id] = comp.dry_matter
|
||||
|
||||
return {
|
||||
cid: _calc_dict_from_full(eav_by_id.get(cid, {}), dm_by_id.get(cid), cid)
|
||||
for cid in unique
|
||||
}
|
||||
|
||||
|
||||
def nutrients_is_empty(component_id: str | None) -> bool:
|
||||
if not component_id:
|
||||
return True
|
||||
return (
|
||||
LabComponentNutrientValue.query.filter(
|
||||
LabComponentNutrientValue.component_id == component_id,
|
||||
LabComponentNutrientValue.nutrient_key.notin_(_EAV_SKIP_KEYS),
|
||||
).first()
|
||||
is None
|
||||
)
|
||||
|
||||
|
||||
def _upsert_eav(component_id: str, nutrients: dict[str, float]) -> None:
|
||||
existing = {
|
||||
row.nutrient_key: row
|
||||
for row in LabComponentNutrientValue.query.filter_by(component_id=component_id).all()
|
||||
}
|
||||
for key in list(existing):
|
||||
if key in _EAV_SKIP_KEYS:
|
||||
db.session.delete(existing[key])
|
||||
existing = {k: v for k, v in existing.items() if k not in _EAV_SKIP_KEYS}
|
||||
for key, value in nutrients.items():
|
||||
if value is None or key in _EAV_SKIP_KEYS:
|
||||
continue
|
||||
row = existing.get(key)
|
||||
if row is None:
|
||||
row = LabComponentNutrientValue(
|
||||
id=str(uuid.uuid4()),
|
||||
component_id=component_id,
|
||||
nutrient_key=key,
|
||||
value=float(value),
|
||||
)
|
||||
db.session.add(row)
|
||||
existing[key] = row
|
||||
else:
|
||||
row.value = float(value)
|
||||
|
||||
|
||||
def _prepare_payload(
|
||||
component_id: str,
|
||||
nutrients: dict[str, Any] | None,
|
||||
*,
|
||||
pin: bool = False,
|
||||
) -> dict[str, float]:
|
||||
dry_matter_pct = _component_dry_matter_pct(component_id)
|
||||
merged = _inject_sv_for_derive(
|
||||
{**nutrients_full_dict(component_id), **(nutrients or {})},
|
||||
dry_matter_pct,
|
||||
)
|
||||
if pin:
|
||||
payload = merged
|
||||
elif _should_derive(merged):
|
||||
ctx = derive_context_for_component(component_id, merged)
|
||||
derived = derive_ingredient_nutrients(merged, context=ctx)
|
||||
payload = {**merged, **derived}
|
||||
else:
|
||||
payload = merged
|
||||
stored = canonicalize_for_storage(payload)
|
||||
return {k: v for k, v in stored.items() if k not in _EAV_SKIP_KEYS}
|
||||
|
||||
|
||||
def save_component_nutrients(
|
||||
component_id: str,
|
||||
nutrients: dict[str, Any] | None,
|
||||
*,
|
||||
user_id: str = "system",
|
||||
pin: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
"""Единственная точка записи EAV + derive. Возвращает stored dict и affectedRecipeIds."""
|
||||
stored = _prepare_payload(component_id, nutrients, pin=pin)
|
||||
_upsert_eav(component_id, stored)
|
||||
from app.modules.zootech.lab.services.ration_recalc import find_rations_by_component
|
||||
|
||||
affected = find_rations_by_component(component_id)
|
||||
return {"stored": stored, "affectedRecipeIds": affected, "userId": user_id}
|
||||
|
||||
|
||||
# Backward-compatible aliases for tests and gradual migration
|
||||
def upsert_from_api_dict(component_id: str, nutrients: dict[str, Any] | None) -> dict[str, float]:
|
||||
result = save_component_nutrients(component_id, nutrients)
|
||||
return result["stored"]
|
||||
|
||||
|
||||
def pin_nutrient_values(component_id: str, values: dict[str, float]) -> None:
|
||||
save_component_nutrients(component_id, values, pin=True)
|
||||
|
||||
|
||||
def upsert_from_column_values(component_id: str, values: dict[str, float | None]) -> dict[str, float]:
|
||||
from app.modules.zootech.lab.nutrient_schema import column_values_to_mapping
|
||||
|
||||
merged = nutrients_full_dict(component_id)
|
||||
merged.update(column_values_to_mapping(values))
|
||||
return upsert_from_api_dict(component_id, merged)
|
||||
@@ -0,0 +1,52 @@
|
||||
"""Сериализация параметров методики норм на профиле."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from app.modules.zootech.lab.calc.norms_resolver import NormsParams
|
||||
from app.modules.zootech.lab.models import LabAnimalProfile
|
||||
|
||||
|
||||
def load_norms_params(profile: LabAnimalProfile) -> NormsParams:
|
||||
raw = profile.norms_params_json
|
||||
if not raw:
|
||||
return NormsParams()
|
||||
try:
|
||||
data = json.loads(raw)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
return NormsParams()
|
||||
return NormsParams.from_dict(data if isinstance(data, dict) else {})
|
||||
|
||||
|
||||
def save_norms_params(profile: LabAnimalProfile, params: NormsParams | dict[str, Any] | None) -> None:
|
||||
if params is None:
|
||||
profile.norms_params_json = None
|
||||
return
|
||||
if isinstance(params, NormsParams):
|
||||
payload = {
|
||||
k: v
|
||||
for k, v in (
|
||||
("milkFatPct", params.milk_fat_pct),
|
||||
("lactationNo", params.lactation_no),
|
||||
("lactationStage", params.lactation_stage),
|
||||
("bodyCondition", params.body_condition),
|
||||
("housingSystem", params.housing_system),
|
||||
("koncOeSv", params.konc_oe_sv),
|
||||
)
|
||||
if v is not None
|
||||
}
|
||||
else:
|
||||
payload = dict(params)
|
||||
profile.norms_params_json = json.dumps(payload, ensure_ascii=False) if payload else None
|
||||
|
||||
|
||||
def norms_params_api(profile: LabAnimalProfile) -> dict[str, Any]:
|
||||
if not profile.norms_params_json:
|
||||
return {}
|
||||
try:
|
||||
data = json.loads(profile.norms_params_json)
|
||||
return data if isinstance(data, dict) else {}
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
return {}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user