From a7e570d9bdb261dc36ddbb2e364fc50ab222d644 Mon Sep 17 00:00:00 2001 From: Lindsay Date: Thu, 11 Feb 2021 13:26:08 +0100 Subject: [PATCH 01/19] [IMP] Handling DespatchAdvice files in order to modify stock pickings --- despatch_advice_import/README.rst | 91 ++++ despatch_advice_import/__init__.py | 3 + despatch_advice_import/__manifest__.py | 20 + .../static/description/icon.png | Bin 0 -> 9455 bytes despatch_advice_import/tests/__init__.py | 1 + .../tests/test_despatch_advice_import.py | 403 ++++++++++++++++++ despatch_advice_import/wizard/__init__.py | 1 + .../wizard/despatch_advice_import.py | 299 +++++++++++++ .../wizard/despatch_advice_import.xml | 46 ++ 9 files changed, 864 insertions(+) create mode 100644 despatch_advice_import/README.rst create mode 100644 despatch_advice_import/__init__.py create mode 100644 despatch_advice_import/__manifest__.py create mode 100644 despatch_advice_import/static/description/icon.png create mode 100644 despatch_advice_import/tests/__init__.py create mode 100644 despatch_advice_import/tests/test_despatch_advice_import.py create mode 100644 despatch_advice_import/wizard/__init__.py create mode 100644 despatch_advice_import/wizard/despatch_advice_import.py create mode 100644 despatch_advice_import/wizard/despatch_advice_import.xml diff --git a/despatch_advice_import/README.rst b/despatch_advice_import/README.rst new file mode 100644 index 0000000000..10082c9df6 --- /dev/null +++ b/despatch_advice_import/README.rst @@ -0,0 +1,91 @@ +.. image:: https://img.shields.io/badge/licence-AGPL--3-blue.svg + :target: http://www.gnu.org/licenses/agpl-3.0-standalone.html + :alt: License: AGPL-3 + +====================== +Despatch Advice Import +====================== + +Despatch Advice import + +Installation +============ + +To install this module, you need to: + +#. Do this ... + +Configuration +============= + +To configure this module, you need to: + +#. Go to ... + +.. figure:: path/to/local/image.png + :alt: alternative description + :width: 600 px + +Usage +===== + +To use this module, you need to: + +#. Go to ... + +.. image:: https://odoo-community.org/website/image/ir.attachment/5784_f2813bd/datas + :alt: Try me on Runbot + :target: https://runbot.odoo-community.org/runbot/{repo_id}/{branch} + +.. repo_id is available in https://github.com/OCA/maintainer-tools/blob/master/tools/repos_with_ids.txt +.. branch is "8.0" for example + +Known issues / Roadmap +====================== + +* ... + +Bug Tracker +=========== + +Bugs are tracked on `GitHub Issues +`_. In case of trouble, please +check there if your issue has already been reported. If you spotted it first, +help us smash it by providing detailed and welcomed feedback. + +Credits +======= + +Images +------ + +* Odoo Community Association: `Icon `_. + +Contributors +------------ + +* Firstname Lastname +* Second Person + +Funders +------- + +The development of this module has been financially supported by: + +* Company 1 name +* Company 2 name + +Maintainer +---------- + +.. image:: https://odoo-community.org/logo.png + :alt: Odoo Community Association + :target: https://odoo-community.org + +This module is maintained by the OCA. + +OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use. + +To contribute to this module, please visit https://odoo-community.org. diff --git a/despatch_advice_import/__init__.py b/despatch_advice_import/__init__.py new file mode 100644 index 0000000000..e948157abd --- /dev/null +++ b/despatch_advice_import/__init__.py @@ -0,0 +1,3 @@ +# -*- coding: utf-8 -*- + +from . import wizard \ No newline at end of file diff --git a/despatch_advice_import/__manifest__.py b/despatch_advice_import/__manifest__.py new file mode 100644 index 0000000000..30eabe0a59 --- /dev/null +++ b/despatch_advice_import/__manifest__.py @@ -0,0 +1,20 @@ +# -*- coding: utf-8 -*- +# Copyright 2020 ACSONE SA/NV +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +{ + 'name': 'Despatch Advice Import', + 'summary': """ + Despatch Advice import""", + 'version': '10.0.1.0.0', + 'license': 'AGPL-3', + 'author': 'ACSONE SA/NV,Odoo Community Association (OCA)', + 'depends': ['purchase', 'base_business_document_import_stock' + ], + 'data': [ + 'wizard/despatch_advice_import.xml', + ], + 'demo': [ + ], + 'installable': True, +} diff --git a/despatch_advice_import/static/description/icon.png b/despatch_advice_import/static/description/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..3a0328b516c4980e8e44cdb63fd945757ddd132d GIT binary patch literal 9455 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 z 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 Xviia!U7SGha1wx#SCgwmn*{w2TRX*I literal 0 HcmV?d00001 diff --git a/despatch_advice_import/tests/__init__.py b/despatch_advice_import/tests/__init__.py new file mode 100644 index 0000000000..2178897879 --- /dev/null +++ b/despatch_advice_import/tests/__init__.py @@ -0,0 +1 @@ +from . import test_despatch_advice_import \ No newline at end of file diff --git a/despatch_advice_import/tests/test_despatch_advice_import.py b/despatch_advice_import/tests/test_despatch_advice_import.py new file mode 100644 index 0000000000..8353b209d7 --- /dev/null +++ b/despatch_advice_import/tests/test_despatch_advice_import.py @@ -0,0 +1,403 @@ +# -*- coding: utf-8 -*- +# Copyright 2020 ACSONE SA/NV +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo import fields, _ +from odoo.tests.common import SavepointCase +from odoo.exceptions import UserError + + +class TestDespatchAdviceImport(SavepointCase): + @classmethod + def setUpClass(cls): + super(TestDespatchAdviceImport, cls).setUpClass() + cls.env = cls.env(context=dict(cls.env.context, tracking_disable=True)) + cls.supplier = cls.env.ref("base.res_partner_12") + cls.supplier.vat = "BE0477472701" + cls.env.user.company_id.partner_id.vat = "BE0421801233" + cls.product_1 = cls.env["product.product"].create( + { + "name": "Product 1", + "default_code": "987654321", + "seller_ids": [(0, 0, {"name": cls.supplier.id, "product_code": "P1"})], + } + ) + cls.product_2 = cls.env["product.product"].create( + { + "name": "Product 2", + "default_code": "987654312", + "seller_ids": [(0, 0, {"name": cls.supplier.id, "product_code": "P2"})], + } + ) + cls.product_3 = cls.env["product.product"].create( + { + "name": "Product 3", + "default_code": "123456789", + "seller_ids": [(0, 0, {"name": cls.supplier.id, "product_code": "P3"})], + } + ) + cls.product_4 = cls.env["product.product"].create( + { + "name": "Product 4", + "default_code": "23456718", + "seller_ids": [(0, 0, {"name": cls.supplier.id, "product_code": "P4"})], + } + ) + cls.purchase_order = cls.env["purchase.order"].create( + { + "partner_id": cls.supplier.id, + "date_order": fields.Datetime.now(), + "date_planned": fields.Datetime.now(), + } + ) + cls.line1 = cls.purchase_order.order_line.create( + { + "order_id": cls.purchase_order.id, + "product_id": cls.product_1.id, + "name": cls.product_1.name, + "date_planned": fields.Datetime.now(), + "product_qty": 24, + "product_uom": cls.env.ref("product.product_uom_unit").id, + "price_unit": 15, + } + ) + cls.line2 = cls.purchase_order.order_line.create( + { + "order_id": cls.purchase_order.id, + "product_id": cls.product_2.id, + "name": cls.product_2.name, + "date_planned": fields.Datetime.now(), + "product_qty": 5, + "product_uom": cls.env.ref("product.product_uom_unit").id, + "price_unit": 25, + } + ) + + cls.line3 = cls.purchase_order.order_line.create( + { + "order_id": cls.purchase_order.id, + "product_id": cls.product_3.id, + "name": cls.product_3.name, + "date_planned": fields.Datetime.now(), + "product_qty": 15, + "product_uom": cls.env.ref("product.product_uom_unit").id, + "price_unit": 25, + } + ) + + cls.line4 = cls.purchase_order.order_line.create( + { + "order_id": cls.purchase_order.id, + "product_id": cls.product_4.id, + "name": cls.product_4.name, + "date_planned": fields.Datetime.now(), + "product_qty": 15, + "product_uom": cls.env.ref("product.product_uom_unit").id, + "price_unit": 25, + } + ) + cls._add_procurements(cls.line3, [5]) + cls._add_procurements(cls.line4, [2, 2, 2]) + cls.purchase_order.button_confirm() + cls.picking = cls.purchase_order.picking_ids + + cls.DespatchAdviceImport = cls.env["despatch.advice.import"] + + + def order_line_to_data(self, order_line, qty=None, backorder_qty=None): + return { + "backorder_qty": backorder_qty, + "qty": qty if qty is not None else order_line.product_qty, + "line_id": order_line.id, + "product_ref": order_line.product_id.default_code, + "uom": {"unece_code": order_line.product_uom.unece_code}, + } + + def _get_base_data(self): + return { + "company": {"vat": "BE0421801233"}, + "date": "2020-02-04", + "chatter_msg": [], + "lines": [], + "supplier": {"vat": "BE0477472701"}, + "ref": str(self.purchase_order.name), + } + + @classmethod + def _add_procurements(cls, line, qties): + for qty in qties: + cls.env['procurement.order'].create( + { + "name": "Test", + "product_id": line.product_id.id, + "product_qty": qty, + "product_uom": line.product_uom.id, + "state": "done", + "purchase_line_id": line.id, + } + ) + + + def test_00(self): + """ + Data: + Data with unknown PO reference + Test Case: + Process data + Expected result: + UserError is raised + """ + data = self._get_base_data() + data["ref"] = "123456" + with self.assertRaises(UserError) as ue: + self.DespatchAdviceImport.process_data(data) + self.assertEqual( + ue.exception.name, _("No purchase order found for name 123456.") + ) + + def test_01(self): + """ + backorder qty + """ + data = self._get_base_data() + confirmed_qty = self.line1.product_qty - 21 + data["lines"] = [ + self.order_line_to_data( + self.line1, qty=confirmed_qty, backorder_qty=21 + ), + self.order_line_to_data(self.line2), + self.order_line_to_data(self.line3), + self.order_line_to_data(self.line4) + ] + self.DespatchAdviceImport.process_data(data) + + self.assertTrue(self.purchase_order.picking_ids) + move_ids = self.line1.move_ids + self.assertEqual(len(move_ids), 2) + self.assertEqual( + sum(move_ids.mapped("product_qty")), self.line1.product_qty + ) + assigned = move_ids.filtered(lambda s: s.state == "assigned" and s.product_qty == 3) + self.assertEqual(assigned.product_qty, confirmed_qty) + + move_backorder = move_ids.filtered( + lambda s: s.state == "assigned" and s.product_qty == 21 + ) + self.assertTrue(move_backorder) + self.assertEqual( + move_backorder.picking_id.backorder_id, assigned.picking_id + ) + + + def test_02(self): + """ + no backorder qty + """ + data = self._get_base_data() + confirmed_qty = self.line1.product_qty - 21 + data["lines"] = [ + self.order_line_to_data( + self.line1, qty=confirmed_qty + ), + self.order_line_to_data(self.line2), + self.order_line_to_data(self.line3), + self.order_line_to_data(self.line4) + ] + self.DespatchAdviceImport.process_data(data) + + self.assertTrue(self.purchase_order.picking_ids) + move_ids = self.line1.move_ids + self.assertEqual(len(move_ids), 2) + self.assertEqual( + sum(move_ids.mapped("product_qty")), self.line1.product_qty + ) + assigned = move_ids.filtered(lambda s: s.state == "assigned") + self.assertEqual(assigned.product_qty, confirmed_qty) + cancel = move_ids.filtered(lambda s: s.state == "cancel") + self.assertEqual(cancel.product_qty, 21) + + def test_03(self): + """ + 2 back order created, second one is put in the same than the first 1 + """ + data = self._get_base_data() + line1_confirmed_qty = self.line1.product_qty - 3 + line2_confirmed_qty = self.line2.product_qty - 3 + data["lines"] = [ + self.order_line_to_data( + self.line1, + qty=line1_confirmed_qty, + backorder_qty=3, + ), + self.order_line_to_data( + self.line2, + qty=line2_confirmed_qty, + backorder_qty=3, + ), + self.order_line_to_data(self.line3), + self.order_line_to_data(self.line4) + ] + + self.DespatchAdviceImport.process_data(data) + self.assertEqual(self.purchase_order.state, "purchase") + self.assertEqual(len(self.purchase_order.picking_ids), 2) + # line1 + line1_move_ids = self.line1.move_ids + self.assertEqual(len(line1_move_ids), 2) + self.assertEqual( + sum(line1_move_ids.mapped("product_qty")), self.line1.product_qty + ) + move_confirmed = line1_move_ids.filtered( + lambda s: s.state == "assigned" + and s.product_qty == line1_confirmed_qty + ) + self.assertTrue(move_confirmed) + self.assertEqual(move_confirmed.product_qty, line1_confirmed_qty) + move_backorder = line1_move_ids.filtered( + lambda s: s.state == "assigned" and s.product_qty == 3 + ) + self.assertTrue(move_backorder) + self.assertEqual( + move_backorder.picking_id.backorder_id, move_confirmed.picking_id, + ) + # line2 + line2_move_ids = self.line2.move_ids + self.assertEqual(len(line2_move_ids), 2) + self.assertEqual( + sum(line2_move_ids.mapped("product_qty")), self.line2.product_qty + ) + move_confirmed = line2_move_ids.filtered( + lambda s: s.state == "assigned" + and s.product_qty == line2_confirmed_qty + ) + self.assertTrue(move_confirmed) + self.assertEqual(move_confirmed.product_qty, line2_confirmed_qty) + + move_backorder = line2_move_ids.filtered( + lambda s: s.state == "assigned" and s.product_qty == 3 + ) + self.assertTrue(move_backorder) + self.assertEqual( + move_backorder.picking_id.backorder_id, move_confirmed.picking_id, + ) + + + def test_04(self): + """ + """ + data = self._get_base_data() + confirmed_qty = self.line1.product_qty - 3 + data["lines"] = [ + self.order_line_to_data( + self.line1, + qty=confirmed_qty, + backorder_qty=2, + ), + self.order_line_to_data(self.line2), + self.order_line_to_data(self.line3), + self.order_line_to_data(self.line4) + ] + self.DespatchAdviceImport.process_data(data) + self.assertEqual(len(self.purchase_order.picking_ids), 2) + move_ids = self.line1.move_ids + self.assertEqual(len(move_ids), 3) + self.assertEqual( + sum(move_ids.mapped("product_qty")), self.line1.product_qty + ) + move_confirmed = move_ids.filtered( + lambda s: s.state == "assigned" and s.product_qty == confirmed_qty + ) + self.assertTrue(move_confirmed) + move_cancel = move_ids.filtered( + lambda s: s.state == "cancel" and s.product_qty == 1 + ) + self.assertTrue(move_cancel) + self.assertEqual( + _("No backorder planned by the supplier."), move_cancel.note, + ) + move_backorder = move_ids.filtered( + lambda s: s.state == "assigned" and s.product_qty == 2 + ) + self.assertTrue(move_backorder) + self.assertEqual( + move_backorder.picking_id.backorder_id, move_confirmed.picking_id, + ) + + def test_05(self): + """ + + """ + data = self._get_base_data() + confirmed_qty = 6 + data["lines"] = [ + self.order_line_to_data(self.line1), + self.order_line_to_data(self.line2), + self.order_line_to_data(self.line3, + qty=confirmed_qty, + backorder_qty=3 + ), + self.order_line_to_data(self.line4) + ] + self.DespatchAdviceImport.process_data(data) + self.assertEqual(len(self.purchase_order.picking_ids), 2) + move_ids = self.line3.move_ids + self.assertEqual(len(move_ids), 4) + self.assertEqual( + sum(move_ids.mapped("product_qty")), self.line3.product_qty + ) + moves_confirmed = move_ids.filtered( + lambda s: s.state == "assigned" and not s.picking_id.backorder_id + ) + self.assertEqual( + sum(moves_confirmed.mapped("product_qty")), confirmed_qty + ) + + move_cancel = move_ids.filtered( + lambda s: s.state == "cancel" and s.product_qty == 6 + ) + self.assertTrue(move_cancel) + move_backorder = move_ids.filtered( + lambda s: s.state == "assigned" and s.product_qty == 3 + ) + self.assertTrue(move_backorder) + self.assertEqual( + move_backorder.picking_id.backorder_id, + moves_confirmed[0].picking_id, + ) + + + def test_06(self): + """ + """ + data = self._get_base_data() + confirmed_qty = 3 + data["lines"] = [ + self.order_line_to_data(self.line1), + self.order_line_to_data(self.line2), + self.order_line_to_data(self.line3), + self.order_line_to_data( + self.line4, + qty=confirmed_qty, + backorder_qty=3, + ), + ] + self.DespatchAdviceImport.process_data(data) + self.assertEqual(len(self.purchase_order.picking_ids), 2) + move_ids = self.line4.move_ids + self.assertEqual( + sum(move_ids.mapped("product_qty")), self.line4.product_qty + ) + moves_confirmed = move_ids.filtered( + lambda s: s.state == "assigned" and not s.picking_id.backorder_id + ) + self.assertEqual(sum(moves_confirmed.mapped("product_qty")), 3) + + moves_cancel = move_ids.filtered( + lambda s: s.state == "cancel" and not s.picking_id.backorder_id + ) + self.assertEqual(sum(moves_cancel.mapped("product_qty")), 9) + moves_backorder = move_ids.filtered( + lambda s: s.state == "assigned" and s.picking_id.backorder_id + ) + self.assertEqual(sum(moves_backorder.mapped("product_qty")), 3) + diff --git a/despatch_advice_import/wizard/__init__.py b/despatch_advice_import/wizard/__init__.py new file mode 100644 index 0000000000..f77ed6fbb2 --- /dev/null +++ b/despatch_advice_import/wizard/__init__.py @@ -0,0 +1 @@ +from . import despatch_advice_import \ No newline at end of file diff --git a/despatch_advice_import/wizard/despatch_advice_import.py b/despatch_advice_import/wizard/despatch_advice_import.py new file mode 100644 index 0000000000..9a618fd9ea --- /dev/null +++ b/despatch_advice_import/wizard/despatch_advice_import.py @@ -0,0 +1,299 @@ +# -*- coding: utf-8 -*- +# Copyright 2020 ACSONE SA/NV +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +import logging +import mimetypes + +from lxml import etree +from odoo import _, api, fields, models +from odoo.exceptions import UserError +from odoo.tools import config, float_compare + +logger = logging.getLogger(__name__) + + +class DespatchAdviceImport(models.TransientModel): + + _name = "despatch.advice.import" + _description = "Despatch Advice Import from Files" + + document = fields.Binary( + string="XML or PDF Despatch Advice", + required=True, + help="Upload an Despatch Advice file that you received from " + "your supplier. Supported formats: XML and PDF " + "(PDF with an embeded XML file).", + ) + filename = fields.Char(string="Filename") + + # Format of parsed despatch advice + # { + # 'ref': 'PO01234' # the buyer party identifier + # # (specified into the Order document -> po's name) + # 'despatch_advice_type_code': ' scheduled | delivered' + # 'supplier': {'vat': 'FR25499247138'}, + # 'company': {'vat': 'FR12123456789'}, # Only used to check we are not + # # importing the quote in the + # # wrong company by mistake + # 'estimated_delivery_date': '2020-11-20' + # 'lines': [{ + # 'id': 123456, + # 'qty': 2.5, + # 'uom': {'unece_code': 'C62'}, + # 'backorder_qty: None # if provided and qty != expected + # # the backorder qty will be delivered + # # in a next shipping + # }] + + @api.model + def parse_despatch_advice(self, document, filename): + if not document: + raise UserError(_("Missing document file")) + if not filename: + raise UserError(_("Missing document filename")) + filetype = mimetypes.guess_type(filename)[0] + logger.debug("DespatchAdvice file mimetype: %s", filetype) + if filetype in ["application/xml", "text/xml"]: + try: + xml_root = etree.fromstring(document) + except Exception: + logger.exception("File is not XML-compliant") + raise UserError(_("This XML file is not XML-compliant")) + if logger.isEnabledFor(logging.DEBUG): + pretty_xml_string = etree.tostring( + xml_root, pretty_print=True, encoding="UTF-8", xml_declaration=True + ) + logger.debug("Starting to import the following XML file:") + logger.debug(pretty_xml_string) + parsed_despatch_advice = self.parse_xml_despatch_advice(xml_root) + elif filetype == "application/pdf": + parsed_despatch_advice = self.parse_pdf_despatch_advice(document) + else: + raise UserError( + _( + "This file '%s' is not recognised as XML nor PDF file. " + "Please check the file and it's extension." + ) + % filename + ) + logger.debug("Result of OrderResponse parsing: ", parsed_despatch_advice) + if "attachments" not in parsed_despatch_advice: + parsed_despatch_advice["attachments"] = {} + parsed_despatch_advice["attachments"][filename] = document.encode("base64") + if "chatter_msg" not in parsed_despatch_advice: + parsed_despatch_advice["chatter_msg"] = [] + if ( + parsed_despatch_advice.get("company") + and not config["test_enable"] + and not self._context.get("edi_skip_company_check") + ): + self.env["business.document.import"]._check_company( + parsed_despatch_advice["company"], parsed_despatch_advice["chatter_msg"] + ) + return parsed_despatch_advice + + @api.model + def parse_xml_despatch_advice(self, xml_root): + raise UserError( + _( + "This type of XML Order Response is not supported. Did you " + "install the module to support this XML format?" + ) + ) + + @api.model + def parse_pdf_despatch_advice(self, document): + """ + Get PDF attachments, filter on XML files and call import_order_xml + """ + xml_files_dict = self.get_xml_files_from_pdf(document) + if not xml_files_dict: + raise UserError(_("There are no embedded XML file in this PDF file.")) + for xml_filename, xml_root in xml_files_dict.iteritems(): + logger.info("Trying to parse XML file %s", xml_filename) + try: + parsed_despatch_advice = self.parse_xml_despatch_advice(xml_root) + return parsed_despatch_advice + except Exception: + continue + raise UserError( + _( + "This type of XML Order Document is not supported. Did you " + "install the module to support this XML format?" + ) + ) + + @api.multi + def process_document(self): + self.ensure_one() + parsed_order_document = self.parse_parse_despatch_advice( + self.document.decode("base64"), self.filename + ) + self.process_data(parsed_order_document) + + @api.model + def process_data(self, parsed_order_document): + bdio = self.env["business.document.import"] + po_name = parsed_order_document.get("ref") + order = self.env["purchase.order"].search([("name", "=", po_name)]) + + if not order: + bdio.user_error_wrap(_("No purchase order found for name %s.") % po_name) + + lines = parsed_order_document.get("lines") + lines_by_id = {int(line["line_id"]): line for line in lines} + + for line_id, line_info in lines_by_id.iteritems(): + line = order.order_line.search([("id", "=", line_id)]) + if line: + stock_moves = line.move_ids.filtered( + lambda x: x.state not in ("cancel", "done") + ) + moves_qty = sum(stock_moves.mapped("product_qty")) + + if line_info["qty"] == moves_qty: + self._process_accepted(stock_moves, parsed_order_document) + elif not line_info["qty"] and not line_info["backorder_qty"]: + self._process_rejected(stock_moves, parsed_order_document) + else: + self._process_conditional(stock_moves, parsed_order_document, line_info) + + @api.model + def _process_rejected(self, stock_moves, parsed_order_document): + parsed_order_document["chatter_msg"] = ( + parsed_order_document["chatter_msg"] or [] + ) + parsed_order_document["chatter_msg"].append( + _("Delivery cancelled by the supplier.") + ) + + stock_moves.action_cancel() + + @api.model + def _process_accepted(self, stock_moves, parsed_order_document): + parsed_order_document["chatter_msg"] = ( + parsed_order_document["chatter_msg"] or [] + ) + parsed_order_document["chatter_msg"].append( + _("Delivery confirmed by the supplier.") + ) + stock_moves.action_confirm() + + @api.model + def _process_conditional(self, moves, parsed_order_document, line): + precision = self.env["decimal.precision"].precision_get( + "Product Unit of Measure" + ) + chatter = parsed_order_document["chatter_msg"] = ( + parsed_order_document["chatter_msg"] or [] + ) + chatter.append(_("Delivery confirmed with amendment by the supplier.")) + + qty = line["qty"] + backorder_qty = line["backorder_qty"] + moves_qty = sum(moves.mapped("product_qty")) + + if float_compare(qty, moves_qty, precision_digits=precision) >= 0: + return + + # confirmed qty < ordered qty + move_ids_to_backorder = [] + move_ids_to_cancel = [] + for move in moves: + self._check_picking_status(move.picking_id) + if ( + float_compare( + qty, move.product_qty, precision_digits=precision + ) + >= 0 + ): + # qty planned => qty into the stock move: Keep it + qty -= move.product_qty + continue + if ( + qty + and float_compare( + qty, move.product_qty, precision_digits=precision + ) + < 0 + ): + # qty planned < qty into the stock move: Split it + new_move_id = move.split(move.product_qty - qty) + move = self.env["stock.move"].browse(new_move_id) + qty -= move.product_qty + if not backorder_qty: + # if no backorder -> we must cancel the move + move_ids_to_cancel.append(move.id) + continue + # from here we process the backorder qty + # we distribute this qty into the remaining moves and + # if this qty is < than the expected one, we split and cancel the + # remaining qty + if ( + float_compare( + backorder_qty, move.product_qty, precision_digits=precision + ) + < 0 + ): + # backorder_qty < qty into the move -> split the move + # anf cancel remaining qty + move_ids_to_cancel.append( + move.split(move.product_qty - backorder_qty) + ) + + backorder_qty -= move.product_qty + move_ids_to_backorder.append(move.id) + # move backorder moves to a backorder + if move_ids_to_backorder: + moves_to_backorder = self.env["stock.move"].browse( + move_ids_to_backorder + ) + self._add_moves_to_backorder(moves_to_backorder) + # cancel moves to cancel + if move_ids_to_cancel: + moves_to_cancel = self.env["stock.move"].browse(move_ids_to_cancel) + moves_to_cancel.action_cancel() + moves_to_cancel.write( + {"note": _("No backorder planned by the supplier.")} + ) + # Reset Operations + moves[0].picking_id.do_prepare_partial() + + @api.model + def _add_moves_to_backorder(self, moves): + """ + Add the move the picking's backorder + return the backorder associated to the current picking. If no backorder + exists, create a new one. + :param move: + """ + StockPicking = self.env["stock.picking"] + current_picking = moves[0].picking_id + backorder = StockPicking.search([("backorder_id", "=", current_picking.id)]) + if not backorder: + date_done = current_picking.date_done + current_picking._create_backorder(backorder_moves=moves) + # preserve date_done.... + current_picking.date_done = date_done + else: + moves.write({"picking_id": backorder.id}) + backorder.action_confirm() + backorder.action_assign() + + @api.model + def _check_picking_status(self, picking): + """ + The picking operations have already begun + :param picking: + :return: + """ + if any(operation.qty_done != 0 for operation in picking.pack_operation_ids): + raise UserError( + _( + "Some Pack Operations have already started! " + "Please validate or reset operations on " + "picking %s to ensure delivery slip to be computed." + ) + % picking.name + ) diff --git a/despatch_advice_import/wizard/despatch_advice_import.xml b/despatch_advice_import/wizard/despatch_advice_import.xml new file mode 100644 index 0000000000..e353c40d1b --- /dev/null +++ b/despatch_advice_import/wizard/despatch_advice_import.xml @@ -0,0 +1,46 @@ + + + + + + + despatch.advice.import (in purchase_order_import) + despatch.advice.import + +
+ +
+

Upload below the DespatchAdvice you received from your supplier. When you click on the import button:

+
    +
  1. If it is an XML file, Odoo will parse it if the module that adds support for this XML format is installed. For the Universal Business Language format (UBL), you should install the module order_response_import_ubl.
  2. +
  3. If it is a PDF file, Odoo will try to find an XML file in the attachments of the PDF file and then use this XML file.
  4. +
+
+
+ + + + +
+
+
+
+
+ + Import + despatch.advice.import + form + new + + + UBL Despatch Advice Importer + + + + + +
From dc1a7d7a09d5ca7e969290ffc4a3dd6db7e917a6 Mon Sep 17 00:00:00 2001 From: Lindsay Date: Fri, 26 Mar 2021 08:19:59 +0100 Subject: [PATCH 02/19] [IMP] Multiple PO in one dspatchAdvice : PO is at the line level --- despatch_advice_import/__manifest__.py | 10 ++-- .../tests/test_despatch_advice_import.py | 28 +++++----- .../wizard/despatch_advice_import.py | 52 +++++++++++-------- 3 files changed, 49 insertions(+), 41 deletions(-) diff --git a/despatch_advice_import/__manifest__.py b/despatch_advice_import/__manifest__.py index 30eabe0a59..92efd8fab3 100644 --- a/despatch_advice_import/__manifest__.py +++ b/despatch_advice_import/__manifest__.py @@ -9,12 +9,8 @@ 'version': '10.0.1.0.0', 'license': 'AGPL-3', 'author': 'ACSONE SA/NV,Odoo Community Association (OCA)', - 'depends': ['purchase', 'base_business_document_import_stock' - ], - 'data': [ - 'wizard/despatch_advice_import.xml', - ], - 'demo': [ - ], + 'depends': ['purchase', 'base_business_document_import_stock'], + 'data': ['wizard/despatch_advice_import.xml'], + 'demo': [], 'installable': True, } diff --git a/despatch_advice_import/tests/test_despatch_advice_import.py b/despatch_advice_import/tests/test_despatch_advice_import.py index 8353b209d7..164e7c7ced 100644 --- a/despatch_advice_import/tests/test_despatch_advice_import.py +++ b/despatch_advice_import/tests/test_despatch_advice_import.py @@ -103,12 +103,12 @@ def setUpClass(cls): cls.DespatchAdviceImport = cls.env["despatch.advice.import"] - def order_line_to_data(self, order_line, qty=None, backorder_qty=None): return { "backorder_qty": backorder_qty, "qty": qty if qty is not None else order_line.product_qty, - "line_id": order_line.id, + "order_line_id": order_line.id, + "ref": order_line.order_id.name, "product_ref": order_line.product_id.default_code, "uom": {"unece_code": order_line.product_uom.unece_code}, } @@ -137,7 +137,6 @@ def _add_procurements(cls, line, qties): } ) - def test_00(self): """ Data: @@ -149,6 +148,11 @@ def test_00(self): """ data = self._get_base_data() data["ref"] = "123456" + data["lines"] = [ + self.order_line_to_data(self.line1) + ] + data["lines"][0]["ref"] = "123456" + with self.assertRaises(UserError) as ue: self.DespatchAdviceImport.process_data(data) self.assertEqual( @@ -170,14 +174,15 @@ def test_01(self): self.order_line_to_data(self.line4) ] self.DespatchAdviceImport.process_data(data) - + self.assertTrue(self.purchase_order.picking_ids) move_ids = self.line1.move_ids self.assertEqual(len(move_ids), 2) self.assertEqual( sum(move_ids.mapped("product_qty")), self.line1.product_qty ) - assigned = move_ids.filtered(lambda s: s.state == "assigned" and s.product_qty == 3) + assigned = move_ids.filtered(lambda s: s.state == "assigned" + and s.product_qty == 3) self.assertEqual(assigned.product_qty, confirmed_qty) move_backorder = move_ids.filtered( @@ -188,7 +193,6 @@ def test_01(self): move_backorder.picking_id.backorder_id, assigned.picking_id ) - def test_02(self): """ no backorder qty @@ -204,7 +208,7 @@ def test_02(self): self.order_line_to_data(self.line4) ] self.DespatchAdviceImport.process_data(data) - + self.assertTrue(self.purchase_order.picking_ids) move_ids = self.line1.move_ids self.assertEqual(len(move_ids), 2) @@ -237,7 +241,7 @@ def test_03(self): self.order_line_to_data(self.line3), self.order_line_to_data(self.line4) ] - + self.DespatchAdviceImport.process_data(data) self.assertEqual(self.purchase_order.state, "purchase") self.assertEqual(len(self.purchase_order.picking_ids), 2) @@ -281,7 +285,6 @@ def test_03(self): move_backorder.picking_id.backorder_id, move_confirmed.picking_id, ) - def test_04(self): """ """ @@ -332,10 +335,11 @@ def test_05(self): data["lines"] = [ self.order_line_to_data(self.line1), self.order_line_to_data(self.line2), - self.order_line_to_data(self.line3, + self.order_line_to_data( + self.line3, qty=confirmed_qty, backorder_qty=3 - ), + ), self.order_line_to_data(self.line4) ] self.DespatchAdviceImport.process_data(data) @@ -365,7 +369,6 @@ def test_05(self): moves_confirmed[0].picking_id, ) - def test_06(self): """ """ @@ -400,4 +403,3 @@ def test_06(self): lambda s: s.state == "assigned" and s.picking_id.backorder_id ) self.assertEqual(sum(moves_backorder.mapped("product_qty")), 3) - diff --git a/despatch_advice_import/wizard/despatch_advice_import.py b/despatch_advice_import/wizard/despatch_advice_import.py index 9a618fd9ea..0f230fdccd 100644 --- a/despatch_advice_import/wizard/despatch_advice_import.py +++ b/despatch_advice_import/wizard/despatch_advice_import.py @@ -45,7 +45,7 @@ class DespatchAdviceImport(models.TransientModel): # # the backorder qty will be delivered # # in a next shipping # }] - + @api.model def parse_despatch_advice(self, document, filename): if not document: @@ -127,7 +127,7 @@ def parse_pdf_despatch_advice(self, document): @api.multi def process_document(self): self.ensure_one() - parsed_order_document = self.parse_parse_despatch_advice( + parsed_order_document = self.parse_despatch_advice( self.document.decode("base64"), self.filename ) self.process_data(parsed_order_document) @@ -136,28 +136,38 @@ def process_document(self): def process_data(self, parsed_order_document): bdio = self.env["business.document.import"] po_name = parsed_order_document.get("ref") - order = self.env["purchase.order"].search([("name", "=", po_name)]) - if not order: - bdio.user_error_wrap(_("No purchase order found for name %s.") % po_name) + lines_doc = parsed_order_document.get("lines") + lines_by_id = {int(line["order_line_id"]): line for line in lines_doc} - lines = parsed_order_document.get("lines") - lines_by_id = {int(line["line_id"]): line for line in lines} + lines = self.env["purchase.order.line"].browse(lines_by_id.keys()) - for line_id, line_info in lines_by_id.iteritems(): - line = order.order_line.search([("id", "=", line_id)]) - if line: - stock_moves = line.move_ids.filtered( - lambda x: x.state not in ("cancel", "done") - ) - moves_qty = sum(stock_moves.mapped("product_qty")) + for line in lines: + order = line.order_id + line_info = lines_by_id.get(line.id) + + if line_info["ref"]: + if order.name != line_info["ref"]: + bdio.user_error_wrap( + _("No purchase order found for name %s.") % + line_info["ref"]) + else: + if order.name != po_name: + bdio.user_error_wrap( + _("No purchase order found for name %s.") % + po_name) - if line_info["qty"] == moves_qty: - self._process_accepted(stock_moves, parsed_order_document) - elif not line_info["qty"] and not line_info["backorder_qty"]: - self._process_rejected(stock_moves, parsed_order_document) - else: - self._process_conditional(stock_moves, parsed_order_document, line_info) + stock_moves = line.move_ids.filtered( + lambda x: x.state not in ("cancel", "done") + ) + moves_qty = sum(stock_moves.mapped("product_qty")) + + if line_info["qty"] == moves_qty: + self._process_accepted(stock_moves, parsed_order_document) + elif not line_info["qty"] and not line_info["backorder_qty"]: + self._process_rejected(stock_moves, parsed_order_document) + else: + self._process_conditional(stock_moves, parsed_order_document, line_info) @api.model def _process_rejected(self, stock_moves, parsed_order_document): @@ -196,7 +206,7 @@ def _process_conditional(self, moves, parsed_order_document, line): if float_compare(qty, moves_qty, precision_digits=precision) >= 0: return - + # confirmed qty < ordered qty move_ids_to_backorder = [] move_ids_to_cancel = [] From 10a75a084144a6b13949e2c5da19c4bdf72b5d1d Mon Sep 17 00:00:00 2001 From: Lindsay Date: Wed, 21 Apr 2021 14:14:21 +0200 Subject: [PATCH 03/19] [IMP] Take lots into account --- .../wizard/despatch_advice_import.py | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/despatch_advice_import/wizard/despatch_advice_import.py b/despatch_advice_import/wizard/despatch_advice_import.py index 0f230fdccd..aca1369c99 100644 --- a/despatch_advice_import/wizard/despatch_advice_import.py +++ b/despatch_advice_import/wizard/despatch_advice_import.py @@ -138,7 +138,20 @@ def process_data(self, parsed_order_document): po_name = parsed_order_document.get("ref") lines_doc = parsed_order_document.get("lines") - lines_by_id = {int(line["order_line_id"]): line for line in lines_doc} + + lines_by_id = {} + for line in lines_doc: + if lines_by_id.has_key(int(line["order_line_id"])): + lines_by_id[int(line["order_line_id"])]["qty"] += line["qty"] + lines_by_id[int(line["order_line_id"])]["backorder_qty"] += line["backorder_qty"] + lines_by_id[int(line["order_line_id"])]["product_lot"].append(line["product_lot"]) + lines_by_id[int(line["order_line_id"])]["product_lot"] = list(set(lines_by_id[int(line["order_line_id"])]["product_lot"])) + lines_by_id[int(line["order_line_id"])]["uom"]["unece_code"].append(line["uom"]["unece_code"]) + lines_by_id[int(line["order_line_id"])]["uom"]["unece_code"] = list(set(lines_by_id[int(line["order_line_id"])]["uom"]["unece_code"])) + else: + lines_by_id[int(line["order_line_id"])] = line + lines_by_id[int(line["order_line_id"])]["product_lot"] = [lines_by_id[int(line["order_line_id"])]["product_lot"]] + lines_by_id[int(line["order_line_id"])]["uom"]["unece_code"] = [lines_by_id[int(line["order_line_id"])]["uom"]["unece_code"]] lines = self.env["purchase.order.line"].browse(lines_by_id.keys()) From ea92774d99c7d9fd96d98e846c348becfbba53da Mon Sep 17 00:00:00 2001 From: Lindsay Date: Fri, 23 Apr 2021 10:39:38 +0200 Subject: [PATCH 04/19] [FIX] assign stock moves after confirmation --- despatch_advice_import/wizard/despatch_advice_import.py | 1 + 1 file changed, 1 insertion(+) diff --git a/despatch_advice_import/wizard/despatch_advice_import.py b/despatch_advice_import/wizard/despatch_advice_import.py index aca1369c99..f176e2b335 100644 --- a/despatch_advice_import/wizard/despatch_advice_import.py +++ b/despatch_advice_import/wizard/despatch_advice_import.py @@ -202,6 +202,7 @@ def _process_accepted(self, stock_moves, parsed_order_document): _("Delivery confirmed by the supplier.") ) stock_moves.action_confirm() + stock_moves.action_assign() @api.model def _process_conditional(self, moves, parsed_order_document, line): From af4f2eefaeeb1b20f69134f2a71d1c1de547e6cc Mon Sep 17 00:00:00 2001 From: thien Date: Thu, 30 Nov 2023 17:25:18 +0700 Subject: [PATCH 05/19] [IMP] despatch_advice_import: pre-commit stuff --- despatch_advice_import/__init__.py | 4 +- despatch_advice_import/__manifest__.py | 20 ++-- despatch_advice_import/tests/__init__.py | 2 +- .../tests/test_despatch_advice_import.py | 94 +++++++------------ despatch_advice_import/wizard/__init__.py | 2 +- .../wizard/despatch_advice_import.py | 68 +++++++------- .../wizard/despatch_advice_import.xml | 38 +++++--- 7 files changed, 105 insertions(+), 123 deletions(-) diff --git a/despatch_advice_import/__init__.py b/despatch_advice_import/__init__.py index e948157abd..40272379f7 100644 --- a/despatch_advice_import/__init__.py +++ b/despatch_advice_import/__init__.py @@ -1,3 +1 @@ -# -*- coding: utf-8 -*- - -from . import wizard \ No newline at end of file +from . import wizard diff --git a/despatch_advice_import/__manifest__.py b/despatch_advice_import/__manifest__.py index 92efd8fab3..bc645ff475 100644 --- a/despatch_advice_import/__manifest__.py +++ b/despatch_advice_import/__manifest__.py @@ -1,16 +1,16 @@ -# -*- coding: utf-8 -*- # Copyright 2020 ACSONE SA/NV # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). { - 'name': 'Despatch Advice Import', - 'summary': """ + "name": "Despatch Advice Import", + "summary": """ Despatch Advice import""", - 'version': '10.0.1.0.0', - 'license': 'AGPL-3', - 'author': 'ACSONE SA/NV,Odoo Community Association (OCA)', - 'depends': ['purchase', 'base_business_document_import_stock'], - 'data': ['wizard/despatch_advice_import.xml'], - 'demo': [], - 'installable': True, + "version": "16.0.1.0.0", + "website": "https://github.com/OCA/edi", + "license": "AGPL-3", + "author": "ACSONE SA/NV,Odoo Community Association (OCA)", + "depends": ["purchase", "base_business_document_import_stock"], + "data": ["wizard/despatch_advice_import.xml"], + "demo": [], + "installable": True, } diff --git a/despatch_advice_import/tests/__init__.py b/despatch_advice_import/tests/__init__.py index 2178897879..3d12552832 100644 --- a/despatch_advice_import/tests/__init__.py +++ b/despatch_advice_import/tests/__init__.py @@ -1 +1 @@ -from . import test_despatch_advice_import \ No newline at end of file +from . import test_despatch_advice_import diff --git a/despatch_advice_import/tests/test_despatch_advice_import.py b/despatch_advice_import/tests/test_despatch_advice_import.py index 164e7c7ced..fd79c8184b 100644 --- a/despatch_advice_import/tests/test_despatch_advice_import.py +++ b/despatch_advice_import/tests/test_despatch_advice_import.py @@ -1,10 +1,9 @@ -# -*- coding: utf-8 -*- # Copyright 2020 ACSONE SA/NV # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). -from odoo import fields, _ -from odoo.tests.common import SavepointCase +from odoo import _, fields from odoo.exceptions import UserError +from odoo.tests.common import SavepointCase class TestDespatchAdviceImport(SavepointCase): @@ -126,7 +125,7 @@ def _get_base_data(self): @classmethod def _add_procurements(cls, line, qties): for qty in qties: - cls.env['procurement.order'].create( + cls.env["procurement.order"].create( { "name": "Test", "product_id": line.product_id.id, @@ -148,9 +147,7 @@ def test_00(self): """ data = self._get_base_data() data["ref"] = "123456" - data["lines"] = [ - self.order_line_to_data(self.line1) - ] + data["lines"] = [self.order_line_to_data(self.line1)] data["lines"][0]["ref"] = "123456" with self.assertRaises(UserError) as ue: @@ -166,32 +163,27 @@ def test_01(self): data = self._get_base_data() confirmed_qty = self.line1.product_qty - 21 data["lines"] = [ - self.order_line_to_data( - self.line1, qty=confirmed_qty, backorder_qty=21 - ), + self.order_line_to_data(self.line1, qty=confirmed_qty, backorder_qty=21), self.order_line_to_data(self.line2), self.order_line_to_data(self.line3), - self.order_line_to_data(self.line4) + self.order_line_to_data(self.line4), ] self.DespatchAdviceImport.process_data(data) self.assertTrue(self.purchase_order.picking_ids) move_ids = self.line1.move_ids self.assertEqual(len(move_ids), 2) - self.assertEqual( - sum(move_ids.mapped("product_qty")), self.line1.product_qty + self.assertEqual(sum(move_ids.mapped("product_qty")), self.line1.product_qty) + assigned = move_ids.filtered( + lambda s: s.state == "assigned" and s.product_qty == 3 ) - assigned = move_ids.filtered(lambda s: s.state == "assigned" - and s.product_qty == 3) self.assertEqual(assigned.product_qty, confirmed_qty) move_backorder = move_ids.filtered( lambda s: s.state == "assigned" and s.product_qty == 21 ) self.assertTrue(move_backorder) - self.assertEqual( - move_backorder.picking_id.backorder_id, assigned.picking_id - ) + self.assertEqual(move_backorder.picking_id.backorder_id, assigned.picking_id) def test_02(self): """ @@ -200,21 +192,17 @@ def test_02(self): data = self._get_base_data() confirmed_qty = self.line1.product_qty - 21 data["lines"] = [ - self.order_line_to_data( - self.line1, qty=confirmed_qty - ), + self.order_line_to_data(self.line1, qty=confirmed_qty), self.order_line_to_data(self.line2), self.order_line_to_data(self.line3), - self.order_line_to_data(self.line4) + self.order_line_to_data(self.line4), ] self.DespatchAdviceImport.process_data(data) self.assertTrue(self.purchase_order.picking_ids) move_ids = self.line1.move_ids self.assertEqual(len(move_ids), 2) - self.assertEqual( - sum(move_ids.mapped("product_qty")), self.line1.product_qty - ) + self.assertEqual(sum(move_ids.mapped("product_qty")), self.line1.product_qty) assigned = move_ids.filtered(lambda s: s.state == "assigned") self.assertEqual(assigned.product_qty, confirmed_qty) cancel = move_ids.filtered(lambda s: s.state == "cancel") @@ -239,7 +227,7 @@ def test_03(self): backorder_qty=3, ), self.order_line_to_data(self.line3), - self.order_line_to_data(self.line4) + self.order_line_to_data(self.line4), ] self.DespatchAdviceImport.process_data(data) @@ -252,8 +240,7 @@ def test_03(self): sum(line1_move_ids.mapped("product_qty")), self.line1.product_qty ) move_confirmed = line1_move_ids.filtered( - lambda s: s.state == "assigned" - and s.product_qty == line1_confirmed_qty + lambda s: s.state == "assigned" and s.product_qty == line1_confirmed_qty ) self.assertTrue(move_confirmed) self.assertEqual(move_confirmed.product_qty, line1_confirmed_qty) @@ -262,7 +249,8 @@ def test_03(self): ) self.assertTrue(move_backorder) self.assertEqual( - move_backorder.picking_id.backorder_id, move_confirmed.picking_id, + move_backorder.picking_id.backorder_id, + move_confirmed.picking_id, ) # line2 line2_move_ids = self.line2.move_ids @@ -271,8 +259,7 @@ def test_03(self): sum(line2_move_ids.mapped("product_qty")), self.line2.product_qty ) move_confirmed = line2_move_ids.filtered( - lambda s: s.state == "assigned" - and s.product_qty == line2_confirmed_qty + lambda s: s.state == "assigned" and s.product_qty == line2_confirmed_qty ) self.assertTrue(move_confirmed) self.assertEqual(move_confirmed.product_qty, line2_confirmed_qty) @@ -282,12 +269,12 @@ def test_03(self): ) self.assertTrue(move_backorder) self.assertEqual( - move_backorder.picking_id.backorder_id, move_confirmed.picking_id, + move_backorder.picking_id.backorder_id, + move_confirmed.picking_id, ) def test_04(self): - """ - """ + """ """ data = self._get_base_data() confirmed_qty = self.line1.product_qty - 3 data["lines"] = [ @@ -298,15 +285,13 @@ def test_04(self): ), self.order_line_to_data(self.line2), self.order_line_to_data(self.line3), - self.order_line_to_data(self.line4) + self.order_line_to_data(self.line4), ] self.DespatchAdviceImport.process_data(data) self.assertEqual(len(self.purchase_order.picking_ids), 2) move_ids = self.line1.move_ids self.assertEqual(len(move_ids), 3) - self.assertEqual( - sum(move_ids.mapped("product_qty")), self.line1.product_qty - ) + self.assertEqual(sum(move_ids.mapped("product_qty")), self.line1.product_qty) move_confirmed = move_ids.filtered( lambda s: s.state == "assigned" and s.product_qty == confirmed_qty ) @@ -316,45 +301,37 @@ def test_04(self): ) self.assertTrue(move_cancel) self.assertEqual( - _("No backorder planned by the supplier."), move_cancel.note, + _("No backorder planned by the supplier."), + move_cancel.note, ) move_backorder = move_ids.filtered( lambda s: s.state == "assigned" and s.product_qty == 2 ) self.assertTrue(move_backorder) self.assertEqual( - move_backorder.picking_id.backorder_id, move_confirmed.picking_id, + move_backorder.picking_id.backorder_id, + move_confirmed.picking_id, ) def test_05(self): - """ - - """ + """ """ data = self._get_base_data() confirmed_qty = 6 data["lines"] = [ self.order_line_to_data(self.line1), self.order_line_to_data(self.line2), - self.order_line_to_data( - self.line3, - qty=confirmed_qty, - backorder_qty=3 - ), - self.order_line_to_data(self.line4) + self.order_line_to_data(self.line3, qty=confirmed_qty, backorder_qty=3), + self.order_line_to_data(self.line4), ] self.DespatchAdviceImport.process_data(data) self.assertEqual(len(self.purchase_order.picking_ids), 2) move_ids = self.line3.move_ids self.assertEqual(len(move_ids), 4) - self.assertEqual( - sum(move_ids.mapped("product_qty")), self.line3.product_qty - ) + self.assertEqual(sum(move_ids.mapped("product_qty")), self.line3.product_qty) moves_confirmed = move_ids.filtered( lambda s: s.state == "assigned" and not s.picking_id.backorder_id ) - self.assertEqual( - sum(moves_confirmed.mapped("product_qty")), confirmed_qty - ) + self.assertEqual(sum(moves_confirmed.mapped("product_qty")), confirmed_qty) move_cancel = move_ids.filtered( lambda s: s.state == "cancel" and s.product_qty == 6 @@ -370,8 +347,7 @@ def test_05(self): ) def test_06(self): - """ - """ + """ """ data = self._get_base_data() confirmed_qty = 3 data["lines"] = [ @@ -387,9 +363,7 @@ def test_06(self): self.DespatchAdviceImport.process_data(data) self.assertEqual(len(self.purchase_order.picking_ids), 2) move_ids = self.line4.move_ids - self.assertEqual( - sum(move_ids.mapped("product_qty")), self.line4.product_qty - ) + self.assertEqual(sum(move_ids.mapped("product_qty")), self.line4.product_qty) moves_confirmed = move_ids.filtered( lambda s: s.state == "assigned" and not s.picking_id.backorder_id ) diff --git a/despatch_advice_import/wizard/__init__.py b/despatch_advice_import/wizard/__init__.py index f77ed6fbb2..ce838ad980 100644 --- a/despatch_advice_import/wizard/__init__.py +++ b/despatch_advice_import/wizard/__init__.py @@ -1 +1 @@ -from . import despatch_advice_import \ No newline at end of file +from . import despatch_advice_import diff --git a/despatch_advice_import/wizard/despatch_advice_import.py b/despatch_advice_import/wizard/despatch_advice_import.py index f176e2b335..d66400353c 100644 --- a/despatch_advice_import/wizard/despatch_advice_import.py +++ b/despatch_advice_import/wizard/despatch_advice_import.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- # Copyright 2020 ACSONE SA/NV # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). @@ -6,6 +5,7 @@ import mimetypes from lxml import etree + from odoo import _, api, fields, models from odoo.exceptions import UserError from odoo.tools import config, float_compare @@ -25,7 +25,7 @@ class DespatchAdviceImport(models.TransientModel): "your supplier. Supported formats: XML and PDF " "(PDF with an embeded XML file).", ) - filename = fields.Char(string="Filename") + filename = fields.Char(string="File Name") # Format of parsed despatch advice # { @@ -59,7 +59,7 @@ def parse_despatch_advice(self, document, filename): xml_root = etree.fromstring(document) except Exception: logger.exception("File is not XML-compliant") - raise UserError(_("This XML file is not XML-compliant")) + raise UserError(_("This XML file is not XML-compliant")) from None if logger.isEnabledFor(logging.DEBUG): pretty_xml_string = etree.tostring( xml_root, pretty_print=True, encoding="UTF-8", xml_declaration=True @@ -141,17 +141,31 @@ def process_data(self, parsed_order_document): lines_by_id = {} for line in lines_doc: - if lines_by_id.has_key(int(line["order_line_id"])): + if (int(line["order_line_id"])) in lines_by_id: lines_by_id[int(line["order_line_id"])]["qty"] += line["qty"] - lines_by_id[int(line["order_line_id"])]["backorder_qty"] += line["backorder_qty"] - lines_by_id[int(line["order_line_id"])]["product_lot"].append(line["product_lot"]) - lines_by_id[int(line["order_line_id"])]["product_lot"] = list(set(lines_by_id[int(line["order_line_id"])]["product_lot"])) - lines_by_id[int(line["order_line_id"])]["uom"]["unece_code"].append(line["uom"]["unece_code"]) - lines_by_id[int(line["order_line_id"])]["uom"]["unece_code"] = list(set(lines_by_id[int(line["order_line_id"])]["uom"]["unece_code"])) + lines_by_id[int(line["order_line_id"])]["backorder_qty"] += line[ + "backorder_qty" + ] + lines_by_id[int(line["order_line_id"])]["product_lot"].append( + line["product_lot"] + ) + lines_by_id[int(line["order_line_id"])]["product_lot"] = list( + set(lines_by_id[int(line["order_line_id"])]["product_lot"]) + ) + lines_by_id[int(line["order_line_id"])]["uom"]["unece_code"].append( + line["uom"]["unece_code"] + ) + lines_by_id[int(line["order_line_id"])]["uom"]["unece_code"] = list( + set(lines_by_id[int(line["order_line_id"])]["uom"]["unece_code"]) + ) else: lines_by_id[int(line["order_line_id"])] = line - lines_by_id[int(line["order_line_id"])]["product_lot"] = [lines_by_id[int(line["order_line_id"])]["product_lot"]] - lines_by_id[int(line["order_line_id"])]["uom"]["unece_code"] = [lines_by_id[int(line["order_line_id"])]["uom"]["unece_code"]] + lines_by_id[int(line["order_line_id"])]["product_lot"] = [ + lines_by_id[int(line["order_line_id"])]["product_lot"] + ] + lines_by_id[int(line["order_line_id"])]["uom"]["unece_code"] = [ + lines_by_id[int(line["order_line_id"])]["uom"]["unece_code"] + ] lines = self.env["purchase.order.line"].browse(lines_by_id.keys()) @@ -162,13 +176,13 @@ def process_data(self, parsed_order_document): if line_info["ref"]: if order.name != line_info["ref"]: bdio.user_error_wrap( - _("No purchase order found for name %s.") % - line_info["ref"]) + _("No purchase order found for name %s.") % line_info["ref"] + ) else: if order.name != po_name: bdio.user_error_wrap( - _("No purchase order found for name %s.") % - po_name) + _("No purchase order found for name %s.") % po_name + ) stock_moves = line.move_ids.filtered( lambda x: x.state not in ("cancel", "done") @@ -226,21 +240,13 @@ def _process_conditional(self, moves, parsed_order_document, line): move_ids_to_cancel = [] for move in moves: self._check_picking_status(move.picking_id) - if ( - float_compare( - qty, move.product_qty, precision_digits=precision - ) - >= 0 - ): + if float_compare(qty, move.product_qty, precision_digits=precision) >= 0: # qty planned => qty into the stock move: Keep it qty -= move.product_qty continue if ( qty - and float_compare( - qty, move.product_qty, precision_digits=precision - ) - < 0 + and float_compare(qty, move.product_qty, precision_digits=precision) < 0 ): # qty planned < qty into the stock move: Split it new_move_id = move.split(move.product_qty - qty) @@ -262,25 +268,19 @@ def _process_conditional(self, moves, parsed_order_document, line): ): # backorder_qty < qty into the move -> split the move # anf cancel remaining qty - move_ids_to_cancel.append( - move.split(move.product_qty - backorder_qty) - ) + move_ids_to_cancel.append(move.split(move.product_qty - backorder_qty)) backorder_qty -= move.product_qty move_ids_to_backorder.append(move.id) # move backorder moves to a backorder if move_ids_to_backorder: - moves_to_backorder = self.env["stock.move"].browse( - move_ids_to_backorder - ) + moves_to_backorder = self.env["stock.move"].browse(move_ids_to_backorder) self._add_moves_to_backorder(moves_to_backorder) # cancel moves to cancel if move_ids_to_cancel: moves_to_cancel = self.env["stock.move"].browse(move_ids_to_cancel) moves_to_cancel.action_cancel() - moves_to_cancel.write( - {"note": _("No backorder planned by the supplier.")} - ) + moves_to_cancel.write({"note": _("No backorder planned by the supplier.")}) # Reset Operations moves[0].picking_id.do_prepare_partial() diff --git a/despatch_advice_import/wizard/despatch_advice_import.xml b/despatch_advice_import/wizard/despatch_advice_import.xml index e353c40d1b..a97bcb3a80 100644 --- a/despatch_advice_import/wizard/despatch_advice_import.xml +++ b/despatch_advice_import/wizard/despatch_advice_import.xml @@ -1,31 +1,41 @@ - + - despatch.advice.import (in purchase_order_import) despatch.advice.import - +
-

Upload below the DespatchAdvice you received from your supplier. When you click on the import button:

+

Upload below the DespatchAdvice you received from your supplier. When you click on the import button:

    -
  1. If it is an XML file, Odoo will parse it if the module that adds support for this XML format is installed. For the Universal Business Language format (UBL), you should install the module order_response_import_ubl.
  2. -
  3. If it is a PDF file, Odoo will try to find an XML file in the attachments of the PDF file and then use this XML file.
  4. +
  5. If it is an XML file, Odoo will parse it if the module that adds support for this XML format is installed. For the Universal Business Language format (UBL), you should install the module order_response_import_ubl.
  6. +
  7. If it is a PDF file, Odoo will try to find an XML file in the attachments of the PDF file and then use this XML file.
- - + +
-
@@ -38,9 +48,9 @@
UBL Despatch Advice Importer - - - + + +
From 7389440d6739f290d3e83f99b62427c6c2a8ecd8 Mon Sep 17 00:00:00 2001 From: thien Date: Fri, 1 Dec 2023 09:34:50 +0700 Subject: [PATCH 06/19] [MIG] despatch_advice_import: Migration to 16.0 --- despatch_advice_import/__manifest__.py | 4 +- .../security/ir.model.access.csv | 2 + .../wizard/despatch_advice_import.py | 194 ++++++++---------- .../wizard/despatch_advice_import.xml | 7 +- 4 files changed, 89 insertions(+), 118 deletions(-) create mode 100644 despatch_advice_import/security/ir.model.access.csv diff --git a/despatch_advice_import/__manifest__.py b/despatch_advice_import/__manifest__.py index bc645ff475..49478bac5d 100644 --- a/despatch_advice_import/__manifest__.py +++ b/despatch_advice_import/__manifest__.py @@ -9,8 +9,8 @@ "website": "https://github.com/OCA/edi", "license": "AGPL-3", "author": "ACSONE SA/NV,Odoo Community Association (OCA)", - "depends": ["purchase", "base_business_document_import_stock"], - "data": ["wizard/despatch_advice_import.xml"], + "depends": ["purchase", "purchase_stock", "base_business_document_import"], + "data": ["security/ir.model.access.csv", "wizard/despatch_advice_import.xml"], "demo": [], "installable": True, } diff --git a/despatch_advice_import/security/ir.model.access.csv b/despatch_advice_import/security/ir.model.access.csv new file mode 100644 index 0000000000..d81576035b --- /dev/null +++ b/despatch_advice_import/security/ir.model.access.csv @@ -0,0 +1,2 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_despatch_advice_import,despatch.advice.import,model_despatch_advice_import,purchase.group_purchase_user,1,1,1,1 diff --git a/despatch_advice_import/wizard/despatch_advice_import.py b/despatch_advice_import/wizard/despatch_advice_import.py index d66400353c..16c65eb2e7 100644 --- a/despatch_advice_import/wizard/despatch_advice_import.py +++ b/despatch_advice_import/wizard/despatch_advice_import.py @@ -3,18 +3,18 @@ import logging import mimetypes +from base64 import b64decode, b64encode from lxml import etree from odoo import _, api, fields, models from odoo.exceptions import UserError -from odoo.tools import config, float_compare +from odoo.tools import float_compare logger = logging.getLogger(__name__) class DespatchAdviceImport(models.TransientModel): - _name = "despatch.advice.import" _description = "Despatch Advice Import from Files" @@ -57,9 +57,8 @@ def parse_despatch_advice(self, document, filename): if filetype in ["application/xml", "text/xml"]: try: xml_root = etree.fromstring(document) - except Exception: - logger.exception("File is not XML-compliant") - raise UserError(_("This XML file is not XML-compliant")) from None + except Exception as err: + raise UserError(_("This XML file is not XML-compliant")) from err if logger.isEnabledFor(logging.DEBUG): pretty_xml_string = etree.tostring( xml_root, pretty_print=True, encoding="UTF-8", xml_declaration=True @@ -77,16 +76,14 @@ def parse_despatch_advice(self, document, filename): ) % filename ) - logger.debug("Result of OrderResponse parsing: ", parsed_despatch_advice) + logger.debug("Result of Despatch Advice parsing: ", parsed_despatch_advice) if "attachments" not in parsed_despatch_advice: parsed_despatch_advice["attachments"] = {} - parsed_despatch_advice["attachments"][filename] = document.encode("base64") + parsed_despatch_advice["attachments"][filename] = b64encode(document) if "chatter_msg" not in parsed_despatch_advice: parsed_despatch_advice["chatter_msg"] = [] - if ( - parsed_despatch_advice.get("company") - and not config["test_enable"] - and not self._context.get("edi_skip_company_check") + if parsed_despatch_advice.get("company") and not self.env.context.get( + "edi_skip_company_check" ): self.env["business.document.import"]._check_company( parsed_despatch_advice["company"], parsed_despatch_advice["chatter_msg"] @@ -110,7 +107,7 @@ def parse_pdf_despatch_advice(self, document): xml_files_dict = self.get_xml_files_from_pdf(document) if not xml_files_dict: raise UserError(_("There are no embedded XML file in this PDF file.")) - for xml_filename, xml_root in xml_files_dict.iteritems(): + for xml_filename, xml_root in xml_files_dict.items(): logger.info("Trying to parse XML file %s", xml_filename) try: parsed_despatch_advice = self.parse_xml_despatch_advice(xml_root) @@ -124,48 +121,48 @@ def parse_pdf_despatch_advice(self, document): ) ) - @api.multi def process_document(self): self.ensure_one() parsed_order_document = self.parse_despatch_advice( - self.document.decode("base64"), self.filename + b64decode(self.document), self.filename ) self.process_data(parsed_order_document) - @api.model - def process_data(self, parsed_order_document): - bdio = self.env["business.document.import"] - po_name = parsed_order_document.get("ref") - - lines_doc = parsed_order_document.get("lines") - + def _collect_lines_by_id(self, lines_doc): lines_by_id = {} for line in lines_doc: - if (int(line["order_line_id"])) in lines_by_id: - lines_by_id[int(line["order_line_id"])]["qty"] += line["qty"] - lines_by_id[int(line["order_line_id"])]["backorder_qty"] += line[ - "backorder_qty" - ] - lines_by_id[int(line["order_line_id"])]["product_lot"].append( - line["product_lot"] - ) - lines_by_id[int(line["order_line_id"])]["product_lot"] = list( - set(lines_by_id[int(line["order_line_id"])]["product_lot"]) - ) - lines_by_id[int(line["order_line_id"])]["uom"]["unece_code"].append( + line_id = int(line["order_line_id"]) + if line_id in lines_by_id: + lines_by_id[line_id]["qty"] += line["qty"] + lines_by_id[line_id]["backorder_qty"] += line["backorder_qty"] + if "product_lot" in line: + lines_by_id[line_id]["product_lot"].append(line["product_lot"]) + lines_by_id[line_id]["product_lot"] = list( + set(lines_by_id[line_id]["product_lot"]) + ) + lines_by_id[line_id]["uom"]["unece_code"].append( line["uom"]["unece_code"] ) - lines_by_id[int(line["order_line_id"])]["uom"]["unece_code"] = list( - set(lines_by_id[int(line["order_line_id"])]["uom"]["unece_code"]) + lines_by_id[line_id]["uom"]["unece_code"] = list( + set(lines_by_id[line_id]["uom"]["unece_code"]) ) else: - lines_by_id[int(line["order_line_id"])] = line - lines_by_id[int(line["order_line_id"])]["product_lot"] = [ - lines_by_id[int(line["order_line_id"])]["product_lot"] - ] - lines_by_id[int(line["order_line_id"])]["uom"]["unece_code"] = [ - lines_by_id[int(line["order_line_id"])]["uom"]["unece_code"] + lines_by_id[line_id] = line + if "product_lot" in line: + lines_by_id[line_id]["product_lot"] = [ + lines_by_id[line_id]["product_lot"] + ] + lines_by_id[line_id]["uom"]["unece_code"] = [ + lines_by_id[line_id]["uom"]["unece_code"] ] + return lines_by_id + + def process_data(self, parsed_order_document): + po_name = parsed_order_document.get("ref") + + lines_doc = parsed_order_document.get("lines") + + lines_by_id = self._collect_lines_by_id(lines_doc) lines = self.env["purchase.order.line"].browse(lines_by_id.keys()) @@ -175,39 +172,41 @@ def process_data(self, parsed_order_document): if line_info["ref"]: if order.name != line_info["ref"]: - bdio.user_error_wrap( - _("No purchase order found for name %s.") % line_info["ref"] + raise UserError( + _("No purchase order found for name %s.") % line_info["ref"], ) else: if order.name != po_name: - bdio.user_error_wrap( - _("No purchase order found for name %s.") % po_name - ) - + raise UserError(_("No purchase order found for name %s.") % po_name) stock_moves = line.move_ids.filtered( lambda x: x.state not in ("cancel", "done") ) moves_qty = sum(stock_moves.mapped("product_qty")) - if line_info["qty"] == moves_qty: self._process_accepted(stock_moves, parsed_order_document) elif not line_info["qty"] and not line_info["backorder_qty"]: self._process_rejected(stock_moves, parsed_order_document) else: self._process_conditional(stock_moves, parsed_order_document, line_info) + self._process_picking_done(lines[0].move_ids[0]) + + def _process_picking_done(self, move): + if all([line.quantity_done != 0 for line in move.picking_id.move_ids]): + move.picking_id.button_validate() + else: + picking = move.picking_id + picking.with_context(skip_backorder=True).button_validate() - @api.model def _process_rejected(self, stock_moves, parsed_order_document): - parsed_order_document["chatter_msg"] = ( - parsed_order_document["chatter_msg"] or [] + parsed_order_document["chatter_msg"] = parsed_order_document.get( + "chatter_msg", [] ) parsed_order_document["chatter_msg"].append( _("Delivery cancelled by the supplier.") ) - stock_moves.action_cancel() + stock_moves._action_cancel() - @api.model def _process_accepted(self, stock_moves, parsed_order_document): parsed_order_document["chatter_msg"] = ( parsed_order_document["chatter_msg"] or [] @@ -215,10 +214,11 @@ def _process_accepted(self, stock_moves, parsed_order_document): parsed_order_document["chatter_msg"].append( _("Delivery confirmed by the supplier.") ) - stock_moves.action_confirm() - stock_moves.action_assign() + stock_moves._action_confirm() + stock_moves._action_assign() + for move in stock_moves: + move.quantity_done = move.product_qty - @api.model def _process_conditional(self, moves, parsed_order_document, line): precision = self.env["decimal.precision"].precision_get( "Product Unit of Measure" @@ -239,19 +239,24 @@ def _process_conditional(self, moves, parsed_order_document, line): move_ids_to_backorder = [] move_ids_to_cancel = [] for move in moves: - self._check_picking_status(move.picking_id) - if float_compare(qty, move.product_qty, precision_digits=precision) >= 0: + if ( + float_compare(qty, move.product_uom_qty, precision_digits=precision) + >= 0 + ): # qty planned => qty into the stock move: Keep it - qty -= move.product_qty + qty -= move.product_uom_qty continue if ( qty - and float_compare(qty, move.product_qty, precision_digits=precision) < 0 + and float_compare(qty, move.product_uom_qty, precision_digits=precision) + < 0 ): # qty planned < qty into the stock move: Split it - new_move_id = move.split(move.product_qty - qty) - move = self.env["stock.move"].browse(new_move_id) - qty -= move.product_qty + new_vals = move._split(move.product_uom_qty - qty) + move.quantity_done = move.product_qty + move = self.env["stock.move"].create(new_vals[0]) + + qty -= move.product_uom_qty if not backorder_qty: # if no backorder -> we must cancel the move move_ids_to_cancel.append(move.id) @@ -262,62 +267,25 @@ def _process_conditional(self, moves, parsed_order_document, line): # remaining qty if ( float_compare( - backorder_qty, move.product_qty, precision_digits=precision + backorder_qty, move.product_uom_qty, precision_digits=precision ) < 0 ): # backorder_qty < qty into the move -> split the move - # anf cancel remaining qty - move_ids_to_cancel.append(move.split(move.product_qty - backorder_qty)) + # and cancel remaining qty + move._action_confirm(merge=False) + new_vals = move._split(move.product_uom_qty - backorder_qty) + move_ids_to_cancel.append(self.env["stock.move"].create(new_vals[0]).id) - backorder_qty -= move.product_qty + backorder_qty -= move.product_uom_qty move_ids_to_backorder.append(move.id) - # move backorder moves to a backorder - if move_ids_to_backorder: - moves_to_backorder = self.env["stock.move"].browse(move_ids_to_backorder) - self._add_moves_to_backorder(moves_to_backorder) + # cancel moves to cancel if move_ids_to_cancel: moves_to_cancel = self.env["stock.move"].browse(move_ids_to_cancel) - moves_to_cancel.action_cancel() - moves_to_cancel.write({"note": _("No backorder planned by the supplier.")}) - # Reset Operations - moves[0].picking_id.do_prepare_partial() - - @api.model - def _add_moves_to_backorder(self, moves): - """ - Add the move the picking's backorder - return the backorder associated to the current picking. If no backorder - exists, create a new one. - :param move: - """ - StockPicking = self.env["stock.picking"] - current_picking = moves[0].picking_id - backorder = StockPicking.search([("backorder_id", "=", current_picking.id)]) - if not backorder: - date_done = current_picking.date_done - current_picking._create_backorder(backorder_moves=moves) - # preserve date_done.... - current_picking.date_done = date_done - else: - moves.write({"picking_id": backorder.id}) - backorder.action_confirm() - backorder.action_assign() - - @api.model - def _check_picking_status(self, picking): - """ - The picking operations have already begun - :param picking: - :return: - """ - if any(operation.qty_done != 0 for operation in picking.pack_operation_ids): - raise UserError( - _( - "Some Pack Operations have already started! " - "Please validate or reset operations on " - "picking %s to ensure delivery slip to be computed." - ) - % picking.name - ) + moves_to_cancel._action_cancel() + # move backorder moves to a backorder + if move_ids_to_backorder: + moves_to_backorder = self.env["stock.move"].browse(move_ids_to_backorder) + for move in moves_to_backorder: + move._action_confirm(merge=False) diff --git a/despatch_advice_import/wizard/despatch_advice_import.xml b/despatch_advice_import/wizard/despatch_advice_import.xml index a97bcb3a80..322d8bdb40 100644 --- a/despatch_advice_import/wizard/despatch_advice_import.xml +++ b/despatch_advice_import/wizard/despatch_advice_import.xml @@ -8,8 +8,8 @@ despatch.advice.import
- -
+ +

Upload below the DespatchAdvice you received from your supplier. When you click on the import button:

    @@ -18,12 +18,13 @@ href="http://ubl.xml.org/" target="_blank" >Universal Business Language format (UBL), you should install the module order_response_import_ubl. + >despatch_advice_import_ubl.
  1. If it is a PDF file, Odoo will try to find an XML file in the attachments of the PDF file and then use this XML file.
+ From 908526516bca3788841ddaafc59a30cade5dcd7e Mon Sep 17 00:00:00 2001 From: thien Date: Wed, 6 Dec 2023 12:15:42 +0700 Subject: [PATCH 07/19] [IMP] despatch_advice_import: add handling for context --- despatch_advice_import/wizard/despatch_advice_import.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/despatch_advice_import/wizard/despatch_advice_import.py b/despatch_advice_import/wizard/despatch_advice_import.py index 16c65eb2e7..71e8e3bc54 100644 --- a/despatch_advice_import/wizard/despatch_advice_import.py +++ b/despatch_advice_import/wizard/despatch_advice_import.py @@ -88,6 +88,10 @@ def parse_despatch_advice(self, document, filename): self.env["business.document.import"]._check_company( parsed_despatch_advice["company"], parsed_despatch_advice["chatter_msg"] ) + defaults = self.env.context.get("despatch_advice_import__default_vals", {}).get( + "despatch_advice", {} + ) + parsed_despatch_advice.update(defaults) return parsed_despatch_advice @api.model From c7cdfb9c68f1a89121f5058018404f50b620124e Mon Sep 17 00:00:00 2001 From: thien Date: Fri, 8 Dec 2023 09:59:23 +0700 Subject: [PATCH 08/19] [IMP] despatch_advice_import: add raise error --- despatch_advice_import/wizard/despatch_advice_import.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/despatch_advice_import/wizard/despatch_advice_import.py b/despatch_advice_import/wizard/despatch_advice_import.py index 71e8e3bc54..4d7837a5f4 100644 --- a/despatch_advice_import/wizard/despatch_advice_import.py +++ b/despatch_advice_import/wizard/despatch_advice_import.py @@ -237,7 +237,9 @@ def _process_conditional(self, moves, parsed_order_document, line): moves_qty = sum(moves.mapped("product_qty")) if float_compare(qty, moves_qty, precision_digits=precision) >= 0: - return + raise UserError( + _("The product quantity is greater than the original product quantity") + ) # confirmed qty < ordered qty move_ids_to_backorder = [] From 880de959790259f32cccc93ce46c484f7227d619 Mon Sep 17 00:00:00 2001 From: thien Date: Fri, 1 Dec 2023 09:46:27 +0700 Subject: [PATCH 09/19] [UDP] despatch_advice_import: update unit test --- .../tests/test_despatch_advice_import.py | 86 ++++++++----------- 1 file changed, 37 insertions(+), 49 deletions(-) diff --git a/despatch_advice_import/tests/test_despatch_advice_import.py b/despatch_advice_import/tests/test_despatch_advice_import.py index fd79c8184b..58b1cab4e4 100644 --- a/despatch_advice_import/tests/test_despatch_advice_import.py +++ b/despatch_advice_import/tests/test_despatch_advice_import.py @@ -3,13 +3,13 @@ from odoo import _, fields from odoo.exceptions import UserError -from odoo.tests.common import SavepointCase +from odoo.tests.common import TransactionCase -class TestDespatchAdviceImport(SavepointCase): +class TestDespatchAdviceImport(TransactionCase): @classmethod def setUpClass(cls): - super(TestDespatchAdviceImport, cls).setUpClass() + super().setUpClass() cls.env = cls.env(context=dict(cls.env.context, tracking_disable=True)) cls.supplier = cls.env.ref("base.res_partner_12") cls.supplier.vat = "BE0477472701" @@ -18,28 +18,36 @@ def setUpClass(cls): { "name": "Product 1", "default_code": "987654321", - "seller_ids": [(0, 0, {"name": cls.supplier.id, "product_code": "P1"})], + "seller_ids": [ + (0, 0, {"partner_id": cls.supplier.id, "product_code": "P1"}) + ], } ) cls.product_2 = cls.env["product.product"].create( { "name": "Product 2", "default_code": "987654312", - "seller_ids": [(0, 0, {"name": cls.supplier.id, "product_code": "P2"})], + "seller_ids": [ + (0, 0, {"partner_id": cls.supplier.id, "product_code": "P2"}) + ], } ) cls.product_3 = cls.env["product.product"].create( { "name": "Product 3", "default_code": "123456789", - "seller_ids": [(0, 0, {"name": cls.supplier.id, "product_code": "P3"})], + "seller_ids": [ + (0, 0, {"partner_id": cls.supplier.id, "product_code": "P3"}) + ], } ) cls.product_4 = cls.env["product.product"].create( { "name": "Product 4", "default_code": "23456718", - "seller_ids": [(0, 0, {"name": cls.supplier.id, "product_code": "P4"})], + "seller_ids": [ + (0, 0, {"partner_id": cls.supplier.id, "product_code": "P4"}) + ], } ) cls.purchase_order = cls.env["purchase.order"].create( @@ -56,7 +64,7 @@ def setUpClass(cls): "name": cls.product_1.name, "date_planned": fields.Datetime.now(), "product_qty": 24, - "product_uom": cls.env.ref("product.product_uom_unit").id, + "product_uom": cls.env.ref("uom.product_uom_unit").id, "price_unit": 15, } ) @@ -67,7 +75,7 @@ def setUpClass(cls): "name": cls.product_2.name, "date_planned": fields.Datetime.now(), "product_qty": 5, - "product_uom": cls.env.ref("product.product_uom_unit").id, + "product_uom": cls.env.ref("uom.product_uom_unit").id, "price_unit": 25, } ) @@ -79,7 +87,7 @@ def setUpClass(cls): "name": cls.product_3.name, "date_planned": fields.Datetime.now(), "product_qty": 15, - "product_uom": cls.env.ref("product.product_uom_unit").id, + "product_uom": cls.env.ref("uom.product_uom_unit").id, "price_unit": 25, } ) @@ -91,14 +99,11 @@ def setUpClass(cls): "name": cls.product_4.name, "date_planned": fields.Datetime.now(), "product_qty": 15, - "product_uom": cls.env.ref("product.product_uom_unit").id, + "product_uom": cls.env.ref("uom.product_uom_unit").id, "price_unit": 25, } ) - cls._add_procurements(cls.line3, [5]) - cls._add_procurements(cls.line4, [2, 2, 2]) cls.purchase_order.button_confirm() - cls.picking = cls.purchase_order.picking_ids cls.DespatchAdviceImport = cls.env["despatch.advice.import"] @@ -122,21 +127,7 @@ def _get_base_data(self): "ref": str(self.purchase_order.name), } - @classmethod - def _add_procurements(cls, line, qties): - for qty in qties: - cls.env["procurement.order"].create( - { - "name": "Test", - "product_id": line.product_id.id, - "product_qty": qty, - "product_uom": line.product_uom.id, - "state": "done", - "purchase_line_id": line.id, - } - ) - - def test_00(self): + def test_no_purchase_order_name(self): """ Data: Data with unknown PO reference @@ -156,7 +147,7 @@ def test_00(self): ue.exception.name, _("No purchase order found for name 123456.") ) - def test_01(self): + def test_process_data_with_backorder_qty(self): """ backorder qty """ @@ -174,9 +165,7 @@ def test_01(self): move_ids = self.line1.move_ids self.assertEqual(len(move_ids), 2) self.assertEqual(sum(move_ids.mapped("product_qty")), self.line1.product_qty) - assigned = move_ids.filtered( - lambda s: s.state == "assigned" and s.product_qty == 3 - ) + assigned = move_ids.filtered(lambda s: s.state == "done" and s.product_qty == 3) self.assertEqual(assigned.product_qty, confirmed_qty) move_backorder = move_ids.filtered( @@ -185,7 +174,7 @@ def test_01(self): self.assertTrue(move_backorder) self.assertEqual(move_backorder.picking_id.backorder_id, assigned.picking_id) - def test_02(self): + def test_process_data_with_no_backorder_qty(self): """ no backorder qty """ @@ -203,12 +192,12 @@ def test_02(self): move_ids = self.line1.move_ids self.assertEqual(len(move_ids), 2) self.assertEqual(sum(move_ids.mapped("product_qty")), self.line1.product_qty) - assigned = move_ids.filtered(lambda s: s.state == "assigned") + assigned = move_ids.filtered(lambda s: s.state == "done") self.assertEqual(assigned.product_qty, confirmed_qty) cancel = move_ids.filtered(lambda s: s.state == "cancel") self.assertEqual(cancel.product_qty, 21) - def test_03(self): + def test_process_data_create_backorder(self): """ 2 back order created, second one is put in the same than the first 1 """ @@ -240,7 +229,7 @@ def test_03(self): sum(line1_move_ids.mapped("product_qty")), self.line1.product_qty ) move_confirmed = line1_move_ids.filtered( - lambda s: s.state == "assigned" and s.product_qty == line1_confirmed_qty + lambda s: s.state == "done" and s.product_qty == line1_confirmed_qty ) self.assertTrue(move_confirmed) self.assertEqual(move_confirmed.product_qty, line1_confirmed_qty) @@ -252,6 +241,7 @@ def test_03(self): move_backorder.picking_id.backorder_id, move_confirmed.picking_id, ) + # line2 line2_move_ids = self.line2.move_ids self.assertEqual(len(line2_move_ids), 2) @@ -259,7 +249,7 @@ def test_03(self): sum(line2_move_ids.mapped("product_qty")), self.line2.product_qty ) move_confirmed = line2_move_ids.filtered( - lambda s: s.state == "assigned" and s.product_qty == line2_confirmed_qty + lambda s: s.state == "done" and s.product_qty == line2_confirmed_qty ) self.assertTrue(move_confirmed) self.assertEqual(move_confirmed.product_qty, line2_confirmed_qty) @@ -273,7 +263,7 @@ def test_03(self): move_confirmed.picking_id, ) - def test_04(self): + def test_partial_delivery_with_backorder(self): """ """ data = self._get_base_data() confirmed_qty = self.line1.product_qty - 3 @@ -290,30 +280,28 @@ def test_04(self): self.DespatchAdviceImport.process_data(data) self.assertEqual(len(self.purchase_order.picking_ids), 2) move_ids = self.line1.move_ids + self.assertEqual(len(move_ids), 3) self.assertEqual(sum(move_ids.mapped("product_qty")), self.line1.product_qty) move_confirmed = move_ids.filtered( - lambda s: s.state == "assigned" and s.product_qty == confirmed_qty + lambda s: s.state == "done" and s.product_qty == confirmed_qty ) self.assertTrue(move_confirmed) move_cancel = move_ids.filtered( lambda s: s.state == "cancel" and s.product_qty == 1 ) self.assertTrue(move_cancel) - self.assertEqual( - _("No backorder planned by the supplier."), - move_cancel.note, - ) move_backorder = move_ids.filtered( lambda s: s.state == "assigned" and s.product_qty == 2 ) + self.assertTrue(move_backorder) self.assertEqual( move_backorder.picking_id.backorder_id, move_confirmed.picking_id, ) - def test_05(self): + def test_qty_larger_backorder_qty(self): """ """ data = self._get_base_data() confirmed_qty = 6 @@ -326,10 +314,10 @@ def test_05(self): self.DespatchAdviceImport.process_data(data) self.assertEqual(len(self.purchase_order.picking_ids), 2) move_ids = self.line3.move_ids - self.assertEqual(len(move_ids), 4) + self.assertEqual(len(move_ids), 3) self.assertEqual(sum(move_ids.mapped("product_qty")), self.line3.product_qty) moves_confirmed = move_ids.filtered( - lambda s: s.state == "assigned" and not s.picking_id.backorder_id + lambda s: s.state == "done" and not s.picking_id.backorder_id ) self.assertEqual(sum(moves_confirmed.mapped("product_qty")), confirmed_qty) @@ -346,7 +334,7 @@ def test_05(self): moves_confirmed[0].picking_id, ) - def test_06(self): + def test_qty_equal_backorder_qty(self): """ """ data = self._get_base_data() confirmed_qty = 3 @@ -365,7 +353,7 @@ def test_06(self): move_ids = self.line4.move_ids self.assertEqual(sum(move_ids.mapped("product_qty")), self.line4.product_qty) moves_confirmed = move_ids.filtered( - lambda s: s.state == "assigned" and not s.picking_id.backorder_id + lambda s: s.state == "done" and not s.picking_id.backorder_id ) self.assertEqual(sum(moves_confirmed.mapped("product_qty")), 3) From 5c63117b720df0367022d5491c7d167301ecd22c Mon Sep 17 00:00:00 2001 From: thien Date: Mon, 4 Dec 2023 10:38:01 +0700 Subject: [PATCH 10/19] [IMP] despatch_advice_import: update readme --- despatch_advice_import/README.rst | 119 +++-- .../i18n/despatch_advice_import.pot | 227 +++++++++ .../readme/CONTRIBUTORS.rst | 6 + despatch_advice_import/readme/CREDITS.rst | 1 + despatch_advice_import/readme/DESCRIPTION.rst | 1 + .../static/description/index.html | 438 ++++++++++++++++++ 6 files changed, 730 insertions(+), 62 deletions(-) create mode 100644 despatch_advice_import/i18n/despatch_advice_import.pot create mode 100644 despatch_advice_import/readme/CONTRIBUTORS.rst create mode 100644 despatch_advice_import/readme/CREDITS.rst create mode 100644 despatch_advice_import/readme/DESCRIPTION.rst create mode 100644 despatch_advice_import/static/description/index.html diff --git a/despatch_advice_import/README.rst b/despatch_advice_import/README.rst index 10082c9df6..ef72c8b018 100644 --- a/despatch_advice_import/README.rst +++ b/despatch_advice_import/README.rst @@ -1,91 +1,86 @@ -.. image:: https://img.shields.io/badge/licence-AGPL--3-blue.svg - :target: http://www.gnu.org/licenses/agpl-3.0-standalone.html - :alt: License: AGPL-3 - ====================== Despatch Advice Import ====================== -Despatch Advice import - -Installation -============ - -To install this module, you need to: - -#. Do this ... - -Configuration -============= - -To configure this module, you need to: - -#. Go to ... - -.. figure:: path/to/local/image.png - :alt: alternative description - :width: 600 px - -Usage -===== - -To use this module, you need to: - -#. Go to ... - -.. image:: https://odoo-community.org/website/image/ir.attachment/5784_f2813bd/datas - :alt: Try me on Runbot - :target: https://runbot.odoo-community.org/runbot/{repo_id}/{branch} - -.. repo_id is available in https://github.com/OCA/maintainer-tools/blob/master/tools/repos_with_ids.txt -.. branch is "8.0" for example - -Known issues / Roadmap -====================== - -* ... +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:db45990c17a4b97cb32e7e24ecf9193bf8fc2adfa1681de59784acd65db7d2a7 + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png + :target: https://odoo-community.org/page/development-status + :alt: Beta +.. |badge2| image:: https://img.shields.io/badge/licence-AGPL--3-blue.png + :target: http://www.gnu.org/licenses/agpl-3.0-standalone.html + :alt: License: AGPL-3 +.. |badge3| image:: https://img.shields.io/badge/github-OCA%2Fedi-lightgray.png?logo=github + :target: https://github.com/OCA/edi/tree/16.0/despatch_advice_import + :alt: OCA/edi +.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png + :target: https://translation.odoo-community.org/projects/edi-16-0/edi-16-0-despatch_advice_import + :alt: Translate me on Weblate +.. |badge5| image:: https://img.shields.io/badge/runboat-Try%20me-875A7B.png + :target: https://runboat.odoo-community.org/builds?repo=OCA/edi&target_branch=16.0 + :alt: Try me on Runboat + +|badge1| |badge2| |badge3| |badge4| |badge5| + +This module will support import despatch advice file + +**Table of contents** + +.. contents:: + :local: Bug Tracker =========== -Bugs are tracked on `GitHub Issues -`_. In case of trouble, please -check there if your issue has already been reported. If you spotted it first, -help us smash it by providing detailed and welcomed feedback. +Bugs are tracked on `GitHub Issues `_. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +`feedback `_. + +Do not contact contributors directly about support or help with technical issues. Credits ======= -Images ------- +Authors +~~~~~~~ -* Odoo Community Association: `Icon `_. +* ACSONE SA/NV Contributors ------------- +~~~~~~~~~~~~ -* Firstname Lastname -* Second Person +* Laurent Mignon +* `Trobz `_: -Funders -------- + * Thien +* Simone Orsi +* Jacques-Etienne Baudoux -The development of this module has been financially supported by: +Other credits +~~~~~~~~~~~~~ -* Company 1 name -* Company 2 name +The migration of this module from 10.0 to 16.0 was financially supported by Camptocamp -Maintainer ----------- +Maintainers +~~~~~~~~~~~ + +This module is maintained by the OCA. .. image:: https://odoo-community.org/logo.png :alt: Odoo Community Association :target: https://odoo-community.org -This module is maintained by the OCA. - OCA, or the Odoo Community Association, is a nonprofit organization whose mission is to support the collaborative development of Odoo features and promote its widespread use. -To contribute to this module, please visit https://odoo-community.org. +This module is part of the `OCA/edi `_ project on GitHub. + +You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. diff --git a/despatch_advice_import/i18n/despatch_advice_import.pot b/despatch_advice_import/i18n/despatch_advice_import.pot new file mode 100644 index 0000000000..370238628e --- /dev/null +++ b/despatch_advice_import/i18n/despatch_advice_import.pot @@ -0,0 +1,227 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * despatch_advice_import +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 16.0\n" +"Report-Msgid-Bugs-To: \n" +"Last-Translator: \n" +"Language-Team: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: \n" + +#. module: despatch_advice_import +#: model_terms:ir.ui.view,arch_db:despatch_advice_import.despatch_advice_import_form_view +msgid "Cancel" +msgstr "" + +#. module: despatch_advice_import +#: model:ir.model.fields,field_description:despatch_advice_import.field_despatch_advice_import__create_uid +msgid "Created by" +msgstr "" + +#. module: despatch_advice_import +#: model:ir.model.fields,field_description:despatch_advice_import.field_despatch_advice_import__create_date +msgid "Created on" +msgstr "" + +#. module: despatch_advice_import +#. odoo-python +#: code:addons/despatch_advice_import/wizard/despatch_advice_import.py:0 +#, python-format +msgid "Delivery cancelled by the supplier." +msgstr "" + +#. module: despatch_advice_import +#. odoo-python +#: code:addons/despatch_advice_import/wizard/despatch_advice_import.py:0 +#, python-format +msgid "Delivery confirmed by the supplier." +msgstr "" + +#. module: despatch_advice_import +#. odoo-python +#: code:addons/despatch_advice_import/wizard/despatch_advice_import.py:0 +#, python-format +msgid "Delivery confirmed with amendment by the supplier." +msgstr "" + +#. module: despatch_advice_import +#: model_terms:ir.ui.view,arch_db:despatch_advice_import.despatch_advice_import_form_view +msgid "Despatch Advice Import" +msgstr "" + +#. module: despatch_advice_import +#: model:ir.model,name:despatch_advice_import.model_despatch_advice_import +msgid "Despatch Advice Import from Files" +msgstr "" + +#. module: despatch_advice_import +#: model:ir.model.fields,field_description:despatch_advice_import.field_despatch_advice_import__display_name +msgid "Display Name" +msgstr "" + +#. module: despatch_advice_import +#: model:ir.model.fields,field_description:despatch_advice_import.field_despatch_advice_import__filename +msgid "File Name" +msgstr "" + +#. module: despatch_advice_import +#: model:ir.model.fields,field_description:despatch_advice_import.field_despatch_advice_import__id +msgid "ID" +msgstr "" + +#. module: despatch_advice_import +#: model_terms:ir.ui.view,arch_db:despatch_advice_import.despatch_advice_import_form_view +msgid "" +"If it is a PDF file, Odoo will try to find an XML file in the attachments of" +" the PDF file and then use this XML file." +msgstr "" + +#. module: despatch_advice_import +#: model_terms:ir.ui.view,arch_db:despatch_advice_import.despatch_advice_import_form_view +msgid "" +"If it is an XML file, Odoo will parse it if the module that adds support for" +" this XML format is installed. For the" +msgstr "" + +#. module: despatch_advice_import +#: model:ir.actions.act_window,name:despatch_advice_import.despatch_advice_import_action +msgid "Import" +msgstr "" + +#. module: despatch_advice_import +#: model_terms:ir.ui.view,arch_db:despatch_advice_import.despatch_advice_import_form_view +msgid "Import document" +msgstr "" + +#. module: despatch_advice_import +#: model:ir.model.fields,field_description:despatch_advice_import.field_despatch_advice_import____last_update +msgid "Last Modified on" +msgstr "" + +#. module: despatch_advice_import +#: model:ir.model.fields,field_description:despatch_advice_import.field_despatch_advice_import__write_uid +msgid "Last Updated by" +msgstr "" + +#. module: despatch_advice_import +#: model:ir.model.fields,field_description:despatch_advice_import.field_despatch_advice_import__write_date +msgid "Last Updated on" +msgstr "" + +#. module: despatch_advice_import +#. odoo-python +#: code:addons/despatch_advice_import/wizard/despatch_advice_import.py:0 +#, python-format +msgid "Missing document file" +msgstr "" + +#. module: despatch_advice_import +#. odoo-python +#: code:addons/despatch_advice_import/wizard/despatch_advice_import.py:0 +#, python-format +msgid "Missing document filename" +msgstr "" + +#. module: despatch_advice_import +#. odoo-python +#: code:addons/despatch_advice_import/wizard/despatch_advice_import.py:0 +#: code:addons/despatch_advice_import/wizard/despatch_advice_import.py:0 +#, python-format +msgid "No purchase order found for name %s." +msgstr "" + +#. module: despatch_advice_import +#. odoo-python +#: code:addons/despatch_advice_import/tests/test_despatch_advice_import.py:0 +#, python-format +msgid "No purchase order found for name 123456." +msgstr "" + +#. module: despatch_advice_import +#. odoo-python +#: code:addons/despatch_advice_import/wizard/despatch_advice_import.py:0 +#, python-format +msgid "The product quantity is greater than the original product quantity" +msgstr "" + +#. module: despatch_advice_import +#. odoo-python +#: code:addons/despatch_advice_import/wizard/despatch_advice_import.py:0 +#, python-format +msgid "There are no embedded XML file in this PDF file." +msgstr "" + +#. module: despatch_advice_import +#. odoo-python +#: code:addons/despatch_advice_import/wizard/despatch_advice_import.py:0 +#, python-format +msgid "This XML file is not XML-compliant" +msgstr "" + +#. module: despatch_advice_import +#. odoo-python +#: code:addons/despatch_advice_import/wizard/despatch_advice_import.py:0 +#, python-format +msgid "" +"This file '%s' is not recognised as XML nor PDF file. Please check the file " +"and it's extension." +msgstr "" + +#. module: despatch_advice_import +#. odoo-python +#: code:addons/despatch_advice_import/wizard/despatch_advice_import.py:0 +#, python-format +msgid "" +"This type of XML Order Document is not supported. Did you install the module" +" to support this XML format?" +msgstr "" + +#. module: despatch_advice_import +#. odoo-python +#: code:addons/despatch_advice_import/wizard/despatch_advice_import.py:0 +#, python-format +msgid "" +"This type of XML Order Response is not supported. Did you install the module" +" to support this XML format?" +msgstr "" + +#. module: despatch_advice_import +#: model:ir.ui.menu,name:despatch_advice_import.despatch_advice_import_importer_menu +msgid "UBL Despatch Advice Importer" +msgstr "" + +#. module: despatch_advice_import +#: model_terms:ir.ui.view,arch_db:despatch_advice_import.despatch_advice_import_form_view +msgid "Universal Business Language" +msgstr "" + +#. module: despatch_advice_import +#: model:ir.model.fields,help:despatch_advice_import.field_despatch_advice_import__document +msgid "" +"Upload an Despatch Advice file that you received from your supplier. " +"Supported formats: XML and PDF (PDF with an embeded XML file)." +msgstr "" + +#. module: despatch_advice_import +#: model_terms:ir.ui.view,arch_db:despatch_advice_import.despatch_advice_import_form_view +msgid "" +"Upload below the DespatchAdvice you received from your supplier. When you " +"click on the import button:" +msgstr "" + +#. module: despatch_advice_import +#: model:ir.model.fields,field_description:despatch_advice_import.field_despatch_advice_import__document +msgid "XML or PDF Despatch Advice" +msgstr "" + +#. module: despatch_advice_import +#: model_terms:ir.ui.view,arch_db:despatch_advice_import.despatch_advice_import_form_view +msgid "" +"format (UBL), you should install the module " +"despatch_advice_import_ubl." +msgstr "" diff --git a/despatch_advice_import/readme/CONTRIBUTORS.rst b/despatch_advice_import/readme/CONTRIBUTORS.rst new file mode 100644 index 0000000000..424b6df221 --- /dev/null +++ b/despatch_advice_import/readme/CONTRIBUTORS.rst @@ -0,0 +1,6 @@ +* Laurent Mignon +* `Trobz `_: + + * Thien +* Simone Orsi +* Jacques-Etienne Baudoux diff --git a/despatch_advice_import/readme/CREDITS.rst b/despatch_advice_import/readme/CREDITS.rst new file mode 100644 index 0000000000..6ebc07938a --- /dev/null +++ b/despatch_advice_import/readme/CREDITS.rst @@ -0,0 +1 @@ +The migration of this module from 10.0 to 16.0 was financially supported by Camptocamp \ No newline at end of file diff --git a/despatch_advice_import/readme/DESCRIPTION.rst b/despatch_advice_import/readme/DESCRIPTION.rst new file mode 100644 index 0000000000..89062fc34c --- /dev/null +++ b/despatch_advice_import/readme/DESCRIPTION.rst @@ -0,0 +1 @@ +This module will support import despatch advice file \ No newline at end of file diff --git a/despatch_advice_import/static/description/index.html b/despatch_advice_import/static/description/index.html new file mode 100644 index 0000000000..b599a265a6 --- /dev/null +++ b/despatch_advice_import/static/description/index.html @@ -0,0 +1,438 @@ + + + + + + +Despatch Advice Import + + + +
+

Despatch Advice Import

+ + +

Beta License: AGPL-3 OCA/edi Translate me on Weblate Try me on Runboat

+

This module will support import despatch advice file

+

Table of contents

+ +
+

Bug Tracker

+

Bugs are tracked on GitHub Issues. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +feedback.

+

Do not contact contributors directly about support or help with technical issues.

+
+
+

Credits

+
+

Authors

+
    +
  • ACSONE SA/NV
  • +
+
+
+

Contributors

+ +
+
+

Other credits

+

The migration of this module from 10.0 to 16.0 was financially supported by Camptocamp

+
+
+

Maintainers

+

This module is maintained by the OCA.

+Odoo Community Association +

OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use.

+

This module is part of the OCA/edi project on GitHub.

+

You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute.

+
+
+
+ + From ae426ab1156ce3b476668a735e9cd838d00379bd Mon Sep 17 00:00:00 2001 From: Ivorra78 Date: Wed, 14 Feb 2024 12:55:39 +0000 Subject: [PATCH 11/19] Added translation using Weblate (Spanish) --- despatch_advice_import/i18n/es.po | 245 ++++++++++++++++++++++++++++++ 1 file changed, 245 insertions(+) create mode 100644 despatch_advice_import/i18n/es.po diff --git a/despatch_advice_import/i18n/es.po b/despatch_advice_import/i18n/es.po new file mode 100644 index 0000000000..909eeb87a7 --- /dev/null +++ b/despatch_advice_import/i18n/es.po @@ -0,0 +1,245 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * despatch_advice_import +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 16.0\n" +"Report-Msgid-Bugs-To: \n" +"PO-Revision-Date: 2024-02-14 15:36+0000\n" +"Last-Translator: Ivorra78 \n" +"Language-Team: none\n" +"Language: es\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: nplurals=2; plural=n != 1;\n" +"X-Generator: Weblate 4.17\n" + +#. module: despatch_advice_import +#: model_terms:ir.ui.view,arch_db:despatch_advice_import.despatch_advice_import_form_view +msgid "Cancel" +msgstr "Cancelar" + +#. module: despatch_advice_import +#: model:ir.model.fields,field_description:despatch_advice_import.field_despatch_advice_import__create_uid +msgid "Created by" +msgstr "Creado por" + +#. module: despatch_advice_import +#: model:ir.model.fields,field_description:despatch_advice_import.field_despatch_advice_import__create_date +msgid "Created on" +msgstr "Creado el" + +#. module: despatch_advice_import +#. odoo-python +#: code:addons/despatch_advice_import/wizard/despatch_advice_import.py:0 +#, python-format +msgid "Delivery cancelled by the supplier." +msgstr "Entrega anulada por el proveedor." + +#. module: despatch_advice_import +#. odoo-python +#: code:addons/despatch_advice_import/wizard/despatch_advice_import.py:0 +#, python-format +msgid "Delivery confirmed by the supplier." +msgstr "Entrega confirmada por el proveedor." + +#. module: despatch_advice_import +#. odoo-python +#: code:addons/despatch_advice_import/wizard/despatch_advice_import.py:0 +#, python-format +msgid "Delivery confirmed with amendment by the supplier." +msgstr "Entrega confirmada con modificación por el proveedor." + +#. module: despatch_advice_import +#: model_terms:ir.ui.view,arch_db:despatch_advice_import.despatch_advice_import_form_view +msgid "Despatch Advice Import" +msgstr "Aviso de Expedición Importación" + +#. module: despatch_advice_import +#: model:ir.model,name:despatch_advice_import.model_despatch_advice_import +msgid "Despatch Advice Import from Files" +msgstr "Aviso de Expedición Importación desde Ficheros" + +#. module: despatch_advice_import +#: model:ir.model.fields,field_description:despatch_advice_import.field_despatch_advice_import__display_name +msgid "Display Name" +msgstr "Mostrar Nombre" + +#. module: despatch_advice_import +#: model:ir.model.fields,field_description:despatch_advice_import.field_despatch_advice_import__filename +msgid "File Name" +msgstr "Nombre del Archivo" + +#. module: despatch_advice_import +#: model:ir.model.fields,field_description:despatch_advice_import.field_despatch_advice_import__id +msgid "ID" +msgstr "ID" + +#. module: despatch_advice_import +#: model_terms:ir.ui.view,arch_db:despatch_advice_import.despatch_advice_import_form_view +msgid "" +"If it is a PDF file, Odoo will try to find an XML file in the attachments of" +" the PDF file and then use this XML file." +msgstr "" +"Si es un archivo PDF, Odoo tratará de encontrar un archivo XML en los " +"adjuntos del archivo PDF y luego usará este archivo XML." + +#. module: despatch_advice_import +#: model_terms:ir.ui.view,arch_db:despatch_advice_import.despatch_advice_import_form_view +msgid "" +"If it is an XML file, Odoo will parse it if the module that adds support for" +" this XML format is installed. For the" +msgstr "" +"Si es un archivo XML, Odoo lo analizará si está instalado el módulo que " +"agrega soporte para este formato XML. Para el" + +#. module: despatch_advice_import +#: model:ir.actions.act_window,name:despatch_advice_import.despatch_advice_import_action +msgid "Import" +msgstr "Importar" + +#. module: despatch_advice_import +#: model_terms:ir.ui.view,arch_db:despatch_advice_import.despatch_advice_import_form_view +msgid "Import document" +msgstr "Importar documento" + +#. module: despatch_advice_import +#: model:ir.model.fields,field_description:despatch_advice_import.field_despatch_advice_import____last_update +msgid "Last Modified on" +msgstr "Última Modificación el" + +#. module: despatch_advice_import +#: model:ir.model.fields,field_description:despatch_advice_import.field_despatch_advice_import__write_uid +msgid "Last Updated by" +msgstr "Última Actualización por" + +#. module: despatch_advice_import +#: model:ir.model.fields,field_description:despatch_advice_import.field_despatch_advice_import__write_date +msgid "Last Updated on" +msgstr "Última Actualización el" + +#. module: despatch_advice_import +#. odoo-python +#: code:addons/despatch_advice_import/wizard/despatch_advice_import.py:0 +#, python-format +msgid "Missing document file" +msgstr "Falta un archivo de documento" + +#. module: despatch_advice_import +#. odoo-python +#: code:addons/despatch_advice_import/wizard/despatch_advice_import.py:0 +#, python-format +msgid "Missing document filename" +msgstr "Falta el nombre de archivo del documento" + +#. module: despatch_advice_import +#. odoo-python +#: code:addons/despatch_advice_import/wizard/despatch_advice_import.py:0 +#: code:addons/despatch_advice_import/wizard/despatch_advice_import.py:0 +#, python-format +msgid "No purchase order found for name %s." +msgstr "No se ha encontrado ninguna orden de compra para el nombre %s." + +#. module: despatch_advice_import +#. odoo-python +#: code:addons/despatch_advice_import/tests/test_despatch_advice_import.py:0 +#, python-format +msgid "No purchase order found for name 123456." +msgstr "No se ha encontrado ninguna orden de compra a nombre de 123456." + +#. module: despatch_advice_import +#. odoo-python +#: code:addons/despatch_advice_import/wizard/despatch_advice_import.py:0 +#, python-format +msgid "The product quantity is greater than the original product quantity" +msgstr "La cantidad de producto es superior a la cantidad de producto original" + +#. module: despatch_advice_import +#. odoo-python +#: code:addons/despatch_advice_import/wizard/despatch_advice_import.py:0 +#, python-format +msgid "There are no embedded XML file in this PDF file." +msgstr "No hay ningún archivo XML incorporado en este archivo PDF." + +#. module: despatch_advice_import +#. odoo-python +#: code:addons/despatch_advice_import/wizard/despatch_advice_import.py:0 +#, python-format +msgid "This XML file is not XML-compliant" +msgstr "Este archivo no es compatible con el formato XML" + +#. module: despatch_advice_import +#. odoo-python +#: code:addons/despatch_advice_import/wizard/despatch_advice_import.py:0 +#, python-format +msgid "" +"This file '%s' is not recognised as XML nor PDF file. Please check the file " +"and it's extension." +msgstr "" +"Este archivo '%s' no se reconoce como archivo XML ni PDF. Compruebe el " +"archivo y su extensión." + +#. module: despatch_advice_import +#. odoo-python +#: code:addons/despatch_advice_import/wizard/despatch_advice_import.py:0 +#, python-format +msgid "" +"This type of XML Order Document is not supported. Did you install the module" +" to support this XML format?" +msgstr "" +"Este tipo de documento de pedido XML no es compatible. ¿Ha instalado el " +"módulo para admitir este formato XML?" + +#. module: despatch_advice_import +#. odoo-python +#: code:addons/despatch_advice_import/wizard/despatch_advice_import.py:0 +#, python-format +msgid "" +"This type of XML Order Response is not supported. Did you install the module" +" to support this XML format?" +msgstr "" +"Este tipo de respuesta de pedido XML no es compatible. ¿Ha instalado el " +"módulo para admitir este formato XML?" + +#. module: despatch_advice_import +#: model:ir.ui.menu,name:despatch_advice_import.despatch_advice_import_importer_menu +msgid "UBL Despatch Advice Importer" +msgstr "Aviso de Expedición UBL Importador" + +#. module: despatch_advice_import +#: model_terms:ir.ui.view,arch_db:despatch_advice_import.despatch_advice_import_form_view +msgid "Universal Business Language" +msgstr "Lenguaje Comercial Universal" + +#. module: despatch_advice_import +#: model:ir.model.fields,help:despatch_advice_import.field_despatch_advice_import__document +msgid "" +"Upload an Despatch Advice file that you received from your supplier. " +"Supported formats: XML and PDF (PDF with an embeded XML file)." +msgstr "" +"Cargue un Archivo de Aviso de Expedición que haya recibido de su proveedor. " +"Formatos admitidos: XML y PDF (PDF con un archivo XML incrustado)." + +#. module: despatch_advice_import +#: model_terms:ir.ui.view,arch_db:despatch_advice_import.despatch_advice_import_form_view +msgid "" +"Upload below the DespatchAdvice you received from your supplier. When you " +"click on the import button:" +msgstr "" +"Cargue a continuación el Aviso de Expedición que ha recibido de su " +"proveedor. Cuando haga clic en el botón de importación:" + +#. module: despatch_advice_import +#: model:ir.model.fields,field_description:despatch_advice_import.field_despatch_advice_import__document +msgid "XML or PDF Despatch Advice" +msgstr "Aviso de Envío XML o PDF" + +#. module: despatch_advice_import +#: model_terms:ir.ui.view,arch_db:despatch_advice_import.despatch_advice_import_form_view +msgid "" +"format (UBL), you should install the module " +"despatch_advice_import_ubl." +msgstr "" +"formato (UBL), debes instalar el módulo despatch_advice_import_ubl." From fcd1c165ffbe914f5b8331eff4d138d07bf083f5 Mon Sep 17 00:00:00 2001 From: duongtq Date: Mon, 26 Feb 2024 09:04:34 +0700 Subject: [PATCH 12/19] [IMP] despatch_advice_import_ubl: collect package (logistic transport units) --- despatch_advice_import/wizard/despatch_advice_import.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/despatch_advice_import/wizard/despatch_advice_import.py b/despatch_advice_import/wizard/despatch_advice_import.py index 4d7837a5f4..72c8455472 100644 --- a/despatch_advice_import/wizard/despatch_advice_import.py +++ b/despatch_advice_import/wizard/despatch_advice_import.py @@ -132,10 +132,10 @@ def process_document(self): ) self.process_data(parsed_order_document) - def _collect_lines_by_id(self, lines_doc): + def _collect_lines_by_id(self, lines_doc, key="order_line_id"): lines_by_id = {} for line in lines_doc: - line_id = int(line["order_line_id"]) + line_id = int(line[key]) if line_id in lines_by_id: lines_by_id[line_id]["qty"] += line["qty"] lines_by_id[line_id]["backorder_qty"] += line["backorder_qty"] From f63319e506b30a663dc3a5dc266ac55d080fef30 Mon Sep 17 00:00:00 2001 From: Tran Anh Tuan Date: Wed, 20 Mar 2024 11:12:18 +0700 Subject: [PATCH 13/19] [IMP] despatch_advice_import: allow to validate picking with quantity larger than the reserved quantity --- despatch_advice_import/README.rst | 2 +- despatch_advice_import/__manifest__.py | 2 +- .../i18n/despatch_advice_import.pot | 5 ++++ despatch_advice_import/i18n/es.po | 26 ++++++++++------- .../static/description/index.html | 3 +- .../tests/test_despatch_advice_import.py | 29 ++++++++++++++++++- .../wizard/despatch_advice_import.py | 11 +++++-- .../wizard/despatch_advice_import.xml | 1 + 8 files changed, 61 insertions(+), 18 deletions(-) diff --git a/despatch_advice_import/README.rst b/despatch_advice_import/README.rst index ef72c8b018..d08ad9cdc3 100644 --- a/despatch_advice_import/README.rst +++ b/despatch_advice_import/README.rst @@ -7,7 +7,7 @@ Despatch Advice Import !! This file is generated by oca-gen-addon-readme !! !! changes will be overwritten. !! !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! - !! source digest: sha256:db45990c17a4b97cb32e7e24ecf9193bf8fc2adfa1681de59784acd65db7d2a7 + !! source digest: sha256:490fabc1b1ec065fc5df45d70be3cc1275f2d9523184258ec6a133f00d9153b0 !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! .. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png diff --git a/despatch_advice_import/__manifest__.py b/despatch_advice_import/__manifest__.py index 49478bac5d..ca4d0f657f 100644 --- a/despatch_advice_import/__manifest__.py +++ b/despatch_advice_import/__manifest__.py @@ -5,7 +5,7 @@ "name": "Despatch Advice Import", "summary": """ Despatch Advice import""", - "version": "16.0.1.0.0", + "version": "16.0.1.1.0", "website": "https://github.com/OCA/edi", "license": "AGPL-3", "author": "ACSONE SA/NV,Odoo Community Association (OCA)", diff --git a/despatch_advice_import/i18n/despatch_advice_import.pot b/despatch_advice_import/i18n/despatch_advice_import.pot index 370238628e..66dabce3c1 100644 --- a/despatch_advice_import/i18n/despatch_advice_import.pot +++ b/despatch_advice_import/i18n/despatch_advice_import.pot @@ -13,6 +13,11 @@ msgstr "" "Content-Transfer-Encoding: \n" "Plural-Forms: \n" +#. module: despatch_advice_import +#: model:ir.model.fields,field_description:despatch_advice_import.field_despatch_advice_import__allow_validate_over_qty +msgid "Allow Validate Over Quantity" +msgstr "" + #. module: despatch_advice_import #: model_terms:ir.ui.view,arch_db:despatch_advice_import.despatch_advice_import_form_view msgid "Cancel" diff --git a/despatch_advice_import/i18n/es.po b/despatch_advice_import/i18n/es.po index 909eeb87a7..a5d506d67f 100644 --- a/despatch_advice_import/i18n/es.po +++ b/despatch_advice_import/i18n/es.po @@ -16,6 +16,11 @@ msgstr "" "Plural-Forms: nplurals=2; plural=n != 1;\n" "X-Generator: Weblate 4.17\n" +#. module: despatch_advice_import +#: model:ir.model.fields,field_description:despatch_advice_import.field_despatch_advice_import__allow_validate_over_qty +msgid "Allow Validate Over Quantity" +msgstr "" + #. module: despatch_advice_import #: model_terms:ir.ui.view,arch_db:despatch_advice_import.despatch_advice_import_form_view msgid "Cancel" @@ -80,8 +85,8 @@ msgstr "ID" #. module: despatch_advice_import #: model_terms:ir.ui.view,arch_db:despatch_advice_import.despatch_advice_import_form_view msgid "" -"If it is a PDF file, Odoo will try to find an XML file in the attachments of" -" the PDF file and then use this XML file." +"If it is a PDF file, Odoo will try to find an XML file in the attachments of " +"the PDF file and then use this XML file." msgstr "" "Si es un archivo PDF, Odoo tratará de encontrar un archivo XML en los " "adjuntos del archivo PDF y luego usará este archivo XML." @@ -89,8 +94,8 @@ msgstr "" #. module: despatch_advice_import #: model_terms:ir.ui.view,arch_db:despatch_advice_import.despatch_advice_import_form_view msgid "" -"If it is an XML file, Odoo will parse it if the module that adds support for" -" this XML format is installed. For the" +"If it is an XML file, Odoo will parse it if the module that adds support for " +"this XML format is installed. For the" msgstr "" "Si es un archivo XML, Odoo lo analizará si está instalado el módulo que " "agrega soporte para este formato XML. Para el" @@ -137,7 +142,6 @@ msgstr "Falta el nombre de archivo del documento" #. module: despatch_advice_import #. odoo-python #: code:addons/despatch_advice_import/wizard/despatch_advice_import.py:0 -#: code:addons/despatch_advice_import/wizard/despatch_advice_import.py:0 #, python-format msgid "No purchase order found for name %s." msgstr "No se ha encontrado ninguna orden de compra para el nombre %s." @@ -186,8 +190,8 @@ msgstr "" #: code:addons/despatch_advice_import/wizard/despatch_advice_import.py:0 #, python-format msgid "" -"This type of XML Order Document is not supported. Did you install the module" -" to support this XML format?" +"This type of XML Order Document is not supported. Did you install the module " +"to support this XML format?" msgstr "" "Este tipo de documento de pedido XML no es compatible. ¿Ha instalado el " "módulo para admitir este formato XML?" @@ -197,8 +201,8 @@ msgstr "" #: code:addons/despatch_advice_import/wizard/despatch_advice_import.py:0 #, python-format msgid "" -"This type of XML Order Response is not supported. Did you install the module" -" to support this XML format?" +"This type of XML Order Response is not supported. Did you install the module " +"to support this XML format?" msgstr "" "Este tipo de respuesta de pedido XML no es compatible. ¿Ha instalado el " "módulo para admitir este formato XML?" @@ -239,7 +243,7 @@ msgstr "Aviso de Envío XML o PDF" #. module: despatch_advice_import #: model_terms:ir.ui.view,arch_db:despatch_advice_import.despatch_advice_import_form_view msgid "" -"format (UBL), you should install the module " -"despatch_advice_import_ubl." +"format (UBL), you should install the module despatch_advice_import_ubl." msgstr "" "formato (UBL), debes instalar el módulo despatch_advice_import_ubl." diff --git a/despatch_advice_import/static/description/index.html b/despatch_advice_import/static/description/index.html index b599a265a6..a6c8ef117f 100644 --- a/despatch_advice_import/static/description/index.html +++ b/despatch_advice_import/static/description/index.html @@ -1,4 +1,3 @@ - @@ -367,7 +366,7 @@

Despatch Advice Import

!! This file is generated by oca-gen-addon-readme !! !! changes will be overwritten. !! !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! -!! source digest: sha256:db45990c17a4b97cb32e7e24ecf9193bf8fc2adfa1681de59784acd65db7d2a7 +!! source digest: sha256:490fabc1b1ec065fc5df45d70be3cc1275f2d9523184258ec6a133f00d9153b0 !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! -->

Beta License: AGPL-3 OCA/edi Translate me on Weblate Try me on Runboat

This module will support import despatch advice file

diff --git a/despatch_advice_import/tests/test_despatch_advice_import.py b/despatch_advice_import/tests/test_despatch_advice_import.py index 58b1cab4e4..70960b39c3 100644 --- a/despatch_advice_import/tests/test_despatch_advice_import.py +++ b/despatch_advice_import/tests/test_despatch_advice_import.py @@ -1,6 +1,8 @@ # Copyright 2020 ACSONE SA/NV # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). +import base64 + from odoo import _, fields from odoo.exceptions import UserError from odoo.tests.common import TransactionCase @@ -105,7 +107,9 @@ def setUpClass(cls): ) cls.purchase_order.button_confirm() - cls.DespatchAdviceImport = cls.env["despatch.advice.import"] + cls.DespatchAdviceImport = cls.env["despatch.advice.import"].create( + {"document": base64.b64encode(bytes("", "utf-8"))} + ) def order_line_to_data(self, order_line, qty=None, backorder_qty=None): return { @@ -365,3 +369,26 @@ def test_qty_equal_backorder_qty(self): lambda s: s.state == "assigned" and s.picking_id.backorder_id ) self.assertEqual(sum(moves_backorder.mapped("product_qty")), 3) + + def test_confirmed_qty_larger_reserved_qty(self): + """ + confirmed qty > reserved qty + """ + data = self._get_base_data() + confirmed_qty = self.line1.product_qty + 6 + data["lines"] = [ + self.order_line_to_data(self.line1, qty=confirmed_qty), + self.order_line_to_data(self.line2), + self.order_line_to_data(self.line3), + self.order_line_to_data(self.line4), + ] + self.DespatchAdviceImport.with_context( + allow_validate_over_qty=True + ).process_data(data) + + self.assertTrue(self.purchase_order.picking_ids) + move_ids = self.line1.move_ids + self.assertEqual(len(move_ids), 1) + self.assertEqual(sum(move_ids.mapped("product_qty")), confirmed_qty) + assigned = move_ids.filtered(lambda s: s.state == "done") + self.assertEqual(assigned.product_qty, confirmed_qty) diff --git a/despatch_advice_import/wizard/despatch_advice_import.py b/despatch_advice_import/wizard/despatch_advice_import.py index 72c8455472..17a64b672e 100644 --- a/despatch_advice_import/wizard/despatch_advice_import.py +++ b/despatch_advice_import/wizard/despatch_advice_import.py @@ -26,6 +26,9 @@ class DespatchAdviceImport(models.TransientModel): "(PDF with an embeded XML file).", ) filename = fields.Char(string="File Name") + allow_validate_over_qty = fields.Boolean( + "Allow Validate Over Quantity", default=True + ) # Format of parsed despatch advice # { @@ -188,6 +191,10 @@ def process_data(self, parsed_order_document): moves_qty = sum(stock_moves.mapped("product_qty")) if line_info["qty"] == moves_qty: self._process_accepted(stock_moves, parsed_order_document) + elif line_info["qty"] > moves_qty and self.allow_validate_over_qty: + self._process_accepted( + stock_moves, parsed_order_document, forced_qty=line_info["qty"] + ) elif not line_info["qty"] and not line_info["backorder_qty"]: self._process_rejected(stock_moves, parsed_order_document) else: @@ -211,7 +218,7 @@ def _process_rejected(self, stock_moves, parsed_order_document): stock_moves._action_cancel() - def _process_accepted(self, stock_moves, parsed_order_document): + def _process_accepted(self, stock_moves, parsed_order_document, forced_qty=False): parsed_order_document["chatter_msg"] = ( parsed_order_document["chatter_msg"] or [] ) @@ -221,7 +228,7 @@ def _process_accepted(self, stock_moves, parsed_order_document): stock_moves._action_confirm() stock_moves._action_assign() for move in stock_moves: - move.quantity_done = move.product_qty + move.quantity_done = forced_qty or move.product_qty def _process_conditional(self, moves, parsed_order_document, line): precision = self.env["decimal.precision"].precision_get( diff --git a/despatch_advice_import/wizard/despatch_advice_import.xml b/despatch_advice_import/wizard/despatch_advice_import.xml index 322d8bdb40..98c1bd0b0a 100644 --- a/despatch_advice_import/wizard/despatch_advice_import.xml +++ b/despatch_advice_import/wizard/despatch_advice_import.xml @@ -28,6 +28,7 @@ +
@@ -42,7 +41,7 @@
- + Import despatch.advice.import form @@ -54,5 +53,4 @@ - From e14ad39ccc2a2ff0aee9e671a81f8c2c9289039d Mon Sep 17 00:00:00 2001 From: Maksym Yankin Date: Mon, 4 May 2026 18:13:40 +0300 Subject: [PATCH 19/19] [MIG] despatch_advice_import: Migration to 19.0 --- despatch_advice_import/README.rst | 3 +- despatch_advice_import/__manifest__.py | 17 +- .../static/description/index.html | 2 - despatch_advice_import/tests/__init__.py | 1 + despatch_advice_import/tests/common.py | 60 ++++ .../tests/test_despatch_advice_import.py | 150 ++------- .../wizard/despatch_advice_import.py | 297 +++++++++++++----- 7 files changed, 320 insertions(+), 210 deletions(-) create mode 100644 despatch_advice_import/tests/common.py diff --git a/despatch_advice_import/README.rst b/despatch_advice_import/README.rst index 4027894f92..c72f889d04 100644 --- a/despatch_advice_import/README.rst +++ b/despatch_advice_import/README.rst @@ -74,8 +74,7 @@ Contributors Other credits ------------- -The migration of this module from 10.0 to 16.0 was financially supported -by Camptocamp + Maintainers ----------- diff --git a/despatch_advice_import/__manifest__.py b/despatch_advice_import/__manifest__.py index fe6261da76..b829b3e242 100644 --- a/despatch_advice_import/__manifest__.py +++ b/despatch_advice_import/__manifest__.py @@ -3,15 +3,20 @@ { "name": "Despatch Advice Import", - "summary": """ - Despatch Advice import""", - "version": "16.0.1.2.1", + "summary": "Despatch Advice import", + "version": "19.0.1.0.0", "website": "https://github.com/OCA/edi", "license": "AGPL-3", - "author": "ACSONE SA/NV,BCIM,Odoo Community Association (OCA)", + "author": "ACSONE SA/NV, BCIM, Odoo Community Association (OCA)", "maintainers": ["jbaudoux"], - "depends": ["purchase", "purchase_stock", "base_business_document_import"], + "depends": [ + # Odoo/core + "purchase_stock", + # OCA/edi + "base_business_document_import", + # OCA/reporting-engine + "pdf_xml_attachment", + ], "data": ["security/ir.model.access.csv", "wizard/despatch_advice_import.xml"], - "demo": [], "installable": True, } diff --git a/despatch_advice_import/static/description/index.html b/despatch_advice_import/static/description/index.html index 0afe8a4d5a..b0ce336881 100644 --- a/despatch_advice_import/static/description/index.html +++ b/despatch_advice_import/static/description/index.html @@ -426,8 +426,6 @@

Contributors

Other credits

-

The migration of this module from 10.0 to 16.0 was financially supported -by Camptocamp

Maintainers

diff --git a/despatch_advice_import/tests/__init__.py b/despatch_advice_import/tests/__init__.py index 3d12552832..d201e095b5 100644 --- a/despatch_advice_import/tests/__init__.py +++ b/despatch_advice_import/tests/__init__.py @@ -1 +1,2 @@ +from . import common from . import test_despatch_advice_import diff --git a/despatch_advice_import/tests/common.py b/despatch_advice_import/tests/common.py new file mode 100644 index 0000000000..e675a3b54f --- /dev/null +++ b/despatch_advice_import/tests/common.py @@ -0,0 +1,60 @@ +# Copyright 2026 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo import Command, fields +from odoo.tests.common import TransactionCase + + +class TestDespatchAdviceImportCommon(TransactionCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.env = cls.env(context=dict(cls.env.context, tracking_disable=True)) + cls.DespatchAdviceImport = cls.env["despatch.advice.import"] + cls.env.company.partner_id.vat = "BE0421801233" + cls.supplier = cls.env["res.partner"].create( + { + "name": "Test Supplier", + "supplier_rank": 1, + "vat": "BE0477472701", + } + ) + + @classmethod + def _create_product(cls, name, code, supplier_code): + return cls.env["product.product"].create( + { + "name": name, + "default_code": code, + "seller_ids": [ + Command.create( + { + "partner_id": cls.supplier.id, + "product_code": supplier_code, + } + ) + ], + } + ) + + @classmethod + def _get_po_line_vals(cls, product, product_qty, price_unit): + return { + "product_id": product.id, + "name": product.name, + "date_planned": fields.Datetime.now(), + "product_qty": product_qty, + "product_uom_id": cls.env.ref("uom.product_uom_unit").id, + "price_unit": price_unit, + } + + @classmethod + def _create_purchase_order(cls, line_vals_list): + return cls.env["purchase.order"].create( + { + "partner_id": cls.supplier.id, + "date_order": fields.Datetime.now(), + "date_planned": fields.Datetime.now(), + "order_line": [Command.create(vals) for vals in line_vals_list], + } + ) diff --git a/despatch_advice_import/tests/test_despatch_advice_import.py b/despatch_advice_import/tests/test_despatch_advice_import.py index 70960b39c3..9d75a68afd 100644 --- a/despatch_advice_import/tests/test_despatch_advice_import.py +++ b/despatch_advice_import/tests/test_despatch_advice_import.py @@ -3,108 +3,28 @@ import base64 -from odoo import _, fields from odoo.exceptions import UserError -from odoo.tests.common import TransactionCase +from .common import TestDespatchAdviceImportCommon -class TestDespatchAdviceImport(TransactionCase): + +class TestDespatchAdviceImport(TestDespatchAdviceImportCommon): @classmethod def setUpClass(cls): super().setUpClass() - cls.env = cls.env(context=dict(cls.env.context, tracking_disable=True)) - cls.supplier = cls.env.ref("base.res_partner_12") - cls.supplier.vat = "BE0477472701" - cls.env.user.company_id.partner_id.vat = "BE0421801233" - cls.product_1 = cls.env["product.product"].create( - { - "name": "Product 1", - "default_code": "987654321", - "seller_ids": [ - (0, 0, {"partner_id": cls.supplier.id, "product_code": "P1"}) - ], - } - ) - cls.product_2 = cls.env["product.product"].create( - { - "name": "Product 2", - "default_code": "987654312", - "seller_ids": [ - (0, 0, {"partner_id": cls.supplier.id, "product_code": "P2"}) - ], - } - ) - cls.product_3 = cls.env["product.product"].create( - { - "name": "Product 3", - "default_code": "123456789", - "seller_ids": [ - (0, 0, {"partner_id": cls.supplier.id, "product_code": "P3"}) - ], - } - ) - cls.product_4 = cls.env["product.product"].create( - { - "name": "Product 4", - "default_code": "23456718", - "seller_ids": [ - (0, 0, {"partner_id": cls.supplier.id, "product_code": "P4"}) - ], - } - ) - cls.purchase_order = cls.env["purchase.order"].create( - { - "partner_id": cls.supplier.id, - "date_order": fields.Datetime.now(), - "date_planned": fields.Datetime.now(), - } - ) - cls.line1 = cls.purchase_order.order_line.create( - { - "order_id": cls.purchase_order.id, - "product_id": cls.product_1.id, - "name": cls.product_1.name, - "date_planned": fields.Datetime.now(), - "product_qty": 24, - "product_uom": cls.env.ref("uom.product_uom_unit").id, - "price_unit": 15, - } - ) - cls.line2 = cls.purchase_order.order_line.create( - { - "order_id": cls.purchase_order.id, - "product_id": cls.product_2.id, - "name": cls.product_2.name, - "date_planned": fields.Datetime.now(), - "product_qty": 5, - "product_uom": cls.env.ref("uom.product_uom_unit").id, - "price_unit": 25, - } - ) - - cls.line3 = cls.purchase_order.order_line.create( - { - "order_id": cls.purchase_order.id, - "product_id": cls.product_3.id, - "name": cls.product_3.name, - "date_planned": fields.Datetime.now(), - "product_qty": 15, - "product_uom": cls.env.ref("uom.product_uom_unit").id, - "price_unit": 25, - } - ) - - cls.line4 = cls.purchase_order.order_line.create( - { - "order_id": cls.purchase_order.id, - "product_id": cls.product_4.id, - "name": cls.product_4.name, - "date_planned": fields.Datetime.now(), - "product_qty": 15, - "product_uom": cls.env.ref("uom.product_uom_unit").id, - "price_unit": 25, - } + cls.product_1 = cls._create_product("Product 1", "987654321", "P1") + cls.product_2 = cls._create_product("Product 2", "987654312", "P2") + cls.product_3 = cls._create_product("Product 3", "123456789", "P3") + cls.product_4 = cls._create_product("Product 4", "23456718", "P4") + cls.purchase_order = cls._create_purchase_order( + [ + cls._get_po_line_vals(cls.product_1, 24, 15), + cls._get_po_line_vals(cls.product_2, 5, 25), + cls._get_po_line_vals(cls.product_3, 15, 25), + cls._get_po_line_vals(cls.product_4, 15, 25), + ] ) + cls.line1, cls.line2, cls.line3, cls.line4 = cls.purchase_order.order_line cls.purchase_order.button_confirm() cls.DespatchAdviceImport = cls.env["despatch.advice.import"].create( @@ -118,7 +38,7 @@ def order_line_to_data(self, order_line, qty=None, backorder_qty=None): "order_line_id": order_line.id, "ref": order_line.order_id.name, "product_ref": order_line.product_id.default_code, - "uom": {"unece_code": order_line.product_uom.unece_code}, + "uom": {"unece_code": order_line.product_uom_id.unece_code}, } def _get_base_data(self): @@ -132,14 +52,7 @@ def _get_base_data(self): } def test_no_purchase_order_name(self): - """ - Data: - Data with unknown PO reference - Test Case: - Process data - Expected result: - UserError is raised - """ + """Raise an error when the imported line references an unknown PO.""" data = self._get_base_data() data["ref"] = "123456" data["lines"] = [self.order_line_to_data(self.line1)] @@ -148,13 +61,12 @@ def test_no_purchase_order_name(self): with self.assertRaises(UserError) as ue: self.DespatchAdviceImport.process_data(data) self.assertEqual( - ue.exception.name, _("No purchase order found for name 123456.") + ue.exception.args[0], + self.env._("No purchase order found for name %(name)s.", name="123456"), ) def test_process_data_with_backorder_qty(self): - """ - backorder qty - """ + """Split the move and keep the postponed quantity on a backorder.""" data = self._get_base_data() confirmed_qty = self.line1.product_qty - 21 data["lines"] = [ @@ -179,9 +91,7 @@ def test_process_data_with_backorder_qty(self): self.assertEqual(move_backorder.picking_id.backorder_id, assigned.picking_id) def test_process_data_with_no_backorder_qty(self): - """ - no backorder qty - """ + """Split the move and cancel the remaining quantity without backorder.""" data = self._get_base_data() confirmed_qty = self.line1.product_qty - 21 data["lines"] = [ @@ -202,9 +112,7 @@ def test_process_data_with_no_backorder_qty(self): self.assertEqual(cancel.product_qty, 21) def test_process_data_create_backorder(self): - """ - 2 back order created, second one is put in the same than the first 1 - """ + """Reuse the same backorder picking for postponed quantities on two lines.""" data = self._get_base_data() line1_confirmed_qty = self.line1.product_qty - 3 line2_confirmed_qty = self.line2.product_qty - 3 @@ -268,7 +176,7 @@ def test_process_data_create_backorder(self): ) def test_partial_delivery_with_backorder(self): - """ """ + """Backorder only the postponed part and cancel the leftover remainder.""" data = self._get_base_data() confirmed_qty = self.line1.product_qty - 3 data["lines"] = [ @@ -306,7 +214,7 @@ def test_partial_delivery_with_backorder(self): ) def test_qty_larger_backorder_qty(self): - """ """ + """Cancel the extra remainder when confirmed quantity exceeds backorder qty.""" data = self._get_base_data() confirmed_qty = 6 data["lines"] = [ @@ -339,7 +247,7 @@ def test_qty_larger_backorder_qty(self): ) def test_qty_equal_backorder_qty(self): - """ """ + """Keep equal confirmed and backorder quantities and cancel the rest.""" data = self._get_base_data() confirmed_qty = 3 data["lines"] = [ @@ -371,9 +279,7 @@ def test_qty_equal_backorder_qty(self): self.assertEqual(sum(moves_backorder.mapped("product_qty")), 3) def test_confirmed_qty_larger_reserved_qty(self): - """ - confirmed qty > reserved qty - """ + """Allow over-delivery when the imported confirmed quantity exceeds reserved.""" data = self._get_base_data() confirmed_qty = self.line1.product_qty + 6 data["lines"] = [ @@ -389,6 +295,6 @@ def test_confirmed_qty_larger_reserved_qty(self): self.assertTrue(self.purchase_order.picking_ids) move_ids = self.line1.move_ids self.assertEqual(len(move_ids), 1) - self.assertEqual(sum(move_ids.mapped("product_qty")), confirmed_qty) + self.assertEqual(sum(move_ids.mapped("product_qty")), self.line1.product_qty) assigned = move_ids.filtered(lambda s: s.state == "done") - self.assertEqual(assigned.product_qty, confirmed_qty) + self.assertEqual(assigned.quantity, confirmed_qty) diff --git a/despatch_advice_import/wizard/despatch_advice_import.py b/despatch_advice_import/wizard/despatch_advice_import.py index 52e95afbb4..0696f61dc9 100644 --- a/despatch_advice_import/wizard/despatch_advice_import.py +++ b/despatch_advice_import/wizard/despatch_advice_import.py @@ -8,7 +8,7 @@ from lxml import etree -from odoo import _, api, fields, models +from odoo import api, fields, models from odoo.exceptions import UserError from odoo.tools import float_compare @@ -16,6 +16,13 @@ class DespatchAdviceImport(models.TransientModel): + """Import a supplier despatch advice and apply it on incoming receipts. + + The wizard parses a despatch advice document, matches it to purchase order + lines, and then updates the related stock moves so the receipt reflects + what the supplier confirmed, postponed, or canceled. + """ + _name = "despatch.advice.import" _description = "Despatch Advice Import from Files" @@ -51,19 +58,30 @@ class DespatchAdviceImport(models.TransientModel): # }] @api.model - def parse_despatch_advice(self, document, filename): + def parse_despatch_advice(self, document: bytes, filename: str): + """Parse an uploaded XML or PDF document into a normalized dict. + + :param document: The raw content of the uploaded file, as bytes. + :param filename: The name of the uploaded file, used to detect the file type. + :return: A dict with the parsed despatch advice data, in a normalized format. + """ if not document: - raise UserError(_("Missing document file")) + raise UserError(self.env._("Missing document file")) if not filename: - raise UserError(_("Missing document filename")) + raise UserError(self.env._("Missing document filename")) + # Detect the file type from the uploaded filename extension to choose + # the appropriate XML or PDF parsing flow. filetype = mimetypes.guess_type(filename)[0] logger.debug("DespatchAdvice file mimetype: %s", filetype) if filetype in ["application/xml", "text/xml"]: try: xml_root = etree.fromstring(document) except Exception as err: - raise UserError(_("This XML file is not XML-compliant")) from err + raise UserError( + self.env._("This XML file is not XML-compliant") + ) from err if logger.isEnabledFor(logging.DEBUG): + # Format the parsed XML for easier inspection in debug logs. pretty_xml_string = etree.tostring( xml_root, pretty_print=True, encoding="UTF-8", xml_declaration=True ) @@ -74,18 +92,26 @@ def parse_despatch_advice(self, document, filename): parsed_despatch_advice = self.parse_pdf_despatch_advice(document) else: raise UserError( - _( - "This file '%s' is not recognised as XML nor PDF file. " - "Please check the file and it's extension." + self.env._( + "This file '%(filename)s' is not recognised as XML nor PDF file. " + "Please check the file and it's extension.", + filename=filename, ) - % filename ) - logger.debug("Result of Despatch Advice parsing: ", parsed_despatch_advice) + logger.debug( + "Result of Despatch Advice parsing: %(advice)s", + {"advice": parsed_despatch_advice}, + ) if "attachments" not in parsed_despatch_advice: parsed_despatch_advice["attachments"] = {} parsed_despatch_advice["attachments"][filename] = b64encode(document) - if "chatter_msg" not in parsed_despatch_advice: + chatter_msg = parsed_despatch_advice.get("chatter_msg") + if chatter_msg is None: parsed_despatch_advice["chatter_msg"] = [] + elif isinstance(chatter_msg, (tuple, set)): + parsed_despatch_advice["chatter_msg"] = list(chatter_msg) + elif not isinstance(chatter_msg, list): + parsed_despatch_advice["chatter_msg"] = [chatter_msg] if parsed_despatch_advice.get("company") and not self.env.context.get( "edi_skip_company_check" ): @@ -99,22 +125,31 @@ def parse_despatch_advice(self, document, filename): return parsed_despatch_advice @api.model - def parse_xml_despatch_advice(self, xml_root): + def parse_xml_despatch_advice(self, xml_root: etree._Element): + """Parse a despatch advice XML tree with a format-specific implementation. + + :param xml_root: The parsed XML root element to interpret. + :return: A normalized dict containing the parsed despatch advice data. + """ raise UserError( - _( + self.env._( "This type of XML Order Response is not supported. Did you " "install the module to support this XML format?" ) ) @api.model - def parse_pdf_despatch_advice(self, document): - """ - Get PDF attachments, filter on XML files and call import_order_xml + def parse_pdf_despatch_advice(self, document: bytes): + """Extract embedded XML files from a PDF and parse the first supported one. + + :param document: The raw content of the uploaded PDF file, as bytes. + :return: A normalized dict containing the parsed despatch advice data. """ xml_files_dict = self.get_xml_files_from_pdf(document) if not xml_files_dict: - raise UserError(_("There are no embedded XML file in this PDF file.")) + raise UserError( + self.env._("There are no embedded XML file in this PDF file.") + ) for xml_filename, xml_root in xml_files_dict.items(): logger.info("Trying to parse XML file %s", xml_filename) try: @@ -123,22 +158,44 @@ def parse_pdf_despatch_advice(self, document): except Exception: continue raise UserError( - _( + self.env._( "This type of XML Order Document is not supported. Did you " "install the module to support this XML format?" ) ) + @api.model + def get_xml_files_from_pdf(self, document: bytes): + """Return embedded XML attachments from a PDF as parsed XML roots. + + :param document: The raw content of the uploaded PDF file, as bytes. + :return: A dict mapping embedded XML filenames to parsed XML root elements. + """ + return self.env["pdf.xml.tool"].pdf_get_xml_files(document) + def process_document(self): + """Decode the uploaded file, parse it, and apply its stock impact.""" self.ensure_one() parsed_order_document = self.parse_despatch_advice( b64decode(self.document), self.filename ) self.process_data(parsed_order_document) - def _collect_lines_by_id(self, lines_doc, key="order_line_id"): + def _collect_lines_by_id(self, lines_doc, key: str = "order_line_id"): + """Aggregate imported lines by purchase order line id. + + A despatch advice may contain several entries for the same purchase + line, so we consolidate quantities, lots, and UoM codes first. + + :param lines_doc: The parsed despatch advice lines to group and normalize. + :param key: The line dict key containing the purchase order line identifier. + :return: A dict mapping purchase order line ids to aggregated line data. + """ lines_by_id = {} for line in lines_doc: + line = dict(line) + line["qty"] = line.get("qty") or 0.0 + line["backorder_qty"] = line.get("backorder_qty") or 0.0 line_id = int(line[key]) if line_id in lines_by_id: lines_by_id[line_id]["qty"] += line["qty"] @@ -165,15 +222,15 @@ def _collect_lines_by_id(self, lines_doc, key="order_line_id"): ] return lines_by_id - def process_data(self, parsed_order_document): - po_name = parsed_order_document.get("ref") + def process_data(self, parsed_order_document: dict): + """Apply the parsed despatch advice to the matching purchase moves. + :param parsed_order_document: The normalized despatch advice data to apply. + """ + po_name = parsed_order_document.get("ref") lines_doc = parsed_order_document.get("lines") - lines_by_id = self._collect_lines_by_id(lines_doc) - - lines = self.env["purchase.order.line"].browse(lines_by_id.keys()) - + lines = self.env["purchase.order.line"].browse(list(lines_by_id)) for line in lines: order = line.order_id line_info = lines_by_id.get(line.id) @@ -181,15 +238,25 @@ def process_data(self, parsed_order_document): if line_info["ref"]: if order.name != line_info["ref"]: raise UserError( - _("No purchase order found for name %s.") % line_info["ref"], + self.env._( + "No purchase order found for name %(name)s.", + name=line_info["ref"], + ), ) else: if order.name != po_name: - raise UserError(_("No purchase order found for name %s.") % po_name) + raise UserError( + self.env._( + "No purchase order found for name %(name)s.", + name=po_name, + ) + ) stock_moves = line.move_ids.filtered( lambda x: x.state not in ("cancel", "done") ) - moves_qty = sum(stock_moves.mapped("product_qty")) + moves_qty = sum(stock_moves.mapped("product_uom_qty")) + # Compare the supplier-confirmed quantity with the open moves to + # decide whether we fully accept, reject, or partially split them. if line_info["qty"] == moves_qty: self._process_accepted(stock_moves, parsed_order_document) elif line_info["qty"] > moves_qty and self.allow_validate_over_qty: @@ -202,7 +269,12 @@ def process_data(self, parsed_order_document): self._process_conditional(stock_moves, parsed_order_document, line_info) self._process_picking_done(lines[0].move_ids[0]) - def _process_picking_done(self, move): + def _process_picking_done(self, move: models.Model): + """Validate the receipt once all line-level changes are applied. + + :param move: A stock move belonging to the picking that should be validated. + :return: True when the picking only contains canceled moves, else None. + """ picking = move.picking_id if all(line.state == "cancel" for line in picking.move_ids): return True @@ -211,102 +283,171 @@ def _process_picking_done(self, move): skip_immediate=True, skip_backorder=True, skip_sms=True, skip_expired=True ).button_validate() - def _cancel_extra_moves(self, moves): + def _cancel_extra_moves(self, moves: models.Model): + """Cancel moves while mimicking the usual backorder-cancel context. + + :param moves: The stock moves that should be canceled. + """ # Loose dependency with stock_picking_restrict_cancel_printed module # that checks we are canceling the backorder to allow move cancellation. # Mimic odoo setting this cancel_backorder context variable in this case. moves.with_context(cancel_backorder=True)._action_cancel() - def _process_rejected(self, stock_moves, parsed_order_document): + def _process_rejected(self, stock_moves: models.Model, parsed_order_document: dict): + """Cancel remaining moves when the supplier cancels the delivery. + + :param stock_moves: The open stock moves linked to the purchase order line. + :param parsed_order_document: The normalized despatch advice data being applied. + """ parsed_order_document["chatter_msg"] = parsed_order_document.get( "chatter_msg", [] ) parsed_order_document["chatter_msg"].append( - _("Delivery cancelled by the supplier.") + self.env._("Delivery cancelled by the supplier.") ) self._cancel_extra_moves(stock_moves) - def _process_accepted(self, stock_moves, parsed_order_document, forced_qty=False): + def _process_accepted( + self, + stock_moves: models.Model, + parsed_order_document: dict, + forced_qty=False, + ): + """Handle a delivery confirmed as-is by the supplier. + + The supplier delivers exactly what was ordered, or more when over-delivery + is allowed. In both cases every move is marked done and picked so the + validation wizard completes without prompting for a backorder. + + Without forced_qty the done quantity matches each move's planned demand. + With forced_qty (over-delivery) that quantity replaces the planned demand + on every move, recording what the supplier actually announced. + + :param stock_moves: The open stock moves linked to the purchase order line. + :param parsed_order_document: The normalized despatch advice data being applied. + :param forced_qty: The supplier-announced quantity to record when it exceeds + the planned demand; False to use each move's planned quantity. + """ parsed_order_document["chatter_msg"] = ( parsed_order_document["chatter_msg"] or [] ) parsed_order_document["chatter_msg"].append( - _("Delivery confirmed by the supplier.") + self.env._("Delivery confirmed by the supplier.") ) stock_moves._action_confirm() stock_moves._action_assign() for move in stock_moves: - move.quantity_done = forced_qty or move.product_qty + move._set_quantity_done(forced_qty or move.product_uom_qty) + move.picked = True + + def _split_move(self, move: models.Model, qty_to_split): + """Split a move and return the newly created split-off moves. - def _process_conditional(self, moves, parsed_order_document, line): - precision = self.env["decimal.precision"].precision_get( - "Product Unit of Measure" + :param move: The stock move that should be split. + :param qty_to_split: The quantity to split off from the original move. + :return: created stock moves, or an empty recordset if nothing was split. + """ + split_moves_vals = move.with_context(cancel_backorder=False)._split( + move.product_uom._compute_quantity( + qty_to_split, + move.product_id.uom_id, + rounding_method="HALF-UP", + ) ) + if not split_moves_vals: + return self.env["stock.move"] + split_moves = self.env["stock.move"].create(split_moves_vals) + split_moves.with_context( + bypass_entire_pack=True, bypass_procurement_creation=True + )._action_confirm(merge=False) + return split_moves + + def _add_moves_to_backorder(self, moves: models.Model): + """Prepare postponed quantities for validation backorder. + + :param moves: The stock moves that should remain open on a backorder. + :return: The picking that will generate the backorder on validation. + """ + moves.write({"picked": False}) + return moves[:1].picking_id + + def _process_conditional( + self, moves: models.Model, parsed_order_document: dict, line: dict + ): + """Handle a partial delivery with backorder and cancellation logic. + + Confirmed quantities are marked done, postponed quantities are left + unpicked for the validation backorder, and any remaining quantity is + canceled. + + :param moves: The open stock moves linked to the purchase order line. + :param parsed_order_document: The normalized despatch advice data being applied. + :param line: The aggregated imported line data for the purchase order line. + :return: A tuple of `(moves_to_backorder, moves_to_cancel)` recordsets. + """ + digits = self.env["decimal.precision"].precision_get("Product Unit") chatter = parsed_order_document["chatter_msg"] = ( parsed_order_document["chatter_msg"] or [] ) - chatter.append(_("Delivery confirmed with amendment by the supplier.")) + chatter.append(self.env._("Delivery confirmed with amendment by the supplier.")) qty = line["qty"] backorder_qty = line["backorder_qty"] - moves_qty = sum(moves.mapped("product_qty")) + moves_qty = sum(moves.mapped("product_uom_qty")) - if float_compare(qty, moves_qty, precision_digits=precision) >= 0: + if float_compare(qty, moves_qty, precision_digits=digits) >= 0: raise UserError( - _("The product quantity is greater than the original product quantity") + self.env._( + "The product quantity is greater than the original product quantity" + ) ) # confirmed qty < ordered qty move_ids_to_backorder = [] move_ids_to_cancel = [] for move in moves: - if ( - float_compare(qty, move.product_uom_qty, precision_digits=precision) - >= 0 - ): - # qty planned => qty into the stock move: Keep it + if move.product_uom.compare(qty, move.product_uom_qty) >= 0: + # This move is fully confirmed for the current delivery. + move._set_quantity_done(move.product_uom_qty) + move.picked = True qty -= move.product_uom_qty continue - if ( - qty - and float_compare(qty, move.product_uom_qty, precision_digits=precision) - < 0 - ): - # qty planned < qty into the stock move: Split it - new_vals = move._split(move.product_uom_qty - qty) - move.quantity_done = move.product_qty - move = self.env["stock.move"].create(new_vals[0]) - - qty -= move.product_uom_qty + if qty and move.product_uom.compare(qty, move.product_uom_qty) < 0: + # Only part of this move is delivered now, so we split the + # remaining quantity into a new move for later handling. + split_moves = self._split_move(move, move.product_uom_qty - qty) + move._set_quantity_done(move.product_uom_qty) + move.picked = True + move = split_moves[:1] + qty = 0.0 if not backorder_qty: - # if no backorder -> we must cancel the move + # No postponed quantity was announced, + # so the remaining move is canceled. move_ids_to_cancel.append(move.id) continue - # from here we process the backorder qty - # we distribute this qty into the remaining moves and - # if this qty is < than the expected one, we split and cancel the - # remaining qty - if ( - float_compare( - backorder_qty, move.product_uom_qty, precision_digits=precision + # The remaining quantity is postponed by the supplier and should + # stay open on a backorder picking. + if move.product_uom.compare(backorder_qty, move.product_uom_qty) < 0: + # Only part of this move belongs to the backorder; the extra + # quantity is split off and canceled. + split_moves = self._split_move( + move, move.product_uom_qty - backorder_qty ) - < 0 - ): - # backorder_qty < qty into the move -> split the move - # and cancel remaining qty - move._action_confirm(merge=False) - new_vals = move._split(move.product_uom_qty - backorder_qty) - move_ids_to_cancel.append(self.env["stock.move"].create(new_vals[0]).id) + move_ids_to_cancel += split_moves.ids backorder_qty -= move.product_uom_qty move_ids_to_backorder.append(move.id) + # move backorder moves to a backorder + if move_ids_to_backorder: + moves_to_backorder = self.env["stock.move"].browse(move_ids_to_backorder) + self._add_moves_to_backorder(moves_to_backorder) + else: + moves_to_backorder = self.env["stock.move"] # cancel moves to cancel if move_ids_to_cancel: moves_to_cancel = self.env["stock.move"].browse(move_ids_to_cancel) self._cancel_extra_moves(moves_to_cancel) - # move backorder moves to a backorder - if move_ids_to_backorder: - moves_to_backorder = self.env["stock.move"].browse(move_ids_to_backorder) - for move in moves_to_backorder: - move._action_confirm(merge=False) + else: + moves_to_cancel = self.env["stock.move"] + return moves_to_backorder, moves_to_cancel