#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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user