diff --git a/edi_purchase_edifact_oca/README.rst b/edi_purchase_edifact_oca/README.rst new file mode 100644 index 000000000..0dbe5b0af --- /dev/null +++ b/edi_purchase_edifact_oca/README.rst @@ -0,0 +1,100 @@ +======================== +EDI PURCHASE EDIFACT OCA +======================== + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:d2e2602bb615321d18583caff366876c642564652bdfcd412395f69355aa8d72 + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |badge1| image:: https://img.shields.io/badge/maturity-Alpha-red.png + :target: https://odoo-community.org/page/development-status + :alt: Alpha +.. |badge2| image:: https://img.shields.io/badge/licence-AGPL--3-blue.png + :target: http://www.gnu.org/licenses/agpl-3.0-standalone.html + :alt: License: AGPL-3 +.. |badge3| image:: https://img.shields.io/badge/github-OCA%2Fedi--framework-lightgray.png?logo=github + :target: https://github.com/OCA/edi-framework/tree/18.0/edi_purchase_edifact_oca + :alt: OCA/edi-framework +.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png + :target: https://translation.odoo-community.org/projects/edi-framework-18-0/edi-framework-18-0-edi_purchase_edifact_oca + :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-framework&target_branch=18.0 + :alt: Try me on Runboat + +|badge1| |badge2| |badge3| |badge4| |badge5| + +| UN/EDIFACT +| United Nations rules for Elec­tronic Data Interchange for + Administration, Commerce and Transport + +This module will support exporting and confirming orders in EDIFACT +format. + +https://www.stedi.com/edi/edifact/D01B/messages/ORDERS +https://www.stedi.com/edi/edifact/D96A/messages/ORDERS +https://www.stedi.com/edi/edifact/D01B/messages/DESADV +https://www.stedi.com/edi/edifact/D96A/messages/DESADV + +.. IMPORTANT:: + This is an alpha version, the data model and design can change at any time without warning. + Only for development or testing purpose, do not use in production. + `More details on development status `_ + +**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 +------- + +* Trobz + +Contributors +------------ + +- Thien (Vo Hong) +- Mathias Francke +- Phuc (Phan Hong) + +Other credits +------------- + +The development of this module has been financially supported by: + +- Trobz + +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-framework `_ project on GitHub. + +You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. diff --git a/edi_purchase_edifact_oca/__init__.py b/edi_purchase_edifact_oca/__init__.py new file mode 100644 index 000000000..29b138774 --- /dev/null +++ b/edi_purchase_edifact_oca/__init__.py @@ -0,0 +1,3 @@ +from . import models +from . import components +from . import wizard diff --git a/edi_purchase_edifact_oca/__manifest__.py b/edi_purchase_edifact_oca/__manifest__.py new file mode 100644 index 000000000..0b5c95d3b --- /dev/null +++ b/edi_purchase_edifact_oca/__manifest__.py @@ -0,0 +1,31 @@ +# Copyright 2024 Trobz +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl-3.0) +{ + "name": "EDI PURCHASE EDIFACT OCA", + "summary": "Create and send EDIFACT order files", + "version": "18.0.1.0.0", + "development_status": "Alpha", + "website": "https://github.com/OCA/edi-framework", + "author": "Trobz, Odoo Community Association (OCA)", + "license": "AGPL-3", + "application": False, + "installable": True, + "depends": [ + "base_edifact", + "stock", + "edi_core_oca", + "edi_storage_oca", + "edi_purchase_oca", + "edi_exchange_template_oca", + "partner_identification_gln", + "base_business_document_import", + ], + "data": [ + "security/ir.model.access.csv", + "views/purchase.xml", + "views/res_partner.xml", + "data/edi_backend.xml", + "data/edi_exchange_type.xml", + "wizard/purchase_order_import_view.xml", + ], +} diff --git a/edi_purchase_edifact_oca/components/__init__.py b/edi_purchase_edifact_oca/components/__init__.py new file mode 100644 index 000000000..a386caa57 --- /dev/null +++ b/edi_purchase_edifact_oca/components/__init__.py @@ -0,0 +1,3 @@ +from . import listener_edifact_output +from . import generate_edifact_output +from . import process_edifact_input diff --git a/edi_purchase_edifact_oca/components/generate_edifact_output.py b/edi_purchase_edifact_oca/components/generate_edifact_output.py new file mode 100644 index 000000000..b51898c4a --- /dev/null +++ b/edi_purchase_edifact_oca/components/generate_edifact_output.py @@ -0,0 +1,34 @@ +# Copyright 2024 Trobz +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +# from odoo.addons.component.core import Component +from odoo import models + +from odoo.addons.edi_core_oca.exceptions import EDINotImplementedError + + +class EDIExchangeEDIFACTOutGenerate(models.AbstractModel): + _name = "edi.output.edifact.out.generate" + _inherit = [ + "edi.oca.handler.generate", + ] + _description = "Process Generate Output EDIFact" + + def generate(self, exchange_record): + exchange_record = self.env["edi.exchange.record"].browse(exchange_record.id) + tmpl = exchange_record.backend_id._get_output_template(exchange_record) + if tmpl: + exchange_record = exchange_record.with_context( + edi_framework_action="generate" + ) + tmpl = tmpl.with_context(edi_framework_action="generate") + if exchange_record: + if exchange_record.model == "purchase.order" and exchange_record.res_id: + order = self.env["purchase.order"].browse(exchange_record.res_id) + if order: + data = order.edifact_purchase_generate_data(exchange_record) + return data + + raise EDINotImplementedError( + self.env._("EDIFact generation process is not implemented yet") + ) diff --git a/edi_purchase_edifact_oca/components/listener_edifact_output.py b/edi_purchase_edifact_oca/components/listener_edifact_output.py new file mode 100644 index 000000000..9c0e56ad1 --- /dev/null +++ b/edi_purchase_edifact_oca/components/listener_edifact_output.py @@ -0,0 +1,47 @@ +# Copyright 2024 Trobz +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +import logging + +from odoo.addons.component.core import Component + +_logger = logging.getLogger(__name__) + + +class PurchaseOrderEdifactListener(Component): + _name = "purchase.order.event.listener.edifact" + _inherit = "base.event.listener" + _apply_on = ["purchase.order"] + + def on_button_confirm_purchase_order(self, order): + if not self._should_create_exchange_record(order): + return None + exchange_type = self.env.ref( + "edi_purchase_edifact_oca.edi_exchange_type_purchase_order_out" + ) + record = exchange_type.backend_id.create_record( + exchange_type.code, self._storage_new_exchange_record_vals() + ) + # Set related record + record._set_related_record(order) + _logger.info( + "Exchange record for purchase order %s was created: %s", + order.name, + record.identifier, + ) + + def _should_create_exchange_record(self, order): + if self.env.context.get("skip_send_edifact", False): + return False + + partner = order.partner_id + if not partner: + return False + + return ( + partner.edifact_purchase_order_out + or partner.parent_id.edifact_purchase_order_out + ) + + def _storage_new_exchange_record_vals(self): + return {"edi_exchange_state": "new"} diff --git a/edi_purchase_edifact_oca/components/process_edifact_input.py b/edi_purchase_edifact_oca/components/process_edifact_input.py new file mode 100644 index 000000000..4b4278fcc --- /dev/null +++ b/edi_purchase_edifact_oca/components/process_edifact_input.py @@ -0,0 +1,35 @@ +# Copyright 2024 Trobz +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +import base64 + +from odoo import models + + +class EDIExchangeEDIFACTInput(models.Model): + _name = "edi.input.process.edifact.input" + _inherit = ["edi.oca.handler.process"] + _description = "Input process despatch advice from EDIFact" + + def process(self, exchange_record): + """Process incoming EDIFACT record and confirm record.""" + file_content = exchange_record._get_file_content() + wizard = self.env["purchase.order.import"].create( + { + "import_type": "edifact", + "order_file": base64.b64encode(file_content.encode()), + "order_filename": exchange_record.exchange_filename, + } + ) + file_name = exchange_record.exchange_filename + action = wizard.import_order_button() + if action and action.get("res_model", False): + exchange_record.update( + { + "model": action["res_model"], + "res_id": action["res_id"], + } + ) + exchange_record.exchange_filename = file_name + + return self.env._("Process incoming EDIFACT completed!") diff --git a/edi_purchase_edifact_oca/data/edi_backend.xml b/edi_purchase_edifact_oca/data/edi_backend.xml new file mode 100644 index 000000000..87c85268d --- /dev/null +++ b/edi_purchase_edifact_oca/data/edi_backend.xml @@ -0,0 +1,11 @@ + + + + EDIFACT + edifact + + + EDIFACT + + + diff --git a/edi_purchase_edifact_oca/data/edi_exchange_type.xml b/edi_purchase_edifact_oca/data/edi_exchange_type.xml new file mode 100644 index 000000000..54fb118c8 --- /dev/null +++ b/edi_purchase_edifact_oca/data/edi_exchange_type.xml @@ -0,0 +1,40 @@ + + + + + + EDIFACT-OUT-ORDER + edifact_out_order + output + D{dt} + txt + True + iso-8859-1 + strict + strict + + + + filename_pattern: + force_tz: Europe/Zurich + date_pattern: "%Y%m%d%H%M%S%f" + + + + + + + EDIFACT-IN-DESPATCH-ADVICE + edifact_in_despatch_advice + output.* + edi + input + + True + iso-8859-1 + strict + strict + + + + diff --git a/edi_purchase_edifact_oca/models/__init__.py b/edi_purchase_edifact_oca/models/__init__.py new file mode 100644 index 000000000..baa643a91 --- /dev/null +++ b/edi_purchase_edifact_oca/models/__init__.py @@ -0,0 +1,4 @@ +from . import purchase +from . import res_partner +from . import business_document_import +from . import edi_exchange_record diff --git a/edi_purchase_edifact_oca/models/business_document_import.py b/edi_purchase_edifact_oca/models/business_document_import.py new file mode 100644 index 000000000..29357b69f --- /dev/null +++ b/edi_purchase_edifact_oca/models/business_document_import.py @@ -0,0 +1,53 @@ +# Copyright 2024 Trobz +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo import _, api, exceptions, models + + +class BusinessDocumentImport(models.AbstractModel): + _inherit = "business.document.import" + + @api.model + def _hook_match_partner(self, partner_dict, chatter_msg, domain, order): + """ + 2 types + partner_dict = {'gln':""} + partner_dict = {'partner': {'gln':""}, 'address':{'country_code':"ES",...}} + """ + partner = partner_dict.get("partner", partner_dict) + partner_dict.get("address", False) + if not partner.get("gln"): + return super()._hook_match_partner(partner_dict, chatter_msg, domain, order) + party_id = partner["gln"] + + partner_id_category = self.env.ref( + "partner_identification_gln.partner_identification_gln_number_category" + ) + if not partner_id_category: + raise exceptions.UserError( + _( + "partner_identification_gln module" + " should be installed with a xmlid: " + "partner_identification_gln_number_category" + ) + ) + id_number = self.env["res.partner.id_number"].search( + [("category_id", "=", int(partner_id_category)), ("name", "=", party_id)], + limit=1, + ) + if not id_number: + ctx = partner.get( + "edi_ctx", {"order_filename": _("Unknown"), "rff_va": _("Unknown")} + ) + raise exceptions.UserError( + _( + "Partner GLN Code: {party} not found in order file: '{file}' " + "from VAT registration number '{vat}'." + ).format( + party=party_id, + file=ctx.get("order_filename"), + vat=ctx.get("rff_va"), + ) + ) + + return id_number.partner_id diff --git a/edi_purchase_edifact_oca/models/edi_exchange_record.py b/edi_purchase_edifact_oca/models/edi_exchange_record.py new file mode 100644 index 000000000..4eeae1701 --- /dev/null +++ b/edi_purchase_edifact_oca/models/edi_exchange_record.py @@ -0,0 +1,15 @@ +# Copyright 2024 Trobz +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo import models + +from odoo.addons.edi_core_oca.models.edi_exchange_record import EDIExchangeRecord + + +class EDIExchangeRecordEDIFact(models.Model): + _inherit = "edi.exchange.record" + + _rollback_state_mapping = { + **EDIExchangeRecord._rollback_state_mapping, + "input_processed": "input_received", + } diff --git a/edi_purchase_edifact_oca/models/purchase.py b/edi_purchase_edifact_oca/models/purchase.py new file mode 100644 index 000000000..aacd9f2fa --- /dev/null +++ b/edi_purchase_edifact_oca/models/purchase.py @@ -0,0 +1,274 @@ +# Copyright 2024 Trobz +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl-3.0) + +from datetime import datetime + +from odoo import _, fields, models +from odoo.exceptions import UserError + + +class PurchaseOrder(models.Model): + _inherit = "purchase.order" + + edifact_version = fields.Selection( + [ + ("d96a", "D.96A"), + ("d01b", "D.01B"), + ], + default="d96a", + ) + + def _replace_edifact_delimiters(self, data): + edifact_delimiters = {"+", ":", ".", "?", "*"} + + def replace_in_string(s): + return "".join( + char if char not in edifact_delimiters else "_" for char in s + ) + + def process_element(element): + if isinstance(element, str): + return ( + replace_in_string(element) + if not element.replace(".", "", 1).isdigit() + else element + ) + elif isinstance(element, list | tuple): + result = map(process_element, element) + return type(element)(result) + else: + return element + + return process_element(data) + + def edifact_purchase_generate_data(self, exchange_record=None): + self.ensure_one() + edifact_model = self.env["base.edifact"] + lines = [] + interchange = self._edifact_purchase_get_interchange() + + header = self._edifact_purchase_get_header(exchange_record) + product, vals = self._edifact_purchase_get_product() + summary = self._edifact_purchase_get_summary(vals, exchange_record) + lines += header + product + summary + for segment in lines: + segment = self._replace_edifact_delimiters(segment) + interchange.add_segment(edifact_model.create_segment(*segment)) + return f"UNA:+.? '{interchange.serialize()}" + + def _edifact_purchase_get_interchange(self): + id_number = self.env["res.partner.id_number"] + # Because the person placing the order will be + # the representative of the ordering company. + # So we will check the code of the company's `id number` here + sender = id_number.search( + [("partner_id", "=", self.company_id.partner_id.id)], limit=1 + ) + recipient = id_number.search([("partner_id", "=", self.partner_id.id)], limit=1) + # if current supplier does not have `Id number` + # then check current supplier's parent + if not recipient and self.partner_id.commercial_partner_id: + recipient = id_number.search( + [("partner_id", "=", self.partner_id.commercial_partner_id.id)], limit=1 + ) + if not sender or not recipient: + raise UserError(_("Partner is not allowed to use the feature.")) + sender_edifact = [sender.name, "14"] + recipient_edifact = [recipient.name, "14"] + syntax_identifier = ["UNOC", "3"] + + return self.env["base.edifact"].create_interchange( + sender_edifact, recipient_edifact, self.name, syntax_identifier + ) + + def _edifact_purchase_get_supplier_code(self, product): + # Make it hookable and use the product.supplierinfo if a code is set + supplierinfo = product.seller_ids.filtered_domain( + [("partner_id", "=", self.partner_id.id)] + ) + if not supplierinfo: + supplierinfo = product.seller_ids.filtered_domain( + [("partner_id", "=", self.partner_id.commercial_partner_id.id)] + ) + + return supplierinfo[:1].product_code or product.default_code + + def _edifact_purchase_get_barcode(self, product): + # Make it hookable for additional modules + return product.barcode + + def _edifact_purchase_get_address(self, partner): + # We apply the same logic as: + # https://github.com/OCA/edi/blob/ + # c41829a8d986c6751c07299807c808d15adbf4db/base_ubl/models/ubl.py#L39 + + # oca/partner-contact/partner_address_street3 is installed + if hasattr(partner, "street3"): + return partner.street3 or partner.street2 or partner.street + else: + return partner.street2 or partner.street + + def _edifact_get_name_and_address(self, partner, code, id_number=""): + street = self._edifact_purchase_get_address(partner) + return [ + # partner information + ( + "NAD", + code, + [id_number, "", "9"], + "", + partner.commercial_company_name, + [street, ""], + partner.city, + partner.state_id.name, + partner.zip, + partner.country_id.code, + ), + # VAT registration number + ("RFF", ["VA", partner.vat]), + # Purchasing contact + ("CTA", "PD", [partner.name, ""]), + ] + + def _edifact_purchase_get_header(self, exchange_record=None): + today = datetime.now().date().strftime("%Y%m%d") + id_number = self.env["res.partner.id_number"] + buyer_id_number = id_number.search( + [("partner_id", "=", self.company_id.partner_id.id)], limit=1 + ) + seller_id_number = id_number.search([("partner_id", "=", self.partner_id.id)]) + if not seller_id_number and self.partner_id.commercial_partner_id: + seller_id_number = id_number.search( + [("partner_id", "=", self.partner_id.commercial_partner_id.id)], limit=1 + ) + message_id = exchange_record.id if exchange_record else "" + warehouse_name = ( + self.picking_type_id.warehouse_id.name if self.picking_type_id else "" + ) + + header = [ + ("UNH", message_id, ["ORDERS", "D", "96A", "UN", "EAN008"]), + # Order + ("BGM", ["220", "", "9", "ORDERS"], self.name, "9"), + # 137: Document/message date/time + ("DTM", ["137", today, "102"]), + # 2: Delivery date/time, requested + ("DTM", ["2", self.date_planned.strftime("%Y%m%d"), "102"]), + # Delivery note number + ("RFF", ["DQ", self.id]), + # Telephone + ("COM", [self.user_id.partner_id.phone or "", "TE"]), + # Reference currency + ("CUX", ["2", self.currency_id.name, "4"]), + # Rate of exchange + ("DTM", ["134", today, "102"]), + # Main-carriage transport + ("TDT", "20", "", "30", "31"), # TODO: add detail of transport + # Warehouse + ( + "LOC", + "18", + [warehouse_name, "", "", "", ""], + ), + ] + if self.edifact_version == "d01b": + header[0] = ( + "UNH", + message_id, + ["ORDERS", "D", "01B", "UN", "EAN010"], + ) + + if not self.user_id.partner_id.phone: + header = header[:5] + header[6:] + + header = ( + header[:5] + + self._edifact_get_name_and_address( + self.company_id.partner_id, "BY", buyer_id_number.name + ) + + self._edifact_get_name_and_address( + self.partner_id, "SU", seller_id_number.name + ) + + self._edifact_get_name_and_address( + self.picking_type_id.warehouse_id.partner_id, "DP", buyer_id_number.name + ) + + header[5:] + ) + + return header + + def _edifact_purchase_get_product(self): + number = 0 + segments = [] + vals = {} + tax = {} + for line in self.order_line: + number += 1 + product_tax = 0 + product = line.product_id + if line.taxes_id and line.taxes_id.amount_type == "percent": + product_tax = line.taxes_id.amount + if product_tax not in tax: + tax[product_tax] = line.price_total + else: + tax[product_tax] += line.price_total + product_type = "EN" + if self.edifact_version == "d01b": + product_type = "SRV" + + supplier_code = self._edifact_purchase_get_supplier_code(product) + barcode = self._edifact_purchase_get_barcode(product) + + product_seg = [ + # Line item number + ("LIN", number, "", [barcode, product_type]), + # Product identification of Supplier's article number + ("PIA", "1", [supplier_code, "SA", "", "91"]), + # Product identification of Buyer's part number + ("PIA", "1", [product.default_code, "BP", "", "92"]), + # Ordered quantity + ("QTY", ["21", line.product_uom_qty, ""]), + # Quantity per pack + ( + "QTY", + [ + "52", + line.package_qty if "package_qty" in line._fields else "", + "", + ], + ), + # Delivery date/time, requested + ("DTM", ["2", line.date_planned.strftime("%Y%m%d"), "102"]), + # Line item amount + ("MOA", ["203", line.price_total]), + # Mutually defined + # ("FTX", "ZZZ", "1", ["", "", "91"]), + # Calculation net + ("PRI", ["AAA", round(line.price_total / line.product_uom_qty, 2)]), + ("PRI", ["AAB", round(line.price_total / line.product_uom_qty, 2)]), + # Order number of line item + ("RFF", ["PL", self.id]), + # TODO: This place can add delivery to multiple locations + # Tax information + ("TAX", "7", "VAT", "", "", ["", "", "", product_tax]), + ] + segments.extend(product_seg) + # Pass tax information to summary + # TODO: can be used to create TAX, MOA segments + vals["tax"] = tax + vals["total_line_item"] = number + return segments, vals + + def _edifact_purchase_get_summary(self, vals, exchange_record=None): + message_id = exchange_record.id if exchange_record else "" + total_line_item = vals["total_line_item"] + len_header = 22 + if not self.user_id.partner_id.phone: + len_header -= 1 + summary = [ + ("UNS", "S"), + # Number of line items in message + ("CNT", ["2", total_line_item]), + ("UNT", len_header + 9 * total_line_item, message_id), + ] + return summary diff --git a/edi_purchase_edifact_oca/models/res_partner.py b/edi_purchase_edifact_oca/models/res_partner.py new file mode 100644 index 000000000..9a4aa5473 --- /dev/null +++ b/edi_purchase_edifact_oca/models/res_partner.py @@ -0,0 +1,20 @@ +# Copyright 2024 Trobz +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). + +from odoo import fields, models + + +class ResPartner(models.Model): + _inherit = "res.partner" + + edifact_purchase_order_out = fields.Boolean( + string="Export Purchase Order with EDIFACT", default=False + ) + + edifact_despatch_advice_ignore_lines_with_unknown_products = fields.Boolean( + default=True, + help="""When this option is enabled, + we will ignore lines with unknown products + when processing a Despatch Advice of this supplier. + """, + ) diff --git a/edi_purchase_edifact_oca/pyproject.toml b/edi_purchase_edifact_oca/pyproject.toml new file mode 100644 index 000000000..4231d0ccc --- /dev/null +++ b/edi_purchase_edifact_oca/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["whool"] +build-backend = "whool.buildapi" diff --git a/edi_purchase_edifact_oca/readme/CONTRIBUTORS.md b/edi_purchase_edifact_oca/readme/CONTRIBUTORS.md new file mode 100644 index 000000000..51e107e45 --- /dev/null +++ b/edi_purchase_edifact_oca/readme/CONTRIBUTORS.md @@ -0,0 +1,3 @@ +- Thien (Vo Hong) \ +- Mathias Francke \ +- Phuc (Phan Hong) \ diff --git a/edi_purchase_edifact_oca/readme/CREDITS.md b/edi_purchase_edifact_oca/readme/CREDITS.md new file mode 100644 index 000000000..b5330f661 --- /dev/null +++ b/edi_purchase_edifact_oca/readme/CREDITS.md @@ -0,0 +1,3 @@ +The development of this module has been financially supported by: + +- Trobz diff --git a/edi_purchase_edifact_oca/readme/DESCRIPTION.md b/edi_purchase_edifact_oca/readme/DESCRIPTION.md new file mode 100644 index 000000000..b2753a3c1 --- /dev/null +++ b/edi_purchase_edifact_oca/readme/DESCRIPTION.md @@ -0,0 +1,11 @@ +UN/EDIFACT +United Nations rules for Elec­tronic Data Interchange for Administration, +Commerce and Transport + +This module will support exporting and confirming orders in EDIFACT +format. + + + + + diff --git a/edi_purchase_edifact_oca/security/ir.model.access.csv b/edi_purchase_edifact_oca/security/ir.model.access.csv new file mode 100644 index 000000000..44281e251 --- /dev/null +++ b/edi_purchase_edifact_oca/security/ir.model.access.csv @@ -0,0 +1,4 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_purchase_order_import,access_purchase_order_import,model_purchase_order_import,base.group_user,1,1,1,1 +access_edi_output_edifact_out_generate,access_edi_output_edifact_out_generate,model_edi_output_edifact_out_generate,base.group_user,1,1,1,1 +access_edi_input_process_edifact_input,access_edi_input_process_edifact_input,model_edi_input_process_edifact_input,base.group_user,1,1,1,1 diff --git a/edi_purchase_edifact_oca/static/description/index.html b/edi_purchase_edifact_oca/static/description/index.html new file mode 100644 index 000000000..65a7ef6eb --- /dev/null +++ b/edi_purchase_edifact_oca/static/description/index.html @@ -0,0 +1,449 @@ + + + + + +EDI PURCHASE EDIFACT OCA + + + +
+

EDI PURCHASE EDIFACT OCA

+ + +

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

+
+
UN/EDIFACT
+
United Nations rules for Elec­tronic Data Interchange for +Administration, Commerce and Transport
+
+

This module will support exporting and confirming orders in EDIFACT +format.

+

https://www.stedi.com/edi/edifact/D01B/messages/ORDERS +https://www.stedi.com/edi/edifact/D96A/messages/ORDERS +https://www.stedi.com/edi/edifact/D01B/messages/DESADV +https://www.stedi.com/edi/edifact/D96A/messages/DESADV

+
+

Important

+

This is an alpha version, the data model and design can change at any time without warning. +Only for development or testing purpose, do not use in production. +More details on development status

+
+

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

+
    +
  • Trobz
  • +
+
+
+

Contributors

+ +
+
+

Other credits

+

The development of this module has been financially supported by:

+
    +
  • Trobz
  • +
+
+
+

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-framework project on GitHub.

+

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

+
+
+
+ + diff --git a/edi_purchase_edifact_oca/tests/__init__.py b/edi_purchase_edifact_oca/tests/__init__.py new file mode 100644 index 000000000..f80e62715 --- /dev/null +++ b/edi_purchase_edifact_oca/tests/__init__.py @@ -0,0 +1 @@ +from . import test_edifact_purchase diff --git a/edi_purchase_edifact_oca/tests/test_edifact_purchase.py b/edi_purchase_edifact_oca/tests/test_edifact_purchase.py new file mode 100644 index 000000000..82302a406 --- /dev/null +++ b/edi_purchase_edifact_oca/tests/test_edifact_purchase.py @@ -0,0 +1,304 @@ +# Copyright 2024 Trobz +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +import base64 +import re +from base64 import b64encode + +from odoo import fields + +from odoo.addons.component.tests.common import TransactionComponentCase +from odoo.addons.edi_oca.tests.common import EDIBackendTestMixin + + +class TestEdifactPurchaseOrder(TransactionComponentCase, EDIBackendTestMixin): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.env = cls.env(context=dict(cls.env.context, tracking_disable=True)) + cls.base_edifact_model = cls.env["base.edifact"] + cls.company = cls.env.ref("base.main_company") + cls.product_1 = cls.env.ref("product.product_delivery_01") + cls.product_2 = cls.env.ref("product.product_delivery_02") + cls.product_3 = cls.env.ref("product.product_order_01") + partner_id_number = cls.env["res.partner.id_number"] + cls.partner_1 = cls.env.ref("base.res_partner_1") + cls.partner_1.edifact_purchase_order_out = True + cls.partner_2 = cls.env.ref("base.res_partner_12") + cls.exc_type_input = cls.env.ref( + "edi_purchase_edifact_oca.edi_exchange_type_purchase_order_input" + ) + cls.edi_config_out_po = cls.env.ref( + "edi_purchase_oca.demo_edi_configuration_confirmed" + ) + cls.edi_config_out_po.write( + { + "backend_id": cls.env.ref( + "edi_purchase_edifact_oca.edi_backend_edifact" + ), + "type_id": cls.env.ref( + "edi_purchase_edifact_oca.edi_exchange_type_purchase_order_out" + ), + } + ) + cls.partner_1.write( + {"edi_purchase_conf_ids": [(6, 0, cls.edi_config_out_po.ids)]} + ) + partner_id_number_data_1 = { + "category_id": cls.env.ref( + "partner_identification_gln.partner_identification_gln_number_category" + ).id, + "partner_id": cls.partner_1.id, + "name": "9780201379624", + } + partner_id_number_data_2 = { + "category_id": cls.env.ref( + "partner_identification_gln.partner_identification_gln_number_category" + ).id, + "partner_id": cls.partner_2.id, + "name": "9780201379174", + } + partner_id_number.create(partner_id_number_data_1) + partner_id_number.create(partner_id_number_data_2) + cls.env.user.partner_id = cls.partner_2 + cls.env.user.company_id.partner_id = cls.partner_2 + + cls.datetime = fields.Datetime.now() + cls.purchase = cls.env["purchase.order"].create( + { + "partner_id": cls.partner_1.id, + "date_order": cls.datetime, + "date_planned": cls.datetime, + } + ) + cls.po_line1 = cls.purchase.order_line.create( + { + "order_id": cls.purchase.id, + "product_id": cls.product_1.id, + "name": cls.product_1.name, + "date_planned": cls.datetime, + "product_qty": 12, + "product_uom": cls.product_1.uom_id.id, + "price_unit": 42.42, + } + ) + cls.po_line2 = cls.purchase.order_line.create( + { + "order_id": cls.purchase.id, + "product_id": cls.product_2.id, + "name": cls.product_2.name, + "date_planned": cls.datetime, + "product_qty": 2, + "product_uom": cls.product_2.uom_id.id, + "price_unit": 12.34, + } + ) + + def test_edifact_purchase_generate_data(self): + edifact_data = self.purchase.edifact_purchase_generate_data() + self.assertTrue(edifact_data) + self.assertEqual(isinstance(edifact_data, str), True) + + def test_edifact_purchase_get_interchange(self): + interchange = self.purchase._edifact_purchase_get_interchange() + self.assertEqual(interchange.sender, ["9780201379174", "14"]) + self.assertEqual(interchange.recipient, ["9780201379624", "14"]) + self.assertEqual(interchange.syntax_identifier, ["UNOC", "3"]) + + def test_edifact_purchase_get_header(self): + segments = self.purchase._edifact_purchase_get_header() + seg = ("UNH", "", ["ORDERS", "D", "96A", "UN", "EAN008"]) + self.assertEqual(segments[0], seg) + self.assertEqual(len(segments), 19) + + def test_edifact_purchase_get_product(self): + segments, vals = self.purchase._edifact_purchase_get_product() + self.assertEqual(len(segments), 22) + self.assertEqual(len(vals), 2) + + def test_edifact_purchase_get_summary(self): + vals = {"total_line_item": 2} + segments = self.purchase._edifact_purchase_get_summary(vals) + self.assertEqual(len(segments), 3) + + def test_edifact_purchase_get_address(self): + partner = self.purchase.partner_id + if hasattr(partner, "street3"): + partner.street3 = "Address" + self.assertEqual( + self.purchase._edifact_purchase_get_address(partner), partner.street3 + ) + else: + self.assertEqual( + self.purchase._edifact_purchase_get_address(partner), partner.street + ) + + def test_action_confirm(self): + self.purchase.button_confirm() + self.assertEqual(self.purchase.exchange_record_count, 1) + exchange_record = self.purchase.exchange_record_ids[0] + exchange_record.backend_id.exchange_generate(exchange_record) + file_content = exchange_record._get_file_content() + self.assertTrue(file_content, "The generated file content should not be empty.") + self.assertEqual(exchange_record.edi_exchange_state, "output_pending") + self.assertEqual(exchange_record.exchanged_on, False) + + # Compare data after generating + expected_data = self.purchase.edifact_purchase_generate_data() + expected_data = expected_data.replace( + "UNH++ORDERS", f"UNH+{exchange_record.id}+ORDERS" + ) + # Add edi_exchange_record.id to UNT segment in expected_data + pattern = r"(UNT\+[^']*\+)[^']*(?=(?:'|$))" + expected_data = re.sub( + pattern, + lambda match: f"{match.group(1)}{exchange_record.id}", + expected_data, + ) + self.assertEqual( + exchange_record.exchange_file, b64encode(expected_data.encode()) + ) + + def test_edifact_purchase_wizard_import(self): + self.partner_1.edifact_purchase_order_out = False + edifact_data = self.purchase.edifact_purchase_generate_data() + self.assertTrue(edifact_data) + self.assertEqual(isinstance(edifact_data, str), True) + self.purchase.button_confirm() + edifact_data = edifact_data.replace("UNH++ORDERS", "UNH+1+DESADV") + edifact_data = edifact_data.replace( + "'UNS+S'", + ( + "'LIN+3++:EN'PIA+1+FURN_9999:SA::91'PIA+1+FURN_9999:BP::92'" + "QTY+21:4.0:'QTY+52::'DTM+2:20250110:102'MOA+203:0.0'PRI+AAA:0.0'" + "PRI+AAB:0.0'RFF+PL:338'TAX+7+VAT+++:::0'UNS+S'" + ), + ) + wiz = self.env["purchase.order.import"].create( + { + "import_type": "edifact", + "order_file": base64.b64encode(edifact_data.encode()), + "order_filename": "test_edifact.txt", + } + ) + self.assertEqual(self.purchase.state, "purchase") + # Import file to confirm purchase order + wiz.import_order_button() + + new_order = self.env["purchase.order.line"].search( + [("product_id.default_code", "=", self.product_3.default_code)] + ) + + self.assertTrue(new_order.order_id) + self.assertEqual(new_order.move_ids.quantity, 4) + + sum_quantity_done = sum(self.purchase.order_line.mapped("move_ids.quantity")) + self.assertEqual(sum_quantity_done, 14.0) + + def test_edifact_purchase_wizard_import_new_po(self): + self.partner_1.edifact_purchase_order_out = False + edifact_data = self.purchase.edifact_purchase_generate_data() + self.assertTrue(edifact_data) + self.assertEqual(isinstance(edifact_data, str), True) + self.purchase.button_confirm() + self.purchase.picking_ids.action_cancel() + edifact_data = edifact_data.replace("UNH++ORDERS", "UNH+1+DESADV") + + wiz = self.env["purchase.order.import"].create( + { + "import_type": "edifact", + "order_file": base64.b64encode(edifact_data.encode()), + "order_filename": "test_edifact.txt", + } + ) + self.assertEqual(self.purchase.state, "purchase") + # Import file to confirm purchase order + action = wiz.import_order_button() + + new_order = self.env["purchase.order"].search([("id", "=", action["res_id"])]) + sum_quantity_done = sum(new_order.order_line.mapped("move_ids.quantity")) + + self.assertNotEqual(self.purchase.id, new_order.id) + self.assertEqual(len(new_order.order_line), 2) + self.assertEqual(sum_quantity_done, 14.0) + + def test_edifact_purchase_wizard_import_ignore_unknown(self): + self.partner_1.edifact_purchase_order_out = False + edifact_data = self.purchase.edifact_purchase_generate_data() + self.assertTrue(edifact_data) + self.assertEqual(isinstance(edifact_data, str), True) + self.purchase.button_confirm() + edifact_data = edifact_data.replace("UNH++ORDERS", "UNH+1+DESADV").replace( + "LIN+1++:EN'PIA+1+FURN_7777:SA::91", "LIN+1++:EN'PIA+1+FURN_77778:SA::91" + ) + + wiz = self.env["purchase.order.import"].create( + { + "import_type": "edifact", + "order_file": base64.b64encode(edifact_data.encode()), + "order_filename": "test_edifact.txt", + } + ) + self.assertEqual(self.purchase.state, "purchase") + # Import file to confirm purchase order + wiz.import_order_button() + + sum_quantity_done = sum(self.purchase.order_line.mapped("move_ids.quantity")) + self.assertEqual(sum_quantity_done, 2.0) + + def test_edifact_purchase_exchange_record_input(self): + self.partner_1.edifact_purchase_order_out = False + edifact_data = self.purchase.edifact_purchase_generate_data() + self.assertTrue(edifact_data) + self.assertEqual(isinstance(edifact_data, str), True) + + self.purchase.button_confirm() + edifact_data = edifact_data.replace("UNH++ORDERS", "UNH+1+DESADV") + edifact_data = edifact_data.replace( + "'UNS+S'", # noqa: E501 + ( + "'LIN+3++:EN'PIA+1+FURN_8888:SA::91'PIA+1+FURN_8888:BP::92'" + "QTY+21:5.0:'QTY+52::'DTM+2:20250707:102'MOA+203:24.68'" + "PRI+AAA:12.34'PRI+AAB:12.34'RFF+PL:35'TAX+7+VAT+++:::0'UNS+S'" + ), + ) + + exchange_record_out = self.purchase.exchange_record_ids[0] + exchange_record_out.backend_id.exchange_generate(exchange_record_out) + + exchange_record = self.env["edi.exchange.record"].create( + { + "backend_id": exchange_record_out.backend_id.id, + "type_id": self.exc_type_input.id, + "model": "purchase.order", + "res_id": self.purchase.id, + } + ) + self.assertEqual(self.purchase.exchange_record_count, 2) + + exchange_record._set_file_content(edifact_data) + exchange_record.write({"edi_exchange_state": "input_received"}) + exchange_record.backend_id.exchange_process(exchange_record) + + record = self.exc_type_input.backend_id.create_record( + self.exc_type_input.code, + { + "edi_exchange_state": "input_received", + "exchange_file": base64.b64encode(edifact_data.encode()), + }, + ) + record.exchange_filename = "output-1.edi" + self.assertEqual(self.purchase.state, "purchase") + + # Run `exchange_process`, It will run through the def `process` of + # the component edi.input.process.edifact.input + record.action_exchange_process() + + self.assertEqual(record.exchange_filename, "output-1.edi") + self.assertEqual(record.res_id, self.purchase.id) + self.assertEqual(record.related_name, self.purchase.name) + + sum_quantity_done = sum(self.purchase.order_line.mapped("move_ids.quantity")) + self.assertEqual(len(self.purchase.picking_ids), 1) + self.assertEqual(len(self.purchase.picking_ids[0].move_line_ids), 3) + self.assertEqual(sum_quantity_done, 19.0) diff --git a/edi_purchase_edifact_oca/views/purchase.xml b/edi_purchase_edifact_oca/views/purchase.xml new file mode 100644 index 000000000..dbb82a742 --- /dev/null +++ b/edi_purchase_edifact_oca/views/purchase.xml @@ -0,0 +1,15 @@ + + + + edi.purchase.edifact.oca.purchase.order.form + purchase.order + + + + + + + + + + diff --git a/edi_purchase_edifact_oca/views/res_partner.xml b/edi_purchase_edifact_oca/views/res_partner.xml new file mode 100644 index 000000000..8762fda5e --- /dev/null +++ b/edi_purchase_edifact_oca/views/res_partner.xml @@ -0,0 +1,18 @@ + + + + view.partner.form.inherit + res.partner + + + + + + + + + + + diff --git a/edi_purchase_edifact_oca/wizard/__init__.py b/edi_purchase_edifact_oca/wizard/__init__.py new file mode 100644 index 000000000..00c66fca6 --- /dev/null +++ b/edi_purchase_edifact_oca/wizard/__init__.py @@ -0,0 +1 @@ +from . import purchase_order_import diff --git a/edi_purchase_edifact_oca/wizard/purchase_order_import.py b/edi_purchase_edifact_oca/wizard/purchase_order_import.py new file mode 100644 index 000000000..6f8a8ef0c --- /dev/null +++ b/edi_purchase_edifact_oca/wizard/purchase_order_import.py @@ -0,0 +1,737 @@ +# Copyright 2024 Trobz +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +import json +import logging +import mimetypes +from base64 import b64decode, b64encode +from collections import defaultdict + +from markupsafe import Markup + +from odoo import api, fields, models +from odoo.exceptions import UserError +from odoo.tools import config, float_is_zero, html_escape + +logger = logging.getLogger(__name__) + + +class PurchaseOrderImport(models.TransientModel): + _name = "purchase.order.import" + _description = "Purchase Order Import from Files" + + partner_id = fields.Many2one("res.partner", string="Customer") + + import_type = fields.Selection( + [("edifact", "EDIFACT")], + required=True, + default="edifact", + help="Select a type which you want to import", + ) + + order_file = fields.Binary( + string="Request for Quotation or Order", + required=True, + ) + order_filename = fields.Char(string="Filename") + + def _get_supported_types(self): + supported_types = { + "edifact": ("text/plain", None), + } + return supported_types + + def _parse_file(self, filename, filecontent): + assert filename, "Missing filename" + assert filecontent, "Missing file content" + filetype = mimetypes.guess_type(filename) + logger.debug("Order file mimetype: %s", filetype) + mimetype = filetype[0] + supported_types = self._get_supported_types() + # Check if the selected import type is supported + if self.import_type not in supported_types: + raise UserError( + self.env._("Please select a valid import type before importing!") + ) + + # Check if the detected MIME type is supported for the selected import type + if mimetype not in supported_types[self.import_type]: + raise UserError( + self.env._( + "This file '%(filename)s' is not recognized as " + "a %(import_type)s file. " + "Please check the file and its extension.", + filename=filename, + import_type=self.import_type.upper(), + ) + ) + if hasattr(self, f"parse_{self.import_type}_order"): + return getattr(self, f"parse_{self.import_type}_order")(filecontent) + else: + raise UserError( + self.env._( + "This Import Type is not supported. Did you install " + "the module to support this type?" + ) + ) + + @api.model + def parse_order(self, order_file, order_filename): + parsed_order = self._parse_file(order_filename, order_file) + logger.debug(f"Result of order parsing: {parsed_order}") + defaults = ( + ("attachments", {}), + ("chatter_msg", []), + ) + for key, val in defaults: + parsed_order.setdefault(key, val) + + parsed_order["attachments"][order_filename] = b64encode(order_file) + if ( + parsed_order.get("company") + and not config["test_enable"] + and not self._context.get("edi_skip_company_check") + ): + self.env["business.document.import"]._check_company( + parsed_order["company"], parsed_order["chatter_msg"] + ) + return parsed_order + + def import_order_button(self): + self.ensure_one() + order_file_decoded = b64decode(self.order_file) + parsed_order = self.parse_order(order_file_decoded, self.order_filename) + + if not parsed_order.get("lines"): + raise UserError(self.env._("This order doesn't have any line !")) + order = parsed_order.get("order", False) + if order and not order.picking_ids.filtered( + lambda picking: picking.state not in ("done", "cancel") + ): + order = order.copy(default={"order_line": [(5, 0, 0)]}) + parsed_order["order"] = order + + return self.update_purchase_order(order, parsed_order) + + @api.model + def parse_edifact_order(self, filecontent): + edifact_model = self.env["base.edifact"] + interchange = edifact_model._loads_edifact(filecontent) + header = interchange.get_header_segment() + # > UNB segment: [['UNOA', '2'], ['5450534000000', '14'], + # ['8435337000003', '14'], ['230306', '0435'], '5506'] + + msg_type, __ = edifact_model._get_msg_type(interchange) + + supported = ["ORDERS", "DESADV"] + if msg_type not in supported: + raise UserError( + self.env._( + "%(msg_type)s document is not a Purchase Order document", + msg_type=msg_type, + ) + ) + + bgm = interchange.get_segment("BGM") + # Supplier PO number + # BGM segment: ['220', '1LP6WZGF', '9'] + order_ref = bgm[1] + + rd = { + # Supplier PO number + "order_ref": order_ref, + "edi_ctx": {"sender": header[1], "recipient": header[2]}, + "msg_type": msg_type, + "reception_lines": len(list(interchange.get_segments("LIN"))), + } + parties = self._prepare_edifact_parties(interchange) + order_dict = { + **rd, + **self._prepare_edifact_dates(interchange), + **self._prepare_edifact_currencies(interchange), + **parties, + } + + existing_quotations = self.env["purchase.order"].search( + [ + ("state", "=", "purchase"), + ("name", "=", order_dict["order_ref"]), + ] + ) + if existing_quotations: + order_dict["order"] = existing_quotations[0] + else: + raise UserError( + self.env._( + "Purchase Order Id %(order_ref)s is not found", + order_ref=order_dict["order_ref"], + ) + ) + + lines = self._prepare_edifact_lines(interchange, order_dict) + if lines: + order_dict["lines"] = lines + return order_dict + + @api.model + def _prepare_edifact_parties(self, interchange): + references = self._prepare_edifact_references(interchange) + parties = self._prepare_edifact_name_and_address(interchange) + if references.get("vat") and parties.get("invoice_to"): + # just for check vat + if parties["invoice_to"].get("partner"): + parties["invoice_to"]["partner"]["rff_va"] = references["vat"] + if parties.get("invoice_to") and parties["invoice_to"].get("partner"): + newpartner = parties["invoice_to"]["partner"].copy() + if parties.get("partner") and parties["partner"].get("gln"): + # To see if NAD_BY is different NAD_IV + newpartner["gln_by"] = parties["partner"]["gln"] + parties["partner"] = newpartner + # add context information + for pval in parties.values(): + partner_dict = pval.get("partner", pval) + partner_dict["edi_ctx"] = { + "order_filename": self.order_filename, + } + if references.get("vat"): + partner_dict["edi_ctx"]["rff_va"] = references["vat"] + if parties.get("company"): + parties["company"]["edi_ctx"]["vendor_code"] = references.get("vendor_code") + if references.get("order_ref"): + parties["order_ref"] = references["order_ref"] + return parties + + @api.model + def _prepare_edifact_dates(self, interchange): + dates = defaultdict(dict) + edifact_model = self.env["base.edifact"] + for seg in interchange.get_segments("DTM"): + date_meaning_code = seg[0][0] + if date_meaning_code == "137": + dates["date"] = edifact_model.map2odoo_date(seg[0]) + elif date_meaning_code == "63": + # latest delivery date + dates["delivery_detail"]["validity_date"] = edifact_model.map2odoo_date( + seg[0] + ) + elif date_meaning_code == "2": + # Date planned + dates["delivery_detail"]["date_planned"] = edifact_model.map2odoo_date( + seg[0] + ) + + return dates + + @api.model + def _prepare_edifact_references(self, interchange): + """ + RFF segment: [['CR', 'IK142']] + """ + refs = {} + for seg in interchange.get_segments("RFF"): + reference = seg[0] + reference_code = reference[0] + if reference_code == "ADE": + # ['firstorder','backorder','advantage','nyp'] + refs["account_reference"] = reference[1] + elif reference_code == "CR": + # Customer reference Number + refs["vendor_code"] = reference[1] + elif reference_code == "PD": + # Promotion Deal Number + # Number assigned by a vendor to a special promotion activity + refs["promotion_number"] = reference[1] + elif reference_code == "VA": + # Unique number assigned by the relevant tax authority to identify a + # party for use in relation to Value Added Tax (VAT). + refs["vat"] = reference[1] + elif reference_code == "ON": + # Order reference number + refs["order_ref"] = reference[1] + + return refs + + @api.model + def _prepare_edifact_name_and_address(self, interchange): + nads = {} + edifact_model = self.env["base.edifact"] + for seg in interchange.get_segments("NAD"): + reference_code = seg[0] + if reference_code == "BY": + # NAD segment: ['BY', ['5450534001649', '', '9']] + # Customer (Buyer's GLN) + nads["partner"] = edifact_model.map2odoo_partner(seg) + elif reference_code == "SU": + # Our number of Supplier's GLN + # Can be used to check that we are not importing the order + # in the wrong company by mistake + nads["company"] = edifact_model.map2odoo_partner(seg) + elif reference_code == "DP": + # Delivery Party + nads["ship_to"] = edifact_model.map2odoo_address(seg) + elif reference_code == "IV": + # Invoice Party + nads["invoice_to"] = edifact_model.map2odoo_address(seg) + return nads + + @api.model + def _prepare_edifact_currencies(self, interchange): + currencies = {} + edifact_model = self.env["base.edifact"] + for seg in interchange.get_segments("CUX"): + usage_code = seg[0][0] + if usage_code == "2": + currencies["currency"] = edifact_model.map2odoo_currency(seg[0]) + return currencies + + @api.model + def _prepare_edifact_lines(self, interchange, order_dict): + edifact_model = self.env["base.edifact"] + bdio = self.env["business.document.import"] + order = order_dict["order"] + partner_id_number = order_dict["company"] + order_dict["unknown_products"] = [] + partner = bdio._match_partner( + partner_id_number, + "", + partner_type="customer", + ) + lines = [] + pia_list = [] + qty_list = [] + pri_list = [] + imd_list = [] + + for i in interchange.get_segments("PIA"): + if i[1][1] == "SA": + pia_list.append(i) + for i in interchange.get_segments("QTY"): + if i[0][0] == "21" or i[0][0] == "12": + qty_list.append(i) + for i in interchange.get_segments("PRI"): + pri_list.append(i) + for i in interchange.get_segments("IMD"): + if i[0] == "A" and i[1] == "": + imd_list.append(i) + + for linseg in interchange.get_segments("LIN"): + piaseg = pia_list.pop(0) if pia_list else None + qtyseg = qty_list.pop(0) if qty_list else None + priseg = pri_list.pop(0) if pri_list else None + imdseg = imd_list.pop(0) if imd_list else None + + line = { + "sequence": int(linseg[0]), + "product": edifact_model.map2odoo_product(linseg, piaseg), + "qty": edifact_model.map2odoo_qty(qtyseg), + } + + price_unit = edifact_model.map2odoo_unit_price(priseg) + # If the product price is not provided, + # the price will be taken from the system + if price_unit != 0.0: + line["price_unit"] = price_unit + + description = edifact_model.map2odoo_description(imdseg) + if description: + line["name"] = description + + try: + # Check product + bdio._match_product(line["product"], "") + except UserError as error: + if ( + order + and partner.edifact_despatch_advice_ignore_lines_with_unknown_products # noqa: E501 + ): + order.message_post(body=self.env._(str(error))) + order_dict["unknown_products"].append(line) + continue + else: + raise error + + lines.append(line) + + return lines + + @api.model + def _prepare_create_order_line(self, product, uom, order, import_line): + """the 'order' arg can be a recordset (in case of an update of a purchase order) + or a dict (in case of the creation of a new purchase order)""" + polo = self.env["purchase.order.line"] + vals = {} + # Ensure the company is loaded before we play onchanges. + # Yes, `company_id` is related to `order_id.company_id` + # but when we call `play_onchanges` it will be empty + # w/out this precaution. + company_id = self._prepare_order_line_get_company_id(order) + vals.update( + { + "name": product.display_name, + "product_id": product.id, + "product_uom_qty": import_line["qty"], + "product_qty": import_line["qty"], + "product_uom": uom.id, + "company_id": company_id, + "order_id": order.id, + } + ) + # Handle additional fields dynamically if available. + # This way, if you add a field to a record + # and it's value is injected by a parser + # you won't have to override `_prepare_create_order_line` + # to let it propagate. + for k, v in import_line.items(): + if k not in vals and k in polo._fields: + vals[k] = v + + defaults = self.env.context.get("purchase_order_import__default_vals", {}).get( + "lines", {} + ) + vals.update(defaults) + return vals + + def _prepare_update_order_line_vals(self, change_dict): + # Allows other module to update some fields on the line + return {} + + def _prepare_order_line_get_company_id(self, order): + company_id = self.env.user.company_id + if isinstance(order, models.Model): + company_id = order.company_id.id + elif isinstance(order, dict): + company_id = order.get("company_id") or company_id + return company_id + + def _add_order_line_from_compare_res(self, order, compare_res, parsed_order): + chatter = parsed_order["chatter_msg"] + polo = self.env["purchase.order.line"] + to_create_label = [] + for add in compare_res["to_add"]: + line_vals = self._prepare_create_order_line( + add["product"], add["uom"], order, add["import_line"] + ) + line_vals["date_planned"] = parsed_order["delivery_detail"]["date_planned"] + new_line = polo.create(line_vals) + to_create_label.append( + f"{new_line.product_uom_qty} {new_line.product_uom.name} " + f"x {new_line.name}" + ) + chatter.append( + self.env._( + "%(nbr_ol)s new order line(s) created: %(to_create_label)s", + nbr_ol=len(compare_res["to_add"]), + to_create_label=", ".join(to_create_label), + ) + ) + # Update quantity with product_uom_qty with PO created based on + # information from Despatch Advice + if order.state not in ["purchase", "done", "cancel"]: + order.with_context(skip_send_edifact=True).button_confirm() + for picking in order.picking_ids.filtered( + lambda p: p.state not in ["done", "cancel"] + ): + for move in picking.move_line_ids: + move.quantity = move.quantity_product_uom + self._update_qty_done_package(picking.move_line_ids) + + def _remove_order_line_from_compare_res(self, compare_res, parsed_order): + chatter = parsed_order["chatter_msg"] + to_remove_label = [ + f"{line.product_uom_qty} {line.product_uom.name} " + f"x {line.product_id.name}" + for line in compare_res["to_remove"] + ] + chatter.append( + self.env._( + "%(nbr_ol)s order line(s) deleted: %(to_remove_label)s", + nbr_ol=len(compare_res["to_remove"]), + to_remove_label=", ".join(to_remove_label), + ) + ) + compare_res["to_remove"].unlink() + + def _merge_import_lines(self, import_lines): + """Merge lines with the same product.""" + + merged_lines = {} + for line in import_lines: + product_key = tuple(sorted(line["product"].items())) + if product_key not in merged_lines: + merged_lines[product_key] = line.copy() + merged_lines[product_key]["qty"] = line["qty"] + else: + merged_lines[product_key]["qty"] += line["qty"] + return list(merged_lines.values()) + + @api.model + def update_order_lines(self, parsed_order, order): + chatter = parsed_order["chatter_msg"] + qty_diff_list = parsed_order["qty_diff"] = [] + dpo = self.env["decimal.precision"] + bdio = self.env["business.document.import"] + qty_prec = dpo.precision_get("Product Unit of Measure") + existing_lines = [] + for oline in order.order_line: + # compute price unit without tax + price_unit = 0.0 + if not float_is_zero(oline.product_uom_qty, precision_digits=qty_prec): + qty = float(oline.product_uom_qty) + price_unit = oline.price_subtotal / qty + existing_lines.append( + { + "product": oline.product_id or False, + "name": oline.name, + # Assign to 0 to get update qty data in `compare_res`` + "qty": 0, + "uom": oline.product_uom, + "line": oline, + "price_unit": price_unit, + } + ) + import_lines = self._merge_import_lines(parsed_order["lines"]) + compare_res = bdio.compare_lines( + existing_lines, + import_lines, + chatter, + qty_precision=qty_prec, + seller=False, + ) + + # Display errors during the comparison between + # the Despatch Advice file and the order data. + if chatter: + order.message_post(body=Markup("

".join(chatter))) + + # NOW, we start to write/delete/create the order lines + number_line_updated = 0 + picking_dict = {} + # An error may occur during the comparison between + # the imported data and the original data. + # In that case, compare_res will be set to False. + if compare_res: + for oline, cdict in compare_res["to_update"].items(): + write_vals = {} + if cdict.get("qty"): + write_vals.update(self._prepare_update_order_line_vals(cdict)) + if oline.product_id.type == "consu": + delivery_qty = cdict["qty"][1] + if oline.product_qty != delivery_qty + oline.qty_received: + qty_diff_list.append( + { + "message": "Mismatch between ordered quantity" + f" ({oline.product_qty}) and " + f"quantity being delivered ({delivery_qty})", + "Order Line Info": { + "id": oline.id, + "product_id": oline.product_id.id, + "barcode": oline.product_id.barcode, + "default_code": oline.product_id.default_code, + "description": oline.name, + "product_qty": oline.product_qty, + "qty_recieved": oline.qty_received, + "incoming_qty": delivery_qty, + }, + } + ) + updated_picking_dict = self._update_stock_moves( + oline, delivery_qty, picking_dict + ) + if updated_picking_dict: + picking_dict = updated_picking_dict + number_line_updated += 1 + + if write_vals: + oline.write(write_vals) + for picking, move_ids in picking_dict.items(): + message = ( + "Record has been updated automatically via the import Despatch Advice." # noqa: E501 + f" Done quantities were updated on {len(move_ids)} lines out of " + f"the {len(picking.move_line_ids)} Reception lines." + ) + picking.message_post(body=self.env._(message)) + + if compare_res["to_remove"] and order.state not in ["purchase", "done"]: + self._remove_order_line_from_compare_res(compare_res, parsed_order) + + if compare_res["to_add"]: + if order.state in ["purchase", "done"]: + sub_order = order.copy(default={"order_line": [(5, 0, 0)]}) + order.message_post( + body=Markup( + self.env._( + "Received some unexpected products. " + "Created a new Purchase Order for it in " + "%(so_name)s.", # noqa: E501 + so_id=sub_order.id, + so_name=sub_order.name, + ) + ) + ) + order = sub_order + self._add_order_line_from_compare_res(order, compare_res, parsed_order) + return number_line_updated + + def _update_qty_done_package(self, moves): + if hasattr(moves, "qty_done_package"): + for move in moves: + if move.purchase_line_id and move.quantity > 0 and move.package_qty: + move.qty_done_package = move.quantity / move.package_qty + return moves + + @api.model + def _update_stock_moves(self, order_line, new_qty, picking_dict=None): + if picking_dict is None: + picking_dict = {} + moves = order_line.move_ids.filtered( + lambda move: move.state not in ("done", "cancel", "draft") + ) + if not moves: + raise UserError( + self.env._( + "No valid moves to update for the line %(order_name)s.", + order_name=order_line.name, + ) + ) + total_qty_update = ( + new_qty - order_line.qty_received - sum(moves.mapped("quantity")) + ) + if total_qty_update <= 0: + order_line.order_id.message_post( + body=Markup( + self.env._( + "The quantity delivered for product " + "" + "%(ol_product_name)s " + "is less than or equal to the quantity received.", + ol_product_id=order_line.product_id.id, + ol_product_name=order_line.product_id.name, + ) + ) + ) + if total_qty_update > 0: + for move in moves: + if total_qty_update <= 0: + break + available_qty = move.product_uom_qty - move.quantity + + if available_qty > 0: + if total_qty_update >= available_qty: + move.quantity = move.product_uom_qty + total_qty_update -= available_qty + else: + move.quantity += total_qty_update + total_qty_update = 0 + + if not picking_dict.get(move.picking_id, False): + picking_dict[move.picking_id] = [] + picking_dict[move.picking_id].append(move.id) + + self._update_qty_done_package(moves) + + # Create new stock.move if still available + if total_qty_update > 0: + new_move = moves[-1].copy( + { + "product_uom_qty": total_qty_update, + "quantity": total_qty_update, + "state": "draft", + } + ) + new_move = self._update_qty_done_package(new_move._action_confirm()) + new_move._action_assign() + + # Return the pickings with updated moves to print the message + return picking_dict + return False + + @api.model + def _prepare_update_order_vals(self, parsed_order): + bdio = self.env["business.document.import"] + partner = bdio._match_partner( + parsed_order["company"], + parsed_order["chatter_msg"], + partner_type="customer", + ) + vals = {"partner_id": partner.id} + return vals + + def update_purchase_order(self, order, parsed_order): + self.ensure_one() + bdio = self.env["business.document.import"] + currency = bdio._match_currency( + parsed_order.get("currency"), parsed_order["chatter_msg"] + ) + if currency != order.currency_id: + raise UserError( + self.env._( + "The currency of the imported order %(old)s is different from " + "the currency of the existing order %(new)s", + old=currency.name, + new=order.currency_id.name, + ) + ) + vals = self._prepare_update_order_vals(parsed_order) + if vals: + order.write(vals) + for move in order.order_line.mapped("move_ids"): + if ( + move.state not in ("done", "cancel", "draft") + and move.product_uom_qty == move.quantity + ): + move.quantity = 0 + number_line_updated = self.update_order_lines(parsed_order, order) + bdio.post_create_or_update(parsed_order, order) + logger.info( + "Order ID %d updated via import of file %s", + order.id, + self.order_filename, + ) + action = self.env.ref("purchase.purchase_form_action").read()[0] + action.update( + { + "view_mode": "form,tree,calendar,graph", + "views": False, + "view_id": False, + "res_id": order.id, + "unknown_products": parsed_order["unknown_products"], + "qty_diff": parsed_order["qty_diff"], + "reception_lines": parsed_order["reception_lines"], + "number_line_updated": number_line_updated, + } + ) + message = self._create_expected_reception_message(action) + order.message_post(body=self.env._(message)) + + return action + + def _create_expected_reception_message(self, action): + message = ( + "

This order has been updated automatically via the import of file " + "{}
" + "Done quantities were updated on {} lines out of the {} Reception lines

" + ).format( + html_escape(self.order_filename), + action.get("number_line_updated", 0), + action.get("reception_lines", 0), + ) + + unknown_products = action.get("unknown_products", False) + if unknown_products: + message += "

Unknown Product:
" + message += "
".join( + f"  * {html_escape(json.dumps(rec, ensure_ascii=False))}" + for rec in unknown_products + ) + message += "

" + + qty_diff = action.get("qty_diff", False) + if qty_diff: + message += "

Difference of Qty:
" + message += "
".join( + f"  * {html_escape(json.dumps(rec, ensure_ascii=False))}" + for rec in qty_diff + ) + message += "

" + return Markup(message) diff --git a/edi_purchase_edifact_oca/wizard/purchase_order_import_view.xml b/edi_purchase_edifact_oca/wizard/purchase_order_import_view.xml new file mode 100644 index 000000000..561452649 --- /dev/null +++ b/edi_purchase_edifact_oca/wizard/purchase_order_import_view.xml @@ -0,0 +1,47 @@ + + + + purchase.order.import.form.dev + purchase.order.import + +
+ +
+

Upload below the customer order as EDIFACT file.

+
+
+ + + + + +
+
+
+
+
+ + Import Purchase Order EDIFACT + purchase.order.import + form + new + + + +
diff --git a/test-requirements.txt b/test-requirements.txt index a8133e4b5..0ae76ba80 100644 --- a/test-requirements.txt +++ b/test-requirements.txt @@ -1,2 +1,4 @@ odoo-test-helper xmlunittest +odoo-addon-base_edifact @ git+https://github.com/OCA/edi.git@refs/pull/1185/head#subdirectory=base_edifact +odoo-addon-edi_purchase_oca @ git+https://github.com/OCA/edi-framework.git@refs/pull/180/head#subdirectory=edi_purchase_oca