"""Sync всех daily_* таблиц + оператор загрузки vs мастер-рацион.""" from __future__ import annotations import os import tempfile import unittest from datetime import date from sqlalchemy import select from app import create_app, db from app.models import ( Component, Ingredient, Recipe, WESP_SUPPRESS_SYNC_ENQUEUE, DailyComponentNormAdjustment, DailyIngredientReplacement, DailyIngredientSkip, DailyTripSkip, DailyUnloadingGroupSkip, SyncQueue, ) from app.services.daily_plan.adjustments import adjust_component_norm from app.services.daily_plan.replacements import replace_ingredient from app.services.daily_plan.skips import ( skip_ingredient, skip_trip, skip_unloading_group, ) from app.services.setup_state import mark_setup_complete from app.services.sync_manager import apply_sync_change from app.services.sync_record_data import get_record_data_for_sync from config import TestingConfig from tests.helpers.daily_plan_operator_helpers import ( DAILY_PLAN_SYNC_TABLES, assert_daily_plan_tables_registered, assert_operator_matches_plan_not_master, first_ingredient_amount, master_recipe_json, operator_weight_display, plan_recipe_json, plan_date_today, ) from tests.helpers.dispenser_recipe_fixtures import ( E2E_DISP_COMP, E2E_DISP_ID, E2E_DISP_RECIPE_1, E2E_DISP_RECIPE_2, E2E_PERIOD_A, seed_dispenser_period_recipes, ) from tests.helpers.sync_dual_harness import SyncDualInstanceHarness from tests.helpers.zootech_test_helpers import ZootechTestConfig def _add_second_ingredient() -> None: db.session.add( Ingredient( id="e2e-disp-ing-extra", name="Ing Extra", weight_per_head=3.0, amount=30.0, dry_matter=55.0, component_id=E2E_DISP_COMP, order=2, recipe_id=E2E_DISP_RECIPE_1, created_by="system", updated_by="system", ) ) def _alt_component(component_id: str = "e2e-disp-comp-alt-sync") -> Component: return Component( id=component_id, name="Alt Silo Sync", type="silage", dry_matter=35.0, protein=3.0, energy=6.0, price=0.0, ) class DailyPlanSyncRegistryTests(unittest.TestCase): def test_all_daily_plan_tables_in_sync_pipeline(self) -> None: assert_daily_plan_tables_registered() class DailyPlanSyncApplyTests(unittest.TestCase): """apply_sync_change для каждой daily_* таблицы.""" 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() def tearDown(self) -> None: db.session.remove() db.drop_all() self.ctx.pop() def _apply_roundtrip(self, table_name: str, row, *, recreate: bool = True) -> None: payload = get_record_data_for_sync(table_name, row.id) self.assertIsNotNone(payload, table_name) if recreate: db.session.delete(row) db.session.commit() db.session.info[WESP_SUPPRESS_SYNC_ENQUEUE] = True try: res = apply_sync_change(table_name, row.id, "create", payload or {}) finally: db.session.info.pop(WESP_SUPPRESS_SYNC_ENQUEUE, None) self.assertTrue(res["success"], res) def test_apply_sync_daily_trip_skip(self) -> None: row = skip_trip(E2E_DISP_RECIPE_1, "2026-06-07") self._apply_roundtrip("daily_trip_skip", row) restored = db.session.get(DailyTripSkip, row.id) self.assertIsNotNone(restored) self.assertEqual(restored.recipe_id, E2E_DISP_RECIPE_1) def test_apply_sync_daily_ingredient_skip(self) -> None: row = skip_ingredient(E2E_DISP_RECIPE_1, "e2e-disp-ing-1", "2026-06-07") self._apply_roundtrip("daily_ingredient_skip", row) restored = db.session.get(DailyIngredientSkip, row.id) self.assertIsNotNone(restored) def test_apply_sync_daily_unloading_group_skip(self) -> None: row = skip_unloading_group(E2E_DISP_RECIPE_1, "e2e-disp-grp-1", "2026-06-07") self._apply_roundtrip("daily_unloading_group_skip", row) restored = db.session.get(DailyUnloadingGroupSkip, row.id) self.assertIsNotNone(restored) def test_apply_sync_daily_ingredient_replacement(self) -> None: db.session.add(_alt_component()) db.session.commit() row = replace_ingredient( E2E_DISP_RECIPE_1, "e2e-disp-ing-1", "e2e-disp-comp-alt-sync", "2026-06-07", ) self._apply_roundtrip("daily_ingredient_replacement", row) restored = db.session.get(DailyIngredientReplacement, row.id) self.assertIsNotNone(restored) def test_apply_sync_daily_component_norm_adjustment(self) -> None: row = adjust_component_norm(E2E_DISP_COMP, "2026-06-07", dry_matter=48.0) self._apply_roundtrip("daily_component_norm_adjustment", row) restored = db.session.get(DailyComponentNormAdjustment, row.id) self.assertIsNotNone(restored) self.assertEqual(float(restored.dry_matter or 0), 48.0) class DailyPlanSyncClientOperatorTests(unittest.TestCase): """Изменение плана на сервере → sync на клиент → оператор видит план, не мастер.""" 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() _add_second_ingredient() db.session.commit() def tearDown(self) -> None: db.session.remove() db.drop_all() self.ctx.pop() def _client_app(self): tmp = tempfile.mkdtemp(prefix="wesp-daily-plan-client-") client_db = os.path.join(tmp, "client.db") class ClientCfg(TestingConfig): SQLALCHEMY_DATABASE_URI = f"sqlite:///{client_db}" SQLALCHEMY_BINDS = { "reports": f"sqlite:///{client_db.replace('.db', '_reports.db')}" } client_app = create_app(ClientCfg) ctx = client_app.app_context() ctx.push() db.create_all() mark_setup_complete(client_app) seed_dispenser_period_recipes() _add_second_ingredient() db.session.commit() return client_app, ctx def _sync_row_to_client(self, table_name: str, row) -> tuple: payload = get_record_data_for_sync(table_name, row.id) self.assertIsNotNone(payload) client_app, client_ctx = self._client_app() db.session.info[WESP_SUPPRESS_SYNC_ENQUEUE] = True try: with client_app.app_context(): res = apply_sync_change(table_name, row.id, "create", payload or {}) self.assertTrue(res["success"], res) db.session.commit() finally: db.session.info.pop(WESP_SUPPRESS_SYNC_ENQUEUE, None) return client_app, client_ctx def test_replacement_sync_operator_sees_plan_not_master(self) -> None: db.session.add(_alt_component("e2e-disp-comp-alt-loading")) recipe = db.session.get(Recipe, E2E_DISP_RECIPE_1) recipe.dry_matter_locked = True db.session.commit() today = plan_date_today() row = replace_ingredient( E2E_DISP_RECIPE_1, "e2e-disp-ing-1", "e2e-disp-comp-alt-loading", today, ) client_app, client_ctx = self._sync_row_to_client( "daily_ingredient_replacement", row ) try: with client_app.app_context(): client_recipe = db.session.get(Recipe, E2E_DISP_RECIPE_1) client_recipe.dry_matter_locked = True db.session.commit() kiosk = client_app.test_client() result = assert_operator_matches_plan_not_master( kiosk, E2E_DISP_RECIPE_1, plan_date=today ) self.assertGreater(result["plan_amounts"][0], result["master_amounts"][0]) finally: db.session.remove() db.drop_all() client_ctx.pop() recipe.dry_matter_locked = False db.session.commit() def test_ingredient_skip_sync_operator_sees_second_component(self) -> None: today = plan_date_today() row = skip_ingredient(E2E_DISP_RECIPE_1, "e2e-disp-ing-1", today) client_app, client_ctx = self._sync_row_to_client("daily_ingredient_skip", row) try: kiosk = client_app.test_client() master = master_recipe_json(kiosk, E2E_DISP_RECIPE_1) plan = plan_recipe_json(kiosk, E2E_DISP_RECIPE_1, plan_date=today) operator = operator_weight_display(kiosk, E2E_DISP_RECIPE_1) self.assertEqual(len(master["ingredients"]), 2) self.assertEqual(len(plan["ingredients"]), 1) self.assertEqual(plan["ingredients"][0]["id"], "e2e-disp-ing-extra") plan_amount = float(plan["ingredients"][0]["amount"]) self.assertNotEqual(int(round(plan_amount)), int(round(first_ingredient_amount(master)))) self.assertEqual(operator["total_component"], int(round(plan_amount))) finally: db.session.remove() db.drop_all() client_ctx.pop() def test_trip_skip_sync_hides_recipe_on_client_period_list(self) -> None: today = plan_date_today() row = skip_trip(E2E_DISP_RECIPE_1, today) queued = db.session.execute( select(SyncQueue).where( SyncQueue.table_name == "daily_trip_skip", SyncQueue.record_id == row.id, SyncQueue.is_deleted.is_(False), ) ).scalars().all() self.assertTrue(queued) client_app, client_ctx = self._sync_row_to_client("daily_trip_skip", row) try: resp = client_app.test_client().get(f"/api/periods/{E2E_PERIOD_A}/recipes") ids = {r["id"] for r in resp.get_json()} self.assertNotIn(E2E_DISP_RECIPE_1, ids) self.assertIn(E2E_DISP_RECIPE_2, ids) finally: db.session.remove() db.drop_all() client_ctx.pop() def test_unloading_group_skip_sync_applies_on_client(self) -> None: today = plan_date_today() row = skip_unloading_group(E2E_DISP_RECIPE_1, "e2e-disp-grp-1", today) client_app, client_ctx = self._sync_row_to_client("daily_unloading_group_skip", row) try: with client_app.app_context(): restored = db.session.get(DailyUnloadingGroupSkip, row.id) self.assertIsNotNone(restored) plan = plan_recipe_json( client_app.test_client(), E2E_DISP_RECIPE_1, plan_date=today, kiosk=False ) groups = plan.get("unloadingGroups") or plan.get("unloading_groups") or [] self.assertTrue(any(g.get("skippedToday") for g in groups)) finally: db.session.remove() db.drop_all() client_ctx.pop() def test_component_norm_adjustment_sync_marks_plan_on_client(self) -> None: today = plan_date_today() row = adjust_component_norm(E2E_DISP_COMP, today, dry_matter=42.0) client_app, client_ctx = self._sync_row_to_client( "daily_component_norm_adjustment", row ) try: plan = plan_recipe_json( client_app.test_client(), E2E_DISP_RECIPE_1, plan_date=today, kiosk=False ) ing = plan["ingredients"][0] self.assertTrue(ing.get("adjustedToday")) self.assertEqual(float(ing.get("dry_matter") or 0), 42.0) master = master_recipe_json(client_app.test_client(), E2E_DISP_RECIPE_1) self.assertNotIn("adjustedToday", master["ingredients"][0]) finally: db.session.remove() db.drop_all() client_ctx.pop() class DailyPlanSyncDualHarnessTests(unittest.TestCase): """Полный цикл: сервер → drain_sync → терминал → оператор.""" def setUp(self) -> None: self.harness = SyncDualInstanceHarness() self.harness.start() with self.harness.server_ctx(): mark_setup_complete(self.harness.server_app) seed_dispenser_period_recipes() _add_second_ingredient() db.session.add(_alt_component("e2e-disp-comp-dual-sync")) recipe = db.session.get(Recipe, E2E_DISP_RECIPE_1) recipe.dry_matter_locked = True db.session.commit() self.harness.drain_sync() def tearDown(self) -> None: self.harness.stop() def test_dual_sync_replacement_operator_matches_plan(self) -> None: today = plan_date_today() with self.harness.server_ctx(): row = replace_ingredient( E2E_DISP_RECIPE_1, "e2e-disp-ing-1", "e2e-disp-comp-dual-sync", today, ) self.assertTrue( self.harness.sync_queue_tasks("daily_ingredient_replacement", row.id) ) self.harness.drain_sync() kiosk = self.harness.term_a_app.test_client() with self.harness.terminal_ctx("a"): client_recipe = db.session.get(Recipe, E2E_DISP_RECIPE_1) client_recipe.dry_matter_locked = True db.session.commit() result = assert_operator_matches_plan_not_master(kiosk, E2E_DISP_RECIPE_1, plan_date=today) self.assertGreater(result["plan_amounts"][0], result["master_amounts"][0]) with self.harness.terminal_ctx("b"): repl = db.session.get(DailyIngredientReplacement, row.id) self.assertIsNotNone(repl) def test_dual_sync_ingredient_skip_both_terminals(self) -> None: today = plan_date_today() with self.harness.server_ctx(): row = skip_ingredient(E2E_DISP_RECIPE_1, "e2e-disp-ing-1", today) row_id = row.id self.harness.drain_sync() for term in ("a", "b"): kiosk = ( self.harness.term_a_app.test_client() if term == "a" else self.harness.term_b_app.test_client() ) plan = plan_recipe_json(kiosk, E2E_DISP_RECIPE_1, plan_date=today) self.assertEqual(len(plan["ingredients"]), 1) operator = operator_weight_display(kiosk, E2E_DISP_RECIPE_1) plan_amount = float(plan["ingredients"][0]["amount"]) self.assertEqual(operator["total_component"], int(round(plan_amount))) with self.harness.terminal_ctx("a"): self.assertIsNotNone(db.session.get(DailyIngredientSkip, row_id)) def test_dual_sync_trip_skip_period_recipes_on_both_terminals(self) -> None: today = plan_date_today() with self.harness.server_ctx(): skip_trip(E2E_DISP_RECIPE_1, today) self.harness.drain_sync() for term in ("a", "b"): client = ( self.harness.term_a_app.test_client() if term == "a" else self.harness.term_b_app.test_client() ) resp = client.get(f"/api/periods/{E2E_PERIOD_A}/recipes") ids = {r["id"] for r in resp.get_json()} self.assertNotIn(E2E_DISP_RECIPE_1, ids, msg=term) if __name__ == "__main__": unittest.main()