diff --git a/tienda_ilusion/don_confiao/services/tryton/products.py b/tienda_ilusion/don_confiao/services/tryton/products.py index 6703c0e..f49c1f4 100644 --- a/tienda_ilusion/don_confiao/services/tryton/products.py +++ b/tienda_ilusion/don_confiao/services/tryton/products.py @@ -214,8 +214,50 @@ class ProductTrytonService: changes.append( f"Unidad: {product.measuring_unit} → {unit_name}" ) + category_changes = self._describe_category_changes( + product, tryton_product + ) + if category_changes: + changes.append(category_changes) return ", ".join(changes) + def _describe_category_changes(self, product, tryton_product): + """Describe los cambios de categorías entre el producto local y Tryton""" + tryton_ids = set( + str(category_id) + for category_id in self._extract_category_ids(tryton_product) + ) + local = { + external_id: name + for external_id, name in product.categories.values_list( + "external_id", "name" + ) + if external_id + } + local_ids = set(local) + + added = sorted(tryton_ids - local_ids) + removed = sorted(local_ids - tryton_ids) + if not added and not removed: + return "" + + parts = [] + if added: + names = {} + for external_id in added: + category = ( + ProductCategory.objects.filter( + external_id=external_id + ) + .values_list("name", flat=True) + .first() + ) + names[external_id] = category or external_id + parts.append(", ".join(f"+{names[e]}" for e in added)) + if removed: + parts.append(", ".join(f"-{local[e]}" for e in removed)) + return f"Categoría: {', '.join(parts)}" + @staticmethod def _format_price(value): """Formatea un precio sin ceros decimales innecesarios""" @@ -269,6 +311,17 @@ class ProductTrytonService: unit = tryton_product.get("default_uom.") if not product.measuring_unit == unit.get("rec_name"): return True + tryton_category_ids = set( + str(category_id) + for category_id in self._extract_category_ids(tryton_product) + ) + local_category_ids = set( + product.categories.exclude( + external_id__isnull=True + ).values_list("external_id", flat=True) + ) + if tryton_category_ids != local_category_ids: + return True return False def _create_product(self, tryton_product): 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 4cb594a..70db33a 100644 --- a/tienda_ilusion/don_confiao/tests/test_products_from_tryton.py +++ b/tienda_ilusion/don_confiao/tests/test_products_from_tryton.py @@ -661,7 +661,7 @@ class TestProductsFromTryton(TestCase, LoginMixin): @patch("sabatron_tryton_rpc_client.client.Client.call") @patch("sabatron_tryton_rpc_client.client.Client.connect") - def test_import_assigns_categories_to_untouched_products( + def test_import_assigns_categories_marks_product_as_updated( self, mock_connect, mock_call ): mock_connect.return_value = None @@ -724,12 +724,20 @@ class TestProductsFromTryton(TestCase, LoginMixin): self.assertEqual(response.status_code, 200) content = json.loads(response.content.decode("utf-8")) + self.assertEqual(content["untouched_products"], []) self.assertEqual( - content["untouched_products"], [{"id": 2, "name": "Papa"}] + content["updated_products"], + [ + { + "id": 2, + "name": "Papa", + "detail": "Categoría: +Tubérculos", + } + ], ) - untouched_product = Product.objects.get(id=2) - categories = untouched_product.categories.all() + updated_product = Product.objects.get(id=2) + categories = updated_product.categories.all() self.assertEqual(len(categories), 1) self.assertEqual(categories[0].name, "Tubérculos") self.assertEqual(categories[0].external_id, "9") @@ -744,3 +752,104 @@ class TestProductsFromTryton(TestCase, LoginMixin): } ], ) + + @patch("sabatron_tryton_rpc_client.client.Client.call") + @patch("sabatron_tryton_rpc_client.client.Client.connect") + def test_category_change_marks_product_as_updated( + self, mock_connect, mock_call + ): + mock_connect.return_value = None + local_abarrotes = ProductCategory.objects.create( + name="Abarrotes", external_id="5" + ) + self.product2.categories.add(local_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 [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": [8]}, + }, + ] + + category_read = "model.product.category.read" + category_args = ( + [8], + ["id", "name"], + {"company": 1}, + ) + if args == (category_read, category_args): + return [ + {"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["untouched_products"], []) + self.assertEqual(content["failed_products"], []) + self.assertEqual( + content["updated_products"], + [ + { + "id": self.product2.pk, + "name": "Papa", + "detail": "Categoría: +Bebidas, -Abarrotes", + } + ], + ) + self.assertEqual( + content["created_categories"], + [ + { + "id": ProductCategory.objects.get( + external_id="8" + ).pk, + "name": "Bebidas", + "external_id": "8", + } + ], + ) + + refreshed_product = Product.objects.get(pk=self.product2.pk) + categories = list(refreshed_product.categories.all()) + self.assertEqual( + [(c.external_id, c.name) for c in categories], + [("8", "Bebidas")], + )