"""API кормоцеха (FeedDispenser type=mill): без периодов, рецепты-сироты.""" from __future__ import annotations import os import tempfile import unittest import uuid from datetime import datetime from sqlalchemy import func, select from app import create_app, db from app.models import ( Component, FeedDispenser, FeedingPeriod, Ingredient, PeriodRecipe, Recipe, SyncQueue, UnloadingGroup, ) from config import TestingConfig class FeedMillApiConfig(TestingConfig): _TMP_DIR = tempfile.mkdtemp(prefix="wesp-feed-mill-api-") SQLALCHEMY_DATABASE_URI = f"sqlite:///{os.path.join(_TMP_DIR, 'recipes_test.db')}" SQLALCHEMY_BINDS = {"reports": f"sqlite:///{os.path.join(_TMP_DIR, 'reports_test.db')}"} AUTH_LOGIN = "mill-api-admin" AUTH_PASSWORD = "mill-api-secret" class FeedMillApiTests(unittest.TestCase): def setUp(self) -> None: self.app = create_app(FeedMillApiConfig) self.client = self.app.test_client() self.ctx = self.app.app_context() self.ctx.push() db.create_all() login = self.client.post( "/api/auth/login", json={"login": "mill-api-admin", "password": "mill-api-secret"} ) self.assertEqual(login.status_code, 200) def tearDown(self) -> None: db.session.remove() db.drop_all() self.ctx.pop() def _create_mill(self, *, name: str = "Кормоцех 1") -> str: resp = self.client.post( "/api/feed_dispensers", json={ "name": name, "farm": "Ферма А", "operator": "Иванов", "type": "mill", }, ) self.assertEqual(resp.status_code, 201, resp.get_data(as_text=True)) return resp.get_json()["id"] def _create_component(self, name: str = "Компонент А") -> str: comp = Component( id=str(uuid.uuid4()), name=name, type="grain", dry_matter=50.0, protein=0.0, energy=0.0, price=0.0, ) db.session.add(comp) db.session.commit() return comp.id def test_mill_crud_and_list_contract(self) -> None: mill_id = self._create_mill() get_one = self.client.get(f"/api/feed_dispensers/{mill_id}") self.assertEqual(get_one.status_code, 200) body = get_one.get_json() self.assertEqual(body["type"], "mill") self.assertEqual(body["name"], "Кормоцех 1") self.assertEqual(body["periods"], []) update = self.client.put( f"/api/feed_dispensers/{mill_id}", json={"name": "Кормоцех обновлён", "operator": "Петров"}, ) self.assertEqual(update.status_code, 200) listed = self.client.get("/api/feed_dispensers?limit=50&offset=0") self.assertEqual(listed.status_code, 200) rows = listed.get_json() mill_rows = [r for r in rows if r["id"] == mill_id] self.assertEqual(len(mill_rows), 1) self.assertEqual(mill_rows[0]["type"], "mill") self.assertEqual(mill_rows[0]["name"], "Кормоцех обновлён") delete = self.client.delete(f"/api/feed_dispensers/{mill_id}") self.assertEqual(delete.status_code, 200) gone = self.client.get(f"/api/feed_dispensers/{mill_id}") self.assertEqual(gone.status_code, 404) def test_mill_periods_empty_without_period_rows(self) -> None: mill_id = self._create_mill() periods = self.client.get(f"/api/feed_dispensers/{mill_id}/periods") self.assertEqual(periods.status_code, 200) self.assertEqual(periods.get_json(), []) def test_mill_recipes_list_only_orphans(self) -> None: mill_id = self._create_mill() disp = FeedDispenser( id="d-disp-mill-api", name="Раздатчик", farm="F", operator="O", type="dispenser", ) period = FeedingPeriod(id="p-disp-mill-api", name="Утро", dispenser_id=disp.id) r_orphan = Recipe(id="r-orphan-mill", name="Сирота", heads_per_trip=1, mixing_time=1) r_linked = Recipe(id="r-linked-mill", name="В периоде", heads_per_trip=2, mixing_time=2) db.session.add_all([disp, period, r_orphan, r_linked]) db.session.flush() db.session.add( PeriodRecipe( period_id=period.id, recipe_id=r_linked.id, order=1, created_at=datetime.now(), created_by="system", updated_by="system", ) ) db.session.commit() mill_recipes = self.client.get(f"/api/feed_dispensers/{mill_id}/recipes") self.assertEqual(mill_recipes.status_code, 200) ids = [x["id"] for x in mill_recipes.get_json()] self.assertIn("r-orphan-mill", ids) self.assertNotIn("r-linked-mill", ids) disp_recipes = self.client.get("/api/feed_dispensers/d-disp-mill-api/recipes") self.assertEqual(disp_recipes.status_code, 200) disp_ids = [x["id"] for x in disp_recipes.get_json()] self.assertEqual(disp_ids, ["r-linked-mill"]) def test_create_recipe_for_mill_without_period(self) -> None: comp_id = self._create_component() create = self.client.post( "/api/recipes", json={ "name": "Рецепт кормоцеха", "headsPerTrip": 15, "mixingTime": 8, "tripPercent": 100, "target_component_id": comp_id, "ingredients": [ { "component_id": comp_id, "amount": 10, "dry_matter": 50, "weight_per_head": 1.0, } ], }, ) self.assertEqual(create.status_code, 201, create.get_data(as_text=True)) recipe_id = create.get_json()["id"] get_recipe = self.client.get(f"/api/recipes/{recipe_id}") self.assertEqual(get_recipe.status_code, 200) body = get_recipe.get_json() self.assertEqual(body["name"], "Рецепт кормоцеха") self.assertEqual(body["target_component_id"], comp_id) self.assertEqual(len(body.get("ingredients") or []), 1) period_links = db.session.execute( select(func.count()) .select_from(PeriodRecipe) .where(PeriodRecipe.recipe_id == recipe_id, PeriodRecipe.is_deleted.is_(False)) ).scalar() self.assertEqual(period_links, 0) def test_mill_recipe_visible_in_mill_list_after_create(self) -> None: mill_id = self._create_mill() comp_id = self._create_component("Зерно") create = self.client.post( "/api/recipes", json={ "name": "Новый для цеха", "headsPerTrip": 5, "mixingTime": 3, "target_component_id": comp_id, "ingredients": [{"component_id": comp_id, "amount": 1}], }, ) self.assertEqual(create.status_code, 201) recipe_id = create.get_json()["id"] listed = self.client.get(f"/api/feed_dispensers/{mill_id}/recipes") self.assertEqual(listed.status_code, 200) ids = [r["id"] for r in listed.get_json()] self.assertIn(recipe_id, ids) def test_mill_recipe_update_and_global_delete(self) -> None: comp_id = self._create_component() create = self.client.post( "/api/recipes", json={ "name": "До правки", "headsPerTrip": 4, "mixingTime": 2, "target_component_id": comp_id, "ingredients": [{"component_id": comp_id, "amount": 1}], }, ) recipe_id = create.get_json()["id"] update = self.client.put( f"/api/recipes/{recipe_id}", json={ "name": "После правки", "heads_count": 6, "mixing_time": 4, "trip_percent": 100.0, "target_component_id": comp_id, "dry_matter_locked": False, "unloading_link_broken": False, "ingredients": [ { "component_id": comp_id, "weight_per_head": 1.0, "amount": 2, "dry_matter": 50.0, "order": 1, } ], "unloading_groups": [ { "name": "Г1", "distribution_type": "percent", "value": 100.0, "weight": 2.0, "order": 1, } ], }, ) self.assertEqual(update.status_code, 200) self.assertTrue(update.get_json().get("success")) get_after = self.client.get(f"/api/recipes/{recipe_id}") self.assertEqual(get_after.get_json()["name"], "После правки") recipe_update_task = db.session.execute( select(SyncQueue).where( SyncQueue.table_name == "recipe", SyncQueue.record_id == recipe_id, SyncQueue.action == "update", ) ).scalar_one_or_none() self.assertIsNotNone(recipe_update_task) delete = self.client.delete(f"/api/recipes/{recipe_id}") self.assertEqual(delete.status_code, 200) self.assertTrue(delete.get_json().get("success")) gone = self.client.get(f"/api/recipes/{recipe_id}") self.assertEqual(gone.status_code, 404) def test_get_recipe_returns_unloading_link_broken_after_update(self) -> None: comp_id = self._create_component() create = self.client.post( "/api/recipes", json={ "name": "Связь выгрузки", "headsPerTrip": 4, "mixingTime": 2, "target_component_id": comp_id, "ingredients": [{"component_id": comp_id, "amount": 1}], }, ) self.assertEqual(create.status_code, 201) recipe_id = create.get_json()["id"] update = self.client.put( f"/api/recipes/{recipe_id}", json={ "name": "Связь выгрузки", "heads_count": 4, "mixing_time": 2, "trip_percent": 100.0, "target_component_id": comp_id, "dry_matter_locked": False, "unloading_link_broken": True, "ingredients": [ { "component_id": comp_id, "weight_per_head": 1.0, "amount": 1, "dry_matter": 50.0, "order": 1, } ], "unloading_groups": [ { "name": "Г1", "distribution_type": "percent", "value": 100.0, "weight": 1.0, "order": 1, } ], }, ) self.assertEqual(update.status_code, 200) row = db.session.get(Recipe, recipe_id) self.assertIsNotNone(row) self.assertTrue(row.unloading_link_broken) get_after = self.client.get(f"/api/recipes/{recipe_id}") self.assertEqual(get_after.status_code, 200) payload = get_after.get_json() self.assertTrue(payload.get("unloading_link_broken")) self.assertTrue(payload.get("unloadingLinkBroken")) def test_get_recipe_serializes_unloading_groups_for_copy(self) -> None: comp_id = self._create_component() create = self.client.post( "/api/recipes", json={ "name": "Копия групп", "headsPerTrip": 10, "mixingTime": 2, "target_component_id": comp_id, "ingredients": [{"component_id": comp_id, "amount": 1}], "unloadingGroups": [ { "name": "Г1", "distributionType": "heads", "value": 5, "weight": 100, "order": 1, } ], }, ) self.assertEqual(create.status_code, 201) recipe_id = create.get_json()["id"] payload = self.client.get(f"/api/recipes/{recipe_id}").get_json() self.assertIn("unloading_groups", payload) self.assertEqual(len(payload["unloading_groups"]), 1) grp = payload["unloading_groups"][0] self.assertEqual(grp["distribution_type"], "heads") self.assertEqual(grp["name"], "Г1") self.assertIn("order", payload["ingredients"][0]) def test_detach_from_period_makes_recipe_visible_on_mill(self) -> None: mill_id = self._create_mill() disp = FeedDispenser( id="d-detach", name="D", farm="F", operator="O", type="dispenser" ) period = FeedingPeriod(id="p-detach", name="P", dispenser_id=disp.id) recipe = Recipe(id="r-detach", name="R", heads_per_trip=1, mixing_time=1) db.session.add_all([disp, period, recipe]) db.session.flush() db.session.add( PeriodRecipe( period_id=period.id, recipe_id=recipe.id, order=1, created_at=datetime.now(), created_by="system", updated_by="system", ) ) db.session.commit() before = self.client.get(f"/api/feed_dispensers/{mill_id}/recipes") self.assertNotIn("r-detach", [x["id"] for x in before.get_json()]) detach = self.client.delete( f"/api/feed_dispensers/d-detach/periods/p-detach/recipes/r-detach" ) self.assertEqual(detach.status_code, 200) after = self.client.get(f"/api/feed_dispensers/{mill_id}/recipes") self.assertIn("r-detach", [x["id"] for x in after.get_json()]) def test_transfer_to_mill_rejected(self) -> None: mill_id = self._create_mill() disp = FeedDispenser( id="d-from-xfer", name="From", farm="F", operator="O", type="dispenser" ) p_from = FeedingPeriod(id="p-from-xfer", name="FromP", dispenser_id=disp.id) p_to = FeedingPeriod(id="p-to-xfer", name="ToP", dispenser_id=mill_id) recipe = Recipe(id="r-xfer-mill", name="Move", heads_per_trip=1, mixing_time=1) db.session.add_all([disp, p_from, p_to, recipe]) db.session.flush() db.session.add( PeriodRecipe( period_id=p_from.id, recipe_id=recipe.id, order=1, created_at=datetime.now(), created_by="system", updated_by="system", ) ) db.session.commit() resp = self.client.post( f"/api/feed_dispensers/{mill_id}/periods/p-to-xfer/recipes/r-xfer-mill/transfer", json={ "from_dispenser_id": "d-from-xfer", "from_period_id": "p-from-xfer", "to_index": 0, }, ) self.assertEqual(resp.status_code, 400) self.assertIn("кормоцех", (resp.get_json().get("message") or "").lower()) def test_mill_recipe_update_deleted_ingredient_ids_syncs_delete(self) -> None: comp1 = self._create_component("К1") comp2 = self._create_component("К2") create = self.client.post( "/api/recipes", json={ "name": "Два компонента", "heads_count": 5, "mixing_time": 3, "trip_percent": 100.0, "target_component_id": comp1, "dry_matter_locked": False, "unloading_link_broken": False, "ingredients": [ { "component_id": comp1, "weight_per_head": 1.0, "amount": 5, "dry_matter": 50, "order": 1, }, { "component_id": comp2, "weight_per_head": 2.0, "amount": 10, "dry_matter": 60, "order": 2, }, ], "unloading_groups": [ { "name": "Г1", "distribution_type": "percent", "value": 100.0, "weight": 15.0, "order": 1, } ], }, ) self.assertEqual(create.status_code, 201) recipe_id = create.get_json()["id"] ings = db.session.execute( select(Ingredient).where( Ingredient.recipe_id == recipe_id, Ingredient.is_deleted.is_(False) ) ).scalars().all() self.assertEqual(len(ings), 2) remove_id = sorted(ings, key=lambda x: x.order)[0].id keep = sorted(ings, key=lambda x: x.order)[1] update = self.client.put( f"/api/recipes/{recipe_id}", json={ "name": "Один компонент", "heads_count": 5, "mixing_time": 3, "trip_percent": 100.0, "target_component_id": comp2, "dry_matter_locked": False, "unloading_link_broken": False, "deleted_ingredient_ids": [remove_id], "ingredients": [ { "id": keep.id, "component_id": comp2, "weight_per_head": 2.0, "amount": 10, "dry_matter": 60, "order": 1, } ], "unloading_groups": [ { "name": "Г1", "distribution_type": "percent", "value": 100.0, "weight": 10.0, "order": 1, } ], }, ) self.assertEqual(update.status_code, 200, update.get_data(as_text=True)) active = db.session.execute( select(Ingredient).where( Ingredient.recipe_id == recipe_id, Ingredient.is_deleted.is_(False) ) ).scalars().all() self.assertEqual(len(active), 1) self.assertEqual(active[0].id, keep.id) delete_task = db.session.execute( select(SyncQueue).where( SyncQueue.table_name == "ingredient", SyncQueue.record_id == remove_id, SyncQueue.action == "delete", ) ).scalar_one_or_none() self.assertIsNotNone(delete_task) def test_mill_recipe_update_change_component_syncs_ingredient_update(self) -> None: comp1 = self._create_component("К1") comp2 = self._create_component("К2") create = self.client.post( "/api/recipes", json={ "name": "Смена компонента", "heads_count": 5, "mixing_time": 3, "trip_percent": 100.0, "target_component_id": comp1, "dry_matter_locked": False, "unloading_link_broken": False, "ingredients": [ { "component_id": comp1, "weight_per_head": 1.0, "amount": 5, "dry_matter": 50, "order": 1, } ], "unloading_groups": [], }, ) self.assertEqual(create.status_code, 201) recipe_id = create.get_json()["id"] ing = db.session.execute( select(Ingredient).where( Ingredient.recipe_id == recipe_id, Ingredient.is_deleted.is_(False) ) ).scalar_one() update = self.client.put( f"/api/recipes/{recipe_id}", json={ "name": "Смена компонента", "heads_count": 5, "mixing_time": 3, "trip_percent": 100.0, "target_component_id": comp2, "dry_matter_locked": False, "unloading_link_broken": False, "ingredients": [ { "id": ing.id, "component_id": comp2, "weight_per_head": 1.0, "amount": 5, "dry_matter": 50, "order": 1, } ], "unloading_groups": [], }, ) self.assertEqual(update.status_code, 200) self.assertEqual(str(db.session.get(Ingredient, ing.id).component_id), comp2) self.assertIsNotNone( db.session.execute( select(SyncQueue).where( SyncQueue.table_name == "ingredient", SyncQueue.record_id == ing.id, SyncQueue.action == "update", ) ).scalar_one_or_none() ) def test_mill_recipe_update_omit_ingredient_syncs_delete(self) -> None: comp1 = self._create_component("К1") comp2 = self._create_component("К2") create = self.client.post( "/api/recipes", json={ "name": "Два", "heads_count": 5, "mixing_time": 3, "trip_percent": 100.0, "target_component_id": comp1, "dry_matter_locked": False, "unloading_link_broken": False, "ingredients": [ {"component_id": comp1, "weight_per_head": 1.0, "amount": 5, "dry_matter": 50, "order": 1}, {"component_id": comp2, "weight_per_head": 2.0, "amount": 10, "dry_matter": 60, "order": 2}, ], "unloading_groups": [], }, ) recipe_id = create.get_json()["id"] ings = sorted( db.session.execute( select(Ingredient).where( Ingredient.recipe_id == recipe_id, Ingredient.is_deleted.is_(False) ) ).scalars().all(), key=lambda x: x.order, ) keep = ings[1] update = self.client.put( f"/api/recipes/{recipe_id}", json={ "name": "Один", "heads_count": 5, "mixing_time": 3, "trip_percent": 100.0, "target_component_id": comp2, "dry_matter_locked": False, "unloading_link_broken": False, "ingredients": [ { "id": keep.id, "component_id": comp2, "weight_per_head": 2.0, "amount": 10, "dry_matter": 60, "order": 1, } ], "unloading_groups": [], }, ) self.assertEqual(update.status_code, 200) self.assertTrue(db.session.get(Ingredient, ings[0].id).is_deleted) self.assertIsNotNone( db.session.execute( select(SyncQueue).where( SyncQueue.table_name == "ingredient", SyncQueue.record_id == ings[0].id, SyncQueue.action == "delete", ) ).scalar_one_or_none() ) def test_mill_recipe_update_deleted_group_ids_syncs_delete(self) -> None: comp = self._create_component() create = self.client.post( "/api/recipes", json={ "name": "Группы", "heads_count": 5, "mixing_time": 3, "trip_percent": 100.0, "target_component_id": comp, "dry_matter_locked": False, "unloading_link_broken": False, "ingredients": [ {"component_id": comp, "weight_per_head": 1.0, "amount": 5, "dry_matter": 50, "order": 1}, ], "unloading_groups": [ {"name": "Г1", "distribution_type": "percent", "value": 50.0, "weight": 5.0, "order": 1}, {"name": "Г2", "distribution_type": "percent", "value": 50.0, "weight": 5.0, "order": 2}, ], }, ) recipe_id = create.get_json()["id"] groups = sorted( db.session.execute( select(UnloadingGroup).where( UnloadingGroup.recipe_id == recipe_id, UnloadingGroup.is_deleted.is_(False) ) ).scalars().all(), key=lambda g: g.order, ) remove_id, keep = groups[0].id, groups[1] ing = db.session.execute( select(Ingredient).where( Ingredient.recipe_id == recipe_id, Ingredient.is_deleted.is_(False) ) ).scalar_one() update = self.client.put( f"/api/recipes/{recipe_id}", json={ "name": "Группы", "heads_count": 5, "mixing_time": 3, "trip_percent": 100.0, "target_component_id": comp, "dry_matter_locked": False, "unloading_link_broken": False, "deleted_unloading_group_ids": [remove_id], "ingredients": [ { "id": ing.id, "component_id": comp, "weight_per_head": 1.0, "amount": 5, "dry_matter": 50, "order": 1, } ], "unloading_groups": [ { "id": keep.id, "name": keep.name, "distribution_type": keep.distribution_type, "value": keep.value, "weight": keep.weight, "order": 1, } ], }, ) self.assertEqual(update.status_code, 200) self.assertTrue(db.session.get(UnloadingGroup, remove_id).is_deleted) self.assertIsNotNone( db.session.execute( select(SyncQueue).where( SyncQueue.table_name == "unloading_group", SyncQueue.record_id == remove_id, SyncQueue.action == "delete", ) ).scalar_one_or_none() ) def test_mill_recipe_delete_cascades_children_sync(self) -> None: comp = self._create_component() create = self.client.post( "/api/recipes", json={ "name": "Каскад", "heads_count": 3, "mixing_time": 2, "trip_percent": 100.0, "target_component_id": comp, "dry_matter_locked": False, "unloading_link_broken": False, "ingredients": [ {"component_id": comp, "weight_per_head": 1.0, "amount": 3, "dry_matter": 50, "order": 1}, ], "unloading_groups": [ {"name": "Г1", "distribution_type": "percent", "value": 100.0, "weight": 3.0, "order": 1}, ], }, ) recipe_id = create.get_json()["id"] ing = db.session.execute( select(Ingredient).where(Ingredient.recipe_id == recipe_id) ).scalar_one() grp = db.session.execute( select(UnloadingGroup).where(UnloadingGroup.recipe_id == recipe_id) ).scalar_one() delete = self.client.delete(f"/api/recipes/{recipe_id}") self.assertEqual(delete.status_code, 200) self.assertTrue(db.session.get(Recipe, recipe_id).is_deleted) self.assertTrue(db.session.get(Ingredient, ing.id).is_deleted) self.assertTrue(db.session.get(UnloadingGroup, grp.id).is_deleted) self.assertIsNotNone( db.session.execute( select(SyncQueue).where( SyncQueue.table_name == "recipe", SyncQueue.record_id == recipe_id, SyncQueue.action == "delete", ) ).scalar_one_or_none() ) self.assertGreaterEqual( db.session.scalar( select(func.count()).select_from(SyncQueue).where( SyncQueue.table_name == "ingredient", SyncQueue.record_id == ing.id, SyncQueue.action == "update", ) ), 1, ) def test_recipe_create_enqueues_sync_without_period_recipes(self) -> None: comp_id = self._create_component() before = db.session.scalar(select(func.count()).select_from(SyncQueue)) or 0 create = self.client.post( "/api/recipes", json={ "name": "Sync mill recipe", "headsPerTrip": 3, "mixingTime": 1, "ingredients": [{"component_id": comp_id, "amount": 1}], }, ) self.assertEqual(create.status_code, 201) recipe_id = create.get_json()["id"] after = db.session.scalar(select(func.count()).select_from(SyncQueue)) or 0 self.assertGreater(after, before) recipe_tasks = db.session.execute( select(SyncQueue).where( SyncQueue.table_name == "recipe", SyncQueue.record_id == recipe_id, ) ).scalars().all() self.assertTrue(recipe_tasks) period_tasks = db.session.execute( select(SyncQueue).where( SyncQueue.table_name == "period_recipes", SyncQueue.record_id.like(f"%{recipe_id}%"), ) ).scalars().all() self.assertEqual(period_tasks, []) if __name__ == "__main__": unittest.main()