577 lines
21 KiB
Python
577 lines
21 KiB
Python
"""Unit-тесты analytics services."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import unittest
|
|
from datetime import datetime
|
|
|
|
from app import create_app, db
|
|
from app.models import (
|
|
Component,
|
|
LoadingReport,
|
|
LoadingReportComponent,
|
|
Recipe,
|
|
UnloadingReport,
|
|
UnloadingReportGroup,
|
|
)
|
|
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.daily_plan.skips import skip_ingredient
|
|
from app.services.setup_state import mark_setup_complete
|
|
from tests.helpers.dispenser_recipe_fixtures import (
|
|
E2E_DISP_COMP,
|
|
E2E_DISP_RECIPE_1,
|
|
seed_dispenser_period_recipes,
|
|
)
|
|
from tests.helpers.zootech_test_helpers import ZootechTestConfig
|
|
|
|
|
|
class AnalyticsFinanceSummaryTests(unittest.TestCase):
|
|
def setUp(self) -> None:
|
|
self.app = create_app(ZootechTestConfig)
|
|
self.ctx = self.app.app_context()
|
|
self.ctx.push()
|
|
db.create_all()
|
|
mark_setup_complete(self.app)
|
|
db.session.add(
|
|
Component(
|
|
id="an-comp-1",
|
|
name="Премикс",
|
|
type="premix",
|
|
dry_matter=90.0,
|
|
protein=0.0,
|
|
energy=0.0,
|
|
price=150.0,
|
|
)
|
|
)
|
|
db.session.add(
|
|
Recipe(id="an-r1", name="Рейс A", heads_per_trip=10, content_hash="")
|
|
)
|
|
report = LoadingReport(
|
|
id="an-lr-1",
|
|
recipe_id="an-r1",
|
|
recipe_name="Рейс A",
|
|
start_time=datetime(2026, 6, 7, 10, 0, 0),
|
|
target_mixing_time=60,
|
|
actual_mixing_time=60,
|
|
total_weight=100.0,
|
|
created_by="system",
|
|
updated_by="system",
|
|
)
|
|
db.session.add(report)
|
|
db.session.flush()
|
|
db.session.add(
|
|
LoadingReportComponent(
|
|
report_id=report.id,
|
|
component_id="an-comp-1",
|
|
component_name="Премикс",
|
|
target_weight=100.0,
|
|
actual_weight=110.0,
|
|
overload=10.0,
|
|
loading_order=1,
|
|
created_by="system",
|
|
updated_by="system",
|
|
)
|
|
)
|
|
db.session.add(
|
|
LoadingReportComponent(
|
|
report_id=report.id,
|
|
component_id="an-comp-1",
|
|
component_name="Премикс",
|
|
target_weight=50.0,
|
|
actual_weight=40.0,
|
|
overload=0.0,
|
|
loading_order=2,
|
|
created_by="system",
|
|
updated_by="system",
|
|
)
|
|
)
|
|
db.session.commit()
|
|
|
|
def tearDown(self) -> None:
|
|
db.session.remove()
|
|
db.drop_all()
|
|
self.ctx.pop()
|
|
|
|
def test_overload_and_underload_rub(self) -> None:
|
|
summary = build_finance_summary(date_from="2026-06-07", date_to="2026-06-07")
|
|
# +10 kg * 150 = 1500 overload; -10 kg * 150 = 1500 underload
|
|
self.assertEqual(summary["overloadRub"], 1500.0)
|
|
self.assertEqual(summary["underloadRub"], 1500.0)
|
|
self.assertEqual(summary["netRub"], 0.0)
|
|
self.assertEqual(summary["dominantIssue"], "balanced")
|
|
self.assertEqual(len(summary["topComponents"]), 1)
|
|
self.assertEqual(summary["topComponents"][0]["name"], "Премикс")
|
|
|
|
|
|
class AnalyticsPlanFactTests(unittest.TestCase):
|
|
def setUp(self) -> None:
|
|
self.app = create_app(ZootechTestConfig)
|
|
self.ctx = self.app.app_context()
|
|
self.ctx.push()
|
|
db.create_all()
|
|
mark_setup_complete(self.app)
|
|
seed_dispenser_period_recipes()
|
|
report = LoadingReport(
|
|
id="pf-lr-1",
|
|
recipe_id=E2E_DISP_RECIPE_1,
|
|
recipe_name="E2E Рейс 1",
|
|
start_time=datetime(2026, 6, 7, 8, 0, 0),
|
|
target_mixing_time=60,
|
|
actual_mixing_time=60,
|
|
total_weight=25.0,
|
|
created_by="system",
|
|
updated_by="system",
|
|
)
|
|
db.session.add(report)
|
|
db.session.flush()
|
|
db.session.add(
|
|
LoadingReportComponent(
|
|
report_id=report.id,
|
|
component_id=E2E_DISP_COMP,
|
|
component_name="E2E Disp компонент",
|
|
target_weight=20.0,
|
|
actual_weight=45.0,
|
|
overload=25.0,
|
|
loading_order=1,
|
|
created_by="system",
|
|
updated_by="system",
|
|
)
|
|
)
|
|
db.session.commit()
|
|
|
|
def tearDown(self) -> None:
|
|
db.session.remove()
|
|
db.drop_all()
|
|
self.ctx.pop()
|
|
|
|
def test_plan_fact_layers_and_notes(self) -> None:
|
|
data = build_plan_fact_rows(date_from="2026-06-07", date_to="2026-06-07")
|
|
self.assertEqual(data["reportCount"], 1)
|
|
item = data["items"][0]
|
|
self.assertEqual(item["recipeId"], E2E_DISP_RECIPE_1)
|
|
comps = item["components"]
|
|
self.assertEqual(len(comps), 1)
|
|
c = comps[0]
|
|
self.assertEqual(c["baseKg"], 20.0)
|
|
self.assertEqual(c["planTodayKg"], 20.0)
|
|
self.assertEqual(c["actualKg"], 45.0)
|
|
exec_notes = [n for n in c["notes"] if n.get("kind") == "execution"]
|
|
self.assertEqual(len(exec_notes), 1)
|
|
self.assertIn("перегруз", exec_notes[0]["text"])
|
|
self.assertEqual(c["fault"], "execution")
|
|
|
|
def test_zootech_skip_without_report_row(self) -> None:
|
|
"""Исключённый ингредиент без строки в отчёте терминала — всё равно в сравнении."""
|
|
skip_ingredient(E2E_DISP_RECIPE_1, "e2e-disp-ing-1", "2026-06-07")
|
|
report = LoadingReport(
|
|
id="pf-lr-skip-only-other",
|
|
recipe_id=E2E_DISP_RECIPE_1,
|
|
recipe_name="E2E Рейс 1",
|
|
start_time=datetime(2026, 6, 7, 11, 0, 0),
|
|
target_mixing_time=60,
|
|
actual_mixing_time=60,
|
|
total_weight=100.0,
|
|
created_by="system",
|
|
updated_by="system",
|
|
)
|
|
db.session.add(report)
|
|
db.session.flush()
|
|
db.session.add(
|
|
LoadingReportComponent(
|
|
report_id=report.id,
|
|
component_id="e2e-disp-comp-other",
|
|
component_name="Другой компонент",
|
|
target_weight=100.0,
|
|
actual_weight=100.0,
|
|
overload=0.0,
|
|
loading_order=2,
|
|
created_by="system",
|
|
updated_by="system",
|
|
)
|
|
)
|
|
db.session.commit()
|
|
|
|
data = build_plan_fact_rows(date_from="2026-06-07", date_to="2026-06-07")
|
|
item = next(
|
|
(i for i in data["items"] if i["loadingReportId"] == "pf-lr-skip-only-other"),
|
|
None,
|
|
)
|
|
self.assertIsNotNone(item)
|
|
skipped_rows = [c for c in item["components"] if c.get("skippedToday")]
|
|
self.assertEqual(len(skipped_rows), 1)
|
|
self.assertEqual(skipped_rows[0]["fault"], "excluded")
|
|
self.assertEqual(skipped_rows[0]["actualKg"], 0.0)
|
|
self.assertGreater(skipped_rows[0]["baseKg"], 0.0)
|
|
|
|
def test_zootech_skip_no_execution_fault(self) -> None:
|
|
skip_ingredient(E2E_DISP_RECIPE_1, "e2e-disp-ing-1", "2026-06-07")
|
|
report = LoadingReport(
|
|
id="pf-lr-skip",
|
|
recipe_id=E2E_DISP_RECIPE_1,
|
|
recipe_name="E2E Рейс 1",
|
|
start_time=datetime(2026, 6, 7, 9, 0, 0),
|
|
target_mixing_time=60,
|
|
actual_mixing_time=60,
|
|
total_weight=0.0,
|
|
created_by="system",
|
|
updated_by="system",
|
|
)
|
|
db.session.add(report)
|
|
db.session.flush()
|
|
db.session.add(
|
|
LoadingReportComponent(
|
|
report_id=report.id,
|
|
component_id=E2E_DISP_COMP,
|
|
component_name="E2E Disp компонент",
|
|
target_weight=0.0,
|
|
actual_weight=0.0,
|
|
overload=0.0,
|
|
loading_order=1,
|
|
created_by="system",
|
|
updated_by="system",
|
|
)
|
|
)
|
|
db.session.commit()
|
|
|
|
data = build_plan_fact_rows(date_from="2026-06-07", date_to="2026-06-07")
|
|
skip_item = next(
|
|
(i for i in data["items"] if i["loadingReportId"] == "pf-lr-skip"),
|
|
None,
|
|
)
|
|
self.assertIsNotNone(skip_item)
|
|
c = skip_item["components"][0]
|
|
self.assertEqual(c["planTodayKg"], 0.0)
|
|
self.assertEqual(c["actualKg"], 0.0)
|
|
self.assertEqual(c["fault"], "excluded")
|
|
self.assertTrue(c["skippedToday"])
|
|
zootech_notes = [n for n in c["notes"] if n.get("kind") == "zootech"]
|
|
self.assertEqual(len(zootech_notes), 1)
|
|
self.assertIn("исключено из плана", zootech_notes[0]["text"])
|
|
self.assertNotIn("execution", [n.get("kind") for n in c["notes"]])
|
|
|
|
def test_operator_underload_is_execution_not_excluded(self) -> None:
|
|
"""Полный недогруз при плане 20 кг — в пределах допуска ±20 кг, без ошибки."""
|
|
report = LoadingReport(
|
|
id="pf-lr-hay",
|
|
recipe_id=E2E_DISP_RECIPE_1,
|
|
recipe_name="E2E Рейс 1",
|
|
start_time=datetime(2026, 6, 7, 10, 0, 0),
|
|
target_mixing_time=60,
|
|
actual_mixing_time=60,
|
|
total_weight=0.0,
|
|
created_by="system",
|
|
updated_by="system",
|
|
)
|
|
db.session.add(report)
|
|
db.session.flush()
|
|
db.session.add(
|
|
LoadingReportComponent(
|
|
report_id=report.id,
|
|
component_id=E2E_DISP_COMP,
|
|
component_name="E2E Disp компонент",
|
|
target_weight=20.0,
|
|
actual_weight=0.0,
|
|
overload=0.0,
|
|
loading_order=1,
|
|
created_by="system",
|
|
updated_by="system",
|
|
)
|
|
)
|
|
db.session.commit()
|
|
|
|
data = build_plan_fact_rows(date_from="2026-06-07", date_to="2026-06-07")
|
|
hay_item = next(
|
|
(i for i in data["items"] if i["loadingReportId"] == "pf-lr-hay"),
|
|
None,
|
|
)
|
|
self.assertIsNotNone(hay_item)
|
|
c = hay_item["components"][0]
|
|
self.assertEqual(c["planTodayKg"], 20.0)
|
|
self.assertEqual(c["actualKg"], 0.0)
|
|
self.assertFalse(c["skippedToday"])
|
|
self.assertEqual(c["fault"], "none")
|
|
self.assertEqual([n for n in c["notes"] if n.get("kind") == "execution"], [])
|
|
|
|
def test_plan_fact_unloading_and_mixer_remainder(self) -> None:
|
|
unloading = UnloadingReport(
|
|
id="pf-ur-1",
|
|
recipe_id=E2E_DISP_RECIPE_1,
|
|
recipe_name="E2E Рейс 1",
|
|
loading_report_id="pf-lr-1",
|
|
start_time=datetime(2026, 6, 7, 8, 30, 0),
|
|
end_time=datetime(2026, 6, 7, 8, 45, 0),
|
|
total_weight=25.0,
|
|
total_unloaded_weight=22.0,
|
|
remaining_weight=3.0,
|
|
created_by="system",
|
|
updated_by="system",
|
|
)
|
|
db.session.add(unloading)
|
|
db.session.flush()
|
|
db.session.add(
|
|
UnloadingReportGroup(
|
|
report_id=unloading.id,
|
|
name="Г1",
|
|
target_weight=20.0,
|
|
unloaded_weight=45.0,
|
|
remaining_weight=0.0,
|
|
distribution_type="percent",
|
|
distribution_value=100.0,
|
|
order=1,
|
|
created_by="system",
|
|
updated_by="system",
|
|
)
|
|
)
|
|
db.session.commit()
|
|
|
|
data = build_plan_fact_rows(date_from="2026-06-07", date_to="2026-06-07")
|
|
item = data["items"][0]
|
|
self.assertEqual(len(item["unloadingGroups"]), 1)
|
|
grp = item["unloadingGroups"][0]
|
|
self.assertEqual(grp["name"], "Г1")
|
|
self.assertEqual(grp["actualKg"], 45.0)
|
|
self.assertEqual(grp["fault"], "execution")
|
|
self.assertIsNotNone(item["mixerRemainder"])
|
|
self.assertEqual(item["mixerRemainder"]["actualKg"], 3.0)
|
|
self.assertEqual(item["mixerRemainder"]["fault"], "none")
|
|
|
|
def test_plan_fact_mixer_remainder_can_be_negative(self) -> None:
|
|
unloading = UnloadingReport(
|
|
id="pf-ur-neg",
|
|
recipe_id=E2E_DISP_RECIPE_1,
|
|
recipe_name="E2E Рейс 1",
|
|
loading_report_id="pf-lr-1",
|
|
start_time=datetime(2026, 6, 7, 9, 0, 0),
|
|
end_time=datetime(2026, 6, 7, 9, 15, 0),
|
|
total_weight=25.0,
|
|
total_unloaded_weight=30.0,
|
|
remaining_weight=-5.0,
|
|
created_by="system",
|
|
updated_by="system",
|
|
)
|
|
db.session.add(unloading)
|
|
db.session.commit()
|
|
|
|
data = build_plan_fact_rows(date_from="2026-06-07", date_to="2026-06-07")
|
|
item = next(i for i in data["items"] if i["loadingReportId"] == "pf-lr-1")
|
|
self.assertIsNotNone(item["mixerRemainder"])
|
|
self.assertEqual(item["mixerRemainder"]["actualKg"], -5.0)
|
|
self.assertEqual(item["mixerRemainder"]["fault"], "none")
|
|
|
|
|
|
class AnalyticsReportsDetailTests(unittest.TestCase):
|
|
def setUp(self) -> None:
|
|
self.app = create_app(ZootechTestConfig)
|
|
self.ctx = self.app.app_context()
|
|
self.ctx.push()
|
|
db.create_all()
|
|
mark_setup_complete(self.app)
|
|
db.session.add(
|
|
Recipe(id="an-r-detail", name="Рейс detail", heads_per_trip=10, content_hash="")
|
|
)
|
|
report = LoadingReport(
|
|
id="an-lr-detail",
|
|
recipe_id="an-r-detail",
|
|
recipe_name="Рейс detail",
|
|
start_time=datetime(2026, 6, 7, 17, 43, 0),
|
|
target_mixing_time=60,
|
|
actual_mixing_time=60,
|
|
total_weight=100.0,
|
|
created_by="system",
|
|
updated_by="system",
|
|
)
|
|
db.session.add(report)
|
|
db.session.add(
|
|
LoadingReportComponent(
|
|
report_id=report.id,
|
|
component_name="БУРТ",
|
|
target_weight=394.1,
|
|
actual_weight=0.0,
|
|
overload=-394.1,
|
|
loading_order=1,
|
|
created_by="system",
|
|
updated_by="system",
|
|
)
|
|
)
|
|
db.session.add(
|
|
LoadingReportComponent(
|
|
report_id=report.id,
|
|
component_name="БУРТ",
|
|
target_weight=394.1,
|
|
actual_weight=0.0,
|
|
overload=-394.1,
|
|
loading_order=2,
|
|
created_by="system",
|
|
updated_by="system",
|
|
)
|
|
)
|
|
db.session.add(
|
|
LoadingReportComponent(
|
|
report_id=report.id,
|
|
component_name="Кукуруза",
|
|
target_weight=400.0,
|
|
actual_weight=450.0,
|
|
overload=50.0,
|
|
loading_order=3,
|
|
created_by="system",
|
|
updated_by="system",
|
|
)
|
|
)
|
|
db.session.commit()
|
|
|
|
def tearDown(self) -> None:
|
|
db.session.remove()
|
|
db.drop_all()
|
|
self.ctx.pop()
|
|
|
|
def test_overload_only_when_fact_exceeds_plan(self) -> None:
|
|
from app.services.analytics.reports_detail import build_reports_detail_rows
|
|
|
|
rows = build_reports_detail_rows(date_from="2026-06-07", date_to="2026-06-07")
|
|
by_name = {r["feedComponent"]: r for r in rows if r["kind"] == "component"}
|
|
|
|
self.assertEqual(by_name["БУРТ"]["deviationKg"], -394.1)
|
|
self.assertEqual(by_name["БУРТ"]["overloadKg"], 0.0)
|
|
|
|
self.assertEqual(by_name["Кукуруза"]["deviationKg"], 50.0)
|
|
self.assertEqual(by_name["Кукуруза"]["overloadKg"], 50.0)
|
|
|
|
def test_dedupe_identical_component_rows(self) -> None:
|
|
from app.services.analytics.reports_detail import build_reports_detail_rows
|
|
|
|
rows = build_reports_detail_rows(date_from="2026-06-07", date_to="2026-06-07")
|
|
burt_rows = [r for r in rows if r.get("feedComponent") == "БУРТ"]
|
|
self.assertEqual(len(burt_rows), 1)
|
|
|
|
|
|
class AnalyticsReportsDetailUnloadingTests(unittest.TestCase):
|
|
def setUp(self) -> None:
|
|
self.app = create_app(ZootechTestConfig)
|
|
self.ctx = self.app.app_context()
|
|
self.ctx.push()
|
|
db.create_all()
|
|
mark_setup_complete(self.app)
|
|
from app.models import UnloadingReport, UnloadingReportGroup
|
|
|
|
db.session.add(
|
|
Recipe(id="an-r-unload", name="Рейс unload", heads_per_trip=10, content_hash="")
|
|
)
|
|
report = LoadingReport(
|
|
id="an-lr-unload",
|
|
recipe_id="an-r-unload",
|
|
recipe_name="Рейс unload",
|
|
start_time=datetime(2026, 6, 7, 18, 0, 0),
|
|
target_mixing_time=300,
|
|
actual_mixing_time=300,
|
|
total_weight=3340.0,
|
|
created_by="system",
|
|
updated_by="system",
|
|
)
|
|
db.session.add(report)
|
|
db.session.flush()
|
|
unloading = UnloadingReport(
|
|
id="an-ur-1",
|
|
recipe_id="an-r-unload",
|
|
recipe_name="Рейс unload",
|
|
loading_report_id=report.id,
|
|
start_time=datetime(2026, 6, 7, 18, 30, 0),
|
|
total_weight=3340.0,
|
|
total_unloaded_weight=3300.0,
|
|
remaining_weight=40.0,
|
|
created_by="system",
|
|
updated_by="system",
|
|
)
|
|
db.session.add(unloading)
|
|
db.session.flush()
|
|
db.session.add(
|
|
UnloadingReportGroup(
|
|
report_id=unloading.id,
|
|
name="выгрузка: 4 гр 1 зам",
|
|
target_weight=3340.0,
|
|
unloaded_weight=3300.0,
|
|
remaining_weight=40.0,
|
|
distribution_type="percent",
|
|
distribution_value=100.0,
|
|
order=1,
|
|
created_by="system",
|
|
updated_by="system",
|
|
)
|
|
)
|
|
db.session.add(
|
|
UnloadingReportGroup(
|
|
report_id=unloading.id,
|
|
name="все",
|
|
target_weight=1710.0,
|
|
unloaded_weight=1710.0,
|
|
remaining_weight=0.0,
|
|
distribution_type="percent",
|
|
distribution_value=100.0,
|
|
order=2,
|
|
created_by="system",
|
|
updated_by="system",
|
|
)
|
|
)
|
|
db.session.commit()
|
|
|
|
def tearDown(self) -> None:
|
|
db.session.remove()
|
|
db.drop_all()
|
|
self.ctx.pop()
|
|
|
|
def test_unloading_row_schema(self) -> None:
|
|
from app.services.analytics.reports_detail import (
|
|
UNLOADING_FEED_COMPONENT_LABEL,
|
|
build_reports_detail_rows,
|
|
)
|
|
|
|
rows = [r for r in build_reports_detail_rows(date_from="2026-06-07", date_to="2026-06-07") if r["kind"] == "unloading"]
|
|
self.assertEqual(len(rows), 2)
|
|
|
|
by_group = {r["animalGroup"]: r for r in rows}
|
|
self.assertEqual(by_group["4 гр 1 зам"]["feedComponent"], UNLOADING_FEED_COMPONENT_LABEL)
|
|
self.assertEqual(by_group["4 гр 1 зам"]["targetKg"], 3340.0)
|
|
self.assertEqual(by_group["4 гр 1 зам"]["actualKg"], 3300.0)
|
|
self.assertEqual(by_group["4 гр 1 зам"]["deviationKg"], -40.0)
|
|
|
|
self.assertEqual(by_group["все"]["feedComponent"], UNLOADING_FEED_COMPONENT_LABEL)
|
|
self.assertEqual(by_group["все"]["targetKg"], 1710.0)
|
|
self.assertEqual(by_group["все"]["actualKg"], 1710.0)
|
|
self.assertEqual(by_group["все"]["deviationKg"], 0.0)
|
|
|
|
def test_detail_xlsx_unloading_column_alignment(self) -> None:
|
|
from io import BytesIO
|
|
|
|
from openpyxl import load_workbook
|
|
|
|
from app.services.analytics.reports_detail import UNLOADING_FEED_COMPONENT_LABEL
|
|
from app.services.analytics.xlsx_summary import build_finance_summary_xlsx
|
|
|
|
xlsx = build_finance_summary_xlsx(
|
|
date_from="2026-06-07",
|
|
date_to="2026-06-07",
|
|
section="reports",
|
|
)
|
|
ws = load_workbook(BytesIO(xlsx))["Подробно"]
|
|
self.assertEqual(ws.cell(1, 8).value, "Группа")
|
|
self.assertGreaterEqual(ws.column_dimensions["B"].width, 14)
|
|
self.assertGreaterEqual(ws.column_dimensions["I"].width, 8)
|
|
farm_cell = ws.cell(row=2, column=2)
|
|
self.assertTrue(farm_cell.alignment.wrap_text)
|
|
|
|
unloading_rows = [
|
|
row
|
|
for row in ws.iter_rows(min_row=2, values_only=True)
|
|
if row[5] == "Выгрузка"
|
|
]
|
|
self.assertEqual(len(unloading_rows), 2)
|
|
for row in unloading_rows:
|
|
self.assertEqual(row[6], UNLOADING_FEED_COMPONENT_LABEL)
|
|
self.assertIsInstance(row[8], (int, float))
|
|
self.assertIsInstance(row[9], (int, float))
|
|
self.assertEqual(round(row[10], 2), round(row[9] - row[8], 2))
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|