diff --git a/tienda_ilusion/don_confiao/services/tryton/categories.py b/tienda_ilusion/don_confiao/services/tryton/categories.py new file mode 100644 index 0000000..89b1420 --- /dev/null +++ b/tienda_ilusion/don_confiao/services/tryton/categories.py @@ -0,0 +1,147 @@ +from ...models.products import ProductCategory + + +def upsert_category(category_data): + """Crea o actualiza una ProductCategory desde datos de Tryton. + + Solo administra name y external_id (fuente Tryton). Preserva los + campos administrados por Don Confiao (description, alternative_name + y active). Devuelve (category, status, changes) donde status es + 'created', 'updated' o 'untouched' y changes es la lista de cambios. + """ + external_id = str(category_data.get("id")) + name = category_data.get("name") + category = ProductCategory.objects.filter(external_id=external_id).first() + if category is None and name: + category = ProductCategory.objects.filter(name=name).first() + is_new = category is None + if is_new: + category = ProductCategory() + + changes = [] + if category.name != name: + changes.append(f"Nombre: {category.name} → {name}") + if category.external_id != external_id: + changes.append( + f"External ID: {category.external_id} → {external_id}" + ) + + category.name = name + category.external_id = external_id + category.save() + + status = "created" if is_new else ("updated" if changes else "untouched") + return category, status, changes + + +class CategoryTrytonService: + """Servicio para sincronización de categorías con Tryton""" + + def __init__(self, tryton_client): + self.client = tryton_client + + def import_from_external(self): + """Importa desde Tryton las categorías asociadas a productos. + + Lee las categorías referenciadas por los productos salables + (sin suponer eliminación de categorías locales). Reporta las + categorías revisadas, creadas, actualizadas, intactas y fallidas. + """ + context = {"company": 1} + product_ids = self._search_salable_products(context) + if not product_ids: + return self._empty_report() + tryton_products = self._get_product_details(product_ids, context) + + category_ids = set() + for tryton_product in tryton_products: + category_ids.update(self._extract_category_ids(tryton_product)) + if not category_ids: + return self._empty_report() + + tryton_categories = self._get_category_details( + sorted(category_ids), context + ) + + checked = [ + {"id": c.get("id"), "name": c.get("name")} + for c in tryton_categories + ] + created_categories = [] + updated_categories = [] + untouched_categories = [] + failed_categories = [] + + for tryton_category in tryton_categories: + try: + category, status, changes = upsert_category(tryton_category) + item = { + "id": category.id, + "name": category.name, + "external_id": category.external_id, + } + if status == "created": + created_categories.append(item) + elif status == "updated": + updated_categories.append( + {**item, "detail": ", ".join(changes)} + ) + else: + untouched_categories.append(item) + except Exception as e: + print( + f"Error al importar categoría: {e}" + f"La categoría: {tryton_category}" + ) + failed_categories.append( + { + "external_id": tryton_category.get("id"), + "name": tryton_category.get("name"), + "error": f"Error al importar la categoría: {e}", + } + ) + + return { + "checked_tryton_categories": checked, + "created_categories": created_categories, + "updated_categories": updated_categories, + "untouched_categories": untouched_categories, + "failed_categories": failed_categories, + } + + def _search_salable_products(self, context): + method = "model.product.product.search" + params = [ + [["salable", "=", True]], + 0, + 1000, + [["rec_name", "ASC"], ["id", None]], + context, + ] + return self.client.call(method, params) or [] + + def _get_product_details(self, product_ids, context): + tryton_fields = ["id", "name", "template.categories"] + method = "model.product.product.read" + params = (product_ids, tryton_fields, context) + return self.client.call(method, params) + + @staticmethod + def _extract_category_ids(tryton_product): + """Extrae los IDs de categorías de la respuesta RPC de Tryton""" + template = tryton_product.get("template.") or {} + return template.get("categories") or [] + + def _get_category_details(self, category_ids, context): + method = "model.product.category.read" + params = (category_ids, ["id", "name"], context) + return self.client.call(method, params) + + def _empty_report(self): + return { + "checked_tryton_categories": [], + "created_categories": [], + "updated_categories": [], + "untouched_categories": [], + "failed_categories": [], + } \ No newline at end of file diff --git a/tienda_ilusion/don_confiao/services/tryton/products.py b/tienda_ilusion/don_confiao/services/tryton/products.py index f49c1f4..d5a2f9d 100644 --- a/tienda_ilusion/don_confiao/services/tryton/products.py +++ b/tienda_ilusion/don_confiao/services/tryton/products.py @@ -1,4 +1,5 @@ from ...models.products import Product, ProductCategory +from .categories import upsert_category class ProductTrytonService: @@ -121,42 +122,16 @@ class ProductTrytonService: ) for tryton_category in tryton_categories: try: - external_id = str(tryton_category.get("id")) - name = tryton_category.get("name") - category = ProductCategory.objects.filter( - external_id=external_id - ).first() - if category is None and name: - category = ProductCategory.objects.filter( - name=name - ).first() - is_new = category is None - if is_new: - category = ProductCategory() - old_name = category.name - old_external_id = category.external_id - category.name = name - category.external_id = external_id - category.save() + category, status, changes = upsert_category(tryton_category) categories[tryton_category.get("id")] = category - item = { "id": category.id, "name": category.name, "external_id": category.external_id, } - if is_new: + if status == "created": report["created"].append(item) - continue - changes = [] - if old_name != category.name: - changes.append(f"Nombre: {old_name} → {category.name}") - if old_external_id != category.external_id: - changes.append( - "External ID: " - f"{old_external_id} → {category.external_id}" - ) - if changes: + elif status == "updated": report["updated"].append( {**item, "detail": ", ".join(changes)} ) diff --git a/tienda_ilusion/don_confiao/tests/test_categories_from_tryton.py b/tienda_ilusion/don_confiao/tests/test_categories_from_tryton.py new file mode 100644 index 0000000..ca57ed3 --- /dev/null +++ b/tienda_ilusion/don_confiao/tests/test_categories_from_tryton.py @@ -0,0 +1,200 @@ +from unittest.mock import patch + +from django.test import TestCase + +from ..models.products import ProductCategory +from ..services.tryton.categories import CategoryTrytonService + + +class TestCategoryTrytonImport(TestCase): + def _service(self, mock_call): + fake_client = type("FakeClient", (), {"call": mock_call})() + return CategoryTrytonService(fake_client) + + def _empty_report(self): + return { + "checked_tryton_categories": [], + "created_categories": [], + "updated_categories": [], + "untouched_categories": [], + "failed_categories": [], + } + + @patch("sabatron_tryton_rpc_client.client.Client.call") + def test_import_creates_only_referenced_categories(self, mock_call): + def fake_call(*args, **kwargs): + if args[0] == "model.product.product.search": + return [1, 2] + if args[0] == "model.product.product.read": + return [ + { + "id": 1, + "name": "Producto 1", + "template.": {"id": 999, "categories": [6]}, + }, + { + "id": 2, + "name": "Producto 2", + "template.": {"id": 999, "categories": [7]}, + }, + ] + if args[0] == "model.product.category.read": + return [ + {"id": 6, "name": "Alimentos"}, + {"id": 7, "name": "Verduras"}, + ] + raise Exception(f"args no esperados: {args}") + + mock_call.side_effect = fake_call + result = self._service(mock_call).import_from_external() + + self.assertEqual( + result["checked_tryton_categories"], + [ + {"id": 6, "name": "Alimentos"}, + {"id": 7, "name": "Verduras"}, + ], + ) + self.assertEqual( + result["created_categories"], + [ + { + "id": ProductCategory.objects.get(external_id="6").pk, + "name": "Alimentos", + "external_id": "6", + }, + { + "id": ProductCategory.objects.get(external_id="7").pk, + "name": "Verduras", + "external_id": "7", + }, + ], + ) + self.assertEqual(result["updated_categories"], []) + self.assertEqual(result["untouched_categories"], []) + self.assertEqual(result["failed_categories"], []) + self.assertEqual(ProductCategory.objects.count(), 2) + + @patch("sabatron_tryton_rpc_client.client.Client.call") + def test_import_preserves_don_confiao_fields_on_update(self, mock_call): + local_category = ProductCategory.objects.create( + name="Granos", + external_id="7", + description="Granos enteros", + alternative_name="Cereales", + active=False, + ) + + def fake_call(*args, **kwargs): + if args[0] == "model.product.product.search": + return [1] + if args[0] == "model.product.product.read": + return [ + { + "id": 1, + "name": "Producto 1", + "template.": {"id": 999, "categories": [7]}, + }, + ] + if args[0] == "model.product.category.read": + return [{"id": 7, "name": "Cereales y Granos"}] + raise Exception(f"args no esperados: {args}") + + mock_call.side_effect = fake_call + result = self._service(mock_call).import_from_external() + + self.assertEqual(result["created_categories"], []) + self.assertEqual( + result["updated_categories"], + [ + { + "id": local_category.pk, + "name": "Cereales y Granos", + "external_id": "7", + "detail": "Nombre: Granos → Cereales y Granos", + } + ], + ) + refreshed = ProductCategory.objects.get(pk=local_category.pk) + self.assertEqual(refreshed.name, "Cereales y Granos") + self.assertEqual(refreshed.description, "Granos enteros") + self.assertEqual(refreshed.alternative_name, "Cereales") + self.assertFalse(refreshed.active) + + @patch("sabatron_tryton_rpc_client.client.Client.call") + def test_import_counts_untouched_categories(self, mock_call): + local_category = ProductCategory.objects.create( + name="Alimentos", external_id="6" + ) + + def fake_call(*args, **kwargs): + if args[0] == "model.product.product.search": + return [1] + if args[0] == "model.product.product.read": + return [ + { + "id": 1, + "name": "Producto 1", + "template.": {"id": 999, "categories": [6]}, + }, + ] + if args[0] == "model.product.category.read": + return [{"id": 6, "name": "Alimentos"}] + raise Exception(f"args no esperados: {args}") + + mock_call.side_effect = fake_call + result = self._service(mock_call).import_from_external() + + self.assertEqual(result["created_categories"], []) + self.assertEqual(result["updated_categories"], []) + self.assertEqual( + result["untouched_categories"], + [ + { + "id": local_category.pk, + "name": "Alimentos", + "external_id": "6", + } + ], + ) + self.assertEqual(result["failed_categories"], []) + self.assertEqual(ProductCategory.objects.count(), 1) + + @patch("sabatron_tryton_rpc_client.client.Client.call") + def test_import_without_products_returns_empty_report(self, mock_call): + def fake_call(*args, **kwargs): + if args[0] == "model.product.product.search": + return [] + raise Exception(f"args no esperados: {args}") + + mock_call.side_effect = fake_call + result = self._service(mock_call).import_from_external() + self.assertEqual(result, self._empty_report()) + self.assertEqual(ProductCategory.objects.count(), 0) + + @patch("sabatron_tryton_rpc_client.client.Client.call") + def test_import_failed_category_reported(self, mock_call): + def fake_call(*args, **kwargs): + if args[0] == "model.product.product.search": + return [1] + if args[0] == "model.product.product.read": + return [ + { + "id": 1, + "name": "Producto 1", + "template.": {"id": 999, "categories": [6]}, + }, + ] + if args[0] == "model.product.category.read": + return [{"id": 6, "name": None}] + raise Exception(f"args no esperados: {args}") + + mock_call.side_effect = fake_call + result = self._service(mock_call).import_from_external() + + self.assertEqual(result["created_categories"], []) + self.assertEqual(len(result["failed_categories"]), 1) + failure = result["failed_categories"][0] + self.assertEqual(failure["external_id"], 6) + self.assertEqual(failure["name"], None) + self.assertIn("Error", failure["error"]) \ No newline at end of file