415 lines
16 KiB
Python
415 lines
16 KiB
Python
"""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()
|