#51 feat: servicio de importacion externa de categorias de productos

Issue #51: discriminacion y administracion de categorias de productos.

- CategoryTrytonService.import_from_external: lee categorias asociadas a
  productos salables, hace upsert por external_id (fallback nombre),
  preserva description/alternative_name/active, reporta
  checked/created/updated/untouched/failed
- upsert_category compartido y reutilizado por ProductTrytonService
This commit is contained in:
2026-10-10 16:38:47 -05:00
parent 73fff016a3
commit 74cd9738b4
3 changed files with 351 additions and 29 deletions

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"])