#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:
@@ -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),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
from ...models.products import Product
|
from ...models.products import Product, ProductCategory
|
||||||
|
|
||||||
|
|
||||||
class ProductTrytonService:
|
class ProductTrytonService:
|
||||||
@@ -20,7 +20,20 @@ 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)
|
||||||
|
|||||||
@@ -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",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user