Интегрирован wesp в сайт
CI / quality (push) Canceled after 0s

This commit is contained in:
влад
2026-07-17 12:57:18 +03:00
parent 5dfa06ddbe
commit 355c0ef9f1
883 changed files with 194576 additions and 177 deletions
+838
View File
@@ -0,0 +1,838 @@
"""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()