"""Согласованность POST /api/recipes/calculate с app.services.recipe_calculator.""" from __future__ import annotations import os import tempfile import unittest from app import create_app, db from app.models import Component from app.services.recipe_calculator import calculate_recipe from config import TestingConfig class RecipeCalculatePipelineConfig(TestingConfig): _TMP_DIR = tempfile.mkdtemp(prefix="wesp-calc-pipeline-") SQLALCHEMY_DATABASE_URI = f"sqlite:///{os.path.join(_TMP_DIR, 'calc_pipeline.db')}" SQLALCHEMY_BINDS = {"reports": f"sqlite:///{os.path.join(_TMP_DIR, 'reports_pipeline.db')}"} AUTH_LOGIN = "calc-pipeline-admin" AUTH_PASSWORD = "calc-pipeline-secret" def _format_like_api(result: dict) -> dict: """Те же правила округления, что в calculate_recipe_endpoint.""" out = { "ingredients": [dict(i) for i in result.get("ingredients", [])], "totals": dict(result.get("totals", {})), "unloadingGroups": [dict(g) for g in result.get("unloadingGroups", [])], "unloadingTotals": dict(result.get("unloadingTotals", {})), } def to_float2(x): try: return round(float(x), 2) except (TypeError, ValueError): return 0.0 def truncate2(x): try: v = float(x) return float(int(v * 100)) / 100.0 except (TypeError, ValueError): return 0.0 for ing in out["ingredients"]: for key in ("weightPerHead", "tripWeight", "totalWeight", "dryMatterPerHead"): if key in ing and ing[key] is not None: raw_val = ing[key] v = truncate2(raw_val) if key == "weightPerHead" else to_float2(raw_val) ing[key] = f"{v:.2f}" if key == "weightPerHead" else v for key in ( "totalWeight", "totalTripWeight", "totalDryMatterPerHead", "totalWeightPerHead", ): if key in out["totals"] and out["totals"][key] is not None: out["totals"][key] = to_float2(out["totals"][key]) return out class RecipeCalculatePipelineTests(unittest.TestCase): def setUp(self) -> None: self.app = create_app(RecipeCalculatePipelineConfig) self.client = self.app.test_client() self.ctx = self.app.app_context() self.ctx.push() db.create_all() self.client.post( "/api/auth/login", json={"login": "calc-pipeline-admin", "password": "calc-pipeline-secret"}, ) self.comp_grain = "pipe-comp-grain" self.comp_protein = "pipe-comp-protein" db.session.add_all( [ Component( id=self.comp_grain, name="Зерно", type="grain", dry_matter=80.0, protein=0.0, energy=0.0, price=0.0, ), Component( id=self.comp_protein, name="Белок", type="protein", dry_matter=90.0, protein=0.0, energy=0.0, price=0.0, ), ] ) db.session.commit() def tearDown(self) -> None: db.session.remove() db.drop_all() self.ctx.pop() def _post_calculate(self, payload: dict) -> dict: resp = self.client.post("/api/recipes/calculate", json=payload) self.assertEqual(resp.status_code, 200, resp.get_data(as_text=True)) return resp.get_json() def _expected_from_service(self, payload: dict) -> dict: heads_count = int( payload.get("headsCount") or payload.get("heads_count") or payload.get("headsPerTrip") or 0 ) trip_percent = float(payload.get("tripPercent") or payload.get("trip_percent") or 100) ingredients = payload.get("ingredients", []) or [] unloading_groups = payload.get("unloadingGroups") or payload.get("unloading_groups") or [] calculate_from_dry_matter = bool( payload.get("calculateFromDryMatter") if payload.get("calculateFromDryMatter") is not None else payload.get("calculate_from_dry_matter", False) ) normalized_ingredients = [] for ing in ingredients: ing = dict(ing) if "component_id" not in ing and "componentId" in ing: ing["component_id"] = ing.get("componentId") if "dryMatterPerHead" not in ing and "dry_matter_per_head" in ing: ing["dryMatterPerHead"] = ing.get("dry_matter_per_head") if "dryMatter" not in ing and "dry_matter" in ing: ing["dryMatter"] = ing.get("dry_matter") if "weightPerHead" not in ing and "weight_per_head" in ing: ing["weightPerHead"] = ing.get("weight_per_head") normalized_ingredients.append(ing) normalized_groups = [] for g in unloading_groups: g = dict(g) if "distributionType" not in g and "distribution_type" in g: g["distributionType"] = g.get("distribution_type") normalized_groups.append(g) component_ids = [i.get("component_id") for i in normalized_ingredients if i.get("component_id")] component_dry_matter_map = {} if component_ids: comps = db.session.query(Component).filter(Component.id.in_(component_ids)).all() component_dry_matter_map = {c.id: c.dry_matter for c in comps} raw = calculate_recipe( ingredients=normalized_ingredients, heads_count=heads_count, trip_percent=trip_percent, unloading_groups=normalized_groups, component_dry_matter_map=component_dry_matter_map or None, calculate_from_dry_matter=calculate_from_dry_matter, ) return _format_like_api(raw) def test_api_matches_service_weight_mode_with_groups(self) -> None: payload = { "headsCount": 137, "tripPercent": 75.0, "ingredients": [ {"weightPerHead": 2.5, "dryMatter": 50.0, "componentId": self.comp_grain}, {"weightPerHead": 1.0, "dryMatter": 0, "componentId": self.comp_protein}, ], "unloadingGroups": [ {"distributionType": "heads", "value": 67}, {"distributionType": "heads", "value": 68}, ], } api = self._post_calculate(payload) expected = self._expected_from_service(payload) self.assertEqual(api["totals"], expected["totals"]) self.assertEqual(api["unloadingGroups"], expected["unloadingGroups"]) self.assertEqual(api["unloadingTotals"], expected["unloadingTotals"]) self.assertEqual(len(api["ingredients"]), 2) self.assertEqual(api["ingredients"][0]["weightPerHead"], expected["ingredients"][0]["weightPerHead"]) self.assertEqual(api["ingredients"][1]["dryMatterPerHead"], expected["ingredients"][1]["dryMatterPerHead"]) def test_api_matches_service_dry_matter_mode(self) -> None: payload = { "heads_count": 50, "trip_percent": 100, "calculate_from_dry_matter": True, "ingredients": [ {"dry_matter_per_head": 0.8, "dry_matter": 0, "component_id": self.comp_grain}, {"dry_matter_per_head": 0.45, "dryMatter": 90.0, "component_id": self.comp_protein}, ], "unloading_groups": [{"distribution_type": "percent", "value": 100}], } api = self._post_calculate(payload) expected = self._expected_from_service(payload) self.assertEqual(api["totals"], expected["totals"]) self.assertEqual(api["ingredients"][0]["weightPerHead"], expected["ingredients"][0]["weightPerHead"]) self.assertEqual(api["ingredients"][1]["weightPerHead"], expected["ingredients"][1]["weightPerHead"]) self.assertEqual(api["unloadingGroups"][0]["calculatedWeight"], expected["unloadingGroups"][0]["calculatedWeight"]) def test_api_rejects_invalid_numeric_params(self) -> None: resp = self.client.post( "/api/recipes/calculate", json={"headsCount": "abc", "ingredients": []}, ) self.assertEqual(resp.status_code, 400) self.assertIn("error", resp.get_json()) if __name__ == "__main__": unittest.main()