from __future__ import annotations from datetime import UTC, datetime from uuid import uuid4 from app.core.exceptions import DomainError from app.modules.sync.engine import SyncEngine from app.modules.sync.schemas import ChangeEventIn from app.modules.zootech.catalog_apply import load_catalog_row from app.modules.zootech.sync_content_hash import compute_content_hash class ZootechServiceError(DomainError): pass def _emit_change(enterprise_id: str, table: str, record_id: str, payload: dict, version: int, content_hash: str) -> None: event = ChangeEventIn( event_id=str(uuid4()), seq=0, domain="global", table=table, record_id=record_id, action="upsert", version=version, content_hash=content_hash, payload=payload, emitted_at=datetime.now(UTC), origin_site_id=SyncEngine.ORCHESTRATOR_SITE_ID, ) SyncEngine(enterprise_id, SyncEngine.ORCHESTRATOR_SITE_ID).process_push([event], origin="orchestrator") def upsert_component(enterprise_id: str, record_id: str | None, body: dict) -> dict: rid = record_id or str(uuid4()) existing = load_catalog_row(enterprise_id, "component", rid) version = int(body.get("version") or (existing or {}).get("version") or 0) + 1 if existing else 1 payload = { "id": rid, "name": body.get("name", (existing or {}).get("name", "")), "type": body.get("type", (existing or {}).get("type", "")), "is_active": body.get("is_active", (existing or {}).get("is_active", True)), "dry_matter": float(body.get("dry_matter", (existing or {}).get("dry_matter", 0.0))), "protein": float(body.get("protein", (existing or {}).get("protein", 0.0))), "energy": float(body.get("energy", (existing or {}).get("energy", 0.0))), "price": float(body.get("price", (existing or {}).get("price", 0.0))), "external_no": body.get("external_no", (existing or {}).get("external_no")), "version": version, } content_hash = compute_content_hash(payload) payload["content_hash"] = content_hash _emit_change(enterprise_id, "component", rid, payload, version, content_hash) return load_catalog_row(enterprise_id, "component", rid) or payload def patch_component(enterprise_id: str, record_id: str, body: dict) -> dict: existing = load_catalog_row(enterprise_id, "component", record_id) if not existing: raise ZootechServiceError("NOT_FOUND") merged = {**existing, **body} return upsert_component(enterprise_id, record_id, merged) def delete_component(enterprise_id: str, record_id: str) -> None: existing = load_catalog_row(enterprise_id, "component", record_id) if not existing: raise ZootechServiceError("NOT_FOUND") version = int(existing.get("version") or 0) + 1 payload = {**existing, "version": version, "is_deleted": True} content_hash = compute_content_hash(payload) event = ChangeEventIn( event_id=str(uuid4()), seq=0, domain="global", table="component", record_id=record_id, action="delete", version=version, content_hash=content_hash, payload=payload, emitted_at=datetime.now(UTC), origin_site_id=SyncEngine.ORCHESTRATOR_SITE_ID, ) SyncEngine(enterprise_id, SyncEngine.ORCHESTRATOR_SITE_ID).process_push([event], origin="orchestrator") def upsert_recipe(enterprise_id: str, record_id: str | None, body: dict) -> dict: rid = record_id or str(uuid4()) existing = load_catalog_row(enterprise_id, "recipe", rid) version = int(body.get("version") or (existing or {}).get("version") or 0) + 1 if existing else 1 payload = { "id": rid, "name": body.get("name", (existing or {}).get("name", "")), "heads_per_trip": int(body.get("heads_per_trip", (existing or {}).get("heads_per_trip", 1))), "mixing_time": int(body.get("mixing_time", (existing or {}).get("mixing_time", 0))), "trip_percent": float(body.get("trip_percent", (existing or {}).get("trip_percent", 100.0))), "dry_matter_locked": body.get("dry_matter_locked", (existing or {}).get("dry_matter_locked", False)), "version": version, } if "ingredients" in body: payload["ingredients"] = body["ingredients"] elif existing and "ingredients" in existing: payload["ingredients"] = existing["ingredients"] content_hash = compute_content_hash(payload) payload["content_hash"] = content_hash _emit_change(enterprise_id, "recipe", rid, payload, version, content_hash) return load_catalog_row(enterprise_id, "recipe", rid) or payload def upsert_feed_dispenser(enterprise_id: str, record_id: str | None, body: dict) -> dict: rid = record_id or str(uuid4()) existing = load_catalog_row(enterprise_id, "feed_dispenser", rid) version = int(body.get("version") or (existing or {}).get("version") or 0) + 1 if existing else 1 payload = { "id": rid, "name": body.get("name", (existing or {}).get("name", "")), "farm": body.get("farm", (existing or {}).get("farm", "")), "operator": body.get("operator", (existing or {}).get("operator", "")), "type": body.get("type", (existing or {}).get("type", "dispenser")), "version": version, } content_hash = compute_content_hash(payload) payload["content_hash"] = content_hash _emit_change(enterprise_id, "feed_dispenser", rid, payload, version, content_hash) return load_catalog_row(enterprise_id, "feed_dispenser", rid) or payload def patch_feed_dispenser(enterprise_id: str, record_id: str, body: dict) -> dict: existing = load_catalog_row(enterprise_id, "feed_dispenser", record_id) if not existing: raise ZootechServiceError("NOT_FOUND") merged = {**existing, **body} return upsert_feed_dispenser(enterprise_id, record_id, merged) def delete_feed_dispenser(enterprise_id: str, record_id: str) -> None: existing = load_catalog_row(enterprise_id, "feed_dispenser", record_id) if not existing: raise ZootechServiceError("NOT_FOUND") version = int(existing.get("version") or 0) + 1 payload = {**existing, "version": version, "is_deleted": True} content_hash = compute_content_hash(payload) event = ChangeEventIn( event_id=str(uuid4()), seq=0, domain="global", table="feed_dispenser", record_id=record_id, action="delete", version=version, content_hash=content_hash, payload=payload, emitted_at=datetime.now(UTC), origin_site_id=SyncEngine.ORCHESTRATOR_SITE_ID, ) SyncEngine(enterprise_id, SyncEngine.ORCHESTRATOR_SITE_ID).process_push([event], origin="orchestrator") def upsert_feeding_period(enterprise_id: str, record_id: str | None, body: dict) -> dict: rid = record_id or str(uuid4()) existing = load_catalog_row(enterprise_id, "feeding_period", rid) version = int(body.get("version") or (existing or {}).get("version") or 0) + 1 if existing else 1 payload = { "id": rid, "name": body.get("name", (existing or {}).get("name", "")), "dispenser_id": body.get("dispenser_id", (existing or {}).get("dispenser_id")), "version": version, } content_hash = compute_content_hash(payload) payload["content_hash"] = content_hash _emit_change(enterprise_id, "feeding_period", rid, payload, version, content_hash) return load_catalog_row(enterprise_id, "feeding_period", rid) or payload def patch_feeding_period(enterprise_id: str, record_id: str, body: dict) -> dict: existing = load_catalog_row(enterprise_id, "feeding_period", record_id) if not existing: raise ZootechServiceError("NOT_FOUND") merged = {**existing, **body} return upsert_feeding_period(enterprise_id, record_id, merged) def delete_feeding_period(enterprise_id: str, record_id: str) -> None: existing = load_catalog_row(enterprise_id, "feeding_period", record_id) if not existing: raise ZootechServiceError("NOT_FOUND") version = int(existing.get("version") or 0) + 1 payload = {**existing, "version": version, "is_deleted": True} content_hash = compute_content_hash(payload) event = ChangeEventIn( event_id=str(uuid4()), seq=0, domain="global", table="feeding_period", record_id=record_id, action="delete", version=version, content_hash=content_hash, payload=payload, emitted_at=datetime.now(UTC), origin_site_id=SyncEngine.ORCHESTRATOR_SITE_ID, ) SyncEngine(enterprise_id, SyncEngine.ORCHESTRATOR_SITE_ID).process_push([event], origin="orchestrator")