diff --git a/purchase_order_import/README.rst b/purchase_order_import/README.rst new file mode 100644 index 0000000000..a6b8d44710 --- /dev/null +++ b/purchase_order_import/README.rst @@ -0,0 +1,123 @@ +.. image:: https://odoo-community.org/readme-banner-image + :target: https://odoo-community.org/get-involved?utm_source=readme + :alt: Odoo Community Association + +===================== +Purchase Order Import +===================== + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:608df04cfb2ce80c875dde9fba3a7fdd4e4d06c50264907b6a50e1b9021b9b8f + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |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/license-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/19.0/purchase_order_import + :alt: OCA/edi +.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png + :target: https://translation.odoo-community.org/projects/edi-19-0/edi-19-0-purchase_order_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=19.0 + :alt: Try me on Runboat + +|badge1| |badge2| |badge3| |badge4| |badge5| + +This module provides the base wizards and hooks to import supplier +quotation documents and supplier OrderResponse documents on purchase +orders. It provides the generic import workflow; format-specific modules +implement the XML parsers. + +The import wizards accept XML files directly and PDF files containing +embedded XML attachments. For the Universal Business Language (UBL) +format, install: + +- ``purchase_order_import_ubl`` to import supplier quotations. +- ``order_response_import_ubl`` to import supplier OrderResponse + documents. + +On a Request for Quotation, the **Import Quotation File** button opens a +wizard to upload the supplier quotation and choose how the RFQ should be +updated: + +- update only the prices of the draft purchase order from the quotation + file (default option); +- update both prices and quantities. + +When the quotation is imported, Odoo compares it with the current RFQ: + +- lines present in the quotation but missing from the RFQ are created on + the purchase order; +- lines present in the RFQ but missing from the quotation are kept, and + a warning is added to the chatter; +- lines present in both documents are updated when prices or, depending + on the selected option, quantities differ; +- the incoterm is updated when the quotation contains a different + incoterm; +- the imported file is attached to the purchase order. + +After importing a quotation, review the purchase order chatter because +it may contain important warnings or import details. + +OrderResponse imports are matched to a purchase order by reference and +can acknowledge, accept, reject, or conditionally accept the order. +Conditional acceptance updates receipt moves according to line +amendments, including splitting moves, creating backorders, or +cancelling remaining quantities when no backorder is planned. + +**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 to smash it by providing a detailed and welcomed +`feedback `_. + +Do not contact contributors directly about support or help with technical issues. + +Credits +======= + +Authors +------- + +* Akretion + +Contributors +------------ + +- Alexis de Lattre +- Laurent Mignon +- Alexandre Fayolle +- Maksym Yankin + +Maintainers +----------- + +This module is maintained by the OCA. + +.. image:: https://odoo-community.org/logo.png + :alt: Odoo Community Association + :target: https://odoo-community.org + +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. diff --git a/purchase_order_import/__init__.py b/purchase_order_import/__init__.py new file mode 100644 index 0000000000..134df27435 --- /dev/null +++ b/purchase_order_import/__init__.py @@ -0,0 +1,2 @@ +from . import wizard +from . import models diff --git a/purchase_order_import/__manifest__.py b/purchase_order_import/__manifest__.py new file mode 100644 index 0000000000..551fdc2304 --- /dev/null +++ b/purchase_order_import/__manifest__.py @@ -0,0 +1,30 @@ +# © 2016-2017 Akretion (Alexis de Lattre ) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +{ + "name": "Purchase Order Import", + "version": "19.0.1.0.0", + "category": "Purchase Management", + "license": "AGPL-3", + "summary": "Update RFQ via the import of quotations from suppliers", + "author": "Akretion,Odoo Community Association (OCA)", + "website": "https://github.com/OCA/edi", + "depends": [ + # Odoo/core + "purchase_stock", + # OCA/edi + "base_business_document_import", + # OCA/reporting-engine + "pdf_xml_attachment", + ], + "data": [ + # Security + "security/ir.model.access.csv", + # Wizard + "wizard/order_response_import_view.xml", + "wizard/purchase_order_import_view.xml", + # Views + "views/purchase_order.xml", + ], + "installable": True, +} diff --git a/purchase_order_import/i18n/es.po b/purchase_order_import/i18n/es.po new file mode 100644 index 0000000000..cb193d4282 --- /dev/null +++ b/purchase_order_import/i18n/es.po @@ -0,0 +1,558 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * purchase_order_import +# +# Translators: +# enjolras , 2018 +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 10.0\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2018-03-12 01:43+0000\n" +"PO-Revision-Date: 2018-03-12 01:43+0000\n" +"Last-Translator: enjolras , 2018\n" +"Language-Team: Spanish (https://www.transifex.com/oca/teams/23907/es/)\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" + +#. module: purchase_order_import +#: code:addons/purchase_order_import/wizard/purchase_order_import.py:210 +#, python-format +msgid "%d new order line(s) created: %s" +msgstr "" + +#. module: purchase_order_import +#: code:addons/purchase_order_import/wizard/purchase_order_import.py:194 +#, python-format +msgid "%d order line(s) are not in the imported quotation: %s" +msgstr "" + +#. module: purchase_order_import +#: code:addons/purchase_order_import/tests/test_order_response_import.py:578 +#: code:addons/purchase_order_import/wizard/order_response_import.py:334 +#, python-format +msgid "%s items should be delivered into a next delivery." +msgstr "" + +#. module: purchase_order_import +#: model:ir.ui.view,arch_db:purchase_order_import.order_response_import_form +#: model:ir.ui.view,arch_db:purchase_order_import.purchase_order_import_form +msgid "Cancel" +msgstr "Cancelar" + +#. module: purchase_order_import +#: model:ir.model.fields,field_description:purchase_order_import.field_order_response_import_create_uid +#: model:ir.model.fields,field_description:purchase_order_import.field_purchase_order_import_create_uid +msgid "Created by" +msgstr "Creado por" + +#. module: purchase_order_import +#: model:ir.model.fields,field_description:purchase_order_import.field_order_response_import_create_date +#: model:ir.model.fields,field_description:purchase_order_import.field_purchase_order_import_create_date +msgid "Created on" +msgstr "Creado el" + +#. module: purchase_order_import +#: model:ir.model.fields,help:purchase_order_import.field_purchase_order_supplier_ack_dt +msgid "" +"Date and time of the acknowledgement by the supplier. This field is filled " +"by Odoo when processing a OrderResponse document." +msgstr "" + +#. module: purchase_order_import +#: model:ir.model.fields,field_description:purchase_order_import.field_order_response_import_display_name +#: model:ir.model.fields,field_description:purchase_order_import.field_purchase_order_import_display_name +msgid "Display Name" +msgstr "Nombre mostrado" + +#. module: purchase_order_import +#: model:ir.model.fields,field_description:purchase_order_import.field_order_response_import_filename +#: model:ir.model.fields,field_description:purchase_order_import.field_purchase_order_import_quote_filename +msgid "Filename" +msgstr "Archivo" + +#. module: purchase_order_import +#: model:ir.model.fields,field_description:purchase_order_import.field_order_response_import_id +#: model:ir.model.fields,field_description:purchase_order_import.field_purchase_order_import_id +msgid "ID" +msgstr "" + +#. module: purchase_order_import +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: purchase_order_import +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: purchase_order_import +msgid "If the status code is Accepted: confirm the PO and create the picking" +msgstr "" + +#. module: purchase_order_import +msgid "" +"If the status code is Conditionally accepted: confirm the PO and create the " +"picking and update the picking operations according to the amendments " +"specified into the document" +msgstr "" + +#. module: purchase_order_import +msgid "If the status code is Rejected: cancel the PO" +msgstr "" + +#. module: purchase_order_import +msgid "" +"If the status code is acknowledgement: update the acknowledge datetime on " +"the PO" +msgstr "" + +#. module: purchase_order_import +msgid "Import OrderResponse File from Supplier" +msgstr "" + +#. module: purchase_order_import +#: model:ir.actions.act_window,name:purchase_order_import.order_response_import_action +#: model:ir.actions.act_window,name:purchase_order_import.purchase_order_import_action +msgid "Import Quotation" +msgstr "" + +#. module: purchase_order_import +#: model:ir.ui.view,arch_db:purchase_order_import.purchase_order_form +msgid "Import Quotation File" +msgstr "" + +#. module: purchase_order_import +msgid "Import Quotations Files from Suppliers" +msgstr "" + +#. module: purchase_order_import +msgid "Import document" +msgstr "" + +#. module: purchase_order_import +#: model:ir.model.fields,field_description:purchase_order_import.field_order_response_import___last_update +#: model:ir.model.fields,field_description:purchase_order_import.field_purchase_order_import___last_update +msgid "Last Modified on" +msgstr "Última modificación el" + +#. module: purchase_order_import +#: model:ir.model.fields,field_description:purchase_order_import.field_order_response_import_write_uid +#: model:ir.model.fields,field_description:purchase_order_import.field_purchase_order_import_write_uid +msgid "Last Updated by" +msgstr "Última modificación por" + +#. module: purchase_order_import +#: model:ir.model.fields,field_description:purchase_order_import.field_order_response_import_write_date +#: model:ir.model.fields,field_description:purchase_order_import.field_purchase_order_import_write_date +msgid "Last Updated on" +msgstr "Última actualización el" + +#. module: purchase_order_import +#: code:addons/purchase_order_import/wizard/order_response_import.py:112 +#, python-format +msgid "Missing document file" +msgstr "" + +#. module: purchase_order_import +#: code:addons/purchase_order_import/wizard/order_response_import.py:114 +#, python-format +msgid "Missing document filename" +msgstr "" + +#. module: purchase_order_import +#: code:addons/purchase_order_import/wizard/order_response_import.py:305 +#, python-format +msgid "" +"More than one move found for PO line.\n" +"Move IDs: %s\n" +"Line Info: %s" +msgstr "" + +#. module: purchase_order_import +#: code:addons/purchase_order_import/tests/test_order_response_import.py:586 +#: code:addons/purchase_order_import/wizard/order_response_import.py:360 +#, python-format +msgid "No backorder planned by the supplier." +msgstr "" + +#. module: purchase_order_import +#: code:addons/purchase_order_import/wizard/order_response_import.py:179 +#, python-format +msgid "No purchase order found for name %s." +msgstr "" + +#. module: purchase_order_import +#: code:addons/purchase_order_import/tests/test_order_response_import.py:125 +#, python-format +msgid "No purchase order found for name 123456." +msgstr "" + +#. module: purchase_order_import +#: code:addons/purchase_order_import/wizard/order_response_import.py:254 +#, python-format +msgid "PO cancelled by the supplier." +msgstr "" + +#. module: purchase_order_import +#: code:addons/purchase_order_import/wizard/order_response_import.py:264 +#, python-format +msgid "PO confirmed by the supplier." +msgstr "" + +#. module: purchase_order_import +#: code:addons/purchase_order_import/wizard/order_response_import.py:276 +#, python-format +msgid "PO confirmed with amendment by the supplier." +msgstr "" + +#. module: purchase_order_import +msgid "Possible line amendments:" +msgstr "" + +#. module: purchase_order_import +#: selection:purchase.order.import,update_option:0 +msgid "Price" +msgstr "" + +#. module: purchase_order_import +#: selection:purchase.order.import,update_option:0 +msgid "Price and Quantity" +msgstr "" + +#. module: purchase_order_import +#: model:ir.model,name:purchase_order_import.model_purchase_order +msgid "Purchase Order" +msgstr "" + +#. module: purchase_order_import +#: model:ir.model,name:purchase_order_import.model_purchase_order_import +msgid "Purchase Order Import from Files" +msgstr "" + +#. module: purchase_order_import +#: model:ir.model,name:purchase_order_import.model_order_response_import +msgid "Purchase Order Response Import from Files" +msgstr "" + +#. module: purchase_order_import +#: model:ir.model.fields,field_description:purchase_order_import.field_purchase_order_import_purchase_id +msgid "RFQ to Update" +msgstr "" + +#. module: purchase_order_import +#: code:addons/purchase_order_import/wizard/order_response_import.py:408 +#, python-format +msgid "" +"Some Pack Operations have already started! Please validate or reset " +"operations on picking %s to ensure delivery slip to be computed." +msgstr "" + +#. module: purchase_order_import +#: model:ir.model.fields,field_description:purchase_order_import.field_purchase_order_supplier_ack_dt +msgid "Supplier Acknowledgement Date" +msgstr "" + +#. module: purchase_order_import +#: code:addons/purchase_order_import/wizard/order_response_import.py:208 +#, python-format +msgid "" +"The currency of the imported OrderResponse (%s) is different from the " +"currency of the purchase order (%s)." +msgstr "" + +#. module: purchase_order_import +#: code:addons/purchase_order_import/tests/test_order_response_import.py:158 +#, python-format +msgid "" +"The currency of the imported OrderResponse (USD) is different from the " +"currency of the purchase order (EUR)." +msgstr "" + +#. module: purchase_order_import +#: code:addons/purchase_order_import/wizard/purchase_order_import.py:250 +#, python-format +msgid "" +"The currency of the imported quotation (%s) is different from the currency " +"of the RFQ (%s)" +msgstr "" + +#. module: purchase_order_import +#: code:addons/purchase_order_import/wizard/purchase_order_import.py:134 +#, python-format +msgid "" +"The incoterm has been updated from %s to %s upon import of the quotation " +"file '%s'" +msgstr "" + +#. module: purchase_order_import +msgid "" +"The order line is accepted with change: The picking operation is modified. " +"(split to stock move). If a backorder qty is provided, a backorder is " +"created. Otherwise the stock.move for the missing quantities is cancelled." +msgstr "" + +#. module: purchase_order_import +msgid "The order line is refused: The picking operation is cancelled" +msgstr "" + +#. module: purchase_order_import +#: code:addons/purchase_order_import/wizard/purchase_order_import.py:180 +#, python-format +msgid "" +"The quantity has been updated on the RFQ line with product '%s' from %s to " +"%s %s." +msgstr "" + +#. module: purchase_order_import +#: code:addons/purchase_order_import/wizard/order_response_import.py:200 +#, python-format +msgid "" +"The supplier of the imported OrderResponse (%s) is different from the " +"supplier of the purchase order (%s)." +msgstr "" + +#. module: purchase_order_import +#: code:addons/purchase_order_import/wizard/purchase_order_import.py:246 +#, python-format +msgid "" +"The supplier of the imported quotation (%s) is different from the supplier " +"of the RFQ (%s)." +msgstr "" + +#. module: purchase_order_import +#: code:addons/purchase_order_import/wizard/purchase_order_import.py:172 +#, python-format +msgid "" +"The unit price has been updated on the RFQ line with product '%s' from %s to " +"%s %s." +msgstr "" + +#. module: purchase_order_import +msgid "Then, Odoo will compare the imported quotation and the current RFQ:" +msgstr "" + +#. module: purchase_order_import +msgid "Then, Odoo will process the related purchase order as follow:" +msgstr "" + +#. module: purchase_order_import +#: code:addons/purchase_order_import/wizard/order_response_import.py:69 +#: code:addons/purchase_order_import/wizard/purchase_order_import.py:53 +#, python-format +msgid "There are no embedded XML file in this PDF file." +msgstr "" + +#. module: purchase_order_import +#: code:addons/purchase_order_import/wizard/purchase_order_import.py:265 +#, python-format +msgid "" +"This RFQ has been updated automatically via the import of quotation file %s" +msgstr "" + +#. module: purchase_order_import +#: code:addons/purchase_order_import/wizard/order_response_import.py:122 +#: code:addons/purchase_order_import/wizard/purchase_order_import.py:100 +#, python-format +msgid "This XML file is not XML-compliant" +msgstr "" + +#. module: purchase_order_import +#: code:addons/purchase_order_import/wizard/order_response_import.py:137 +#: code:addons/purchase_order_import/wizard/purchase_order_import.py:110 +#, python-format +msgid "" +"This file '%s' is not recognised as XML nor PDF file. Please check the file " +"and it's extension." +msgstr "" + +#. module: purchase_order_import +#: code:addons/purchase_order_import/wizard/order_response_import.py:235 +#, python-format +msgid "" +"This purchase order has been updated automatically via the import of " +"OrderResponse file %s." +msgstr "" + +#. module: purchase_order_import +#: code:addons/purchase_order_import/wizard/purchase_order_import.py:258 +#, python-format +msgid "This quotation doesn't have any line !" +msgstr "" + +#. module: purchase_order_import +#: code:addons/purchase_order_import/wizard/order_response_import.py:79 +#, python-format +msgid "" +"This type of XML Order Document is not supported. Did you install the module " +"to support this XML format?" +msgstr "" + +#. module: purchase_order_import +#: code:addons/purchase_order_import/wizard/order_response_import.py:55 +#, python-format +msgid "" +"This type of XML Order Response is not supported. Did you install the module " +"to support this XML format?" +msgstr "" + +#. module: purchase_order_import +#: code:addons/purchase_order_import/wizard/purchase_order_import.py:42 +#: code:addons/purchase_order_import/wizard/purchase_order_import.py:62 +#, python-format +msgid "" +"This type of XML quotation is not supported. Did you install the module to " +"support this XML format?" +msgstr "" + +#. module: purchase_order_import +#: model:ir.ui.menu,name:purchase_order_import.order_response_import_importer_menu +msgid "UBL OrderResponse Importer" +msgstr "" + +#. module: purchase_order_import +#: code:addons/purchase_order_import/wizard/order_response_import.py:281 +#, python-format +msgid "" +"Unable to conditionally confirm the purchase order. \n" +"Line IDS into the parsed document differs from the expected list of order " +"line ids: \n" +" received: %s\n" +"expected: %s\n" +msgstr "" + +#. module: purchase_order_import +#: code:addons/purchase_order_import/tests/test_order_response_import.py:259 +#, python-format +msgid "" +"Unable to conditionally confirm the purchase order. \n" +"Line IDS into the parsed document differs from the expected list of order " +"line ids: \n" +" received: [%s]\n" +"expected: %s\n" +msgstr "" + +#. module: purchase_order_import +#: code:addons/purchase_order_import/tests/test_order_response_import.py:230 +#, python-format +msgid "" +"Unable to conditionally confirm the purchase order. \n" +"Line IDS into the parsed document differs from the expected list of order " +"line ids: \n" +" received: []\n" +"expected: %s\n" +msgstr "" + +#. module: purchase_order_import +msgid "Universal Business Language" +msgstr "Universal Business Language" + +#. module: purchase_order_import +#: code:addons/purchase_order_import/wizard/order_response_import.py:226 +#, python-format +msgid "Unknown status '%s'." +msgstr "" + +#. module: purchase_order_import +#: code:addons/purchase_order_import/tests/test_order_response_import.py:141 +#, python-format +msgid "Unknown status 'unknown'." +msgstr "" + +#. module: purchase_order_import +#: model:ir.model.fields,field_description:purchase_order_import.field_purchase_order_import_update_option +msgid "Update Option" +msgstr "" + +#. module: purchase_order_import +msgid "Update RFQ" +msgstr "" + +#. module: purchase_order_import +#: model:ir.model.fields,help:purchase_order_import.field_purchase_order_import_quote_file +msgid "" +"Upload a quotation file that you received from your supplier. Supported " +"formats: XML and PDF (PDF with an embeded XML file)." +msgstr "" + +#. module: purchase_order_import +#: model:ir.model.fields,help:purchase_order_import.field_order_response_import_document +msgid "" +"Upload an Order response file that you received from your supplier. " +"Supported formats: XML and PDF (PDF with an embeded XML file)." +msgstr "" + +#. module: purchase_order_import +msgid "" +"Upload below the OrderResponse you received from your supplier. When you " +"click on the import button:" +msgstr "" + +#. module: purchase_order_import +msgid "" +"Upload below the quotation that you received from your supplier for this RFQ " +"as XML or PDF file. When you click on the Update RFQ button:" +msgstr "" + +#. module: purchase_order_import +#: model:ir.model.fields,field_description:purchase_order_import.field_order_response_import_document +msgid "XML or PDF Order response" +msgstr "" + +#. module: purchase_order_import +#: model:ir.model.fields,field_description:purchase_order_import.field_purchase_order_import_quote_file +msgid "XML or PDF Quotation" +msgstr "" + +#. module: purchase_order_import +#: code:addons/purchase_order_import/wizard/purchase_order_import.py:233 +#, python-format +msgid "You must select a quotation to update." +msgstr "" + +#. module: purchase_order_import +msgid "" +"for lines that are present both in the imported quotation and in the RFQ, " +"Odoo will update the unit prices (and also the quantities, depending on the " +"chosen option)," +msgstr "" + +#. module: purchase_order_import +msgid "" +"for the lines that are present only in the RFQ, Odoo will put a warning " +"message in the chatter (it won't delete them automatically)." +msgstr "" + +#. module: purchase_order_import +msgid "" +"for the lines that are present only in the quotation (shipping costs for " +"example), Odoo will add them to the RFQ," +msgstr "" + +#. module: purchase_order_import +msgid "" +"format (UBL), you should install the module order_response_import_ubl." +msgstr "" + +#. module: purchase_order_import +msgid "" +"format (UBL), you should install the module purchase_order_import_ubl." +msgstr "" + +#. module: purchase_order_import +#: code:addons/purchase_order_import/tests/test_order_response_import.py:434 +#: code:addons/purchase_order_import/tests/test_order_response_import.py:500 +#: code:addons/purchase_order_import/tests/test_order_response_import.py:523 +#, python-format +msgid "" +"my note\n" +"%s items should be delivered into a next delivery." +msgstr "" diff --git a/purchase_order_import/i18n/fr.po b/purchase_order_import/i18n/fr.po new file mode 100644 index 0000000000..3c210e28d5 --- /dev/null +++ b/purchase_order_import/i18n/fr.po @@ -0,0 +1,558 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * purchase_order_import +# +# Translators: +# OCA Transbot , 2016 +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 8.0\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2016-11-12 13:36+0000\n" +"PO-Revision-Date: 2016-11-12 13:36+0000\n" +"Last-Translator: OCA Transbot , 2016\n" +"Language-Team: French (https://www.transifex.com/oca/teams/23907/fr/)\n" +"Language: fr\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" + +#. module: purchase_order_import +#: code:addons/purchase_order_import/wizard/purchase_order_import.py:210 +#, python-format +msgid "%d new order line(s) created: %s" +msgstr "" + +#. module: purchase_order_import +#: code:addons/purchase_order_import/wizard/purchase_order_import.py:194 +#, python-format +msgid "%d order line(s) are not in the imported quotation: %s" +msgstr "" + +#. module: purchase_order_import +#: code:addons/purchase_order_import/tests/test_order_response_import.py:578 +#: code:addons/purchase_order_import/wizard/order_response_import.py:334 +#, python-format +msgid "%s items should be delivered into a next delivery." +msgstr "" + +#. module: purchase_order_import +#: model:ir.ui.view,arch_db:purchase_order_import.order_response_import_form +#: model:ir.ui.view,arch_db:purchase_order_import.purchase_order_import_form +msgid "Cancel" +msgstr "Annuler" + +#. module: purchase_order_import +#: model:ir.model.fields,field_description:purchase_order_import.field_order_response_import_create_uid +#: model:ir.model.fields,field_description:purchase_order_import.field_purchase_order_import_create_uid +msgid "Created by" +msgstr "Créé par" + +#. module: purchase_order_import +#: model:ir.model.fields,field_description:purchase_order_import.field_order_response_import_create_date +#: model:ir.model.fields,field_description:purchase_order_import.field_purchase_order_import_create_date +msgid "Created on" +msgstr "Créé le" + +#. module: purchase_order_import +#: model:ir.model.fields,help:purchase_order_import.field_purchase_order_supplier_ack_dt +msgid "" +"Date and time of the acknowledgement by the supplier. This field is filled " +"by Odoo when processing a OrderResponse document." +msgstr "" + +#. module: purchase_order_import +#: model:ir.model.fields,field_description:purchase_order_import.field_order_response_import_display_name +#: model:ir.model.fields,field_description:purchase_order_import.field_purchase_order_import_display_name +msgid "Display Name" +msgstr "" + +#. module: purchase_order_import +#: model:ir.model.fields,field_description:purchase_order_import.field_order_response_import_filename +#: model:ir.model.fields,field_description:purchase_order_import.field_purchase_order_import_quote_filename +msgid "Filename" +msgstr "Nom du fichier" + +#. module: purchase_order_import +#: model:ir.model.fields,field_description:purchase_order_import.field_order_response_import_id +#: model:ir.model.fields,field_description:purchase_order_import.field_purchase_order_import_id +msgid "ID" +msgstr "ID" + +#. module: purchase_order_import +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: purchase_order_import +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: purchase_order_import +msgid "If the status code is Accepted: confirm the PO and create the picking" +msgstr "" + +#. module: purchase_order_import +msgid "" +"If the status code is Conditionally accepted: confirm the PO and create the " +"picking and update the picking operations according to the amendments " +"specified into the document" +msgstr "" + +#. module: purchase_order_import +msgid "If the status code is Rejected: cancel the PO" +msgstr "" + +#. module: purchase_order_import +msgid "" +"If the status code is acknowledgement: update the acknowledge datetime on " +"the PO" +msgstr "" + +#. module: purchase_order_import +msgid "Import OrderResponse File from Supplier" +msgstr "" + +#. module: purchase_order_import +#: model:ir.actions.act_window,name:purchase_order_import.order_response_import_action +#: model:ir.actions.act_window,name:purchase_order_import.purchase_order_import_action +msgid "Import Quotation" +msgstr "" + +#. module: purchase_order_import +#: model:ir.ui.view,arch_db:purchase_order_import.purchase_order_form +msgid "Import Quotation File" +msgstr "" + +#. module: purchase_order_import +msgid "Import Quotations Files from Suppliers" +msgstr "" + +#. module: purchase_order_import +msgid "Import document" +msgstr "" + +#. module: purchase_order_import +#: model:ir.model.fields,field_description:purchase_order_import.field_order_response_import___last_update +#: model:ir.model.fields,field_description:purchase_order_import.field_purchase_order_import___last_update +msgid "Last Modified on" +msgstr "" + +#. module: purchase_order_import +#: model:ir.model.fields,field_description:purchase_order_import.field_order_response_import_write_uid +#: model:ir.model.fields,field_description:purchase_order_import.field_purchase_order_import_write_uid +msgid "Last Updated by" +msgstr "Dernière mise-à-jour par" + +#. module: purchase_order_import +#: model:ir.model.fields,field_description:purchase_order_import.field_order_response_import_write_date +#: model:ir.model.fields,field_description:purchase_order_import.field_purchase_order_import_write_date +msgid "Last Updated on" +msgstr "Dernière mise-à-jour le" + +#. module: purchase_order_import +#: code:addons/purchase_order_import/wizard/order_response_import.py:112 +#, python-format +msgid "Missing document file" +msgstr "" + +#. module: purchase_order_import +#: code:addons/purchase_order_import/wizard/order_response_import.py:114 +#, python-format +msgid "Missing document filename" +msgstr "" + +#. module: purchase_order_import +#: code:addons/purchase_order_import/wizard/order_response_import.py:305 +#, python-format +msgid "" +"More than one move found for PO line.\n" +"Move IDs: %s\n" +"Line Info: %s" +msgstr "" + +#. module: purchase_order_import +#: code:addons/purchase_order_import/tests/test_order_response_import.py:586 +#: code:addons/purchase_order_import/wizard/order_response_import.py:360 +#, python-format +msgid "No backorder planned by the supplier." +msgstr "" + +#. module: purchase_order_import +#: code:addons/purchase_order_import/wizard/order_response_import.py:179 +#, python-format +msgid "No purchase order found for name %s." +msgstr "" + +#. module: purchase_order_import +#: code:addons/purchase_order_import/tests/test_order_response_import.py:125 +#, python-format +msgid "No purchase order found for name 123456." +msgstr "" + +#. module: purchase_order_import +#: code:addons/purchase_order_import/wizard/order_response_import.py:254 +#, python-format +msgid "PO cancelled by the supplier." +msgstr "" + +#. module: purchase_order_import +#: code:addons/purchase_order_import/wizard/order_response_import.py:264 +#, python-format +msgid "PO confirmed by the supplier." +msgstr "" + +#. module: purchase_order_import +#: code:addons/purchase_order_import/wizard/order_response_import.py:276 +#, python-format +msgid "PO confirmed with amendment by the supplier." +msgstr "" + +#. module: purchase_order_import +msgid "Possible line amendments:" +msgstr "" + +#. module: purchase_order_import +#: selection:purchase.order.import,update_option:0 +msgid "Price" +msgstr "" + +#. module: purchase_order_import +#: selection:purchase.order.import,update_option:0 +msgid "Price and Quantity" +msgstr "" + +#. module: purchase_order_import +#: model:ir.model,name:purchase_order_import.model_purchase_order +msgid "Purchase Order" +msgstr "" + +#. module: purchase_order_import +#: model:ir.model,name:purchase_order_import.model_purchase_order_import +msgid "Purchase Order Import from Files" +msgstr "" + +#. module: purchase_order_import +#: model:ir.model,name:purchase_order_import.model_order_response_import +msgid "Purchase Order Response Import from Files" +msgstr "" + +#. module: purchase_order_import +#: model:ir.model.fields,field_description:purchase_order_import.field_purchase_order_import_purchase_id +msgid "RFQ to Update" +msgstr "" + +#. module: purchase_order_import +#: code:addons/purchase_order_import/wizard/order_response_import.py:408 +#, python-format +msgid "" +"Some Pack Operations have already started! Please validate or reset " +"operations on picking %s to ensure delivery slip to be computed." +msgstr "" + +#. module: purchase_order_import +#: model:ir.model.fields,field_description:purchase_order_import.field_purchase_order_supplier_ack_dt +msgid "Supplier Acknowledgement Date" +msgstr "" + +#. module: purchase_order_import +#: code:addons/purchase_order_import/wizard/order_response_import.py:208 +#, python-format +msgid "" +"The currency of the imported OrderResponse (%s) is different from the " +"currency of the purchase order (%s)." +msgstr "" + +#. module: purchase_order_import +#: code:addons/purchase_order_import/tests/test_order_response_import.py:158 +#, python-format +msgid "" +"The currency of the imported OrderResponse (USD) is different from the " +"currency of the purchase order (EUR)." +msgstr "" + +#. module: purchase_order_import +#: code:addons/purchase_order_import/wizard/purchase_order_import.py:250 +#, python-format +msgid "" +"The currency of the imported quotation (%s) is different from the currency " +"of the RFQ (%s)" +msgstr "" + +#. module: purchase_order_import +#: code:addons/purchase_order_import/wizard/purchase_order_import.py:134 +#, python-format +msgid "" +"The incoterm has been updated from %s to %s upon import of the quotation " +"file '%s'" +msgstr "" + +#. module: purchase_order_import +msgid "" +"The order line is accepted with change: The picking operation is modified. " +"(split to stock move). If a backorder qty is provided, a backorder is " +"created. Otherwise the stock.move for the missing quantities is cancelled." +msgstr "" + +#. module: purchase_order_import +msgid "The order line is refused: The picking operation is cancelled" +msgstr "" + +#. module: purchase_order_import +#: code:addons/purchase_order_import/wizard/purchase_order_import.py:180 +#, python-format +msgid "" +"The quantity has been updated on the RFQ line with product '%s' from %s to " +"%s %s." +msgstr "" + +#. module: purchase_order_import +#: code:addons/purchase_order_import/wizard/order_response_import.py:200 +#, python-format +msgid "" +"The supplier of the imported OrderResponse (%s) is different from the " +"supplier of the purchase order (%s)." +msgstr "" + +#. module: purchase_order_import +#: code:addons/purchase_order_import/wizard/purchase_order_import.py:246 +#, python-format +msgid "" +"The supplier of the imported quotation (%s) is different from the supplier " +"of the RFQ (%s)." +msgstr "" + +#. module: purchase_order_import +#: code:addons/purchase_order_import/wizard/purchase_order_import.py:172 +#, python-format +msgid "" +"The unit price has been updated on the RFQ line with product '%s' from %s to " +"%s %s." +msgstr "" + +#. module: purchase_order_import +msgid "Then, Odoo will compare the imported quotation and the current RFQ:" +msgstr "" + +#. module: purchase_order_import +msgid "Then, Odoo will process the related purchase order as follow:" +msgstr "" + +#. module: purchase_order_import +#: code:addons/purchase_order_import/wizard/order_response_import.py:69 +#: code:addons/purchase_order_import/wizard/purchase_order_import.py:53 +#, python-format +msgid "There are no embedded XML file in this PDF file." +msgstr "" + +#. module: purchase_order_import +#: code:addons/purchase_order_import/wizard/purchase_order_import.py:265 +#, python-format +msgid "" +"This RFQ has been updated automatically via the import of quotation file %s" +msgstr "" + +#. module: purchase_order_import +#: code:addons/purchase_order_import/wizard/order_response_import.py:122 +#: code:addons/purchase_order_import/wizard/purchase_order_import.py:100 +#, python-format +msgid "This XML file is not XML-compliant" +msgstr "" + +#. module: purchase_order_import +#: code:addons/purchase_order_import/wizard/order_response_import.py:137 +#: code:addons/purchase_order_import/wizard/purchase_order_import.py:110 +#, python-format +msgid "" +"This file '%s' is not recognised as XML nor PDF file. Please check the file " +"and it's extension." +msgstr "" + +#. module: purchase_order_import +#: code:addons/purchase_order_import/wizard/order_response_import.py:235 +#, python-format +msgid "" +"This purchase order has been updated automatically via the import of " +"OrderResponse file %s." +msgstr "" + +#. module: purchase_order_import +#: code:addons/purchase_order_import/wizard/purchase_order_import.py:258 +#, python-format +msgid "This quotation doesn't have any line !" +msgstr "" + +#. module: purchase_order_import +#: code:addons/purchase_order_import/wizard/order_response_import.py:79 +#, python-format +msgid "" +"This type of XML Order Document is not supported. Did you install the module " +"to support this XML format?" +msgstr "" + +#. module: purchase_order_import +#: code:addons/purchase_order_import/wizard/order_response_import.py:55 +#, python-format +msgid "" +"This type of XML Order Response is not supported. Did you install the module " +"to support this XML format?" +msgstr "" + +#. module: purchase_order_import +#: code:addons/purchase_order_import/wizard/purchase_order_import.py:42 +#: code:addons/purchase_order_import/wizard/purchase_order_import.py:62 +#, python-format +msgid "" +"This type of XML quotation is not supported. Did you install the module to " +"support this XML format?" +msgstr "" + +#. module: purchase_order_import +#: model:ir.ui.menu,name:purchase_order_import.order_response_import_importer_menu +msgid "UBL OrderResponse Importer" +msgstr "" + +#. module: purchase_order_import +#: code:addons/purchase_order_import/wizard/order_response_import.py:281 +#, python-format +msgid "" +"Unable to conditionally confirm the purchase order. \n" +"Line IDS into the parsed document differs from the expected list of order " +"line ids: \n" +" received: %s\n" +"expected: %s\n" +msgstr "" + +#. module: purchase_order_import +#: code:addons/purchase_order_import/tests/test_order_response_import.py:259 +#, python-format +msgid "" +"Unable to conditionally confirm the purchase order. \n" +"Line IDS into the parsed document differs from the expected list of order " +"line ids: \n" +" received: [%s]\n" +"expected: %s\n" +msgstr "" + +#. module: purchase_order_import +#: code:addons/purchase_order_import/tests/test_order_response_import.py:230 +#, python-format +msgid "" +"Unable to conditionally confirm the purchase order. \n" +"Line IDS into the parsed document differs from the expected list of order " +"line ids: \n" +" received: []\n" +"expected: %s\n" +msgstr "" + +#. module: purchase_order_import +msgid "Universal Business Language" +msgstr "" + +#. module: purchase_order_import +#: code:addons/purchase_order_import/wizard/order_response_import.py:226 +#, python-format +msgid "Unknown status '%s'." +msgstr "" + +#. module: purchase_order_import +#: code:addons/purchase_order_import/tests/test_order_response_import.py:141 +#, python-format +msgid "Unknown status 'unknown'." +msgstr "" + +#. module: purchase_order_import +#: model:ir.model.fields,field_description:purchase_order_import.field_purchase_order_import_update_option +msgid "Update Option" +msgstr "" + +#. module: purchase_order_import +msgid "Update RFQ" +msgstr "" + +#. module: purchase_order_import +#: model:ir.model.fields,help:purchase_order_import.field_purchase_order_import_quote_file +msgid "" +"Upload a quotation file that you received from your supplier. Supported " +"formats: XML and PDF (PDF with an embeded XML file)." +msgstr "" + +#. module: purchase_order_import +#: model:ir.model.fields,help:purchase_order_import.field_order_response_import_document +msgid "" +"Upload an Order response file that you received from your supplier. " +"Supported formats: XML and PDF (PDF with an embeded XML file)." +msgstr "" + +#. module: purchase_order_import +msgid "" +"Upload below the OrderResponse you received from your supplier. When you " +"click on the import button:" +msgstr "" + +#. module: purchase_order_import +msgid "" +"Upload below the quotation that you received from your supplier for this RFQ " +"as XML or PDF file. When you click on the Update RFQ button:" +msgstr "" + +#. module: purchase_order_import +#: model:ir.model.fields,field_description:purchase_order_import.field_order_response_import_document +msgid "XML or PDF Order response" +msgstr "" + +#. module: purchase_order_import +#: model:ir.model.fields,field_description:purchase_order_import.field_purchase_order_import_quote_file +msgid "XML or PDF Quotation" +msgstr "" + +#. module: purchase_order_import +#: code:addons/purchase_order_import/wizard/purchase_order_import.py:233 +#, python-format +msgid "You must select a quotation to update." +msgstr "" + +#. module: purchase_order_import +msgid "" +"for lines that are present both in the imported quotation and in the RFQ, " +"Odoo will update the unit prices (and also the quantities, depending on the " +"chosen option)," +msgstr "" + +#. module: purchase_order_import +msgid "" +"for the lines that are present only in the RFQ, Odoo will put a warning " +"message in the chatter (it won't delete them automatically)." +msgstr "" + +#. module: purchase_order_import +msgid "" +"for the lines that are present only in the quotation (shipping costs for " +"example), Odoo will add them to the RFQ," +msgstr "" + +#. module: purchase_order_import +msgid "" +"format (UBL), you should install the module order_response_import_ubl." +msgstr "" + +#. module: purchase_order_import +msgid "" +"format (UBL), you should install the module purchase_order_import_ubl." +msgstr "" + +#. module: purchase_order_import +#: code:addons/purchase_order_import/tests/test_order_response_import.py:434 +#: code:addons/purchase_order_import/tests/test_order_response_import.py:500 +#: code:addons/purchase_order_import/tests/test_order_response_import.py:523 +#, python-format +msgid "" +"my note\n" +"%s items should be delivered into a next delivery." +msgstr "" diff --git a/purchase_order_import/i18n/purchase_order_import.pot b/purchase_order_import/i18n/purchase_order_import.pot new file mode 100644 index 0000000000..3c36a37fad --- /dev/null +++ b/purchase_order_import/i18n/purchase_order_import.pot @@ -0,0 +1,487 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * purchase_order_import +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 10.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: purchase_order_import +#: code:addons/purchase_order_import/wizard/purchase_order_import.py:210 +#, python-format +msgid "%d new order line(s) created: %s" +msgstr "" + +#. module: purchase_order_import +#: code:addons/purchase_order_import/wizard/purchase_order_import.py:194 +#, python-format +msgid "%d order line(s) are not in the imported quotation: %s" +msgstr "" + +#. module: purchase_order_import +#: code:addons/purchase_order_import/tests/test_order_response_import.py:578 +#: code:addons/purchase_order_import/wizard/order_response_import.py:334 +#, python-format +msgid "%s items should be delivered into a next delivery." +msgstr "" + +#. module: purchase_order_import +#: model:ir.ui.view,arch_db:purchase_order_import.order_response_import_form +#: model:ir.ui.view,arch_db:purchase_order_import.purchase_order_import_form +msgid "Cancel" +msgstr "" + +#. module: purchase_order_import +#: model:ir.model.fields,field_description:purchase_order_import.field_order_response_import_create_uid +#: model:ir.model.fields,field_description:purchase_order_import.field_purchase_order_import_create_uid +msgid "Created by" +msgstr "" + +#. module: purchase_order_import +#: model:ir.model.fields,field_description:purchase_order_import.field_order_response_import_create_date +#: model:ir.model.fields,field_description:purchase_order_import.field_purchase_order_import_create_date +msgid "Created on" +msgstr "" + +#. module: purchase_order_import +#: model:ir.model.fields,help:purchase_order_import.field_purchase_order_supplier_ack_dt +msgid "Date and time of the acknowledgement by the supplier. This field is filled by Odoo when processing a OrderResponse document." +msgstr "" + +#. module: purchase_order_import +#: model:ir.model.fields,field_description:purchase_order_import.field_order_response_import_display_name +#: model:ir.model.fields,field_description:purchase_order_import.field_purchase_order_import_display_name +msgid "Display Name" +msgstr "" + +#. module: purchase_order_import +#: model:ir.model.fields,field_description:purchase_order_import.field_order_response_import_filename +#: model:ir.model.fields,field_description:purchase_order_import.field_purchase_order_import_quote_filename +msgid "Filename" +msgstr "" + +#. module: purchase_order_import +#: model:ir.model.fields,field_description:purchase_order_import.field_order_response_import_id +#: model:ir.model.fields,field_description:purchase_order_import.field_purchase_order_import_id +msgid "ID" +msgstr "" + +#. module: purchase_order_import +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: purchase_order_import +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: purchase_order_import +msgid "If the status code is Accepted: confirm the PO and create the picking" +msgstr "" + +#. module: purchase_order_import +msgid "If the status code is Conditionally accepted: confirm the PO and create the picking and update the picking operations according to the amendments specified into the document" +msgstr "" + +#. module: purchase_order_import +msgid "If the status code is Rejected: cancel the PO" +msgstr "" + +#. module: purchase_order_import +msgid "If the status code is acknowledgement: update the acknowledge datetime on the PO" +msgstr "" + +#. module: purchase_order_import +msgid "Import OrderResponse File from Supplier" +msgstr "" + +#. module: purchase_order_import +#: model:ir.actions.act_window,name:purchase_order_import.order_response_import_action +#: model:ir.actions.act_window,name:purchase_order_import.purchase_order_import_action +msgid "Import Quotation" +msgstr "" + +#. module: purchase_order_import +#: model:ir.ui.view,arch_db:purchase_order_import.purchase_order_form +msgid "Import Quotation File" +msgstr "" + +#. module: purchase_order_import +msgid "Import Quotations Files from Suppliers" +msgstr "" + +#. module: purchase_order_import +msgid "Import document" +msgstr "" + +#. module: purchase_order_import +#: model:ir.model.fields,field_description:purchase_order_import.field_order_response_import___last_update +#: model:ir.model.fields,field_description:purchase_order_import.field_purchase_order_import___last_update +msgid "Last Modified on" +msgstr "" + +#. module: purchase_order_import +#: model:ir.model.fields,field_description:purchase_order_import.field_order_response_import_write_uid +#: model:ir.model.fields,field_description:purchase_order_import.field_purchase_order_import_write_uid +msgid "Last Updated by" +msgstr "" + +#. module: purchase_order_import +#: model:ir.model.fields,field_description:purchase_order_import.field_order_response_import_write_date +#: model:ir.model.fields,field_description:purchase_order_import.field_purchase_order_import_write_date +msgid "Last Updated on" +msgstr "" + +#. module: purchase_order_import +#: code:addons/purchase_order_import/wizard/order_response_import.py:112 +#, python-format +msgid "Missing document file" +msgstr "" + +#. module: purchase_order_import +#: code:addons/purchase_order_import/wizard/order_response_import.py:114 +#, python-format +msgid "Missing document filename" +msgstr "" + +#. module: purchase_order_import +#: code:addons/purchase_order_import/wizard/order_response_import.py:305 +#, python-format +msgid "More than one move found for PO line.\n" +"Move IDs: %s\n" +"Line Info: %s" +msgstr "" + +#. module: purchase_order_import +#: code:addons/purchase_order_import/tests/test_order_response_import.py:586 +#: code:addons/purchase_order_import/wizard/order_response_import.py:360 +#, python-format +msgid "No backorder planned by the supplier." +msgstr "" + +#. module: purchase_order_import +#: code:addons/purchase_order_import/wizard/order_response_import.py:179 +#, python-format +msgid "No purchase order found for name %s." +msgstr "" + +#. module: purchase_order_import +#: code:addons/purchase_order_import/tests/test_order_response_import.py:125 +#, python-format +msgid "No purchase order found for name 123456." +msgstr "" + +#. module: purchase_order_import +#: code:addons/purchase_order_import/wizard/order_response_import.py:254 +#, python-format +msgid "PO cancelled by the supplier." +msgstr "" + +#. module: purchase_order_import +#: code:addons/purchase_order_import/wizard/order_response_import.py:264 +#, python-format +msgid "PO confirmed by the supplier." +msgstr "" + +#. module: purchase_order_import +#: code:addons/purchase_order_import/wizard/order_response_import.py:276 +#, python-format +msgid "PO confirmed with amendment by the supplier." +msgstr "" + +#. module: purchase_order_import +msgid "Possible line amendments:" +msgstr "" + +#. module: purchase_order_import +#: selection:purchase.order.import,update_option:0 +msgid "Price" +msgstr "" + +#. module: purchase_order_import +#: selection:purchase.order.import,update_option:0 +msgid "Price and Quantity" +msgstr "" + +#. module: purchase_order_import +#: model:ir.model,name:purchase_order_import.model_purchase_order +msgid "Purchase Order" +msgstr "" + +#. module: purchase_order_import +#: model:ir.model,name:purchase_order_import.model_purchase_order_import +msgid "Purchase Order Import from Files" +msgstr "" + +#. module: purchase_order_import +#: model:ir.model,name:purchase_order_import.model_order_response_import +msgid "Purchase Order Response Import from Files" +msgstr "" + +#. module: purchase_order_import +#: model:ir.model.fields,field_description:purchase_order_import.field_purchase_order_import_purchase_id +msgid "RFQ to Update" +msgstr "" + +#. module: purchase_order_import +#: code:addons/purchase_order_import/wizard/order_response_import.py:408 +#, python-format +msgid "Some Pack Operations have already started! Please validate or reset operations on picking %s to ensure delivery slip to be computed." +msgstr "" + +#. module: purchase_order_import +#: model:ir.model.fields,field_description:purchase_order_import.field_purchase_order_supplier_ack_dt +msgid "Supplier Acknowledgement Date" +msgstr "" + +#. module: purchase_order_import +#: code:addons/purchase_order_import/wizard/order_response_import.py:208 +#, python-format +msgid "The currency of the imported OrderResponse (%s) is different from the currency of the purchase order (%s)." +msgstr "" + +#. module: purchase_order_import +#: code:addons/purchase_order_import/tests/test_order_response_import.py:158 +#, python-format +msgid "The currency of the imported OrderResponse (USD) is different from the currency of the purchase order (EUR)." +msgstr "" + +#. module: purchase_order_import +#: code:addons/purchase_order_import/wizard/purchase_order_import.py:250 +#, python-format +msgid "The currency of the imported quotation (%s) is different from the currency of the RFQ (%s)" +msgstr "" + +#. module: purchase_order_import +#: code:addons/purchase_order_import/wizard/purchase_order_import.py:134 +#, python-format +msgid "The incoterm has been updated from %s to %s upon import of the quotation file '%s'" +msgstr "" + +#. module: purchase_order_import +msgid "The order line is accepted with change: The picking operation is modified. (split to stock move). If a backorder qty is provided, a backorder is created. Otherwise the stock.move for the missing quantities is cancelled." +msgstr "" + +#. module: purchase_order_import +msgid "The order line is refused: The picking operation is cancelled" +msgstr "" + +#. module: purchase_order_import +#: code:addons/purchase_order_import/wizard/purchase_order_import.py:180 +#, python-format +msgid "The quantity has been updated on the RFQ line with product '%s' from %s to %s %s." +msgstr "" + +#. module: purchase_order_import +#: code:addons/purchase_order_import/wizard/order_response_import.py:200 +#, python-format +msgid "The supplier of the imported OrderResponse (%s) is different from the supplier of the purchase order (%s)." +msgstr "" + +#. module: purchase_order_import +#: code:addons/purchase_order_import/wizard/purchase_order_import.py:246 +#, python-format +msgid "The supplier of the imported quotation (%s) is different from the supplier of the RFQ (%s)." +msgstr "" + +#. module: purchase_order_import +#: code:addons/purchase_order_import/wizard/purchase_order_import.py:172 +#, python-format +msgid "The unit price has been updated on the RFQ line with product '%s' from %s to %s %s." +msgstr "" + +#. module: purchase_order_import +msgid "Then, Odoo will compare the imported quotation and the current RFQ:" +msgstr "" + +#. module: purchase_order_import +msgid "Then, Odoo will process the related purchase order as follow:" +msgstr "" + +#. module: purchase_order_import +#: code:addons/purchase_order_import/wizard/order_response_import.py:69 +#: code:addons/purchase_order_import/wizard/purchase_order_import.py:53 +#, python-format +msgid "There are no embedded XML file in this PDF file." +msgstr "" + +#. module: purchase_order_import +#: code:addons/purchase_order_import/wizard/purchase_order_import.py:265 +#, python-format +msgid "This RFQ has been updated automatically via the import of quotation file %s" +msgstr "" + +#. module: purchase_order_import +#: code:addons/purchase_order_import/wizard/order_response_import.py:122 +#: code:addons/purchase_order_import/wizard/purchase_order_import.py:100 +#, python-format +msgid "This XML file is not XML-compliant" +msgstr "" + +#. module: purchase_order_import +#: code:addons/purchase_order_import/wizard/order_response_import.py:137 +#: code:addons/purchase_order_import/wizard/purchase_order_import.py:110 +#, python-format +msgid "This file '%s' is not recognised as XML nor PDF file. Please check the file and it's extension." +msgstr "" + +#. module: purchase_order_import +#: code:addons/purchase_order_import/wizard/order_response_import.py:235 +#, python-format +msgid "This purchase order has been updated automatically via the import of OrderResponse file %s." +msgstr "" + +#. module: purchase_order_import +#: code:addons/purchase_order_import/wizard/purchase_order_import.py:258 +#, python-format +msgid "This quotation doesn't have any line !" +msgstr "" + +#. module: purchase_order_import +#: code:addons/purchase_order_import/wizard/order_response_import.py:79 +#, python-format +msgid "This type of XML Order Document is not supported. Did you install the module to support this XML format?" +msgstr "" + +#. module: purchase_order_import +#: code:addons/purchase_order_import/wizard/order_response_import.py:55 +#, python-format +msgid "This type of XML Order Response is not supported. Did you install the module to support this XML format?" +msgstr "" + +#. module: purchase_order_import +#: code:addons/purchase_order_import/wizard/purchase_order_import.py:42 +#: code:addons/purchase_order_import/wizard/purchase_order_import.py:62 +#, python-format +msgid "This type of XML quotation is not supported. Did you install the module to support this XML format?" +msgstr "" + +#. module: purchase_order_import +#: model:ir.ui.menu,name:purchase_order_import.order_response_import_importer_menu +msgid "UBL OrderResponse Importer" +msgstr "" + +#. module: purchase_order_import +#: code:addons/purchase_order_import/wizard/order_response_import.py:281 +#, python-format +msgid "Unable to conditionally confirm the purchase order. \n" +"Line IDS into the parsed document differs from the expected list of order line ids: \n" +" received: %s\n" +"expected: %s\n" +"" +msgstr "" + +#. module: purchase_order_import +#: code:addons/purchase_order_import/tests/test_order_response_import.py:259 +#, python-format +msgid "Unable to conditionally confirm the purchase order. \n" +"Line IDS into the parsed document differs from the expected list of order line ids: \n" +" received: [%s]\n" +"expected: %s\n" +"" +msgstr "" + +#. module: purchase_order_import +#: code:addons/purchase_order_import/tests/test_order_response_import.py:230 +#, python-format +msgid "Unable to conditionally confirm the purchase order. \n" +"Line IDS into the parsed document differs from the expected list of order line ids: \n" +" received: []\n" +"expected: %s\n" +"" +msgstr "" + +#. module: purchase_order_import +msgid "Universal Business Language" +msgstr "" + +#. module: purchase_order_import +#: code:addons/purchase_order_import/wizard/order_response_import.py:226 +#, python-format +msgid "Unknown status '%s'." +msgstr "" + +#. module: purchase_order_import +#: code:addons/purchase_order_import/tests/test_order_response_import.py:141 +#, python-format +msgid "Unknown status 'unknown'." +msgstr "" + +#. module: purchase_order_import +#: model:ir.model.fields,field_description:purchase_order_import.field_purchase_order_import_update_option +msgid "Update Option" +msgstr "" + +#. module: purchase_order_import +msgid "Update RFQ" +msgstr "" + +#. module: purchase_order_import +#: model:ir.model.fields,help:purchase_order_import.field_purchase_order_import_quote_file +msgid "Upload a quotation file that you received from your supplier. Supported formats: XML and PDF (PDF with an embeded XML file)." +msgstr "" + +#. module: purchase_order_import +#: model:ir.model.fields,help:purchase_order_import.field_order_response_import_document +msgid "Upload an Order response file that you received from your supplier. Supported formats: XML and PDF (PDF with an embeded XML file)." +msgstr "" + +#. module: purchase_order_import +msgid "Upload below the OrderResponse you received from your supplier. When you click on the import button:" +msgstr "" + +#. module: purchase_order_import +msgid "Upload below the quotation that you received from your supplier for this RFQ as XML or PDF file. When you click on the Update RFQ button:" +msgstr "" + +#. module: purchase_order_import +#: model:ir.model.fields,field_description:purchase_order_import.field_order_response_import_document +msgid "XML or PDF Order response" +msgstr "" + +#. module: purchase_order_import +#: model:ir.model.fields,field_description:purchase_order_import.field_purchase_order_import_quote_file +msgid "XML or PDF Quotation" +msgstr "" + +#. module: purchase_order_import +#: code:addons/purchase_order_import/wizard/purchase_order_import.py:233 +#, python-format +msgid "You must select a quotation to update." +msgstr "" + +#. module: purchase_order_import +msgid "for lines that are present both in the imported quotation and in the RFQ, Odoo will update the unit prices (and also the quantities, depending on the chosen option)," +msgstr "" + +#. module: purchase_order_import +msgid "for the lines that are present only in the RFQ, Odoo will put a warning message in the chatter (it won't delete them automatically)." +msgstr "" + +#. module: purchase_order_import +msgid "for the lines that are present only in the quotation (shipping costs for example), Odoo will add them to the RFQ," +msgstr "" + +#. module: purchase_order_import +msgid "format (UBL), you should install the module order_response_import_ubl." +msgstr "" + +#. module: purchase_order_import +msgid "format (UBL), you should install the module purchase_order_import_ubl." +msgstr "" + +#. module: purchase_order_import +#: code:addons/purchase_order_import/tests/test_order_response_import.py:434 +#: code:addons/purchase_order_import/tests/test_order_response_import.py:500 +#: code:addons/purchase_order_import/tests/test_order_response_import.py:523 +#, python-format +msgid "my note\n" +"%s items should be delivered into a next delivery." +msgstr "" + diff --git a/purchase_order_import/models/__init__.py b/purchase_order_import/models/__init__.py new file mode 100644 index 0000000000..9f03530643 --- /dev/null +++ b/purchase_order_import/models/__init__.py @@ -0,0 +1 @@ +from . import purchase_order diff --git a/purchase_order_import/models/purchase_order.py b/purchase_order_import/models/purchase_order.py new file mode 100644 index 0000000000..67f3ed7ef7 --- /dev/null +++ b/purchase_order_import/models/purchase_order.py @@ -0,0 +1,16 @@ +# Copyright 2020 ACSONE SA/NV +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo import fields, models + + +class PurchaseOrder(models.Model): + _inherit = "purchase.order" + + supplier_ack_dt = fields.Datetime( + "Supplier Acknowledgement Date", + help="Date and time of the acknowledgement by the supplier. " + "This field is filled by Odoo when processing a " + "OrderResponse document.", + index=True, + ) diff --git a/purchase_order_import/pyproject.toml b/purchase_order_import/pyproject.toml new file mode 100644 index 0000000000..4231d0cccb --- /dev/null +++ b/purchase_order_import/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["whool"] +build-backend = "whool.buildapi" diff --git a/purchase_order_import/readme/CONTRIBUTORS.md b/purchase_order_import/readme/CONTRIBUTORS.md new file mode 100644 index 0000000000..517ec4d96f --- /dev/null +++ b/purchase_order_import/readme/CONTRIBUTORS.md @@ -0,0 +1,4 @@ +- Alexis de Lattre \<\> +- Laurent Mignon \<\> +- Alexandre Fayolle \<\> +- Maksym Yankin \<\> diff --git a/purchase_order_import/readme/DESCRIPTION.md b/purchase_order_import/readme/DESCRIPTION.md new file mode 100644 index 0000000000..33863b4d5f --- /dev/null +++ b/purchase_order_import/readme/DESCRIPTION.md @@ -0,0 +1,36 @@ +This module provides the base wizards and hooks to import supplier quotation +documents and supplier OrderResponse documents on purchase orders. It provides +the generic import workflow; format-specific modules implement the XML parsers. + +The import wizards accept XML files directly and PDF files containing embedded +XML attachments. For the Universal Business Language (UBL) format, install: + +- `purchase_order_import_ubl` to import supplier quotations. +- `order_response_import_ubl` to import supplier OrderResponse documents. + +On a Request for Quotation, the **Import Quotation File** button opens a wizard +to upload the supplier quotation and choose how the RFQ should be updated: + +- update only the prices of the draft purchase order from the quotation file + (default option); +- update both prices and quantities. + +When the quotation is imported, Odoo compares it with the current RFQ: + +- lines present in the quotation but missing from the RFQ are created on the + purchase order; +- lines present in the RFQ but missing from the quotation are kept, and a + warning is added to the chatter; +- lines present in both documents are updated when prices or, depending on the + selected option, quantities differ; +- the incoterm is updated when the quotation contains a different incoterm; +- the imported file is attached to the purchase order. + +After importing a quotation, review the purchase order chatter because it may +contain important warnings or import details. + +OrderResponse imports are matched to a purchase order by reference and can +acknowledge, accept, reject, or conditionally accept the order. Conditional +acceptance updates receipt moves according to line amendments, including +splitting moves, creating backorders, or cancelling remaining quantities when no +backorder is planned. diff --git a/purchase_order_import/security/ir.model.access.csv b/purchase_order_import/security/ir.model.access.csv new file mode 100644 index 0000000000..a5f29e2bfd --- /dev/null +++ b/purchase_order_import/security/ir.model.access.csv @@ -0,0 +1,3 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_order_response_import,access.order.response.import,purchase_order_import.model_order_response_import,base.group_user,1,1,1,0 +access_purchase_order_import,access.purchase.order.import,purchase_order_import.model_purchase_order_import,base.group_user,1,1,1,0 diff --git a/purchase_order_import/static/description/icon.png b/purchase_order_import/static/description/icon.png new file mode 100644 index 0000000000..3a0328b516 Binary files /dev/null and b/purchase_order_import/static/description/icon.png differ diff --git a/purchase_order_import/static/description/index.html b/purchase_order_import/static/description/index.html new file mode 100644 index 0000000000..54ca9ae75c --- /dev/null +++ b/purchase_order_import/static/description/index.html @@ -0,0 +1,470 @@ + + + + + +README.rst + + + +
+ + + +Odoo Community Association + +
+

Purchase Order Import

+ +

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

+

This module provides the base wizards and hooks to import supplier +quotation documents and supplier OrderResponse documents on purchase +orders. It provides the generic import workflow; format-specific modules +implement the XML parsers.

+

The import wizards accept XML files directly and PDF files containing +embedded XML attachments. For the Universal Business Language (UBL) +format, install:

+
    +
  • purchase_order_import_ubl to import supplier quotations.
  • +
  • order_response_import_ubl to import supplier OrderResponse +documents.
  • +
+

On a Request for Quotation, the Import Quotation File button opens a +wizard to upload the supplier quotation and choose how the RFQ should be +updated:

+
    +
  • update only the prices of the draft purchase order from the quotation +file (default option);
  • +
  • update both prices and quantities.
  • +
+

When the quotation is imported, Odoo compares it with the current RFQ:

+
    +
  • lines present in the quotation but missing from the RFQ are created on +the purchase order;
  • +
  • lines present in the RFQ but missing from the quotation are kept, and +a warning is added to the chatter;
  • +
  • lines present in both documents are updated when prices or, depending +on the selected option, quantities differ;
  • +
  • the incoterm is updated when the quotation contains a different +incoterm;
  • +
  • the imported file is attached to the purchase order.
  • +
+

After importing a quotation, review the purchase order chatter because +it may contain important warnings or import details.

+

OrderResponse imports are matched to a purchase order by reference and +can acknowledge, accept, reject, or conditionally accept the order. +Conditional acceptance updates receipt moves according to line +amendments, including splitting moves, creating backorders, or +cancelling remaining quantities when no backorder is planned.

+

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

+
    +
  • Akretion
  • +
+
+
+

Contributors

+ +
+
+

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.

+
+
+
+
+ + diff --git a/purchase_order_import/tests/__init__.py b/purchase_order_import/tests/__init__.py new file mode 100644 index 0000000000..315c6f8bdb --- /dev/null +++ b/purchase_order_import/tests/__init__.py @@ -0,0 +1 @@ +from . import test_order_response_import diff --git a/purchase_order_import/tests/common.py b/purchase_order_import/tests/common.py new file mode 100644 index 0000000000..d097516f86 --- /dev/null +++ b/purchase_order_import/tests/common.py @@ -0,0 +1,133 @@ +# Copyright 2026 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo import Command, fields + +from odoo.addons.base.tests.common import BaseCommon + + +class TestOrderResponseImportCommon(BaseCommon): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.OrderResponseImport = cls.env["order.response.import"] + cls.env.user.company_id.partner_id.vat = "BE0421801233" + cls.currency_euro = cls._enable_currency("EUR") + cls.currency_usd = cls._enable_currency("USD") + cls.product_uom_unit = cls.env.ref("uom.product_uom_unit") + cls.supplier = cls._create_supplier() + cls.product_1 = cls._create_product("Product 1", "P1") + cls.product_2 = cls._create_product("Product 2", "P2") + cls.purchase_order = cls._create_purchase_order() + cls.line1 = cls._create_purchase_order_line( + cls.purchase_order, + cls.product_1, + qty=10, + price_unit=15, + ) + cls.line2 = cls._create_purchase_order_line( + cls.purchase_order, + cls.product_2, + qty=5, + price_unit=25, + ) + + @classmethod + def _create_supplier(cls): + return cls.env["res.partner"].create( + { + "name": "Order Response Supplier", + "supplier_rank": 1, + "vat": "BE0477472701", + } + ) + + @classmethod + def _create_product(cls, name, product_code): + return cls.env["product.product"].create( + { + "name": name, + "is_storable": True, + "seller_ids": [ + Command.create( + { + "partner_id": cls.supplier.id, + "product_code": product_code, + }, + ) + ], + } + ) + + @classmethod + def _create_purchase_order(cls): + return cls.env["purchase.order"].create( + { + "partner_id": cls.supplier.id, + "date_order": fields.Datetime.now(), + "date_planned": fields.Datetime.now(), + "currency_id": cls.currency_euro.id, + } + ) + + @classmethod + def _create_purchase_order_line( + cls, + order, + product, + qty, + price_unit, + ): + return cls.env["purchase.order.line"].create( + { + "order_id": order.id, + "product_id": product.id, + "name": product.name, + "date_planned": fields.Datetime.now(), + "product_qty": qty, + "product_uom_id": cls.product_uom_unit.id, + "price_unit": price_unit, + } + ) + + def _get_base_data(self, **values): + """Return a normalized parsed order response payload.""" + data = { + "status": "acknowledgement", + "company": {"vat": "BE0421801233"}, + "currency": {"iso": "EUR"}, + "date": "2020-02-04", + "chatter_msg": [], + "lines": [], + "note": "Note1\nNote2", + "time": "22:10:30", + "supplier": {"vat": "BE0477472701"}, + "ref": str(self.purchase_order.name), + } + data.update(values) + return data + + def _order_line_to_data( + self, + order_line, + qty=None, + status="accepted", + backorder_qty=None, + note=None, + ): + """Return parsed order response data for a purchase order line.""" + return { + "status": status, + "backorder_qty": backorder_qty, + "qty": qty if qty is not None else order_line.product_qty, + "note": note, + "line_id": str(order_line.id), + "uom": {"unece_code": order_line.product_uom_id.unece_code}, + } + + def _line_response_data(self, *lines): + """Return conditional acceptance payload with the provided lines.""" + return self._get_base_data( + status="conditionally_accepted", + lines=list(lines), + ) diff --git a/purchase_order_import/tests/test_order_response_import.py b/purchase_order_import/tests/test_order_response_import.py new file mode 100644 index 0000000000..c4f294df34 --- /dev/null +++ b/purchase_order_import/tests/test_order_response_import.py @@ -0,0 +1,271 @@ +# Copyright 2020 ACSONE SA/NV +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo.exceptions import UserError + +from .common import TestOrderResponseImportCommon + + +class TestOrderResponseImportWizard(TestOrderResponseImportCommon): + def test_unknown_purchase_order_reference(self): + """Raise an error when no purchase order matches the response reference.""" + data = self._get_base_data(ref="123456") + with self.assertRaisesRegex( + UserError, + "No purchase order found for name 123456.", + ): + self.OrderResponseImport.process_data(data) + + def test_unknown_status(self): + """Raise an error when the response status is not supported.""" + data = self._get_base_data(status="unknown") + with self.assertRaisesRegex(UserError, "Unknown status 'unknown'."): + self.OrderResponseImport.process_data(data) + + def test_different_currency(self): + """Raise an error when the response currency differs from the order.""" + data = self._get_base_data(currency={"iso": self.currency_usd.name}) + with self.assertRaisesRegex( + UserError, + "The currency of the imported OrderResponse", + ): + self.OrderResponseImport.process_data(data) + + def test_acknowledgement_sets_supplier_acknowledgement_date(self): + """Set the supplier acknowledgement date on acknowledgement.""" + data = self._get_base_data(status="acknowledgement") + self.assertFalse(self.purchase_order.supplier_ack_dt) + self.OrderResponseImport.process_data(data) + self.assertTrue(self.purchase_order.supplier_ack_dt) + + def test_accepted_response_confirms_order(self): + """Confirm the purchase order and create a receipt on acceptance.""" + data = self._get_base_data(status="accepted") + self.assertFalse(self.purchase_order.picking_ids) + self.assertEqual(self.purchase_order.state, "draft") + self.OrderResponseImport.process_data(data) + self.assertTrue(self.purchase_order.picking_ids) + self.assertEqual(self.purchase_order.state, "purchase") + + def test_rejected_response_cancels_order(self): + """Cancel the purchase order on rejection.""" + data = self._get_base_data(status="rejected") + self.assertEqual(self.purchase_order.state, "draft") + self.OrderResponseImport.process_data(data) + self.assertEqual(self.purchase_order.state, "cancel") + + def test_conditional_response_requires_all_lines(self): + """Raise an error when conditional acceptance omits order lines.""" + data = self._line_response_data() + with self.assertRaisesRegex( + UserError, + "Unable to conditionally confirm the purchase order.", + ): + self.OrderResponseImport.process_data(data) + + def test_conditional_response_rejects_wrong_line_id(self): + """Raise an error when conditional acceptance references unknown lines.""" + line2 = self._order_line_to_data(self.line2) + line2["line_id"] = "WRONG" + data = self._line_response_data( + self._order_line_to_data(self.line1), + line2, + ) + with self.assertRaisesRegex( + UserError, + "Unable to conditionally confirm the purchase order.", + ): + self.OrderResponseImport.process_data(data) + + def test_conditional_response_accepts_all_lines(self): + """Confirm the order when all conditional response lines are accepted.""" + data = self._line_response_data( + self._order_line_to_data(self.line1), + self._order_line_to_data(self.line2), + ) + self.OrderResponseImport.process_data(data) + self.assertEqual(self.purchase_order.state, "purchase") + self.assertTrue(self.purchase_order.picking_ids) + self.assertEqual(self.line1.move_ids.state, "assigned") + self.assertEqual(self.line2.move_ids.state, "assigned") + + def test_conditional_response_rejects_a_line(self): + """Cancel the receipt move for a rejected conditional response line.""" + data = self._line_response_data( + self._order_line_to_data(self.line1), + self._order_line_to_data( + self.line2, + status="rejected", + note="cancel by import", + ), + ) + self.OrderResponseImport.process_data(data) + self.assertEqual(self.purchase_order.state, "purchase") + self.assertTrue(self.purchase_order.picking_ids) + self.assertEqual(self.line1.move_ids.state, "assigned") + self.assertEqual(self.line2.move_ids.state, "cancel") + self.assertIn("cancel by import", self.line2.move_ids.description_picking) + + def test_conditional_response_amends_qty_without_backorder(self): + """Cancel the remaining receipt quantity when no backorder is planned.""" + confirmed_qty = self.line1.product_qty - 3 + data = self._line_response_data( + self._order_line_to_data( + self.line1, + status="amend", + qty=confirmed_qty, + ), + self._order_line_to_data(self.line2), + ) + self.OrderResponseImport.process_data(data) + self.assertEqual(self.purchase_order.state, "purchase") + 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 move: move.state == "assigned") + self.assertEqual(assigned.product_qty, confirmed_qty) + cancel = move_ids.filtered(lambda move: move.state == "cancel") + self.assertEqual(cancel.product_qty, 3) + self.assertEqual( + cancel.description_picking, + "No backorder planned by the supplier.", + ) + + def test_conditional_response_amends_qty_with_full_backorder(self): + """Split the remaining receipt quantity into a backorder.""" + confirmed_qty = self.line1.product_qty - 3 + data = self._line_response_data( + self._order_line_to_data( + self.line1, + status="amend", + qty=confirmed_qty, + backorder_qty=3, + note="my note", + ), + self._order_line_to_data(self.line2), + ) + self.OrderResponseImport.process_data(data) + self.assertEqual(self.purchase_order.state, "purchase") + self.assertEqual(len(self.purchase_order.picking_ids), 2) + move_ids = self.line1.move_ids + self.assertEqual(len(move_ids), 2) + self.assertEqual(sum(move_ids.mapped("product_qty")), self.line1.product_qty) + move_confirmed = move_ids.filtered( + lambda move: move.state == "assigned" and move.product_qty == confirmed_qty + ) + self.assertTrue(move_confirmed) + self.assertEqual( + "my note\n3 items should be delivered into a next delivery.", + move_confirmed.description_picking, + ) + move_backorder = move_ids.filtered( + lambda move: move.state == "assigned" and move.product_qty == 3 + ) + self.assertTrue(move_backorder) + self.assertEqual( + move_backorder.picking_id.backorder_id, + move_confirmed.picking_id, + ) + + def test_conditional_response_amends_multiple_lines_with_backorder(self): + """Create one backorder containing amended quantities for several lines.""" + line1_confirmed_qty = self.line1.product_qty - 3 + line2_confirmed_qty = self.line2.product_qty - 3 + data = self._line_response_data( + self._order_line_to_data( + self.line1, + status="amend", + qty=line1_confirmed_qty, + backorder_qty=3, + note="my note", + ), + self._order_line_to_data( + self.line2, + status="amend", + qty=line2_confirmed_qty, + backorder_qty=3, + note="my note", + ), + ) + self.OrderResponseImport.process_data(data) + self.assertEqual(self.purchase_order.state, "purchase") + self.assertEqual(len(self.purchase_order.picking_ids), 2) + self._assert_amended_moves(self.line1, line1_confirmed_qty, 3, "my note") + self._assert_amended_moves(self.line2, line2_confirmed_qty, 3, "my note") + + def test_conditional_response_amends_qty_with_partial_backorder(self): + """Cancel the unplanned part of an amended remaining quantity.""" + confirmed_qty = self.line1.product_qty - 3 + data = self._line_response_data( + self._order_line_to_data( + self.line1, + status="amend", + qty=confirmed_qty, + backorder_qty=2, + ), + self._order_line_to_data(self.line2), + ) + self.OrderResponseImport.process_data(data) + self.assertEqual(self.purchase_order.state, "purchase") + 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 move: move.state == "assigned" and move.product_qty == confirmed_qty + ) + self.assertTrue(move_confirmed) + self.assertIn( + "2 items should be delivered into a next delivery.", + move_confirmed.description_picking, + ) + move_cancel = move_ids.filtered( + lambda move: move.state == "cancel" and move.product_qty == 1 + ) + self.assertTrue(move_cancel) + self.assertEqual( + "No backorder planned by the supplier.", + move_cancel.description_picking, + ) + move_backorder = move_ids.filtered( + lambda move: move.state == "assigned" and move.product_qty == 2 + ) + self.assertTrue(move_backorder) + self.assertEqual( + move_backorder.picking_id.backorder_id, + move_confirmed.picking_id, + ) + + def _assert_amended_moves( + self, + order_line, + confirmed_qty: float, + backorder_qty: float, + note: str | None = None, + ): + """Assert that amended moves are split between receipt and backorder.""" + move_ids = order_line.move_ids + self.assertEqual(len(move_ids), 2) + self.assertEqual(sum(move_ids.mapped("product_qty")), order_line.product_qty) + move_confirmed = move_ids.filtered( + lambda move: move.state == "assigned" and move.product_qty == confirmed_qty + ) + self.assertTrue(move_confirmed) + expected_note = ( + f"{backorder_qty} items should be delivered into a next delivery." + ) + if note: + expected_note = f"{note}\n{expected_note}" + self.assertEqual( + expected_note, + move_confirmed.description_picking, + ) + move_backorder = move_ids.filtered( + lambda move: move.state == "assigned" and move.product_qty == backorder_qty + ) + self.assertTrue(move_backorder) + self.assertEqual( + move_backorder.picking_id.backorder_id, + move_confirmed.picking_id, + ) diff --git a/purchase_order_import/views/purchase_order.xml b/purchase_order_import/views/purchase_order.xml new file mode 100644 index 0000000000..f7a2204bf2 --- /dev/null +++ b/purchase_order_import/views/purchase_order.xml @@ -0,0 +1,25 @@ + + + + + purchase.order + + + + + + + + + diff --git a/purchase_order_import/wizard/__init__.py b/purchase_order_import/wizard/__init__.py new file mode 100644 index 0000000000..5d8ab4abcd --- /dev/null +++ b/purchase_order_import/wizard/__init__.py @@ -0,0 +1,2 @@ +from . import purchase_order_import +from . import order_response_import diff --git a/purchase_order_import/wizard/order_response_import.py b/purchase_order_import/wizard/order_response_import.py new file mode 100644 index 0000000000..b78a74567f --- /dev/null +++ b/purchase_order_import/wizard/order_response_import.py @@ -0,0 +1,453 @@ +# Copyright 2020 ACSONE SA/NV +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +import logging +import mimetypes +from base64 import b64decode, b64encode +from typing import Any + +from lxml import etree + +from odoo import api, fields, models +from odoo.exceptions import UserError, ValidationError +from odoo.fields import Domain + +logger = logging.getLogger(__name__) + + +class OrderResponseImport(models.TransientModel): + _name = "order.response.import" + _description = "Purchase Order Response Import from Files" + + document = fields.Binary( + string="XML or PDF Order response", + required=True, + help="Upload an Order response file that you received from " + "your supplier. Supported formats: XML and PDF " + "(PDF with an embeded XML file).", + ) + filename = fields.Char() + + @api.model + def parse_xml_order_document(self, xml_root: etree._Element) -> dict[str, Any]: + """Parse an XML order response document. + + The hook method to be implemented by other modules supporting + specific XML formats. It should return the parsed document in a + normalized dictionary format. + """ + 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_order_document(self, document: bytes) -> dict[str, Any]: + """Get PDF attachments, filter on XML files and call import_order_xml.""" + xml_files_dict = self.env["pdf.xml.tool"].pdf_get_xml_files(document) + if not xml_files_dict: + raise UserError( + self.env._("There are no embedded XML files 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: + return self.parse_xml_order_document(xml_root) + except UserError: + continue + raise UserError( + self.env._( + "This type of XML Order Document is not supported. Did you " + "install the module to support this XML format?" + ) + ) + + # Format of parsed order response + # { + # 'ref': 'SO01234' # the buyer party identifier + # # (specified into the Order document -> po's name) + # 'supplier': {'vat': 'FR25499247138'}, + # 'company': {'vat': 'FR12123456789'}, # Only used to check we are not + # # importing the quote in the + # # wrong company by mistake + # 'status': 'acknowledgement | accepted | rejected | + # conditionally_accepted' + # 'currency': {'iso': 'EUR', 'symbol': u'€'}, + # 'note': 'some notes', + # 'chatter_msg': ['msg1', 'msg2'] + # 'lines': [{ + # 'id': 123456, + # 'qty': 2.5, + # 'uom': {'unece_code': 'C62'}, + # 'status': 5, + # 'note': 'my note' + # 'backorder_qty: None # if provided and qty != expected + # # the backorder qty will be delivered + # # in a next shipping + # }] + + @api.model + def parse_order_response(self, document: bytes, filename: str) -> dict[str, Any]: + """Parse an uploaded order response file into normalized import data.""" + if not document: + raise UserError(self.env._("Missing document file.")) + if not filename: + raise UserError(self.env._("Missing document filename.")) + filetype = mimetypes.guess_type(filename)[0] + logger.debug("OrderResponse file mimetype: %s", filetype) + if filetype in ["application/xml", "text/xml"]: + try: + xml_root = etree.fromstring(document) + except etree.XMLSyntaxError as e: + logger.exception("File is not XML-compliant") + raise UserError( + self.env._("This XML file is not XML-compliant.") + ) from e + 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_order_document = self.parse_xml_order_document(xml_root) + elif filetype == "application/pdf": + parsed_order_document = self.parse_pdf_order_document(document) + else: + raise UserError( + self.env._( + "This file '%(filename)s' is not recognised as XML nor PDF file. " + "Please check the file and it's extension.", + filename=filename, + ) + ) + logger.debug("Result of OrderResponse parsing: %s", parsed_order_document) + if "attachments" not in parsed_order_document: + parsed_order_document["attachments"] = {} + parsed_order_document["attachments"][filename] = b64encode(document).decode() + if "chatter_msg" not in parsed_order_document: + parsed_order_document["chatter_msg"] = [] + if parsed_order_document.get("company") and not self.env.context.get( + "edi_skip_company_check" + ): + self.env["business.document.import"]._check_company( + parsed_order_document["company"], + parsed_order_document["chatter_msg"], + ) + return parsed_order_document + + def process_document(self) -> dict[str, Any]: + """Process the uploaded document from the import wizard.""" + self.ensure_one() + parsed_order_document = self.parse_order_response( + b64decode(self.document), self.filename + ) + return self.process_data(parsed_order_document) + + @api.model + def process_data(self, parsed_order_document: dict[str, Any]) -> dict[str, Any]: + """Apply parsed order response data to its matching purchase order.""" + bdio = self.env["business.document.import"] + po_name = parsed_order_document.get("ref") + order = self.env["purchase.order"].search([Domain("name", "=", po_name)]) + if not order: + bdio.user_error_wrap( + "process_data", + parsed_order_document, + self.env._( + "No purchase order found for name %(po_name)s.", + po_name=po_name, + ), + parsed_order_document["chatter_msg"], + True, + ) + + currency = bdio._match_currency( + parsed_order_document.get("currency"), + parsed_order_document["chatter_msg"], + ) + partner = bdio._match_partner( + parsed_order_document["supplier"], + parsed_order_document["chatter_msg"], + partner_type="supplier", + ) + if partner.commercial_partner_id != order.partner_id.commercial_partner_id: + bdio.user_error_wrap( + "process_data", + parsed_order_document, + self.env._( + "The supplier of the imported OrderResponse (%(supplier)s) " + "is different from the supplier of the purchase order " + "(%(order_supplier)s).", + supplier=partner.commercial_partner_id.name, + order_supplier=order.partner_id.commercial_partner_id.name, + ), + parsed_order_document["chatter_msg"], + True, + ) + if currency and currency != order.currency_id: + bdio.user_error_wrap( + "process_data", + parsed_order_document, + self.env._( + "The currency of the imported OrderResponse (%(currency)s) " + "is different from the currency of the purchase order " + "(%(order_currency)s).", + currency=currency.name, + order_currency=order.currency_id.name, + ), + parsed_order_document["chatter_msg"], + True, + ) + + status = parsed_order_document.get("status") + if status == "acknowledgement": + self._process_ack(order, parsed_order_document) + elif status == "rejected": + self._process_rejected(order, parsed_order_document) + elif status == "accepted": + self._process_accepted(order, parsed_order_document) + elif status == "conditionally_accepted": + self._process_conditional(order, parsed_order_document) + else: + bdio.user_error_wrap( + "process_data", + parsed_order_document, + self.env._("Unknown status '%(status)s'.", status=status), + parsed_order_document["chatter_msg"], + True, + ) + + bdio.post_create_or_update(parsed_order_document, order) + logger.info( + "purchase.order ID %d updated via import of file %s.", + order.id, + self.filename, + ) + order.message_post( + body=self.env._( + "This purchase order has been updated automatically via the import " + "of OrderResponse file %(filename)s.", + filename=self.filename, + ) + ) + return order.get_formview_action() + + @api.model + def _process_ack( + self, purchase_order: models.Model, parsed_order_document: dict[str, Any] + ): + """Store the supplier acknowledgement date on the purchase order.""" + if not purchase_order.supplier_ack_dt: + purchase_order.supplier_ack_dt = fields.Datetime.now() + + @api.model + def _process_rejected( + self, purchase_order: models.Model, parsed_order_document: dict[str, Any] + ): + """Cancel the purchase order rejected by the supplier.""" + parsed_order_document["chatter_msg"] = ( + parsed_order_document["chatter_msg"] or [] + ) + parsed_order_document["chatter_msg"].append( + self.env._("PO cancelled by the supplier.") + ) + purchase_order.button_cancel() + + @api.model + def _process_accepted( + self, purchase_order: models.Model, parsed_order_document: dict[str, Any] + ): + """Confirm the purchase order accepted by the supplier.""" + parsed_order_document["chatter_msg"] = ( + parsed_order_document["chatter_msg"] or [] + ) + parsed_order_document["chatter_msg"].append( + self.env._("PO confirmed by the supplier.") + ) + purchase_order.button_approve() + + @api.model + def _process_conditional( + self, purchase_order: models.Model, parsed_order_document: dict[str, Any] + ): + """Confirm an amended order response and synchronize receipt moves. + + A conditional supplier acceptance must describe every PO line. Accepted + lines keep their receipt move, rejected lines cancel it, and amended + lines split the receipt between accepted, backordered, and cancelled + quantities according to the supplier response. + """ + chatter = parsed_order_document["chatter_msg"] = ( + parsed_order_document["chatter_msg"] or [] + ) + chatter.append(self.env._("PO confirmed with amendment by the supplier.")) + lines = parsed_order_document["lines"] + lines_by_id = self._get_conditional_lines_by_id( + purchase_order, parsed_order_document, lines, chatter + ) + if lines_by_id is None: + return + purchase_order.button_approve() + for order_line in purchase_order.order_line: + self._process_conditional_line( + order_line, + lines_by_id[order_line.id], + parsed_order_document, + chatter, + ) + + @api.model + def _get_conditional_lines_by_id( + self, + purchase_order: models.Model, + parsed_order_document: dict[str, Any], + lines: list[dict[str, Any]], + chatter: list[str], + ) -> dict[int, dict[str, Any]] | None: + """Return conditional response lines keyed by PO line id.""" + try: + line_ids = {int(line["line_id"]) for line in lines} + except (KeyError, TypeError, ValueError): + line_ids = set() + if line_ids != set(purchase_order.order_line.ids): + self.env["business.document.import"].user_error_wrap( + "_process_conditional", + parsed_order_document, + self.env._( + "Unable to conditionally confirm the purchase order. \n" + "Line IDS into the parsed document differs from the " + "expected list of order line ids: \n " + "received: %(received_line_ids)s\n" + "expected: %(expected_line_ids)s\n", + received_line_ids=[line.get("line_id") for line in lines], + expected_line_ids=purchase_order.order_line.ids, + ), + chatter, + True, + ) + return None + return {int(line["line_id"]): line for line in lines} + + @api.model + def _process_conditional_line( + self, + order_line: models.Model, + line_info: dict[str, Any], + parsed_order_document: dict[str, Any], + chatter: list[str], + ): + """Apply one conditional response line to its receipt move.""" + note = line_info.get("note") + move = order_line.move_ids.filtered(lambda x: x.state not in ("cancel", "done")) + if len(move) != 1: + self.env["business.document.import"].user_error_wrap( + "_process_conditional", + parsed_order_document, + self.env._( + "More than one move found for PO line.\n" + "Move IDs: %(move_ids)s\n" + "Line Info: %(line_info)s", + move_ids=move.ids, + line_info=line_info, + ), + chatter, + True, + ) + if note: + move.write({"description_picking": note}) + status = line_info["status"] + if status == "accepted": + return + if status == "rejected": + order_line.move_ids._action_cancel() + elif status == "amend": + self._process_amended_move(move, line_info, note) + + @api.model + def _process_amended_move( + self, move: models.Model, line_info: dict[str, Any], note: str | None + ): + """Split amended receipt quantities into kept, backordered, and cancelled.""" + qty = line_info["qty"] + backorder_qty = line_info["backorder_qty"] + move_qty = move.product_qty + if move.product_uom.compare(qty, move_qty) >= 0: + return + self._check_picking_status(move.picking_id) + new_move = self._split_move(move, move_qty - qty) + to_cancel = None + if backorder_qty: + self._add_backorder_note(move, note, backorder_qty) + if new_move.product_uom.compare(backorder_qty, new_move.product_qty) < 0: + to_cancel = self._split_move( + new_move, new_move.product_qty - backorder_qty + ) + else: + to_cancel = new_move + if to_cancel: + to_cancel._action_cancel() + to_cancel.write( + { + "description_picking": self.env._( + "No backorder planned by the supplier." + ) + } + ) + if new_move.state != "cancel": + self._add_move_to_backorder(new_move) + move.picking_id.action_assign() + + @api.model + def _add_backorder_note( + self, move: models.Model, note: str | None, backorder_qty: float + ): + """Append backorder information on the confirmed receipt move.""" + note = note + "\n" if note else "" + move.description_picking = note + self.env._( + "%(qty)s items should be delivered into a next delivery.", + qty=backorder_qty, + ) + + @api.model + def _split_move(self, move: models.Model, qty: float) -> models.Model: + """Split a stock move and create the new move with modern stock APIs.""" + new_move_vals = move._split(qty) + new_move = self.env["stock.move"].create(new_move_vals) + new_move._action_confirm(merge=False, create_proc=False) + return new_move + + @api.model + def _add_move_to_backorder(self, move: models.Model): + """Move a split stock move to the receipt backorder.""" + StockPicking = self.env["stock.picking"] + current_picking = move.picking_id + backorder = StockPicking.search( + [Domain("backorder_id", "=", current_picking.id)] + ) + if not backorder: + date_done = current_picking.date_done + move.picking_id._create_backorder(backorder_moves=move) + # preserve date_done.... + current_picking.date_done = date_done + else: + move.write({"picking_id": backorder.id}) + backorder.action_confirm() + backorder.action_assign() + + @api.model + def _check_picking_status(self, picking: models.Model): + """Block amendments when receipt operations have already started.""" + if any(move_line.picked for move_line in picking.move_line_ids): + raise ValidationError( + self.env._( + "Some operations have already started! " + "Please validate or reset operations on " + "picking %(picking)s to ensure delivery slip to be computed.", + picking=picking.name, + ) + ) diff --git a/purchase_order_import/wizard/order_response_import_view.xml b/purchase_order_import/wizard/order_response_import_view.xml new file mode 100644 index 0000000000..904fccd1be --- /dev/null +++ b/purchase_order_import/wizard/order_response_import_view.xml @@ -0,0 +1,76 @@ + + + + + order.response.import.form + order.response.import + +
+ +
+

Upload below the OrderResponse 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. +
+

Then, Odoo will process the related purchase order as follows:

+
    +
  • If the status code is acknowledgement: update the acknowledge datetime on the PO
  • +
  • If the status code is Rejected: cancel the PO
  • +
  • If the status code is Accepted: confirm the PO and create the picking
  • +
  • If the status code is Conditionally accepted: confirm the PO, create the picking, and update the receipt moves according to the amendments specified in the document
  • +
+

Possible line amendments:

+
    +
  • The order line is refused: The receipt move is cancelled
  • +
  • The order line is accepted with change: The receipt move is split. If a backorder quantity is provided, a backorder is created. Otherwise the move for the remaining quantity is cancelled.
  • +
+
+
+ + + + +
+
+
+
+
+ + + Import Quotation + order.response.import + form + new + + + +
diff --git a/purchase_order_import/wizard/purchase_order_import.py b/purchase_order_import/wizard/purchase_order_import.py new file mode 100644 index 0000000000..ea90fab784 --- /dev/null +++ b/purchase_order_import/wizard/purchase_order_import.py @@ -0,0 +1,380 @@ +# Copyright 2016-2018 Akretion France (http://www.akretion.com/) +# @author: Alexis de Lattre +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +import logging +import mimetypes +from base64 import b64decode, b64encode +from typing import Any + +from lxml import etree + +from odoo import api, fields, models +from odoo.exceptions import UserError + +logger = logging.getLogger(__name__) + + +class PurchaseOrderImport(models.TransientModel): + _name = "purchase.order.import" + _description = "Purchase Order Import from Files" + + @api.model + def _get_purchase_id(self) -> models.Model: + """Return the active purchase order selected for quotation import.""" + if self.env.context.get("active_model") != "purchase.order": + raise UserError( + self.env._("This wizard should be called from a purchase order.") + ) + return self.env["purchase.order"].browse(self.env.context["active_id"]) + + quote_file = fields.Binary( + string="XML or PDF Quotation", + required=True, + help="Upload a quotation file that you received from " + "your supplier. Supported formats: XML and PDF " + "(PDF with an embeded XML file).", + ) + quote_filename = fields.Char(string="Filename") + update_option = fields.Selection( + [ + ("price", "Price"), + ("all", "Price and Quantity"), + ], + default="price", + required=True, + ) + purchase_id = fields.Many2one( + "purchase.order", + string="RFQ to Update", + default=lambda self: self._get_purchase_id(), + readonly=True, + ) + + @api.model + def parse_xml_quote(self, xml_root: etree._Element) -> dict[str, Any]: + """Parse an XML quotation document. + + The hook method to be implemented by other modules supporting + specific XML formats. It should return the parsed quotation in a + normalized dictionary format. + """ + raise UserError( + self.env._( + "This type of XML quotation is not supported. Did you install " + "the module to support this XML format?" + ) + ) + + @api.model + def parse_pdf_quote(self, quote_file: bytes) -> dict[str, Any]: + """Parse the first supported XML attachment embedded in a PDF.""" + xml_files_dict = self.env["pdf.xml.tool"].pdf_get_xml_files(quote_file) + if not xml_files_dict: + raise UserError( + self.env._("There are no embedded XML files 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: + return self.parse_xml_quote(xml_root) + except UserError: + continue + raise UserError( + self.env._( + "This type of XML quotation is not supported. Did you install " + "the module to support this XML format?" + ) + ) + + # Format of parsed_quote + # { + # 'partner': { + # 'vat': 'FR25499247138', + # 'name': 'Camptocamp', + # 'email': 'luc@camptocamp.com', + # }, + # 'company': {'vat': 'FR12123456789'}, # Only used to check we are not + # # importing the quote in the + # # wrong company by mistake + # 'currency': {'iso': 'EUR', 'symbol': u'€'}, + # 'incoterm': 'EXW', + # 'note': 'some notes', + # 'chatter_msg': ['msg1', 'msg2'] + # 'lines': [{ + # 'product': { + # 'code': 'EA7821', + # 'ean13': '2100002000003', + # }, + # 'qty': 2.5, + # 'uom': {'unece_code': 'C62'}, + # 'price_unit': 12.42, # without taxes + # }] + + @api.model + def parse_quote(self, quote_file: bytes, quote_filename: str) -> dict[str, Any]: + """Parse an uploaded quotation file into normalized import data.""" + if not quote_file: + raise UserError(self.env._("Missing quote file.")) + if not quote_filename: + raise UserError(self.env._("Missing quote filename.")) + filetype = mimetypes.guess_type(quote_filename)[0] + logger.debug("Quote file mimetype: %s", filetype) + if filetype in ["application/xml", "text/xml"]: + try: + xml_root = etree.fromstring(quote_file) + except etree.XMLSyntaxError as e: + raise UserError( + self.env._("This XML file is not XML-compliant.") + ) from e + 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_quote = self.parse_xml_quote(xml_root) + elif filetype == "application/pdf": + parsed_quote = self.parse_pdf_quote(quote_file) + else: + raise UserError( + self.env._( + "This file '%(filename)s' is not recognised as XML nor PDF file. " + "Please check the file and it's extension.", + filename=quote_filename, + ) + ) + logger.debug("Result of quotation parsing: %s", parsed_quote) + if "attachments" not in parsed_quote: + parsed_quote["attachments"] = {} + parsed_quote["attachments"][quote_filename] = b64encode(quote_file).decode() + if "chatter_msg" not in parsed_quote: + parsed_quote["chatter_msg"] = [] + if parsed_quote.get("company") and not self.env.context.get( + "edi_skip_company_check" + ): + self.env["business.document.import"]._check_company( + parsed_quote["company"], parsed_quote["chatter_msg"] + ) + return parsed_quote + + @api.model + def _prepare_update_order_vals( + self, parsed_quote: dict[str, Any], order: models.Model + ) -> dict[str, Any]: + """Prepare purchase order values updated from the parsed quotation.""" + vals = {} + incoterm = self.env["business.document.import"]._match_incoterm( + parsed_quote.get("incoterm"), parsed_quote["chatter_msg"] + ) + if incoterm and incoterm != order.incoterm_id: + parsed_quote["chatter_msg"].append( + self.env._( + "The incoterm has been updated from %(old_incoterm)s to " + "%(new_incoterm)s upon import of the quotation file " + "'%(filename)s'", + old_incoterm=order.incoterm_id.code, + new_incoterm=incoterm.code, + filename=self.quote_filename, + ) + ) + vals["incoterm_id"] = incoterm.id + return vals + + def update_order_lines( + self, parsed_quote: dict[str, Any], order: models.Model + ) -> bool: + """Update RFQ lines from a parsed supplier quotation. + + The import compares matched products and keeps a conservative behavior: + it updates existing prices, optionally updates quantities, adds missing + quoted products, and only warns about RFQ lines absent from the quote. + """ + chatter = parsed_quote["chatter_msg"] + bdio = self.env["business.document.import"] + compare_res = bdio.compare_lines( + self._existing_order_lines_for_import(order), + parsed_quote["lines"], + chatter, + seller=order.partner_id.commercial_partner_id, + ) + if not compare_res: + return True + self._update_matched_order_lines(compare_res, order, chatter) + self._warn_missing_order_lines(compare_res, chatter) + self._create_missing_order_lines(compare_res, order, chatter) + return True + + @api.model + def _existing_order_lines_for_import(self, order: models.Model) -> list[dict]: + """Return existing RFQ lines in the format expected by compare_lines.""" + existing_lines = [] + for oline in order.order_line: + price_unit = 0.0 + if not oline.product_uom_id.is_zero(oline.product_qty): + price_unit = oline.price_subtotal / float(oline.product_qty) + existing_lines.append( + { + "product": oline.product_id, + "name": oline.name, + "qty": oline.product_qty, + "uom": oline.product_uom_id, + "price_unit": price_unit, + "line": oline, + } + ) + return existing_lines + + def _update_matched_order_lines( + self, compare_res: dict[str, Any], order: models.Model, chatter: list[str] + ): + """Update existing RFQ lines that matched quoted lines.""" + for oline, cdict in compare_res["to_update"].items(): + write_vals = {} + if cdict.get("price_unit"): + chatter.append( + self.env._( + "The unit price has been updated on the RFQ line with " + "product '%(product)s' from %(old_price)s to " + "%(new_price)s %(currency)s.", + product=oline.product_id.display_name, + old_price=cdict["price_unit"][0], + new_price=cdict["price_unit"][1], + currency=order.currency_id.name, + ) + ) + write_vals["price_unit"] = cdict["price_unit"][1] # TODO + if self.update_option == "all" and cdict.get("qty"): + chatter.append( + self.env._( + "The quantity has been updated on the RFQ line with " + "product '%(product)s' from %(old_qty)s to " + "%(new_qty)s %(uom)s.", + product=oline.product_id.display_name, + old_qty=cdict["qty"][0], + new_qty=cdict["qty"][1], + uom=oline.product_uom_id.name, + ) + ) + write_vals["product_qty"] = cdict["qty"][1] + if write_vals: + oline.write(write_vals) + + @api.model + def _warn_missing_order_lines( + self, compare_res: dict[str, Any], chatter: list[str] + ): + """Warn about RFQ lines missing from the imported quotation.""" + if compare_res["to_remove"]: # we don't delete the lines, only warn + warn_label = [ + f"{line.product_qty} {line.product_uom_id.name} x " + f"{line.product_id.name}" + for line in compare_res["to_remove"] + ] + chatter.append( + self.env._( + "%(line_count)d order line(s) are not in the imported " + "quotation: %(lines)s", + line_count=len(compare_res["to_remove"]), + lines=", ".join(warn_label), + ) + ) + + def _create_missing_order_lines( + self, compare_res: dict[str, Any], order: models.Model, chatter: list[str] + ): + """Create RFQ lines for quoted products missing from the order.""" + if compare_res["to_add"]: + polo = self.env["purchase.order.line"] + to_create_label = [] + for add in compare_res["to_add"]: + line_vals = self._prepare_order_line_import_vals( + add["product"], add["uom"], add["import_line"] + ) + line_vals["order_id"] = order.id + new_line = polo.create(line_vals) + to_create_label.append( + f"{new_line.product_qty} {new_line.product_uom_id.name} x " + f"{new_line.name}" + ) + chatter.append( + self.env._( + "%(line_count)d new order line(s) created: %(lines)s", + line_count=len(compare_res["to_add"]), + lines=", ".join(to_create_label), + ) + ) + + @api.model + def _prepare_order_line_import_vals( + self, + product: models.Model, + uom: models.Model, + import_line: dict[str, Any], + ) -> dict[str, Any]: + """Prepare a order line import vals for a quoted product missing on the RFQ.""" + return { + "product_id": product.id, + "price_unit": import_line["price_unit"], + "product_qty": import_line.get("qty") or 1.0, + "product_uom_id": uom.id, + } + + def update_rfq_button(self) -> bool: + """Update the active RFQ from the uploaded quotation file.""" + self.ensure_one() + bdio = self.env["business.document.import"] + order = self.purchase_id + if not order: + raise UserError(self.env._("You must select a quotation to update.")) + order.ensure_one() + parsed_quote = self.parse_quote(b64decode(self.quote_file), self.quote_filename) + currency = bdio._match_currency( + parsed_quote.get("currency"), parsed_quote["chatter_msg"] + ) + partner = bdio._match_partner( + parsed_quote["partner"], + parsed_quote["chatter_msg"], + partner_type="supplier", + ) + if partner.commercial_partner_id != order.partner_id.commercial_partner_id: + raise UserError( + self.env._( + "The supplier of the imported quotation (%(supplier)s) is " + "different from the supplier of the RFQ (%(order_supplier)s).", + supplier=partner.commercial_partner_id.name, + order_supplier=order.partner_id.commercial_partner_id.name, + ) + ) + if currency != order.currency_id: + raise UserError( + self.env._( + "The currency of the imported quotation (%(currency)s) is " + "different from the currency of the RFQ (%(order_currency)s)", + currency=currency.name, + order_currency=order.currency_id.name, + ) + ) + vals = self._prepare_update_order_vals(parsed_quote, order) + if vals: + order.write(vals) + if not parsed_quote.get("lines"): + raise UserError(self.env._("This quotation doesn't have any line.")) + self.update_order_lines(parsed_quote, order) + bdio.post_create_or_update(parsed_quote, order) + logger.info( + "purchase.order ID %d updated via import of file %s", + order.id, + self.quote_filename, + ) + order.message_post( + body=self.env._( + "This RFQ has been updated automatically via the import of " + "quotation file %(filename)s", + filename=self.quote_filename, + ) + ) + return True diff --git a/purchase_order_import/wizard/purchase_order_import_view.xml b/purchase_order_import/wizard/purchase_order_import_view.xml new file mode 100644 index 0000000000..8546e63679 --- /dev/null +++ b/purchase_order_import/wizard/purchase_order_import_view.xml @@ -0,0 +1,66 @@ + + + + + purchase.order.import.form + purchase.order.import + +
+ +
+

Upload below the quotation that you received from your supplier for this RFQ as XML or PDF file. When you click on the Update RFQ 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 purchase_order_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. +
+

Then, Odoo will compare the imported quotation and the current RFQ:

+
    +
  • for lines that are present both in the imported quotation and in the RFQ, Odoo will update the unit prices (and also the quantities, depending on the chosen option),
  • +
  • for the lines that are present only in the quotation (shipping costs for example), Odoo will add them to the RFQ,
  • +
  • for the lines that are present only in the RFQ, Odoo will put a warning message in the chatter (it won't delete them automatically).
  • +
+
+
+ + + + + +
+
+
+
+
+ + + Import Quotation + purchase.order.import + form + new + +