From 53cc333c198bc54ffa11f7d59ebc148a6f6e59a9 Mon Sep 17 00:00:00 2001 From: aserrador Date: Sat, 22 Aug 2026 16:40:40 -0500 Subject: [PATCH] =?UTF-8?q?#58=20feat:=20importar=20y=20vincular=20categor?= =?UTF-8?q?=C3=ADas=20de=20Tryton=20al=20sincronizar=20productos?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Campo external_id en ProductCategory para vincular con Tryton (migración 0055) - Import/upsert de categorías usadas por los productos sincronizados - Asignación M2M en productos creados, actualizados y sin cambios - Respuesta del import incluye created_categories y updated_categories con detalle - Fix: leer las categorías desde la clave agrupada "template." que devuelve el RPC de Tryton --- .../0055_productcategory_external_id.py | 18 + tienda_ilusion/don_confiao/models/products.py | 1 + .../don_confiao/services/tryton/products.py | 139 +++++- .../tests/test_products_from_tryton.py | 435 +++++++++++++++++- 4 files changed, 583 insertions(+), 10 deletions(-) create mode 100644 tienda_ilusion/don_confiao/migrations/0055_productcategory_external_id.py diff --git a/tienda_ilusion/don_confiao/migrations/0055_productcategory_external_id.py b/tienda_ilusion/don_confiao/migrations/0055_productcategory_external_id.py new file mode 100644 index 0000000..3396172 --- /dev/null +++ b/tienda_ilusion/don_confiao/migrations/0055_productcategory_external_id.py @@ -0,0 +1,18 @@ +# Generated by Django 5.0.6 on 2026-08-22 20:33 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('don_confiao', '0054_municipality_latitude_municipality_longitude'), + ] + + operations = [ + migrations.AddField( + model_name='productcategory', + name='external_id', + field=models.CharField(blank=True, max_length=100, null=True), + ), + ] diff --git a/tienda_ilusion/don_confiao/models/products.py b/tienda_ilusion/don_confiao/models/products.py index 7260f6a..3783337 100644 --- a/tienda_ilusion/don_confiao/models/products.py +++ b/tienda_ilusion/don_confiao/models/products.py @@ -8,6 +8,7 @@ class MeasuringUnits(models.TextChoices): class ProductCategory(models.Model): name = models.CharField(max_length=100, unique=True) + external_id = models.CharField(max_length=100, null=True, blank=True) def __str__(self): return self.name diff --git a/tienda_ilusion/don_confiao/services/tryton/products.py b/tienda_ilusion/don_confiao/services/tryton/products.py index e9c9ea4..6703c0e 100644 --- a/tienda_ilusion/don_confiao/services/tryton/products.py +++ b/tienda_ilusion/don_confiao/services/tryton/products.py @@ -1,4 +1,4 @@ -from ...models.products import Product +from ...models.products import Product, ProductCategory class ProductTrytonService: @@ -20,7 +20,20 @@ class ProductTrytonService: ] product_ids = self.client.call(method, params) tryton_products = self._get_product_details(product_ids, context) - + + try: + ( + categories_by_tryton_id, + category_report, + ) = self._import_categories(tryton_products, context) + created_categories = category_report["created"] + updated_categories = category_report["updated"] + except Exception as e: + print(f"Error al importar categorías: {e}") + categories_by_tryton_id = {} + created_categories = [] + updated_categories = [] + checked_tryton_products = [ {"id": p.get("id"), "name": p.get("name")} for p in tryton_products @@ -32,10 +45,15 @@ class ProductTrytonService: for tryton_product in tryton_products: try: - product = Product.objects.get(external_id=tryton_product.get("id")) + product = Product.objects.get( + external_id=tryton_product.get("id") + ) except Product.DoesNotExist: try: product = self._create_product(tryton_product) + self._sync_product_categories( + product, tryton_product, categories_by_tryton_id + ) created_products.append(self._build_item(product)) continue except Exception as e: @@ -49,12 +67,20 @@ class ProductTrytonService: try: if self._need_update(product, tryton_product): - changes = self._describe_changes(product, tryton_product) + changes = self._describe_changes( + product, tryton_product + ) self._update_product(product, tryton_product) + self._sync_product_categories( + product, tryton_product, categories_by_tryton_id + ) updated_products.append( {**self._build_item(product), "detail": changes} ) else: + self._sync_product_categories( + product, tryton_product, categories_by_tryton_id + ) untouched_products.append(self._build_item(product)) except Exception as e: print( @@ -70,8 +96,101 @@ class ProductTrytonService: "updated_products": updated_products, "created_products": created_products, "untouched_products": untouched_products, + "created_categories": created_categories, + "updated_categories": updated_categories, } + def _import_categories(self, tryton_products, context): + """Importa las categorías de Tryton usadas por los productos + sincronizados. Devuelve un mapa {id_tryton: ProductCategory} y un + reporte {"created": [...], "updated": [...]} + """ + empty_report = {"created": [], "updated": []} + category_ids = { + category_id + for tryton_product in tryton_products + for category_id in self._extract_category_ids(tryton_product) + } + if not category_ids: + return {}, empty_report + + categories = {} + report = {"created": [], "updated": []} + tryton_categories = self._get_category_details( + sorted(category_ids), context + ) + 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() + categories[tryton_category.get("id")] = category + + item = { + "id": category.id, + "name": category.name, + "external_id": category.external_id, + } + if is_new: + 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: + report["updated"].append( + {**item, "detail": ", ".join(changes)} + ) + except Exception as e: + print( + f"Error al importar categoría: {e}" + f"La categoría: {tryton_category}" + ) + return categories, report + + @staticmethod + def _extract_category_ids(tryton_product): + """Extrae los IDs de categorías de la respuesta RPC de Tryton + (los campos con puntos llegan agrupados bajo la clave 'template.')""" + template = tryton_product.get("template.") or {} + return template.get("categories") or [] + + def _get_category_details(self, category_ids, context): + """Obtiene los nombres de categorías desde Tryton""" + method = "model.product.category.read" + params = (category_ids, ["id", "name"], context) + return self.client.call(method, params) + + def _sync_product_categories(self, product, tryton_product, categories): + """Vincula al producto las categorías importadas desde Tryton""" + category_ids = self._extract_category_ids(tryton_product) + product.categories.set( + [ + categories[category_id] + for category_id in category_ids + if category_id in categories + ] + ) + def _build_item(self, product): """Construye el objeto de un producto sincronizado""" return {"id": product.id, "name": product.name} @@ -114,15 +233,16 @@ class ProductTrytonService: def _friendly_error(self, tryton_product, message): """Traduce causas conocidas de error a mensajes legibles""" if "price" in message and ( - "not-null constraint" in message or "NOT NULL constraint" in message + "not-null constraint" in message + or "NOT NULL constraint" in message ): - return "El producto no tiene precio en Tryton (list_price nulo)" + return ( + "El producto no tiene precio en Tryton (list_price nulo)" + ) if "name" in message and ( "duplicate key" in message or "UNIQUE constraint" in message ): - return ( - f"Ya existe un producto con el nombre '{tryton_product.get('name')}'" - ) + return f"Ya existe un producto con el nombre '{tryton_product.get('name')}'" return message def _get_product_details(self, product_ids, context): @@ -133,6 +253,7 @@ class ProductTrytonService: "default_uom.id", "default_uom.rec_name", "list_price", + "template.categories", ] method = "model.product.product.read" params = (product_ids, tryton_fields, context) diff --git a/tienda_ilusion/don_confiao/tests/test_products_from_tryton.py b/tienda_ilusion/don_confiao/tests/test_products_from_tryton.py index 33300f3..4cb594a 100644 --- a/tienda_ilusion/don_confiao/tests/test_products_from_tryton.py +++ b/tienda_ilusion/don_confiao/tests/test_products_from_tryton.py @@ -3,7 +3,7 @@ from decimal import Decimal from unittest.mock import patch from django.test import TestCase -from ..models.products import Product +from ..models.products import Product, ProductCategory from .Mixins import LoginMixin @@ -55,6 +55,7 @@ class TestProductsFromTryton(TestCase, LoginMixin): "default_uom.id", "default_uom.rec_name", "list_price", + "template.categories", ], {"company": 1}, ) @@ -65,18 +66,21 @@ class TestProductsFromTryton(TestCase, LoginMixin): "list_price": Decimal("25000"), "name": "Producto 1", "default_uom.": {"id": 1, "rec_name": "Unit"}, + "template.": {"id": 999, "categories": []}, }, { "id": 191, "list_price": Decimal("6000"), "name": "Panela2", "default_uom.": {"id": 1, "rec_name": "Unit"}, + "template.": {"id": 999, "categories": []}, }, { "id": 192, "list_price": Decimal("4500"), "name": "Papa", "default_uom.": {"id": 2, "rec_name": "Kilogram"}, + "template.": {"id": 999, "categories": []}, }, ] @@ -107,6 +111,8 @@ class TestProductsFromTryton(TestCase, LoginMixin): "detail": "Nombre: Panela → Panela2, Precio: 5000 → 6000, Unidad: UNIT → Unit", } ], + "created_categories": [], + "updated_categories": [], } self.assertEqual(content, expected_response) @@ -150,6 +156,7 @@ class TestProductsFromTryton(TestCase, LoginMixin): "default_uom.id", "default_uom.rec_name", "list_price", + "template.categories", ], {"company": 1}, ) @@ -160,6 +167,7 @@ class TestProductsFromTryton(TestCase, LoginMixin): "list_price": Decimal("25000"), "name": self.product.name, "default_uom.": {"id": 1, "rec_name": "Unit"}, + "template.": {"id": 999, "categories": []}, }, ] @@ -186,6 +194,8 @@ class TestProductsFromTryton(TestCase, LoginMixin): } ], "updated_products": [], + "created_categories": [], + "updated_categories": [], } self.assertEqual(content, expected_response) @@ -215,6 +225,7 @@ class TestProductsFromTryton(TestCase, LoginMixin): "default_uom.id", "default_uom.rec_name", "list_price", + "template.categories", ], {"company": 1}, ) @@ -225,6 +236,7 @@ class TestProductsFromTryton(TestCase, LoginMixin): "list_price": None, "name": "ENVASES Y EMPAQUES", "default_uom.": {"id": 1, "rec_name": "Unit"}, + "template.": {"id": 999, "categories": []}, }, ] @@ -275,6 +287,7 @@ class TestProductsFromTryton(TestCase, LoginMixin): "default_uom.id", "default_uom.rec_name", "list_price", + "template.categories", ], {"company": 1}, ) @@ -285,12 +298,14 @@ class TestProductsFromTryton(TestCase, LoginMixin): "list_price": Decimal("6000"), "name": "Panela", "default_uom.": None, + "template.": {"id": 999, "categories": []}, }, { "id": 192, "list_price": Decimal("4500"), "name": "Papa", "default_uom.": {"id": 2, "rec_name": "Kilogram"}, + "template.": {"id": 999, "categories": []}, }, ] @@ -311,3 +326,421 @@ class TestProductsFromTryton(TestCase, LoginMixin): self.assertEqual(failure["name"], "Panela") self.assertIn("Error al actualizar producto", failure["error"]) self.assertEqual(content["untouched_products"], [{"id": 2, "name": "Papa"}]) + + @patch("sabatron_tryton_rpc_client.client.Client.call") + @patch("sabatron_tryton_rpc_client.client.Client.connect") + def test_import_products_with_categories(self, mock_connect, mock_call): + mock_connect.return_value = None + + def fake_call(*args, **kwargs): + product_search = "model.product.product.search" + search_args = [ + [["salable", "=", True]], + 0, + 1000, + [["rec_name", "ASC"], ["id", None]], + {"company": 1}, + ] + if args == (product_search, search_args): + return [190] + + product_read = "model.product.product.read" + product_args = ( + [190], + [ + "id", + "name", + "default_uom.id", + "default_uom.rec_name", + "list_price", + "template.categories", + ], + {"company": 1}, + ) + if args == (product_read, product_args): + return [ + { + "id": 190, + "list_price": Decimal("25000"), + "name": "Producto 1", + "default_uom.": {"id": 1, "rec_name": "Unit"}, + "template.": {"id": 999, "categories": [5, 8]}, + }, + ] + + category_read = "model.product.category.read" + category_args = ( + [5, 8], + ["id", "name"], + {"company": 1}, + ) + if args == (category_read, category_args): + return [ + {"id": 5, "name": "Abarrotes"}, + {"id": 8, "name": "Bebidas"}, + ] + + raise Exception( + f"Sorry, args non expected on this test: {args}" + ) + + mock_call.side_effect = fake_call + + url = "/don_confiao/api/importar_productos_de_tryton" + response = self.client.post(url) + self.assertEqual(response.status_code, 200) + + content = json.loads(response.content.decode("utf-8")) + self.assertEqual(content["failed_products"], []) + self.assertEqual( + content["created_products"], [{"id": 3, "name": "Producto 1"}] + ) + self.assertEqual(content["updated_categories"], []) + self.assertEqual( + content["created_categories"], + [ + {"id": 1, "name": "Abarrotes", "external_id": "5"}, + {"id": 2, "name": "Bebidas", "external_id": "8"}, + ], + ) + + created_product = Product.objects.get(name="Producto 1") + self.assertEqual(created_product.external_id, str(190)) + categories = created_product.categories.order_by("external_id") + self.assertEqual( + [(c.external_id, c.name) for c in categories], + [("5", "Abarrotes"), ("8", "Bebidas")], + ) + self.assertEqual(ProductCategory.objects.count(), 2) + + @patch("sabatron_tryton_rpc_client.client.Client.call") + @patch("sabatron_tryton_rpc_client.client.Client.connect") + def test_import_updates_category_name_from_tryton( + self, mock_connect, mock_call + ): + mock_connect.return_value = None + local_category = ProductCategory.objects.create( + name="Granos", external_id="7" + ) + + def fake_call(*args, **kwargs): + product_search = "model.product.product.search" + search_args = [ + [["salable", "=", True]], + 0, + 1000, + [["rec_name", "ASC"], ["id", None]], + {"company": 1}, + ] + if args == (product_search, search_args): + return [190] + + product_read = "model.product.product.read" + product_args = ( + [190], + [ + "id", + "name", + "default_uom.id", + "default_uom.rec_name", + "list_price", + "template.categories", + ], + {"company": 1}, + ) + if args == (product_read, product_args): + return [ + { + "id": 190, + "list_price": Decimal("25000"), + "name": "Producto 1", + "default_uom.": {"id": 1, "rec_name": "Unit"}, + "template.": {"id": 999, "categories": [7]}, + }, + ] + + category_read = "model.product.category.read" + category_args = ( + [7], + ["id", "name"], + {"company": 1}, + ) + if args == (category_read, category_args): + return [ + {"id": 7, "name": "Cereales"}, + ] + + raise Exception( + f"Sorry, args non expected on this test: {args}" + ) + + mock_call.side_effect = fake_call + + url = "/don_confiao/api/importar_productos_de_tryton" + response = self.client.post(url) + self.assertEqual(response.status_code, 200) + + content = json.loads(response.content.decode("utf-8")) + self.assertEqual(content["created_categories"], []) + self.assertEqual( + content["updated_categories"], + [ + { + "id": local_category.pk, + "name": "Cereales", + "external_id": "7", + "detail": "Nombre: Granos → Cereales", + } + ], + ) + + refreshed_category = ProductCategory.objects.get( + pk=local_category.pk + ) + self.assertEqual(refreshed_category.name, "Cereales") + self.assertEqual(refreshed_category.external_id, "7") + self.assertEqual(ProductCategory.objects.count(), 1) + + created_product = Product.objects.get(name="Producto 1") + self.assertEqual( + list(created_product.categories.all()), [refreshed_category] + ) + + @patch("sabatron_tryton_rpc_client.client.Client.call") + @patch("sabatron_tryton_rpc_client.client.Client.connect") + def test_import_links_existing_category_by_name( + self, mock_connect, mock_call + ): + mock_connect.return_value = None + existing_category = ProductCategory.objects.create(name="Abarrotes") + + def fake_call(*args, **kwargs): + product_search = "model.product.product.search" + search_args = [ + [["salable", "=", True]], + 0, + 1000, + [["rec_name", "ASC"], ["id", None]], + {"company": 1}, + ] + if args == (product_search, search_args): + return [190] + + product_read = "model.product.product.read" + product_args = ( + [190], + [ + "id", + "name", + "default_uom.id", + "default_uom.rec_name", + "list_price", + "template.categories", + ], + {"company": 1}, + ) + if args == (product_read, product_args): + return [ + { + "id": 190, + "list_price": Decimal("25000"), + "name": "Producto 1", + "default_uom.": {"id": 1, "rec_name": "Unit"}, + "template.": {"id": 999, "categories": [5]}, + }, + ] + + category_read = "model.product.category.read" + category_args = ( + [5], + ["id", "name"], + {"company": 1}, + ) + if args == (category_read, category_args): + return [ + {"id": 5, "name": "Abarrotes"}, + ] + + raise Exception( + f"Sorry, args non expected on this test: {args}" + ) + + mock_call.side_effect = fake_call + + url = "/don_confiao/api/importar_productos_de_tryton" + response = self.client.post(url) + self.assertEqual(response.status_code, 200) + + content = json.loads(response.content.decode("utf-8")) + self.assertEqual(content["created_categories"], []) + self.assertEqual( + content["updated_categories"], + [ + { + "id": existing_category.pk, + "name": "Abarrotes", + "external_id": "5", + "detail": "External ID: None → 5", + } + ], + ) + + self.assertEqual( + ProductCategory.objects.filter(name="Abarrotes").count(), 1 + ) + linked_category = ProductCategory.objects.get( + pk=existing_category.pk + ) + self.assertEqual(linked_category.external_id, "5") + + created_product = Product.objects.get(name="Producto 1") + self.assertEqual( + list(created_product.categories.all()), [linked_category] + ) + + @patch("sabatron_tryton_rpc_client.client.Client.call") + @patch("sabatron_tryton_rpc_client.client.Client.connect") + def test_import_product_without_categories( + self, mock_connect, mock_call + ): + mock_connect.return_value = None + + def fake_call(*args, **kwargs): + product_search = "model.product.product.search" + search_args = [ + [["salable", "=", True]], + 0, + 1000, + [["rec_name", "ASC"], ["id", None]], + {"company": 1}, + ] + if args == (product_search, search_args): + return [190] + + product_read = "model.product.product.read" + product_args = ( + [190], + [ + "id", + "name", + "default_uom.id", + "default_uom.rec_name", + "list_price", + "template.categories", + ], + {"company": 1}, + ) + if args == (product_read, product_args): + return [ + { + "id": 190, + "list_price": Decimal("25000"), + "name": "Producto 1", + "default_uom.": {"id": 1, "rec_name": "Unit"}, + "template.": {"id": 999, "categories": []}, + }, + ] + + raise Exception( + f"Sorry, args non expected on this test: {args}" + ) + + mock_call.side_effect = fake_call + + url = "/don_confiao/api/importar_productos_de_tryton" + response = self.client.post(url) + self.assertEqual(response.status_code, 200) + + content = json.loads(response.content.decode("utf-8")) + self.assertEqual(content["failed_products"], []) + self.assertEqual(content["created_categories"], []) + self.assertEqual(content["updated_categories"], []) + created_product = Product.objects.get(name="Producto 1") + self.assertEqual(created_product.categories.count(), 0) + self.assertEqual(ProductCategory.objects.count(), 0) + + @patch("sabatron_tryton_rpc_client.client.Client.call") + @patch("sabatron_tryton_rpc_client.client.Client.connect") + def test_import_assigns_categories_to_untouched_products( + self, mock_connect, mock_call + ): + mock_connect.return_value = None + + def fake_call(*args, **kwargs): + product_search = "model.product.product.search" + search_args = [ + [["salable", "=", True]], + 0, + 1000, + [["rec_name", "ASC"], ["id", None]], + {"company": 1}, + ] + if args == (product_search, search_args): + return [192] + + product_read = "model.product.product.read" + product_args = ( + [192], + [ + "id", + "name", + "default_uom.id", + "default_uom.rec_name", + "list_price", + "template.categories", + ], + {"company": 1}, + ) + if args == (product_read, product_args): + return [ + { + "id": 192, + "list_price": Decimal("4500"), + "name": "Papa", + "default_uom.": {"id": 2, "rec_name": "Kilogram"}, + "template.": {"id": 999, "categories": [9]}, + }, + ] + + category_read = "model.product.category.read" + category_args = ( + [9], + ["id", "name"], + {"company": 1}, + ) + if args == (category_read, category_args): + return [ + {"id": 9, "name": "Tubérculos"}, + ] + + raise Exception( + f"Sorry, args non expected on this test: {args}" + ) + + mock_call.side_effect = fake_call + + url = "/don_confiao/api/importar_productos_de_tryton" + response = self.client.post(url) + self.assertEqual(response.status_code, 200) + + content = json.loads(response.content.decode("utf-8")) + self.assertEqual( + content["untouched_products"], [{"id": 2, "name": "Papa"}] + ) + + untouched_product = Product.objects.get(id=2) + categories = untouched_product.categories.all() + self.assertEqual(len(categories), 1) + self.assertEqual(categories[0].name, "Tubérculos") + self.assertEqual(categories[0].external_id, "9") + self.assertEqual(content["updated_categories"], []) + self.assertEqual( + content["created_categories"], + [ + { + "id": categories[0].id, + "name": "Tubérculos", + "external_id": "9", + } + ], + )