#58 feat: importar y vincular categorías de Tryton al sincronizar productos

- 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
This commit is contained in:
2026-08-22 16:40:40 -05:00
parent 0fd79a2ba7
commit 53cc333c19
4 changed files with 583 additions and 10 deletions

View File

@@ -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),
),
]

View File

@@ -8,6 +8,7 @@ class MeasuringUnits(models.TextChoices):
class ProductCategory(models.Model): class ProductCategory(models.Model):
name = models.CharField(max_length=100, unique=True) name = models.CharField(max_length=100, unique=True)
external_id = models.CharField(max_length=100, null=True, blank=True)
def __str__(self): def __str__(self):
return self.name return self.name

View File

@@ -1,4 +1,4 @@
from ...models.products import Product from ...models.products import Product, ProductCategory
class ProductTrytonService: class ProductTrytonService:
@@ -21,6 +21,19 @@ class ProductTrytonService:
product_ids = self.client.call(method, params) product_ids = self.client.call(method, params)
tryton_products = self._get_product_details(product_ids, context) 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 = [ checked_tryton_products = [
{"id": p.get("id"), "name": p.get("name")} {"id": p.get("id"), "name": p.get("name")}
for p in tryton_products for p in tryton_products
@@ -32,10 +45,15 @@ class ProductTrytonService:
for tryton_product in tryton_products: for tryton_product in tryton_products:
try: try:
product = Product.objects.get(external_id=tryton_product.get("id")) product = Product.objects.get(
external_id=tryton_product.get("id")
)
except Product.DoesNotExist: except Product.DoesNotExist:
try: try:
product = self._create_product(tryton_product) product = self._create_product(tryton_product)
self._sync_product_categories(
product, tryton_product, categories_by_tryton_id
)
created_products.append(self._build_item(product)) created_products.append(self._build_item(product))
continue continue
except Exception as e: except Exception as e:
@@ -49,12 +67,20 @@ class ProductTrytonService:
try: try:
if self._need_update(product, tryton_product): 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._update_product(product, tryton_product)
self._sync_product_categories(
product, tryton_product, categories_by_tryton_id
)
updated_products.append( updated_products.append(
{**self._build_item(product), "detail": changes} {**self._build_item(product), "detail": changes}
) )
else: else:
self._sync_product_categories(
product, tryton_product, categories_by_tryton_id
)
untouched_products.append(self._build_item(product)) untouched_products.append(self._build_item(product))
except Exception as e: except Exception as e:
print( print(
@@ -70,8 +96,101 @@ class ProductTrytonService:
"updated_products": updated_products, "updated_products": updated_products,
"created_products": created_products, "created_products": created_products,
"untouched_products": untouched_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): def _build_item(self, product):
"""Construye el objeto de un producto sincronizado""" """Construye el objeto de un producto sincronizado"""
return {"id": product.id, "name": product.name} return {"id": product.id, "name": product.name}
@@ -114,15 +233,16 @@ class ProductTrytonService:
def _friendly_error(self, tryton_product, message): def _friendly_error(self, tryton_product, message):
"""Traduce causas conocidas de error a mensajes legibles""" """Traduce causas conocidas de error a mensajes legibles"""
if "price" in message and ( 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 ( if "name" in message and (
"duplicate key" in message or "UNIQUE constraint" in message "duplicate key" in message or "UNIQUE constraint" in message
): ):
return ( return f"Ya existe un producto con el nombre '{tryton_product.get('name')}'"
f"Ya existe un producto con el nombre '{tryton_product.get('name')}'"
)
return message return message
def _get_product_details(self, product_ids, context): def _get_product_details(self, product_ids, context):
@@ -133,6 +253,7 @@ class ProductTrytonService:
"default_uom.id", "default_uom.id",
"default_uom.rec_name", "default_uom.rec_name",
"list_price", "list_price",
"template.categories",
] ]
method = "model.product.product.read" method = "model.product.product.read"
params = (product_ids, tryton_fields, context) params = (product_ids, tryton_fields, context)

View File

@@ -3,7 +3,7 @@ from decimal import Decimal
from unittest.mock import patch from unittest.mock import patch
from django.test import TestCase from django.test import TestCase
from ..models.products import Product from ..models.products import Product, ProductCategory
from .Mixins import LoginMixin from .Mixins import LoginMixin
@@ -55,6 +55,7 @@ class TestProductsFromTryton(TestCase, LoginMixin):
"default_uom.id", "default_uom.id",
"default_uom.rec_name", "default_uom.rec_name",
"list_price", "list_price",
"template.categories",
], ],
{"company": 1}, {"company": 1},
) )
@@ -65,18 +66,21 @@ class TestProductsFromTryton(TestCase, LoginMixin):
"list_price": Decimal("25000"), "list_price": Decimal("25000"),
"name": "Producto 1", "name": "Producto 1",
"default_uom.": {"id": 1, "rec_name": "Unit"}, "default_uom.": {"id": 1, "rec_name": "Unit"},
"template.": {"id": 999, "categories": []},
}, },
{ {
"id": 191, "id": 191,
"list_price": Decimal("6000"), "list_price": Decimal("6000"),
"name": "Panela2", "name": "Panela2",
"default_uom.": {"id": 1, "rec_name": "Unit"}, "default_uom.": {"id": 1, "rec_name": "Unit"},
"template.": {"id": 999, "categories": []},
}, },
{ {
"id": 192, "id": 192,
"list_price": Decimal("4500"), "list_price": Decimal("4500"),
"name": "Papa", "name": "Papa",
"default_uom.": {"id": 2, "rec_name": "Kilogram"}, "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", "detail": "Nombre: Panela → Panela2, Precio: 5000 → 6000, Unidad: UNIT → Unit",
} }
], ],
"created_categories": [],
"updated_categories": [],
} }
self.assertEqual(content, expected_response) self.assertEqual(content, expected_response)
@@ -150,6 +156,7 @@ class TestProductsFromTryton(TestCase, LoginMixin):
"default_uom.id", "default_uom.id",
"default_uom.rec_name", "default_uom.rec_name",
"list_price", "list_price",
"template.categories",
], ],
{"company": 1}, {"company": 1},
) )
@@ -160,6 +167,7 @@ class TestProductsFromTryton(TestCase, LoginMixin):
"list_price": Decimal("25000"), "list_price": Decimal("25000"),
"name": self.product.name, "name": self.product.name,
"default_uom.": {"id": 1, "rec_name": "Unit"}, "default_uom.": {"id": 1, "rec_name": "Unit"},
"template.": {"id": 999, "categories": []},
}, },
] ]
@@ -186,6 +194,8 @@ class TestProductsFromTryton(TestCase, LoginMixin):
} }
], ],
"updated_products": [], "updated_products": [],
"created_categories": [],
"updated_categories": [],
} }
self.assertEqual(content, expected_response) self.assertEqual(content, expected_response)
@@ -215,6 +225,7 @@ class TestProductsFromTryton(TestCase, LoginMixin):
"default_uom.id", "default_uom.id",
"default_uom.rec_name", "default_uom.rec_name",
"list_price", "list_price",
"template.categories",
], ],
{"company": 1}, {"company": 1},
) )
@@ -225,6 +236,7 @@ class TestProductsFromTryton(TestCase, LoginMixin):
"list_price": None, "list_price": None,
"name": "ENVASES Y EMPAQUES", "name": "ENVASES Y EMPAQUES",
"default_uom.": {"id": 1, "rec_name": "Unit"}, "default_uom.": {"id": 1, "rec_name": "Unit"},
"template.": {"id": 999, "categories": []},
}, },
] ]
@@ -275,6 +287,7 @@ class TestProductsFromTryton(TestCase, LoginMixin):
"default_uom.id", "default_uom.id",
"default_uom.rec_name", "default_uom.rec_name",
"list_price", "list_price",
"template.categories",
], ],
{"company": 1}, {"company": 1},
) )
@@ -285,12 +298,14 @@ class TestProductsFromTryton(TestCase, LoginMixin):
"list_price": Decimal("6000"), "list_price": Decimal("6000"),
"name": "Panela", "name": "Panela",
"default_uom.": None, "default_uom.": None,
"template.": {"id": 999, "categories": []},
}, },
{ {
"id": 192, "id": 192,
"list_price": Decimal("4500"), "list_price": Decimal("4500"),
"name": "Papa", "name": "Papa",
"default_uom.": {"id": 2, "rec_name": "Kilogram"}, "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.assertEqual(failure["name"], "Panela")
self.assertIn("Error al actualizar producto", failure["error"]) self.assertIn("Error al actualizar producto", failure["error"])
self.assertEqual(content["untouched_products"], [{"id": 2, "name": "Papa"}]) 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",
}
],
)