diff --git a/edi_purchase_edifact_oca/README.rst b/edi_purchase_edifact_oca/README.rst new file mode 100644 index 0000000000..dbba976d1a --- /dev/null +++ b/edi_purchase_edifact_oca/README.rst @@ -0,0 +1,96 @@ +======================== +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-lightgray.png?logo=github + :target: https://github.com/OCA/edi/tree/12.0/edi_purchase_edifact_oca + :alt: OCA/edi +.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png + :target: https://translation.odoo-community.org/projects/edi-12-0/edi-12-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&target_branch=12.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) + +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 `_ 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 0000000000..29b1387748 --- /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 0000000000..f45d271b24 --- /dev/null +++ b/edi_purchase_edifact_oca/__manifest__.py @@ -0,0 +1,29 @@ +# 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": "12.0.1.0.0", + "development_status": "Alpha", + "website": "https://github.com/OCA/edi", + "author": "Trobz, Odoo Community Association (OCA)", + "license": "AGPL-3", + "application": False, + "installable": True, + "depends": [ + "base_edifact", + "stock", + "edi_storage_oca", + "edi_purchase_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 0000000000..a386caa571 --- /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 0000000000..77ab19864c --- /dev/null +++ b/edi_purchase_edifact_oca/components/generate_edifact_output.py @@ -0,0 +1,21 @@ +# Copyright 2024 Trobz +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo.addons.component.core import Component + + +class EDIExchangeEDIFACTOutGenerate(Component): + _name = "edi.output.edifact.out.generate" + _inherit = "edi.component.output.mixin" + _usage = "output.generate.edifact" + + def generate(self): + data = False + exchange_record = self.exchange_record + + 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 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 0000000000..2837ade564 --- /dev/null +++ b/edi_purchase_edifact_oca/components/listener_edifact_output.py @@ -0,0 +1,38 @@ +# 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): + partner = order.partner_id + return (partner and partner.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 0000000000..e91afd7fd4 --- /dev/null +++ b/edi_purchase_edifact_oca/components/process_edifact_input.py @@ -0,0 +1,23 @@ +# Copyright 2024 Trobz +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +import base64 +from odoo.addons.component.core import Component + + +class EDIExchangeEDIFACTInput(Component): + + _name = "edi.input.process.edifact.input" + _inherit = "edi.component.input.mixin" + _usage = "input.process.edifact.input" + + def process(self): + """Process incoming EDIFACT record and confirm record.""" + file_content = self.exchange_record._get_file_content() + wizard = self.env["purchase.order.import"].create({ + "import_type": "edifact", + "order_file": base64.b64encode(file_content.encode()), + "order_filename": self.exchange_record.exchange_filename + }) + action = wizard.import_order_button() + return action 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 0000000000..87c85268d1 --- /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 0000000000..7e1db87320 --- /dev/null +++ b/edi_purchase_edifact_oca/data/edi_exchange_type.xml @@ -0,0 +1,48 @@ + + + + + + EDIFACT-OUT-ORDER + edifact_out_order + output + D{dt} + txt + True + iso-8859-1 + strict + strict + + + components: + generate: + usage: output.generate.edifact + env_ctx: + msg_type: Picking + filename_pattern: + force_tz: Europe/Zurich + date_pattern: "%Y%m%d%H%M%S%f" + + + + + + + EDIFACT-IN-DESPATCH-ADVICE + edifact_in_despatch_advice + D{dt} + txt + input + True + iso-8859-1 + strict + strict + + components: + process: + usage: input.process.edifact.input + env_ctx: + msg_type: "EDIFACT Input" + + + diff --git a/edi_purchase_edifact_oca/models/__init__.py b/edi_purchase_edifact_oca/models/__init__.py new file mode 100644 index 0000000000..698169f8c5 --- /dev/null +++ b/edi_purchase_edifact_oca/models/__init__.py @@ -0,0 +1,3 @@ +from . import purchase +from . import res_partner +from . import business_document_import 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 0000000000..c7bad30dce --- /dev/null +++ b/edi_purchase_edifact_oca/models/business_document_import.py @@ -0,0 +1,51 @@ +# 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/purchase.py b/edi_purchase_edifact_oca/models/purchase.py new file mode 100644 index 0000000000..8ab3e50751 --- /dev/null +++ b/edi_purchase_edifact_oca/models/purchase.py @@ -0,0 +1,253 @@ +# Copyright 2024 Trobz +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl-3.0) + +from odoo import _, models, fields +from datetime import datetime +from odoo.exceptions import UserError + + +class PurchaseOrder(models.Model): + _inherit = "purchase.order" + + edifact_version = fields.Selection( + [ + ("d96a", "D.96A"), + ("d01b", "D.01B"), + ], + default="d96a", + string="Edifact Version", + ) + + 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"] + sender = id_number.search( + [("partner_id", "=", self.user_id.partner_id.id)], limit=1 + ) + recipient = id_number.search([("partner_id", "=", self.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_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]), + ] + + 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.user_id.partner_id.id)] + ) + seller_id_number = id_number.search([("partner_id", "=", self.partner_id.id)]) + 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"]), + ("PAI", ["", "", "42"]), + # Mutually defined + ("FTX", "ZZZ", "1", ["PO", "", "91"]), + # Message batch number + ("RFF", ["ALL", self.id]), + # Reference date/time + ("DTM", ["171", self.write_date.strftime("%Y%m%d"), "102"]), + # Delivery note number + ("RFF", ["DQ", self.id]), + # Purchasing contact + ("CTA", "PD", [self.user_id.partner_id.name, ""]), + # 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"], + ) + + header = ( + header[:9] + + self._edifact_get_name_and_address( + self.user_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.partner_id, "DP", seller_id_number.name + ) + + header[9:] + ) + + 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 + product_per_pack = line.product_uom._compute_quantity( + line.product_qty, product.uom_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" + product_seg = [ + # Line item number + ("LIN", number, "", [product.barcode, product_type]), + # Product identification of Supplier's article number + ("PIA", "1", [product.default_code, "SA", "", "91"]), + # Product identification of Buyer's part number + ("PIA", "1", [product.default_code, "BP", "", "92"]), + # Item description of product + ( + "IMD", + "F", + "8", + ["", "", "", product.product_tmpl_id.description_sale], + ), + # Ordered quantity + ("QTY", ["21", line.product_uom_qty, line.product_uom.unece_code]), + # Quantity per pack + ("QTY", ["52", product_per_pack if product_per_pack else 1, "PCE"]), + # Received quantity + ("QTY", ["48", line.qty_received, line.product_uom.unece_code]), + # 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]), + # Reference date/time + ("DTM", ["171", self.write_date.strftime("%Y%m%d"), "102"]), + # Package + ("PAC", line.product_uom_qty, ["", "51"], line.product_uom.name), + # Package Identification + ("PCI", "14"), + # 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"] + summary = [ + ("UNS", "S"), + # Number of line items in message + ("CNT", ["2", total_line_item]), + ("UNT", 24 + 17 * 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 0000000000..63d46e04d4 --- /dev/null +++ b/edi_purchase_edifact_oca/models/res_partner.py @@ -0,0 +1,13 @@ +# 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 + ) diff --git a/edi_purchase_edifact_oca/readme/CONTRIBUTORS.rst b/edi_purchase_edifact_oca/readme/CONTRIBUTORS.rst new file mode 100644 index 0000000000..42455569c5 --- /dev/null +++ b/edi_purchase_edifact_oca/readme/CONTRIBUTORS.rst @@ -0,0 +1 @@ +* Thien (Vo Hong) diff --git a/edi_purchase_edifact_oca/readme/CREDITS.rst b/edi_purchase_edifact_oca/readme/CREDITS.rst new file mode 100644 index 0000000000..b777d8a494 --- /dev/null +++ b/edi_purchase_edifact_oca/readme/CREDITS.rst @@ -0,0 +1,3 @@ +The development of this module has been financially supported by: + +* Trobz diff --git a/edi_purchase_edifact_oca/readme/DESCRIPTION.rst b/edi_purchase_edifact_oca/readme/DESCRIPTION.rst new file mode 100644 index 0000000000..81a0bb18df --- /dev/null +++ b/edi_purchase_edifact_oca/readme/DESCRIPTION.rst @@ -0,0 +1,9 @@ +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 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 0000000000..ee5b62eeb0 --- /dev/null +++ b/edi_purchase_edifact_oca/security/ir.model.access.csv @@ -0,0 +1,2 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_purchase_order_import,access_purchase_order_import,model_purchase_order_import,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 0000000000..c8a0e64f7e --- /dev/null +++ b/edi_purchase_edifact_oca/static/description/index.html @@ -0,0 +1,443 @@ + + + + + + +EDI PURCHASE EDIFACT OCA + + + +
+

EDI PURCHASE EDIFACT OCA

+ + +

Alpha License: AGPL-3 OCA/edi 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 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 0000000000..f80e627152 --- /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 0000000000..7359e2ac8c --- /dev/null +++ b/edi_purchase_edifact_oca/tests/test_edifact_purchase.py @@ -0,0 +1,184 @@ +# Copyright 2024 Trobz +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +import base64 +from odoo.addons.component.tests.common import TransactionComponentCase +from odoo.addons.edi_oca.tests.common import EDIBackendTestMixin + +from odoo import fields +from base64 import b64encode +import re + + +class TestEdifactPurchaseOrder(TransactionComponentCase, EDIBackendTestMixin): + def setUp(self): + super(TestEdifactPurchaseOrder, self).setUp() + self.env = self.env(context=dict(self.env.context, tracking_disable=True)) + self.base_edifact_model = self.env["base.edifact"] + self.company = self.env.ref("base.main_company") + self.product_1 = self.env.ref("product.product_product_1") + self.product_1.default_code = "FURN_6666" + self.product_2 = self.env.ref("product.product_product_4") + self.product_2.default_code = "FURN_8855" + partner_id_number = self.env["res.partner.id_number"] + self.partner_1 = self.env.ref("base.res_partner_1") + self.partner_1.edifact_purchase_order_out = True + self.partner_2 = self.env.ref("base.res_partner_12") + self.exc_type_input = self.env.ref( + "edi_purchase_edifact_oca.edi_exchange_type_purchase_order_input" + ) + partner_id_number_data_1 = { + "category_id": self.env.ref( + "partner_identification_gln.partner_identification_gln_number_category" + ).id, + "partner_id": self.partner_1.id, + "name": "9780201379624", + } + + partner_id_number_data_2 = { + "category_id": self.env.ref( + "partner_identification_gln.partner_identification_gln_number_category" + ).id, + "partner_id": self.partner_2.id, + "name": "9780201379174", + } + partner_id_number.create(partner_id_number_data_1) + partner_id_number.create(partner_id_number_data_2) + self.env.user.partner_id = self.partner_2 + + self.datetime = fields.Datetime.now() + self.purchase = self.env["purchase.order"].create( + { + "partner_id": self.partner_1.id, + "date_order": self.datetime, + "date_planned": self.datetime, + } + ) + self.po_line1 = self.purchase.order_line.create( + { + "order_id": self.purchase.id, + "product_id": self.product_1.id, + "name": self.product_1.name, + "date_planned": self.datetime, + "product_qty": 12, + "product_uom": self.product_1.uom_id.id, + "price_unit": 42.42, + } + ) + self.po_line2 = self.purchase.order_line.create( + { + "order_id": self.purchase.id, + "product_id": self.product_2.id, + "name": self.product_2.name, + "date_planned": self.datetime, + "product_qty": 2, + "product_uom": self.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), 21) + + def test_edifact_purchase_get_product(self): + segments, vals = self.purchase._edifact_purchase_get_product() + self.assertEqual(len(segments), 34) + 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() + exchange_record = self.env["edi.exchange.record"].search( + [ + ("model", "=", "purchase.order"), + ("res_id", "=", self.purchase.id), + ] + ) + exchange_record.action_exchange_generate() + self.assertNotEqual(exchange_record.exchange_file, False) + 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) + 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, "draft") + # Import file to confirm purchase order + wiz.import_order_button() + self.assertEqual(self.purchase.state, "purchase") + + 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) + edifact_data = edifact_data.replace("UNH++ORDERS", "UNH+1+DESADV") + + 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()), + }, + ) + self.assertEqual(self.purchase.state, "draft") + + record.action_exchange_process() + self.assertEqual(self.purchase.state, "purchase") diff --git a/edi_purchase_edifact_oca/views/purchase.xml b/edi_purchase_edifact_oca/views/purchase.xml new file mode 100644 index 0000000000..df590f2dff --- /dev/null +++ b/edi_purchase_edifact_oca/views/purchase.xml @@ -0,0 +1,16 @@ + + + + + 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 0000000000..6ff0372aba --- /dev/null +++ b/edi_purchase_edifact_oca/views/res_partner.xml @@ -0,0 +1,16 @@ + + + + + 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 0000000000..00c66fca65 --- /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 0000000000..ce409ea7e0 --- /dev/null +++ b/edi_purchase_edifact_oca/wizard/purchase_order_import.py @@ -0,0 +1,510 @@ +# Copyright 2024 Trobz +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +import logging +import mimetypes +from base64 import b64decode, b64encode +from collections import defaultdict + +from odoo import _, api, fields, models +from odoo.exceptions import UserError +from odoo.tools import config, float_is_zero + +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(_("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( + _( + "This file '%(filename)s' is not recognized as a %(type)s file. " + "Please check the file and its extension.", + filename=filename, + type=self.import_type.upper(), + ) + ) + if hasattr(self, "parse_%s_order" % self.import_type): + return getattr(self, "parse_%s_order" % self.import_type)(filecontent) + else: + raise UserError( + _( + "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("Result of order parsing: %s", 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(_("This order doesn't have any line !")) + + existing_quotations = self.env["purchase.order"].search( + [ + ("state", "in", ("draft", "sent")), + ("name", "=", parsed_order["order_ref"]), + ] + ) + if existing_quotations: + return self.update_purchase_order(existing_quotations[0], parsed_order) + else: + raise UserError( + _("Purchase Order Id {id} is not found").format( + id=parsed_order["order_ref"] + ) + ) + + @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( + _("{msg_type} document is not a Purchase Order document").format( + 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, + } + parties = self._prepare_edifact_parties(interchange) + order_dict = { + **rd, + **self._prepare_edifact_dates(interchange), + **self._prepare_edifact_currencies(interchange), + **parties, + } + lines = self._prepare_edifact_lines(interchange) + 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") + 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.setdefault("delivery_detail",{}) + 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] + + 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): + edifact_model = self.env["base.edifact"] + bdio = self.env["business.document.import"] + 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[1] == "79": + 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), + } + + # Check product + bdio._match_product(line["product"], "") + + 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 + + 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 + + @api.model + def update_order_lines(self, parsed_order, order): + chatter = parsed_order["chatter_msg"] + polo = self.env["purchase.order.line"] + dpo = self.env["decimal.precision"] + bdio = self.env["business.document.import"] + qty_prec = dpo.precision_get("Product UoS") + 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, + "qty": oline.product_uom_qty, + "uom": oline.product_uom, + "line": oline, + "price_unit": price_unit, + } + ) + compare_res = bdio.compare_lines( + existing_lines, + parsed_order["lines"], + chatter, + qty_precision=qty_prec, + seller=False, + ) + + # NOW, we start to write/delete/create the order lines + for oline, cdict in compare_res["to_update"].items(): + write_vals = {} + if cdict.get("qty"): + chatter.append( + _( + "The quantity has been updated on the order line " + "with product '%(product)s' from %(qty0)s to %(qty1)s %(uom)s" + ).format( + product=oline.product_id.display_name, + qty0=cdict["qty"][0], + qty1=cdict["qty"][1], + uom=oline.product_uom.name, + ) + ) + write_vals["product_uom_qty"] = cdict["qty"][1] + write_vals["product_qty"] = cdict["qty"][1] + write_vals.update(self._prepare_update_order_line_vals(cdict)) + if write_vals: + oline.write(write_vals) + if compare_res["to_remove"]: + to_remove_label = [ + "%s %s x %s" + % (line.product_uom_qty, line.product_uom.name, line.product_id.name) + for line in compare_res["to_remove"] + ] + chatter.append( + _("{orders} order line(s) deleted: {label}").format( + orders=len(compare_res["to_remove"]), + label=", ".join(to_remove_label), + ) + ) + compare_res["to_remove"].unlink() + if compare_res["to_add"]: + 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( + "%s %s x %s" + % ( + new_line.product_uom_qty, + new_line.product_uom.name, + new_line.name, + ) + ) + chatter.append( + _("%(orders)s new order line(s) created: %(label)s").format( + orders=len(compare_res["to_add"]), label=", ".join(to_create_label) + ) + ) + return True + + @api.model + def _prepare_update_order_vals(self, parsed_order): + bdio = self.env["business.document.import"] + partner = bdio._match_partner( + parsed_order["partner"], + 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( + _( + "The currency of the imported order {old} is different from " + "the currency of the existing order {new}" + ).format( + old=currency.name, + new=order.currency_id.name, + ) + ) + vals = self._prepare_update_order_vals(parsed_order) + if vals: + order.write(vals) + self.update_order_lines(parsed_order, order) + bdio.post_create_or_update(parsed_order, order) + logger.info( + "Quotation ID %d updated via import of file %s", + order.id, + self.order_filename, + ) + order.message_post( + body=_( + "This quotation has been updated automatically via the import of " + "file %s" + ) + % self.order_filename + ) + if parsed_order["msg_type"] == "DESADV": + order.button_confirm() + 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, + } + ) + return action 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 0000000000..b9be118933 --- /dev/null +++ b/edi_purchase_edifact_oca/wizard/purchase_order_import_view.xml @@ -0,0 +1,49 @@ + + + + + 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/setup/edi_purchase_edifact_oca/odoo/addons/edi_purchase_edifact_oca b/setup/edi_purchase_edifact_oca/odoo/addons/edi_purchase_edifact_oca new file mode 120000 index 0000000000..ca07ff7c7b --- /dev/null +++ b/setup/edi_purchase_edifact_oca/odoo/addons/edi_purchase_edifact_oca @@ -0,0 +1 @@ +../../../../edi_purchase_edifact_oca \ No newline at end of file diff --git a/setup/edi_purchase_edifact_oca/setup.py b/setup/edi_purchase_edifact_oca/setup.py new file mode 100644 index 0000000000..28c57bb640 --- /dev/null +++ b/setup/edi_purchase_edifact_oca/setup.py @@ -0,0 +1,6 @@ +import setuptools + +setuptools.setup( + setup_requires=['setuptools-odoo'], + odoo_addon=True, +)