From 332647b89e53e68808f63c5dc787bbb8c1ffcd43 Mon Sep 17 00:00:00 2001 From: Florent Xicluna Date: Mon, 27 Jan 2025 23:44:59 +0100 Subject: [PATCH 1/4] [IMP] product_import: Reorganize for inheritance to import by batch --- product_import/tests/common.py | 4 +- product_import/tests/test_product_import.py | 13 ++++- product_import/wizard/product_import.py | 65 ++++++++++++++------- 3 files changed, 57 insertions(+), 25 deletions(-) diff --git a/product_import/tests/common.py b/product_import/tests/common.py index dac4cb1b48..404df803b8 100644 --- a/product_import/tests/common.py +++ b/product_import/tests/common.py @@ -14,8 +14,8 @@ def setUpClass(cls): cls.wiz_model = cls.env["product.import"] cls.supplier = cls.env["res.partner"].create({"name": "Catalogue Vendor"}) - def _mock(self, method_name): - return mock.patch.object(type(self.wiz_model), method_name) + def _mock(self, method_name, **kw): + return mock.patch.object(type(self.wiz_model), method_name, **kw) @property def wiz_form(self): diff --git a/product_import/tests/test_product_import.py b/product_import/tests/test_product_import.py index 953850796b..5cc227aa8f 100644 --- a/product_import/tests/test_product_import.py +++ b/product_import/tests/test_product_import.py @@ -50,6 +50,7 @@ }, ], "ref": "1387", + "company": {"name": "Customer ABC"}, "seller": { "contact": False, "email": False, @@ -88,9 +89,15 @@ def test_get_company_id(self): def test_product_import(self): # product.product - products = self.wiz_model._create_products( - self.parsed_catalog, seller=self.supplier + product_obj = self.env["product.product"].with_context(active_test=False) + existing = product_obj.search([], order="id") + + wiz = self.wiz_model.create( + {"product_file": b"====", "product_filename": "test_import.xml"} ) + with self._mock("parse_product_catalogue", return_value=self.parsed_catalog): + wiz.import_button() + products = product_obj.search([], order="id") - existing self.assertEqual(len(products), 3) for product, parsed in zip(products, PARSED_CATALOG["products"]): @@ -140,7 +147,7 @@ def test_product_import(self): self.assertEqual(product.seller_ids, product_tmpl.seller_ids) self.assertEqual( product.seller_ids.mapped("delay")[0], parsed.get("sale_delay", 0) - ), + ) def test_import_button(self): form = self.wiz_form diff --git a/product_import/wizard/product_import.py b/product_import/wizard/product_import.py index cbf1c13002..7102a185c2 100644 --- a/product_import/wizard/product_import.py +++ b/product_import/wizard/product_import.py @@ -150,7 +150,7 @@ def _prepare_supplierinfo(self, seller_info, product): return result @api.model - def _prepare_product(self, parsed_product, chatter_msg, seller=None): + def _prepare_product(self, parsed_product, seller, chatter_msg): # Important: barcode is unique key of product.template model # So records product.product are created with company_id=False. # Only the pricelist (product.supplierinfo) is company-specific. @@ -197,14 +197,12 @@ def _prepare_product(self, parsed_product, chatter_msg, seller=None): return product_vals @api.model - def create_product(self, parsed_product, chatter_msg, seller=None): - product_vals = self._prepare_product(parsed_product, chatter_msg, seller=seller) - if not product_vals: - return False + def _save_product(self, product_vals, chatter_msg): + """Create / Update a product.""" product = product_vals.pop("recordset", None) if product: product.write(product_vals) - logger.info("Product %d updated", product.id) + logger.debug("Product %s updated", product.default_code) else: product_active = product_vals.pop("active") product = self.env["product.product"].create(product_vals) @@ -213,33 +211,60 @@ def create_product(self, parsed_product, chatter_msg, seller=None): # all characteristics into product.template product.flush() product.action_archive() - logger.info("Product %d created", product.id) + logger.debug("Product %s created", product.default_code) + return product @api.model - def _create_products(self, catalogue, seller, filename=None): - products = self.env["product.product"].browse() - for product in catalogue.get("products"): - record = self.create_product( - product, - catalogue["chatter_msg"], + def _create_update_products(self, products, seller_id, chatter_msg): + """Create / Update all products.""" + seller = self.env["res.partner"].browse(seller_id) + + for parsed_product in products: + product_vals = self._prepare_product( + parsed_product, seller=seller, + chatter_msg=chatter_msg, ) - if record: - products |= record - self._bdimport.post_create_or_update(catalogue, seller, doc_filename=filename) - logger.info("Products updated for vendor %d", seller.id) - return products + if product_vals: + product = self._save_product(product_vals, chatter_msg=chatter_msg) + chatter_msg.append( + f"Product created/updated {product.default_code} ({product.id})" + ) + return True + + @api.model + def create_update_products(self, products, seller_id, chatter_msg): + """Create / Update a product. + + This method can be overriden, for example to import asynchronously with queue_job. + """ + return self._create_update_products( + products, seller_id, chatter_msg=chatter_msg + ) def import_button(self): self.ensure_one() file_content = b64decode(self.product_file) + # 1st step: Parse the (UBL) document --> get a "catalogue" dictionary catalogue = self.parse_product_catalogue(file_content, self.product_filename) if not catalogue.get("products"): raise UserError(_("This catalogue doesn't have any product!")) company_id = self._get_company_id(catalogue) seller = self._get_seller(catalogue) - self.with_context(product_company_id=company_id)._create_products( - catalogue, seller, filename=self.product_filename + wiz = self.with_context(product_company_id=company_id) + # 2nd step: Prepare values and create the "product.product" records in Odoo + wiz.create_update_products( + catalogue["products"], + seller.id, + chatter_msg=catalogue["chatter_msg"], + ) + # Save imported file as attachment + self._bdimport.post_create_or_update( + catalogue, seller, doc_filename=self.product_filename ) + logger.info( + "Update for vendor %s: %d products", seller.name, len(catalogue["products"]) + ) + return {"type": "ir.actions.act_window_close"} From a56f0449f26705276e575bf091713eaddd6529ab Mon Sep 17 00:00:00 2001 From: Florent Xicluna Date: Tue, 28 Jan 2025 00:05:04 +0100 Subject: [PATCH 2/4] [REF] product_import: do not use the env.context to configure import --- product_import/wizard/product_import.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/product_import/wizard/product_import.py b/product_import/wizard/product_import.py index 7102a185c2..82fae5661a 100644 --- a/product_import/wizard/product_import.py +++ b/product_import/wizard/product_import.py @@ -150,11 +150,10 @@ def _prepare_supplierinfo(self, seller_info, product): return result @api.model - def _prepare_product(self, parsed_product, seller, chatter_msg): + def _prepare_product(self, parsed_product, seller, company_id, chatter_msg): # Important: barcode is unique key of product.template model # So records product.product are created with company_id=False. # Only the pricelist (product.supplierinfo) is company-specific. - product_company_id = self.env.context.get("product_company_id", False) if not parsed_product["barcode"]: chatter_msg.append( _("Cannot import product without barcode: %s") % (parsed_product,) @@ -187,7 +186,7 @@ def _prepare_product(self, parsed_product, seller, chatter_msg): "price": parsed_product["price"], "currency_id": currency.id, "min_qty": parsed_product["min_qty"], - "company_id": product_company_id, + "company_id": company_id, "delay": parsed_product.get("sale_delay", 0), } product_vals["seller_ids"] = self._prepare_supplierinfo(seller_info, product) @@ -216,7 +215,7 @@ def _save_product(self, product_vals, chatter_msg): return product @api.model - def _create_update_products(self, products, seller_id, chatter_msg): + def _create_update_products(self, products, seller_id, company_id, chatter_msg): """Create / Update all products.""" seller = self.env["res.partner"].browse(seller_id) @@ -224,6 +223,7 @@ def _create_update_products(self, products, seller_id, chatter_msg): product_vals = self._prepare_product( parsed_product, seller=seller, + company_id=company_id, chatter_msg=chatter_msg, ) if product_vals: @@ -234,13 +234,13 @@ def _create_update_products(self, products, seller_id, chatter_msg): return True @api.model - def create_update_products(self, products, seller_id, chatter_msg): + def create_update_products(self, products, seller_id, company_id, chatter_msg): """Create / Update a product. This method can be overriden, for example to import asynchronously with queue_job. """ return self._create_update_products( - products, seller_id, chatter_msg=chatter_msg + products, seller_id, company_id, chatter_msg=chatter_msg ) def import_button(self): @@ -252,11 +252,11 @@ def import_button(self): raise UserError(_("This catalogue doesn't have any product!")) company_id = self._get_company_id(catalogue) seller = self._get_seller(catalogue) - wiz = self.with_context(product_company_id=company_id) # 2nd step: Prepare values and create the "product.product" records in Odoo - wiz.create_update_products( + self.create_update_products( catalogue["products"], seller.id, + company_id, chatter_msg=catalogue["chatter_msg"], ) # Save imported file as attachment From 905376d75857b7a438200f96b606a0434d035367 Mon Sep 17 00:00:00 2001 From: Florent Xicluna Date: Mon, 27 Jan 2025 18:05:44 +0100 Subject: [PATCH 3/4] [IMP] product_import: archive/unarchive product template --- product_import/wizard/product_import.py | 7 +++++++ product_import_ubl/wizard/product_import.py | 6 ++++-- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/product_import/wizard/product_import.py b/product_import/wizard/product_import.py index 82fae5661a..2b23a712b9 100644 --- a/product_import/wizard/product_import.py +++ b/product_import/wizard/product_import.py @@ -142,6 +142,10 @@ def _prepare_supplierinfo(self, seller_info, product): and s_info.delay == seller_info["delay"] ): seller_id = s_info.id + elif s_info.date_start == today: + # Overwrite if created the same day + seller_id = s_info.id + result.append((1, s_info.id, seller_info)) else: result.append((1, s_info.id, {"date_end": yesterday})) if not seller_id: @@ -212,6 +216,9 @@ def _save_product(self, product_vals, chatter_msg): product.action_archive() logger.debug("Product %s created", product.default_code) + # Archive product template, if product is archived + if product.active != product.product_tmpl_id.active: + product.product_tmpl_id.toggle_active() return product @api.model diff --git a/product_import_ubl/wizard/product_import.py b/product_import_ubl/wizard/product_import.py index 246536ff93..61bff17a6e 100644 --- a/product_import_ubl/wizard/product_import.py +++ b/product_import_ubl/wizard/product_import.py @@ -90,8 +90,10 @@ def parse_ubl_catalogue_line(self, line, ns): "price": float(ele_price.text or 0), "currency": {"iso": currency} if currency else False, "min_qty": min_qty, - "sale_delay": xline.text( - "cac:RequiredItemLocationQuantity/cbc:LeadTimeMeasure" + "sale_delay": int( + xline.text( + "cac:RequiredItemLocationQuantity/cbc:LeadTimeMeasure", 0 + ) ), } ) From a374d244758a7d1abb8c9bafea1a30c2fc110389 Mon Sep 17 00:00:00 2001 From: Florent Xicluna Date: Mon, 27 Jan 2025 18:09:27 +0100 Subject: [PATCH 4/4] [IMP] product_import: more tests --- product_import/tests/test_product_import.py | 163 +++++++++++++++++++- 1 file changed, 162 insertions(+), 1 deletion(-) diff --git a/product_import/tests/test_product_import.py b/product_import/tests/test_product_import.py index 5cc227aa8f..d7eba06dc3 100644 --- a/product_import/tests/test_product_import.py +++ b/product_import/tests/test_product_import.py @@ -25,7 +25,7 @@ "currency": {"iso": "EUR"}, "description": "Photo copy paper 80g A4, package of 500 sheets.", "external_ref": "102", - "min_qty": 1.0, + "min_qty": 5.0, "name": "Copy paper", "price": 12.55, "product_code": "MNTR011", @@ -50,6 +50,31 @@ }, ], "ref": "1387", + "seller": {"name": "Catalogue Vendor"}, +} +PARSED_CATALOG2 = { + "date": "2016-09-03", + "doc_type": "catalogue", + "products": [ + { + "barcode": "1234567890114", + "code": "MNTR011", + "active": False, + "name": "Konnektor 1/4x1/4 CB4629", + "description": "Konnektor 1/4x1/4 CB4629", + "external_ref": "201459", + "uom": {"unece_code": "H87"}, + "product_code": "201459", + "weight": 29.0, + "weight_uom": False, + "price": 0.0, + "currency": {"iso": "EUR"}, + "min_qty": 9.0, + "manufacturer_ref": False, + "sale_delay": 11, + }, + ], + "ref": "214R", "company": {"name": "Customer ABC"}, "seller": { "contact": False, @@ -148,6 +173,142 @@ def test_product_import(self): self.assertEqual( product.seller_ids.mapped("delay")[0], parsed.get("sale_delay", 0) ) + # Pricelist is linked to the Product Template, not the Product Variant + self.assertEqual( + product.seller_ids.product_tmpl_id, product.product_tmpl_id + ) + self.assertFalse(product.seller_ids.product_id) + + def test_product_import_change(self): + # product.product + product_obj = self.env["product.product"].with_context(active_test=False) + existing = product_obj.search([], order="id") + parsed_catalog = {**PARSED_CATALOG2, "chatter_msg": []} + + wiz = self.wiz_model.create( + {"product_file": b"====", "product_filename": "test_import.xml"} + ) + + # 1st import + with self._mock("parse_product_catalogue", return_value=self.parsed_catalog): + wiz.import_button() + + # 2nd import + with self._mock("parse_product_catalogue", return_value=parsed_catalog): + wiz.import_button() + products = product_obj.search([], order="id") - existing + self.assertEqual(len(products), 3) + for product, parsed in zip(products, PARSED_CATALOG["products"]): + if product.default_code == "MNTR011": + [parsed] = PARSED_CATALOG2["products"] + + # Expected + expected = { + "code": parsed["code"], + "seller": PARSED_CATALOG["seller"]["name"], + "min_qty": parsed["min_qty"], + "price": parsed["price"], + "currency": parsed["currency"]["iso"], + "type": "product", + "uom_id": 1, # Units + "uom_po_id": 1, + "active": parsed.get("active", True), + } + + # product.product "Product Variant" + [p_supplierinfo] = product.seller_ids[:1] + p_values = { + "code": product.default_code, + "seller": p_supplierinfo.name.name, + "min_qty": p_supplierinfo.min_qty, + "price": p_supplierinfo.price, + "currency": p_supplierinfo.currency_id.name, + "type": product.type, + "uom_id": product.uom_id.id, + "uom_po_id": product.uom_po_id.id, + "active": product.active, + } + for key in "name", "barcode", "description": + expected[key] = parsed[key] + p_values[key] = getattr(product, key) + + # product.template "Product" + product_tmpl = product.product_tmpl_id + pt_values = { + **p_values, + "code": product_tmpl.default_code, + "uom_id": product_tmpl.uom_id.id, + "uom_po_id": product_tmpl.uom_po_id.id, + } + for key in "name", "barcode", "description", "type", "active": + pt_values[key] = getattr(product_tmpl, key) + + self.assertEqual(p_values, expected) + self.assertEqual(pt_values, expected) + self.assertEqual(product.seller_ids, product_tmpl.seller_ids) + self.assertEqual( + product.seller_ids.mapped("delay")[0], parsed.get("sale_delay", 0) + ) + + # 3rd import + with self._mock("parse_product_catalogue", return_value=self.parsed_catalog): + wiz.import_button() + + self.assertEqual(product_obj.search([], order="id") - existing, products) + for product, parsed in zip(products, PARSED_CATALOG["products"]): + + # Expected + expected = { + "code": parsed["code"], + "seller": PARSED_CATALOG["seller"]["name"], + "min_qty": parsed["min_qty"], + "price": parsed["price"], + "currency": parsed["currency"]["iso"], + "type": "product", + "uom_id": 1, # Units + "uom_po_id": 1, + "active": parsed.get("active", True), + } + + # product.product "Product Variant" + [p_supplierinfo] = product.seller_ids + p_values = { + "code": product.default_code, + "seller": p_supplierinfo.name.name, + "min_qty": p_supplierinfo.min_qty, + "price": p_supplierinfo.price, + "currency": p_supplierinfo.currency_id.name, + "type": product.type, + "uom_id": product.uom_id.id, + "uom_po_id": product.uom_po_id.id, + "active": product.active, + } + for key in "name", "barcode", "description": + expected[key] = parsed[key] + p_values[key] = getattr(product, key) + + # product.template "Product" + product_tmpl = product.product_tmpl_id + pt_values = { + **p_values, + "code": product_tmpl.default_code, + "uom_id": product_tmpl.uom_id.id, + "uom_po_id": product_tmpl.uom_po_id.id, + } + for key in "name", "barcode", "description", "type", "active": + pt_values[key] = getattr(product_tmpl, key) + + self.assertEqual(p_values, expected) + self.assertEqual(pt_values, expected) + self.assertEqual(product.seller_ids, product_tmpl.seller_ids) + self.assertEqual( + product.seller_ids.mapped("delay")[0], parsed.get("sale_delay", 0) + ) + # Pricelist is linked to the Product Template, not the Product Variant + self.assertEqual( + product.seller_ids.product_tmpl_id, product.product_tmpl_id + ) + self.assertFalse(product.seller_ids.product_id) def test_import_button(self): form = self.wiz_form