diff --git a/tienda_ilusion/don_confiao/admin.py b/tienda_ilusion/don_confiao/admin.py index 1fb3fe4..6ca0f2a 100644 --- a/tienda_ilusion/don_confiao/admin.py +++ b/tienda_ilusion/don_confiao/admin.py @@ -21,6 +21,7 @@ class StoreSettingsAdmin(admin.ModelAdmin): @admin.register(Customer) class CustomerAdmin(admin.ModelAdmin): list_display = ( + "id", "name", "email", "phone", @@ -72,7 +73,7 @@ class CatalogSaleLineAdmin(admin.ModelAdmin): @admin.register(Product) class ProductAdmin(admin.ModelAdmin): - list_display = ("name", "price", "measuring_unit", "external_id") + list_display = ("id", "name", "price", "measuring_unit", "external_id") search_fields = ("id", "name",) list_filter = ("name", "id") diff --git a/tienda_ilusion/don_confiao/services/tryton/customers.py b/tienda_ilusion/don_confiao/services/tryton/customers.py index ffdf677..21eb3fa 100644 --- a/tienda_ilusion/don_confiao/services/tryton/customers.py +++ b/tienda_ilusion/don_confiao/services/tryton/customers.py @@ -15,7 +15,10 @@ class CustomerTrytonService: party_ids = self.client.call(method, params) tryton_parties = self._get_party_details(party_ids, context) - checked_tryton_parties = party_ids + checked_tryton_parties = [ + {"id": p.get("id"), "name": p.get("name")} + for p in tryton_parties + ] failed_parties = [] updated_customers = [] created_customers = [] @@ -25,15 +28,35 @@ class CustomerTrytonService: try: customer = Customer.objects.get(external_id=tryton_party.get("id")) except Customer.DoesNotExist: - customer = self._create_customer(tryton_party) - created_customers.append(customer.id) - continue - - if self._need_update(customer, tryton_party): - self._update_customer(customer, tryton_party) - updated_customers.append(customer.id) - else: - untouched_customers.append(customer.id) + try: + customer = self._create_customer(tryton_party) + created_customers.append(self._build_item(customer)) + continue + except Exception as e: + print( + f"Error al importar clientes: {e}El cliente: {tryton_party}" + ) + failed_parties.append( + self._build_failure(tryton_party, "crear", e) + ) + continue + + try: + if self._need_update(customer, tryton_party): + changes = self._describe_changes(customer, tryton_party) + self._update_customer(customer, tryton_party) + updated_customers.append( + {**self._build_item(customer), "detail": changes} + ) + else: + untouched_customers.append(self._build_item(customer)) + except Exception as e: + print( + f"Error al importar clientes: {e}El cliente: {tryton_party}" + ) + failed_parties.append( + self._build_failure(tryton_party, "actualizar", e) + ) return { "checked_tryton_parties": checked_tryton_parties, @@ -43,6 +66,41 @@ class CustomerTrytonService: "untouched_customers": untouched_customers, } + def _build_item(self, customer): + """Construye el objeto de un cliente sincronizado""" + return {"id": customer.id, "name": customer.name} + + def _describe_changes(self, customer, tryton_party): + """Describe los cambios detectados entre el cliente local y Tryton""" + changes = [] + name = tryton_party.get("name") + if customer.name != name: + changes.append(f"Nombre: {customer.name} → {name}") + if tryton_party.get("addresses") and tryton_party.get("addresses")[0]: + new_address = str(tryton_party.get("addresses")[0]) + if customer.address_external_id != new_address: + changes.append( + f"Dirección: {customer.address_external_id} → {new_address}" + ) + return ", ".join(changes) + + def _build_failure(self, tryton_party, operation, exc): + """Construye el detalle de un cliente que falló al sincronizar""" + error = self._friendly_error(tryton_party, str(exc)) + return { + "external_id": tryton_party.get("id"), + "name": tryton_party.get("name"), + "error": f"Error al {operation} cliente: {error}", + } + + def _friendly_error(self, tryton_party, message): + """Traduce causas conocidas de error a mensajes legibles""" + if "name" in message and ( + "not-null constraint" in message or "NOT NULL constraint" in message + ): + return "El cliente no tiene nombre en Tryton" + return message + def _get_party_details(self, party_ids, context): """Obtiene detalles de clientes desde Tryton""" tryton_fields = ["id", "name", "addresses"] diff --git a/tienda_ilusion/don_confiao/services/tryton/products.py b/tienda_ilusion/don_confiao/services/tryton/products.py index 1510bba..e9c9ea4 100644 --- a/tienda_ilusion/don_confiao/services/tryton/products.py +++ b/tienda_ilusion/don_confiao/services/tryton/products.py @@ -21,7 +21,10 @@ class ProductTrytonService: product_ids = self.client.call(method, params) tryton_products = self._get_product_details(product_ids, context) - checked_tryton_products = product_ids + checked_tryton_products = [ + {"id": p.get("id"), "name": p.get("name")} + for p in tryton_products + ] failed_products = [] updated_products = [] created_products = [] @@ -33,20 +36,33 @@ class ProductTrytonService: except Product.DoesNotExist: try: product = self._create_product(tryton_product) - created_products.append(product.id) + created_products.append(self._build_item(product)) continue except Exception as e: print( f"Error al importar productos: {e}El producto: {tryton_product}" ) - failed_products.append(tryton_product.get("id")) + failed_products.append( + self._build_failure(tryton_product, "crear", e) + ) continue - if self._need_update(product, tryton_product): - self._update_product(product, tryton_product) - updated_products.append(product.id) - else: - untouched_products.append(product.id) + try: + if self._need_update(product, tryton_product): + changes = self._describe_changes(product, tryton_product) + self._update_product(product, tryton_product) + updated_products.append( + {**self._build_item(product), "detail": changes} + ) + else: + untouched_products.append(self._build_item(product)) + except Exception as e: + print( + f"Error al importar productos: {e}El producto: {tryton_product}" + ) + failed_products.append( + self._build_failure(tryton_product, "actualizar", e) + ) return { "checked_tryton_products": checked_tryton_products, @@ -56,6 +72,59 @@ class ProductTrytonService: "untouched_products": untouched_products, } + def _build_item(self, product): + """Construye el objeto de un producto sincronizado""" + return {"id": product.id, "name": product.name} + + def _describe_changes(self, product, tryton_product): + """Describe los cambios detectados entre el producto local y Tryton""" + changes = [] + name = tryton_product.get("name") + if product.name != name: + changes.append(f"Nombre: {product.name} → {name}") + price = tryton_product.get("list_price") + if product.price != price: + changes.append( + f"Precio: {self._format_price(product.price)} → " + f"{self._format_price(price)}" + ) + unit = tryton_product.get("default_uom.") + if unit: + unit_name = unit.get("rec_name") + if product.measuring_unit != unit_name: + changes.append( + f"Unidad: {product.measuring_unit} → {unit_name}" + ) + return ", ".join(changes) + + @staticmethod + def _format_price(value): + """Formatea un precio sin ceros decimales innecesarios""" + return f"{value:.2f}".rstrip("0").rstrip(".") + + def _build_failure(self, tryton_product, operation, exc): + """Construye el detalle de un producto que falló al sincronizar""" + error = self._friendly_error(tryton_product, str(exc)) + return { + "external_id": tryton_product.get("id"), + "name": tryton_product.get("name"), + "error": f"Error al {operation} producto: {error}", + } + + 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 + ): + 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 message + def _get_product_details(self, product_ids, context): """Obtiene detalles de productos desde Tryton""" tryton_fields = [ diff --git a/tienda_ilusion/don_confiao/services/tryton/sales.py b/tienda_ilusion/don_confiao/services/tryton/sales.py index 4a71dd9..d895333 100644 --- a/tienda_ilusion/don_confiao/services/tryton/sales.py +++ b/tienda_ilusion/don_confiao/services/tryton/sales.py @@ -27,10 +27,10 @@ class SaleTrytonService: external_ids = self.client.call(method, tryton_params) sale.external_id = external_ids[0] sale.save() - successful.append(sale.id) + successful.append(self._build_item(sale)) except Exception as e: print(f"Error al enviar la venta: {e}venta_id: {sale.id}") - failed.append(sale.id) + failed.append(self._build_failure(sale, e)) continue return {"successful": successful, "failed": failed} @@ -61,16 +61,28 @@ class SaleTrytonService: external_ids = self.client.call(method, tryton_params) catalog_sale.external_id = external_ids[0] catalog_sale.save() - successful.append(catalog_sale.id) + successful.append(self._build_item(catalog_sale)) except Exception as e: print( f"Error al enviar catalog sale: {e}, catalog_sale_id: {catalog_sale.id}" ) - failed.append(catalog_sale.id) + failed.append(self._build_failure(catalog_sale, e)) continue return {"successful": successful, "failed": failed} + def _build_item(self, sale): + """Construye el objeto de una venta sincronizada""" + return {"id": sale.id, "code": sale.code} + + def _build_failure(self, sale, exc): + """Construye el detalle de una venta que falló al sincronizar""" + return { + "id": sale.id, + "code": sale.code, + "error": f"Error al enviar la venta: {exc}", + } + def _catalog_sale_to_tryton_params(self, catalog_sale, lines, tryton_context): """Convierte catalog sale a parámetros para Tryton""" sale_tryton = TrytonCatalogSale(catalog_sale, lines) diff --git a/tienda_ilusion/don_confiao/tests/test_customers_from_tryton.py b/tienda_ilusion/don_confiao/tests/test_customers_from_tryton.py index aec294f..3e1f24c 100644 --- a/tienda_ilusion/don_confiao/tests/test_customers_from_tryton.py +++ b/tienda_ilusion/don_confiao/tests/test_customers_from_tryton.py @@ -62,11 +62,25 @@ class TestCustomersFromTryton(TestCase, LoginMixin): content = json.loads(response.content.decode("utf-8")) expected_response = { - "checked_tryton_parties": [5, 6, 7, 8], - "created_customers": [3, 4], - "untouched_customers": [2], + "checked_tryton_parties": [ + {"id": 5, "name": "Carlos"}, + {"id": 6, "name": "Cristian"}, + {"id": 7, "name": "Ana"}, + {"id": 8, "name": "José"}, + ], + "created_customers": [ + {"id": 3, "name": "Ana"}, + {"id": 4, "name": "José"}, + ], + "untouched_customers": [{"id": 2, "name": "Cristian"}], "failed_parties": [], - "updated_customers": [1], + "updated_customers": [ + { + "id": 1, + "name": "Carlos", + "detail": "Nombre: Calos → Carlos, Dirección: None → 303", + } + ], } self.assertEqual(content, expected_response) @@ -79,3 +93,115 @@ class TestCustomersFromTryton(TestCase, LoginMixin): self.assertEqual(updated_customer.external_id, str(5)) self.assertEqual(updated_customer.name, "Carlos") self.assertIn(updated_customer.address_external_id, str(303)) + + @patch("sabatron_tryton_rpc_client.client.Client.call") + @patch("sabatron_tryton_rpc_client.client.Client.connect") + def test_create_failure_customer(self, mock_connect, mock_call): + def fake_call(*args, **kwargs): + party_search = "model.party.party.search" + search_args = [ + [], + 0, + 1000, + [["name", "ASC"], ["id", None]], + {"company": 1}, + ] + + if args == (party_search, search_args): + return [5, 9] + + party_read = "model.party.party.read" + read_args = ( + [5, 9], + ["id", "name", "addresses"], + {"company": 1}, + ) + if args == (party_read, read_args): + return [ + {"id": 5, "name": "Carlos", "addresses": [303]}, + {"id": 9, "name": None, "addresses": []}, + ] + + raise Exception( + f"Sorry, args non expected on this test: {args}" + ) + + mock_call.side_effect = fake_call + + url = "/don_confiao/api/importar_clientes_de_tryton" + response = self.client.post(url) + self.assertEqual(response.status_code, 200) + + content = json.loads(response.content.decode("utf-8")) + self.assertEqual(len(content["failed_parties"]), 1) + failure = content["failed_parties"][0] + self.assertEqual(failure["external_id"], 9) + self.assertEqual(failure["name"], None) + self.assertEqual( + failure["error"], + "Error al crear cliente: El cliente no tiene nombre en Tryton", + ) + self.assertEqual( + content["updated_customers"], + [ + { + "id": 1, + "name": "Carlos", + "detail": "Nombre: Calos → Carlos, Dirección: None → 303", + } + ], + ) + + @patch("sabatron_tryton_rpc_client.client.Client.call") + @patch("sabatron_tryton_rpc_client.client.Client.connect") + def test_update_failure_customer(self, mock_connect, mock_call): + def fake_call(*args, **kwargs): + party_search = "model.party.party.search" + search_args = [ + [], + 0, + 1000, + [["name", "ASC"], ["id", None]], + {"company": 1}, + ] + + if args == (party_search, search_args): + return [5, 6] + + party_read = "model.party.party.read" + read_args = ( + [5, 6], + ["id", "name", "addresses"], + {"company": 1}, + ) + if args == (party_read, read_args): + return [ + {"id": 5, "name": "Carlos", "addresses": [303]}, + {"id": 6, "name": None, "addresses": []}, + ] + + raise Exception( + f"Sorry, args non expected on this test: {args}" + ) + + mock_call.side_effect = fake_call + + url = "/don_confiao/api/importar_clientes_de_tryton" + response = self.client.post(url) + self.assertEqual(response.status_code, 200) + + content = json.loads(response.content.decode("utf-8")) + self.assertEqual(len(content["failed_parties"]), 1) + failure = content["failed_parties"][0] + self.assertEqual(failure["external_id"], 6) + self.assertIn("Error al actualizar cliente", failure["error"]) + self.assertEqual( + content["updated_customers"], + [ + { + "id": 1, + "name": "Carlos", + "detail": "Nombre: Calos → Carlos, Dirección: None → 303", + } + ], + ) diff --git a/tienda_ilusion/don_confiao/tests/test_exportar_ventas_para_tryton.py b/tienda_ilusion/don_confiao/tests/test_exportar_ventas_para_tryton.py index da6657f..1213515 100644 --- a/tienda_ilusion/don_confiao/tests/test_exportar_ventas_para_tryton.py +++ b/tienda_ilusion/don_confiao/tests/test_exportar_ventas_para_tryton.py @@ -79,7 +79,13 @@ class TestSendSalesToTryton(TestCase, LoginMixin): self.assertEqual(response.status_code, 200) content = json.loads(response.content.decode("utf-8")) self.assertEqual( - content, {"successful": [self.sale.id], "failed": []} + content, + { + "successful": [ + {"id": self.sale.id, "code": self.sale.code} + ], + "failed": [], + }, ) updated_sale = Sale.objects.get(id=self.sale.id) @@ -165,7 +171,15 @@ class TestSendSalesToTryton(TestCase, LoginMixin): content = json.loads(response.content.decode("utf-8")) self.assertEqual( content, - {"successful": [self.catalog_sale.id], "failed": []}, + { + "successful": [ + { + "id": self.catalog_sale.id, + "code": self.catalog_sale.code, + } + ], + "failed": [], + }, ) updated = CatalogSale.objects.get(id=self.catalog_sale.id) @@ -247,3 +261,51 @@ class TestSendSalesToTryton(TestCase, LoginMixin): lines[1]["unit_price"], {"__class__": "Decimal", "decimal": "5000.00"}, ) + + @patch("don_confiao.api.sales.get_tryton_client") + def test_send_sales_to_tryton_failure(self, mock_get_tryton_client): + mock_client = MagicMock() + mock_client.call.side_effect = Exception("error de tryton") + mock_get_tryton_client.return_value = mock_client + + url = "/don_confiao/api/enviar_ventas_a_tryton" + response = self.client.post(url) + + self.assertEqual(response.status_code, 200) + content = json.loads(response.content.decode("utf-8")) + self.assertEqual(content["successful"], []) + self.assertEqual(len(content["failed"]), 1) + failure = content["failed"][0] + self.assertEqual(failure["id"], self.sale.id) + self.assertEqual(failure["code"], self.sale.code) + self.assertEqual( + failure["error"], "Error al enviar la venta: error de tryton" + ) + + sale = Sale.objects.get(id=self.sale.id) + self.assertIsNone(sale.external_id) + + @patch("don_confiao.api.sales.get_tryton_client") + def test_send_catalog_sales_to_tryton_failure( + self, mock_get_tryton_client + ): + mock_client = MagicMock() + mock_client.call.side_effect = Exception("error de tryton") + mock_get_tryton_client.return_value = mock_client + + url = "/don_confiao/api/enviar_catalog_sales_a_tryton" + response = self.client.post(url) + + self.assertEqual(response.status_code, 200) + content = json.loads(response.content.decode("utf-8")) + self.assertEqual(content["successful"], []) + self.assertEqual(len(content["failed"]), 1) + failure = content["failed"][0] + self.assertEqual(failure["id"], self.catalog_sale.id) + self.assertEqual(failure["code"], self.catalog_sale.code) + self.assertEqual( + failure["error"], "Error al enviar la venta: error de tryton" + ) + + catalog_sale = CatalogSale.objects.get(id=self.catalog_sale.id) + self.assertIsNone(catalog_sale.external_id) 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 6f122c2..33300f3 100644 --- a/tienda_ilusion/don_confiao/tests/test_products_from_tryton.py +++ b/tienda_ilusion/don_confiao/tests/test_products_from_tryton.py @@ -92,11 +92,21 @@ class TestProductsFromTryton(TestCase, LoginMixin): content = json.loads(response.content.decode("utf-8")) expected_response = { - "checked_tryton_products": [190, 191, 192], - "created_products": [3], - "untouched_products": [2], + "checked_tryton_products": [ + {"id": 190, "name": "Producto 1"}, + {"id": 191, "name": "Panela2"}, + {"id": 192, "name": "Papa"}, + ], + "created_products": [{"id": 3, "name": "Producto 1"}], + "untouched_products": [{"id": 2, "name": "Papa"}], "failed_products": [], - "updated_products": [1], + "updated_products": [ + { + "id": 1, + "name": "Panela2", + "detail": "Nombre: Panela → Panela2, Precio: 5000 → 6000, Unidad: UNIT → Unit", + } + ], } self.assertEqual(content, expected_response) @@ -165,10 +175,139 @@ class TestProductsFromTryton(TestCase, LoginMixin): content = json.loads(response.content.decode("utf-8")) expected_response = { - "checked_tryton_products": [200], + "checked_tryton_products": [{"id": 200, "name": "Panela"}], "created_products": [], "untouched_products": [], - "failed_products": [200], + "failed_products": [ + { + "external_id": 200, + "name": "Panela", + "error": "Error al crear producto: Ya existe un producto con el nombre 'Panela'", + } + ], "updated_products": [], } self.assertEqual(content, expected_response) + + @patch("sabatron_tryton_rpc_client.client.Client.call") + @patch("sabatron_tryton_rpc_client.client.Client.connect") + def test_import_null_price_product(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 [201] + + product_read = "model.product.product.read" + product_args = ( + [201], + [ + "id", + "name", + "default_uom.id", + "default_uom.rec_name", + "list_price", + ], + {"company": 1}, + ) + if args == (product_read, product_args): + return [ + { + "id": 201, + "list_price": None, + "name": "ENVASES Y EMPAQUES", + "default_uom.": {"id": 1, "rec_name": "Unit"}, + }, + ] + + 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_products"], []) + self.assertEqual(len(content["failed_products"]), 1) + failure = content["failed_products"][0] + self.assertEqual(failure["external_id"], 201) + self.assertEqual(failure["name"], "ENVASES Y EMPAQUES") + self.assertEqual( + failure["error"], + "Error al crear producto: El producto no tiene precio en Tryton (list_price nulo)", + ) + + @patch("sabatron_tryton_rpc_client.client.Client.call") + @patch("sabatron_tryton_rpc_client.client.Client.connect") + def test_update_failure_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 [191, 192] + + product_read = "model.product.product.read" + product_args = ( + [191, 192], + [ + "id", + "name", + "default_uom.id", + "default_uom.rec_name", + "list_price", + ], + {"company": 1}, + ) + if args == (product_read, product_args): + return [ + { + "id": 191, + "list_price": Decimal("6000"), + "name": "Panela", + "default_uom.": None, + }, + { + "id": 192, + "list_price": Decimal("4500"), + "name": "Papa", + "default_uom.": {"id": 2, "rec_name": "Kilogram"}, + }, + ] + + 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(len(content["failed_products"]), 1) + failure = content["failed_products"][0] + self.assertEqual(failure["external_id"], 191) + self.assertEqual(failure["name"], "Panela") + self.assertIn("Error al actualizar producto", failure["error"]) + self.assertEqual(content["untouched_products"], [{"id": 2, "name": "Papa"}])