categorias-productos #51 #52

Open
mono wants to merge 13 commits from feat/51-categorias-productos into main
3 changed files with 351 additions and 29 deletions
Showing only changes of commit 74cd9738b4 - Show all commits

View File

@@ -0,0 +1,147 @@
from ...models.products import ProductCategory
def upsert_category(category_data):
"""Crea o actualiza una ProductCategory desde datos de Tryton.
Solo administra name y external_id (fuente Tryton). Preserva los
campos administrados por Don Confiao (description, alternative_name
y active). Devuelve (category, status, changes) donde status es
'created', 'updated' o 'untouched' y changes es la lista de cambios.
"""
external_id = str(category_data.get("id"))
name = category_data.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()
changes = []
if category.name != name:
changes.append(f"Nombre: {category.name} → {name}")
if category.external_id != external_id:
changes.append(
f"External ID: {category.external_id} → {external_id}"
)
category.name = name
category.external_id = external_id
category.save()
status = "created" if is_new else ("updated" if changes else "untouched")
return category, status, changes
class CategoryTrytonService:
"""Servicio para sincronización de categorías con Tryton"""
def __init__(self, tryton_client):
self.client = tryton_client
def import_from_external(self):
"""Importa desde Tryton las categorías asociadas a productos.
Lee las categorías referenciadas por los productos salables
(sin suponer eliminación de categorías locales). Reporta las
categorías revisadas, creadas, actualizadas, intactas y fallidas.
"""
context = {"company": 1}
product_ids = self._search_salable_products(context)
if not product_ids:
return self._empty_report()
tryton_products = self._get_product_details(product_ids, context)
category_ids = set()
for tryton_product in tryton_products:
category_ids.update(self._extract_category_ids(tryton_product))
if not category_ids:
return self._empty_report()
tryton_categories = self._get_category_details(
sorted(category_ids), context
)
checked = [
{"id": c.get("id"), "name": c.get("name")}
for c in tryton_categories
]
created_categories = []
updated_categories = []
untouched_categories = []
failed_categories = []
for tryton_category in tryton_categories:
try:
category, status, changes = upsert_category(tryton_category)
item = {
"id": category.id,
"name": category.name,
"external_id": category.external_id,
}
if status == "created":
created_categories.append(item)
elif status == "updated":
updated_categories.append(
{**item, "detail": ", ".join(changes)}
)
else:
untouched_categories.append(item)
except Exception as e:
print(
f"Error al importar categoría: {e}"
f"La categoría: {tryton_category}"
)
failed_categories.append(
{
"external_id": tryton_category.get("id"),
"name": tryton_category.get("name"),
"error": f"Error al importar la categoría: {e}",
}
)
return {
"checked_tryton_categories": checked,
"created_categories": created_categories,
"updated_categories": updated_categories,
"untouched_categories": untouched_categories,
"failed_categories": failed_categories,
}
def _search_salable_products(self, context):
method = "model.product.product.search"
params = [
[["salable", "=", True]],
0,
1000,
[["rec_name", "ASC"], ["id", None]],
context,
]
return self.client.call(method, params) or []
def _get_product_details(self, product_ids, context):
tryton_fields = ["id", "name", "template.categories"]
method = "model.product.product.read"
params = (product_ids, tryton_fields, context)
return self.client.call(method, params)
@staticmethod
def _extract_category_ids(tryton_product):
"""Extrae los IDs de categorías de la respuesta RPC de Tryton"""
template = tryton_product.get("template.") or {}
return template.get("categories") or []
def _get_category_details(self, category_ids, context):
method = "model.product.category.read"
params = (category_ids, ["id", "name"], context)
return self.client.call(method, params)
def _empty_report(self):
return {
"checked_tryton_categories": [],
"created_categories": [],
"updated_categories": [],
"untouched_categories": [],
"failed_categories": [],
}

View File

@@ -1,4 +1,5 @@
from ...models.products import Product, ProductCategory
from .categories import upsert_category
class ProductTrytonService:
@@ -121,42 +122,16 @@ class ProductTrytonService:
)
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()
category, status, changes = upsert_category(tryton_category)
categories[tryton_category.get("id")] = category
item = {
"id": category.id,
"name": category.name,
"external_id": category.external_id,
}
if is_new:
if status == "created":
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:
elif status == "updated":
report["updated"].append(
{**item, "detail": ", ".join(changes)}
)

View File

@@ -0,0 +1,200 @@
from unittest.mock import patch
from django.test import TestCase
from ..models.products import ProductCategory
from ..services.tryton.categories import CategoryTrytonService
class TestCategoryTrytonImport(TestCase):
def _service(self, mock_call):
fake_client = type("FakeClient", (), {"call": mock_call})()
return CategoryTrytonService(fake_client)
def _empty_report(self):
return {
"checked_tryton_categories": [],
"created_categories": [],
"updated_categories": [],
"untouched_categories": [],
"failed_categories": [],
}
@patch("sabatron_tryton_rpc_client.client.Client.call")
def test_import_creates_only_referenced_categories(self, mock_call):
def fake_call(*args, **kwargs):
if args[0] == "model.product.product.search":
return [1, 2]
if args[0] == "model.product.product.read":
return [
{
"id": 1,
"name": "Producto 1",
"template.": {"id": 999, "categories": [6]},
},
{
"id": 2,
"name": "Producto 2",
"template.": {"id": 999, "categories": [7]},
},
]
if args[0] == "model.product.category.read":
return [
{"id": 6, "name": "Alimentos"},
{"id": 7, "name": "Verduras"},
]
raise Exception(f"args no esperados: {args}")
mock_call.side_effect = fake_call
result = self._service(mock_call).import_from_external()
self.assertEqual(
result["checked_tryton_categories"],
[
{"id": 6, "name": "Alimentos"},
{"id": 7, "name": "Verduras"},
],
)
self.assertEqual(
result["created_categories"],
[
{
"id": ProductCategory.objects.get(external_id="6").pk,
"name": "Alimentos",
"external_id": "6",
},
{
"id": ProductCategory.objects.get(external_id="7").pk,
"name": "Verduras",
"external_id": "7",
},
],
)
self.assertEqual(result["updated_categories"], [])
self.assertEqual(result["untouched_categories"], [])
self.assertEqual(result["failed_categories"], [])
self.assertEqual(ProductCategory.objects.count(), 2)
@patch("sabatron_tryton_rpc_client.client.Client.call")
def test_import_preserves_don_confiao_fields_on_update(self, mock_call):
local_category = ProductCategory.objects.create(
name="Granos",
external_id="7",
description="Granos enteros",
alternative_name="Cereales",
active=False,
)
def fake_call(*args, **kwargs):
if args[0] == "model.product.product.search":
return [1]
if args[0] == "model.product.product.read":
return [
{
"id": 1,
"name": "Producto 1",
"template.": {"id": 999, "categories": [7]},
},
]
if args[0] == "model.product.category.read":
return [{"id": 7, "name": "Cereales y Granos"}]
raise Exception(f"args no esperados: {args}")
mock_call.side_effect = fake_call
result = self._service(mock_call).import_from_external()
self.assertEqual(result["created_categories"], [])
self.assertEqual(
result["updated_categories"],
[
{
"id": local_category.pk,
"name": "Cereales y Granos",
"external_id": "7",
"detail": "Nombre: Granos → Cereales y Granos",
}
],
)
refreshed = ProductCategory.objects.get(pk=local_category.pk)
self.assertEqual(refreshed.name, "Cereales y Granos")
self.assertEqual(refreshed.description, "Granos enteros")
self.assertEqual(refreshed.alternative_name, "Cereales")
self.assertFalse(refreshed.active)
@patch("sabatron_tryton_rpc_client.client.Client.call")
def test_import_counts_untouched_categories(self, mock_call):
local_category = ProductCategory.objects.create(
name="Alimentos", external_id="6"
)
def fake_call(*args, **kwargs):
if args[0] == "model.product.product.search":
return [1]
if args[0] == "model.product.product.read":
return [
{
"id": 1,
"name": "Producto 1",
"template.": {"id": 999, "categories": [6]},
},
]
if args[0] == "model.product.category.read":
return [{"id": 6, "name": "Alimentos"}]
raise Exception(f"args no esperados: {args}")
mock_call.side_effect = fake_call
result = self._service(mock_call).import_from_external()
self.assertEqual(result["created_categories"], [])
self.assertEqual(result["updated_categories"], [])
self.assertEqual(
result["untouched_categories"],
[
{
"id": local_category.pk,
"name": "Alimentos",
"external_id": "6",
}
],
)
self.assertEqual(result["failed_categories"], [])
self.assertEqual(ProductCategory.objects.count(), 1)
@patch("sabatron_tryton_rpc_client.client.Client.call")
def test_import_without_products_returns_empty_report(self, mock_call):
def fake_call(*args, **kwargs):
if args[0] == "model.product.product.search":
return []
raise Exception(f"args no esperados: {args}")
mock_call.side_effect = fake_call
result = self._service(mock_call).import_from_external()
self.assertEqual(result, self._empty_report())
self.assertEqual(ProductCategory.objects.count(), 0)
@patch("sabatron_tryton_rpc_client.client.Client.call")
def test_import_failed_category_reported(self, mock_call):
def fake_call(*args, **kwargs):
if args[0] == "model.product.product.search":
return [1]
if args[0] == "model.product.product.read":
return [
{
"id": 1,
"name": "Producto 1",
"template.": {"id": 999, "categories": [6]},
},
]
if args[0] == "model.product.category.read":
return [{"id": 6, "name": None}]
raise Exception(f"args no esperados: {args}")
mock_call.side_effect = fake_call
result = self._service(mock_call).import_from_external()
self.assertEqual(result["created_categories"], [])
self.assertEqual(len(result["failed_categories"]), 1)
failure = result["failed_categories"][0]
self.assertEqual(failure["external_id"], 6)
self.assertEqual(failure["name"], None)
self.assertIn("Error", failure["error"])