"""Unit-тесты daily_plan/builder.py.""" from __future__ import annotations import unittest from app import create_app, db from app.models import Component, FeedDispenser, Ingredient, Recipe from app.services.daily_plan.builder import ( ALL_DISPENSERS_ID, ALL_MILLS_ID, build_daily_plan, ) from app.services.setup_state import mark_setup_complete from tests.helpers.dispenser_recipe_fixtures import ( E2E_DISP_COMP, E2E_DISP_ID, E2E_DISP_RECIPE_1, E2E_PERIOD_A, seed_dispenser_period_recipes, ) from tests.helpers.zootech_test_helpers import ZootechTestConfig class DailyPlanBuilderTests(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) def tearDown(self) -> None: db.session.remove() db.drop_all() self.ctx.pop() def test_build_aggregates_periods_trips_and_totals(self) -> None: seed_dispenser_period_recipes() plan = build_daily_plan(dispenser_id=E2E_DISP_ID, plan_date="2026-06-07") self.assertEqual(plan["date"], "2026-06-07") self.assertEqual(plan["dispenserId"], E2E_DISP_ID) self.assertEqual(plan["dispenserName"], "E2E Раздатчик") self.assertEqual(plan["dispenserType"], "dispenser") self.assertIn("skippedTrips", plan) self.assertEqual(plan["skippedTrips"], []) periods = plan["periods"] self.assertEqual(len(periods), 2) morning = next(p for p in periods if p["id"] == E2E_PERIOD_A) self.assertEqual(morning["name"], "E2E Утро") self.assertEqual(len(morning["trips"]), 2) trip1 = morning["trips"][0] self.assertEqual(trip1["recipeId"], E2E_DISP_RECIPE_1) self.assertEqual(trip1["headsPerTrip"], 20) self.assertEqual(trip1["mixingTimeSec"], 3) self.assertEqual(len(trip1["ingredients"]), 1) self.assertEqual(trip1["ingredients"][0]["name"], "Ing 1") self.assertEqual(trip1["ingredients"][0]["totalKg"], 20.0) self.assertEqual(len(trip1["unloadingGroups"]), 1) self.assertEqual(trip1["unloadingGroups"][0]["name"], "Г1") self.assertEqual(trip1["unloadingGroups"][0]["weightKg"], 20.0) self.assertEqual(trip1["totalWeightKg"], 20.0) self.assertEqual(trip1["unloadingTotalKg"], 20.0) totals = {row["name"]: row["totalKg"] for row in plan["ingredientTotals"]} self.assertEqual(totals["Ing 1"], 20.0) self.assertEqual(totals["Ing 2"], 30.0) def test_all_dispensers_aggregates_with_prefixed_periods(self) -> None: seed_dispenser_period_recipes() plan = build_daily_plan(dispenser_id=ALL_DISPENSERS_ID, plan_date="2026-06-07") self.assertEqual(plan["dispenserName"], "Все кормораздатчики") self.assertTrue(plan["periods"]) self.assertTrue(any("E2E Раздатчик" in p["name"] for p in plan["periods"])) self.assertTrue(plan["ingredientTotals"]) def test_all_mills_returns_orphan_recipes(self) -> None: plan = build_daily_plan(dispenser_id=ALL_MILLS_ID) self.assertEqual(plan["dispenserName"], "Все кормоцеха") self.assertEqual(plan["dispenserType"], "mill") def test_unloading_group_has_distribution_label(self) -> None: seed_dispenser_period_recipes() plan = build_daily_plan(dispenser_id=E2E_DISP_ID) group = plan["periods"][0]["trips"][0]["unloadingGroups"][0] self.assertEqual(group["distributionType"], "percent") self.assertIn("%", group["distributionLabel"]) def test_missing_dispenser_raises(self) -> None: with self.assertRaises(LookupError): build_daily_plan(dispenser_id="missing-disp") def test_mill_fallback_uses_orphan_recipes(self) -> None: mill_id = "mill-disp-1" recipe_id = "mill-recipe-1" comp_id = "mill-comp-1" db.session.add_all( [ FeedDispenser( id=mill_id, name="Мельница", farm="Ферма", operator="Тест", type="mill", content_hash="", ), Component( id=comp_id, name="Компонент", type="grain", dry_matter=55.0, protein=0.0, energy=0.0, price=0.0, ), Recipe( id=recipe_id, name="Мельничный рейс", heads_per_trip=10, mixing_time=5, trip_percent=100.0, target_component_id=comp_id, content_hash="", ), ] ) db.session.flush() db.session.add( Ingredient( id="mill-ing-1", name="Зерно", weight_per_head=2.5, amount=0.0, dry_matter=55.0, component_id=comp_id, order=1, recipe_id=recipe_id, created_by="system", updated_by="system", ) ) db.session.commit() plan = build_daily_plan(dispenser_id=mill_id) self.assertEqual(plan["dispenserType"], "mill") self.assertEqual(len(plan["periods"]), 1) self.assertEqual(plan["periods"][0]["name"], "Рейсы") self.assertEqual(len(plan["periods"][0]["trips"]), 1) self.assertEqual(plan["periods"][0]["trips"][0]["recipeName"], "Мельничный рейс") self.assertEqual(plan["ingredientTotals"][0]["totalKg"], 25.0) def test_unloading_weights_follow_trip_total_after_ingredient_skip(self) -> None: from app.services.daily_plan.skips import skip_ingredient seed_dispenser_period_recipes() db.session.add( Ingredient( id="e2e-disp-ing-extra", name="Extra", weight_per_head=5.0, amount=0.0, dry_matter=55.0, component_id=E2E_DISP_COMP, order=2, recipe_id=E2E_DISP_RECIPE_1, created_by="system", updated_by="system", ) ) db.session.commit() plan_before = build_daily_plan(dispenser_id=E2E_DISP_ID, plan_date="2026-06-07") trip_before = plan_before["periods"][0]["trips"][0] self.assertEqual(trip_before["totalWeightKg"], 120.0) self.assertEqual(trip_before["unloadingGroups"][0]["weightKg"], 120.0) skip_ingredient(E2E_DISP_RECIPE_1, "e2e-disp-ing-extra", "2026-06-07") plan_after = build_daily_plan(dispenser_id=E2E_DISP_ID, plan_date="2026-06-07") trip_after = plan_after["periods"][0]["trips"][0] self.assertEqual(trip_after["totalWeightKg"], 20.0) self.assertEqual(trip_after["unloadingGroups"][0]["weightKg"], 20.0) self.assertEqual(trip_after["unloadingTotalKg"], 20.0) def test_skipped_ingredient_includes_baseline_weights(self) -> None: from app.services.daily_plan.skips import skip_ingredient seed_dispenser_period_recipes() skip_ingredient(E2E_DISP_RECIPE_1, "e2e-disp-ing-1", "2026-06-07") plan = build_daily_plan(dispenser_id=E2E_DISP_ID, plan_date="2026-06-07") skipped = next( ing for trip in plan["periods"][0]["trips"] for ing in trip["ingredients"] if ing.get("skippedToday") ) self.assertEqual(skipped["totalKg"], 0.0) self.assertEqual(skipped["baselineWeightPerHead"], 1.0) self.assertEqual(skipped["baselineTotalKg"], 20.0)