diff --git a/edi_sale_ubl_oca/README.rst b/edi_sale_ubl_oca/README.rst new file mode 100644 index 0000000000..f8cab0d150 --- /dev/null +++ b/edi_sale_ubl_oca/README.rst @@ -0,0 +1 @@ +bot, please, take care of this! \ No newline at end of file diff --git a/edi_sale_ubl_oca/__init__.py b/edi_sale_ubl_oca/__init__.py new file mode 100644 index 0000000000..f24d3e2426 --- /dev/null +++ b/edi_sale_ubl_oca/__init__.py @@ -0,0 +1,2 @@ +from . import components +from . import models diff --git a/edi_sale_ubl_oca/__manifest__.py b/edi_sale_ubl_oca/__manifest__.py new file mode 100644 index 0000000000..f1c23c0ac9 --- /dev/null +++ b/edi_sale_ubl_oca/__manifest__.py @@ -0,0 +1,37 @@ +# Copyright 2022 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +{ + "name": "EDI Sales", + "summary": """ + Configuration and special behaviors for EDI on sales. + """, + "version": "14.0.1.0.0", + "development_status": "Alpha", + "license": "AGPL-3", + "author": "Camptocamp,Odoo Community Association (OCA)", + "website": "https://github.com/OCA/edi", + "depends": [ + "sale_order_import_ubl", + "edi_sale_oca", + "edi_ubl_oca", + "edi_xml_oca", + "edi_exchange_template_oca", + "edi_exchange_template_party_data", + "edi_state_oca", + # This could be made optional + # but the delivery part would need another source of data + "sale_stock", + ], + "data": [ + "data/edi_state.xml", + "templates/qweb_tmpl_party.xml", + "templates/qweb_tmpl_order_response.xml", + "views/sale_order.xml", + ], + "demo": [ + "demo/edi_exchange_type.xml", + "demo/edi_configuration.xml", + "demo/exc_templ_order_response.xml", + ], +} diff --git a/edi_sale_ubl_oca/components/__init__.py b/edi_sale_ubl_oca/components/__init__.py new file mode 100644 index 0000000000..a441d8bd1f --- /dev/null +++ b/edi_sale_ubl_oca/components/__init__.py @@ -0,0 +1 @@ +from . import listeners diff --git a/edi_sale_ubl_oca/components/listeners.py b/edi_sale_ubl_oca/components/listeners.py new file mode 100644 index 0000000000..42b7297cf1 --- /dev/null +++ b/edi_sale_ubl_oca/components/listeners.py @@ -0,0 +1,106 @@ +# Copyright 2023 Camptocamp SA +# @author: Simone Orsi +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +import logging + +from odoo.addons.component.core import AbstractComponent, Component + +_logger = logging.getLogger(__file__) + + +class EDISOEventListenerMixin(AbstractComponent): + _name = "edi.sale.order.event.listener.mixin" + _inherit = "base.event.listener" + _apply_on = ["sale.order", "sale.order.line"] + + def on_record_create(self, record, fields=None): + self._on_record_operation(record, fields=fields, operation="create") + + def on_record_write(self, record, fields=None): + self._on_record_operation(record, fields=fields, operation="write") + + def _on_record_operation(self, record, fields=None, operation=None): + if self._skip_state_update(record, fields=fields, operation=operation): + return + order, lines = self._get_records(record) + order = order.with_context(edi_sale_skip_state_update=True) + lines = lines.with_context(edi_sale_skip_state_update=True) + self._handle_order_state(order, lines=lines) + + # TODO: what to do? + # def on_record_unlink(self, record): + + def _get_records(self, record): + raise NotImplementedError() + + def _handle_order_state(self, order, lines=None): + state = order._edi_update_state(lines=lines) + if not state: + self._handle_order_state_no_state(order) + + def _skip_state_update(self, record, fields=None, operation=None): + if record.env.context.get( + "edi_sale_skip_state_update" + ) or not self._is_ubl_exchange(record): + return True + return False + + def _is_ubl_exchange(self, record): + return record.origin_exchange_type_id.backend_type_id == self.env.ref( + "edi_ubl_oca.edi_backend_type_ubl" + ) + + def _handle_order_state_no_state(self, order): + msg = "Cannot determine EDI state for order %(order_name)s" + msg_args = dict(order_name=order.name) + exc_type = order.origin_exchange_type_id + if not exc_type.state_workflow_ids: + msg += ". No workflow configured on exc type " "%(type_name)s'" + msg_args["type_name"] = exc_type.name + _logger.error(msg, msg_args) + + +class EDISOEventListener(Component): + _name = "edi.sale.order.event.listener" + _inherit = "edi.sale.order.event.listener.mixin" + _apply_on = ["sale.order"] + + def _get_records(self, record): + return record, record.order_line + + def _skip_state_update(self, record, fields=None, operation=None): + res = super()._skip_state_update(record, fields=fields, operation=operation) + if res: + return res + fields = fields or [] + # EDI state will be recomputed only at state change on write or in any case at creation + skip = operation == "write" and ( + "state" not in fields or fields == ["edi_state_id"] + ) + return skip + + +class EDISOLineEventListener(Component): + _name = "edi.sale.order.line.event.listener" + _inherit = "edi.sale.order.event.listener.mixin" + _apply_on = ["sale.order.line"] + + def _get_records(self, record): + return record.order_id, record + + def _skip_state_update(self, record, fields=None, operation=None): + res = super()._skip_state_update(record, fields=fields, operation=operation) + if res: + return res + if self.env.context.get("evt_from_create") == "sale.order": + # If lines are created from an SO creation straight + # bypass check and state compute because it will be done anyway at create. + return True + fields = fields or [] + # EDI state will be recomputed when critical line info has changed + # TODO: tie this list w/ the fields in `s.o.l._edi_compare_orig_values` + trigger_fields = ("product_id", "product_uom_qty") + for fname in trigger_fields: + if fname in fields: + return False diff --git a/edi_sale_ubl_oca/data/edi_state.xml b/edi_sale_ubl_oca/data/edi_state.xml new file mode 100644 index 0000000000..298b774855 --- /dev/null +++ b/edi_sale_ubl_oca/data/edi_state.xml @@ -0,0 +1,70 @@ + + + + + + EDI sale order + + + + + AP + Accepted + + + + + AB + Message acknowledgement + + + + RE + Rejected + + + + CA + Conditionally accepted + + + + + EDI sale order line + + + + + 1 + Order line is added + The information is to be or has been added + + + + 3 + Changed + Order line is accepted with change + + + + 5 + Accepted without amendment + + + + + 7 + Not accepted + + + + 42 + Already delivered + + + diff --git a/edi_sale_ubl_oca/demo/edi_configuration.xml b/edi_sale_ubl_oca/demo/edi_configuration.xml new file mode 100644 index 0000000000..60337a4a33 --- /dev/null +++ b/edi_sale_ubl_oca/demo/edi_configuration.xml @@ -0,0 +1,19 @@ + + + + Demo UBL Sale OrderResponse - order confirmed + Show case how you can send out an order response automatically + + + + + +record._edi_send_via_edi(conf.type_id) + + + diff --git a/edi_sale_ubl_oca/demo/edi_exchange_type.xml b/edi_sale_ubl_oca/demo/edi_exchange_type.xml new file mode 100644 index 0000000000..a25b1be08a --- /dev/null +++ b/edi_sale_ubl_oca/demo/edi_exchange_type.xml @@ -0,0 +1,52 @@ + + + + + + + Demo UBL Sale Order Response + demo_UBL_SaleOrderResponse_out + output + {record_name}-{type.code}-{dt} + xml + + +components: + generate: + usage: ubl.generate.OrderResponse + validate: + usage: edi.xml + work_ctx: + schema_path: base_ubl:data/xsd-2.2/maindoc/UBL-OrderResponse-2.2.xsd + + + + + + + + Demo UBL Sale Order + demo_UBL_SaleOrder_in + input + xml + + +components: + process: + usage: input.process.sale.order + + + + diff --git a/edi_sale_ubl_oca/demo/exc_templ_order_response.xml b/edi_sale_ubl_oca/demo/exc_templ_order_response.xml new file mode 100644 index 0000000000..91eac4c9d7 --- /dev/null +++ b/edi_sale_ubl_oca/demo/exc_templ_order_response.xml @@ -0,0 +1,34 @@ + + + + UBL OrderResponse output + ubl.generate.OrderResponse + xml + + + + +seller = record.company_id.partner_id +buyer = record.partner_id + +seller_party = get_party_data(exchange_record, seller) +buyer_party = get_party_data(exchange_record, buyer) +delivery = record.picking_ids.filtered(lambda x: x.picking_type_id.code == "outgoing") + +_result = { + "seller_party": seller_party, + "buyer_party": buyer_party, + "delivery": delivery, +} + +provider = get_info_provider(exchange_record, work_ctx=_result) +if provider: + _result["info"] = provider.generate_info() + +result = _result + + + diff --git a/edi_sale_ubl_oca/models/__init__.py b/edi_sale_ubl_oca/models/__init__.py new file mode 100644 index 0000000000..6aacb75313 --- /dev/null +++ b/edi_sale_ubl_oca/models/__init__.py @@ -0,0 +1 @@ +from . import sale_order diff --git a/edi_sale_ubl_oca/models/sale_order.py b/edi_sale_ubl_oca/models/sale_order.py new file mode 100644 index 0000000000..73f2839798 --- /dev/null +++ b/edi_sale_ubl_oca/models/sale_order.py @@ -0,0 +1,118 @@ +# Copyright 2022 Camptocamp SA +# @author: Simone Orsi +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +import logging + +from odoo import api, models + +_logger = logging.getLogger(__file__) + + +class SaleOrder(models.Model): + _name = "sale.order" + _inherit = [ + "sale.order", + "edi.state.consumer.mixin", + ] + + # See data/edi_state.xml + # order + EDI_STATE_ORDER_ACCEPTED = "AP" + EDI_STATE_ORDER_CONDITIONALLY_ACCEPTED = "CA" + EDI_STATE_ORDER_MSG_ACK = "AB" + EDI_STATE_ORDER_REJECTED = "RE" + EDI_STATE_ORDER_LINE_ADDED = "1" + EDI_STATE_ORDER_LINE_CHANGED = "3" + EDI_STATE_ORDER_LINE_ACCEPTED = "5" + EDI_STATE_ORDER_LINE_NOT_ACCEPTED = "7" + EDI_STATE_ORDER_LINE_ALREADY_DELIVERED = "42" + # Line states that make the order not fully accepted + EDI_STATE_ORDER_LINE_ALTERED_STATES = ( + EDI_STATE_ORDER_LINE_ADDED, + EDI_STATE_ORDER_LINE_CHANGED, + EDI_STATE_ORDER_LINE_NOT_ACCEPTED, + ) + + def _edi_update_state(self, lines=None): + metadata = self._edi_get_metadata() + orig_vals = metadata.get("orig_values", {}) + line_vals = orig_vals.get("lines", {}) + if not orig_vals.get("lines"): + _logger.debug( + "_edi_update_state: no line value found for order %s", self.id + ) + # TODO: test + lines = lines or self.order_line + lines._edi_determine_lines_state(line_vals) + state_code = self._edi_update_state_code(orig_vals) + state = self.edi_find_state(code=state_code) + self._edi_set_state(state) + return state + + def _edi_update_state_code(self, orig_vals): + state_code = self._edi_state_code_by_order_state() + if state_code: + return state_code + satisfied = self._edi_compare_orig_values(orig_vals) + state_code = self.EDI_STATE_ORDER_ACCEPTED + if not satisfied: + state_code = self.EDI_STATE_ORDER_CONDITIONALLY_ACCEPTED + return state_code + + def _edi_state_code_by_order_state(self): + return { + "cancel": self.EDI_STATE_ORDER_REJECTED, + }.get(self.state) + + def _edi_compare_orig_values(self, orig_vals): + # ATM check only if lines have changes + for rec in self.order_line: + if rec.edi_state_id.code in self.EDI_STATE_ORDER_LINE_ALTERED_STATES: + return False + return True + + @api.model_create_multi + def create(self, vals_list): + # Inject a key to check if we are in a SO create session + # to not mess up w/ lines when not needed. + # The key is removed right aftewards. + return ( + super(SaleOrder, self.with_context(evt_from_create=self._name)) + .create(vals_list) + .with_context(evt_from_create=None) + ) + + +class SaleOrderLine(models.Model): + _name = "sale.order.line" + _inherit = [ + "sale.order.line", + "edi.id.mixin", + "edi.state.consumer.mixin", + ] + + def _edi_determine_lines_state(self, orig_vals): + # Make sure lines are up to date + self.flush() + # Defaults + for line in self: + if not line.edi_exchange_ready: + continue + state_code = line._edi_get_line_state_code(orig_vals) + state = self.edi_find_state(code=state_code) + line._edi_set_state(state) + + def _edi_get_line_state_code(self, vals_by_edi_id): + vals = vals_by_edi_id.get(self.edi_id) + if not vals: + # Brand new line + return self.order_id.EDI_STATE_ORDER_LINE_ADDED + qty = self.product_uom_qty + if not qty: + return self.order_id.EDI_STATE_ORDER_LINE_NOT_ACCEPTED + if qty > vals["product_uom_qty"] or qty < vals["product_uom_qty"]: + return self.order_id.EDI_STATE_ORDER_LINE_CHANGED + if self.product_id.id != vals["product_id"]: + return self.order_id.EDI_STATE_ORDER_LINE_CHANGED + return self.order_id.EDI_STATE_ORDER_LINE_ACCEPTED diff --git a/edi_sale_ubl_oca/readme/CONTRIBUTORS.rst b/edi_sale_ubl_oca/readme/CONTRIBUTORS.rst new file mode 100644 index 0000000000..f1c71bce18 --- /dev/null +++ b/edi_sale_ubl_oca/readme/CONTRIBUTORS.rst @@ -0,0 +1 @@ +* Simone Orsi diff --git a/edi_sale_ubl_oca/readme/DESCRIPTION.rst b/edi_sale_ubl_oca/readme/DESCRIPTION.rst new file mode 100644 index 0000000000..1333ed77b7 --- /dev/null +++ b/edi_sale_ubl_oca/readme/DESCRIPTION.rst @@ -0,0 +1 @@ +TODO diff --git a/edi_sale_ubl_oca/templates/qweb_tmpl_order_response.xml b/edi_sale_ubl_oca/templates/qweb_tmpl_order_response.xml new file mode 100644 index 0000000000..4804acd1d5 --- /dev/null +++ b/edi_sale_ubl_oca/templates/qweb_tmpl_order_response.xml @@ -0,0 +1,201 @@ + + + + + diff --git a/edi_sale_ubl_oca/templates/qweb_tmpl_party.xml b/edi_sale_ubl_oca/templates/qweb_tmpl_party.xml new file mode 100644 index 0000000000..cc80d3c503 --- /dev/null +++ b/edi_sale_ubl_oca/templates/qweb_tmpl_party.xml @@ -0,0 +1,52 @@ + + + + + + + + diff --git a/edi_sale_ubl_oca/tests/__init__.py b/edi_sale_ubl_oca/tests/__init__.py new file mode 100644 index 0000000000..7851d6d712 --- /dev/null +++ b/edi_sale_ubl_oca/tests/__init__.py @@ -0,0 +1,4 @@ +from . import test_order_state +from . import test_order_in +from . import test_order_response_out +from . import test_order_in_full_flow diff --git a/edi_sale_ubl_oca/tests/common.py b/edi_sale_ubl_oca/tests/common.py new file mode 100644 index 0000000000..aabef12d82 --- /dev/null +++ b/edi_sale_ubl_oca/tests/common.py @@ -0,0 +1,86 @@ +# Copyright 2022 Camptocamp SA +# @author: Simone Orsi +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + + +import os + +import xmlunittest + +from odoo.tests.common import SavepointCase + +from odoo.addons.sale_order_import_ubl.tests.common import get_test_data + + +def get_xml_handler(backend, schema_path, model=None): + model = model or backend._name + return backend._find_component( + model, + ["edi.xml"], + work_ctx={"schema_path": schema_path}, + safe=False, + ) + + +def flatten(txt): + return "".join([x.strip() for x in txt.splitlines()]) + + +def dev_write_example_file(filename, content, test_file=None): + test_file = test_file or __file__ + from pathlib import Path + + path = Path(test_file).parent / ("examples/test." + filename) + with open(path, "w") as out: + out.write(content) + + +def read_test_file(filename): + path = os.path.join(os.path.dirname(__file__), "examples", filename) + with open(path, "r") as thefile: + return thefile.read() + + +# TODO: reuse common class from edi_xml_oca +class XMLBaseTestCase(SavepointCase, xmlunittest.XmlTestMixin): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.env = cls.env(context=dict(cls.env.context, tracking_disable=True)) + cls.backend = cls._get_backend() + + @classmethod + def _get_backend(cls): + raise NotImplementedError() + + +class OrderInboundTestMixin: + @classmethod + def _setup_inbound_order(cls, backend): + cls.exc_type_in = cls.env.ref("edi_sale_ubl_oca.demo_edi_exc_type_order_in") + cls.exc_type_out = cls.env.ref( + "edi_sale_ubl_oca.demo_edi_exc_type_order_response_out" + ) + cls.exc_type_in.backend_id = backend + cls.exc_type_out.backend_id = backend + cls.exc_record_in = backend.create_record( + cls.exc_type_in.code, {"edi_exchange_state": "input_received"} + ) + cls.ubl_data = get_test_data(cls.env) + # Ensure all products have a barcode + for data in cls.ubl_data.values(): + for prod in data.products: + prod.barcode = prod.id * 14 + fname = "UBL-Order-2.1-Example.xml" + cls.order_data = cls.ubl_data[fname] + fcontent = cls.order_data._get_content() + cls.exc_record_in._set_file_content(fcontent) + cls.err_msg_already_imported = "Sales order has already been imported before" + + def _find_order(self): + return self.env["sale.order"].search( + [ + ("client_order_ref", "=", self.order_data.client_order_ref), + ("commercial_partner_id", "=", self.order_data.partner.parent_id.id), + ] + ) diff --git a/edi_sale_ubl_oca/tests/test_order_in.py b/edi_sale_ubl_oca/tests/test_order_in.py new file mode 100644 index 0000000000..9c62a81a0a --- /dev/null +++ b/edi_sale_ubl_oca/tests/test_order_in.py @@ -0,0 +1,100 @@ +# Copyright 2022 Camptocamp SA +# @author: Simone Orsi +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo import exceptions +from odoo.tests.common import SavepointCase + +from odoo.addons.edi_oca.tests.common import EDIBackendTestMixin + +from .common import OrderInboundTestMixin + + +class TestOrderInbound(SavepointCase, EDIBackendTestMixin, OrderInboundTestMixin): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.backend = cls._get_backend() + cls._setup_inbound_order(cls.backend) + + @classmethod + def _get_backend(cls): + return cls.env.ref("edi_ubl_oca.edi_backend_ubl_demo") + + def test_existing_order_break_on_error(self): + self.assertEqual(self.exc_record_in.edi_exchange_state, "input_received") + self.env["sale.order"].create( + { + "partner_id": self.order_data.partner.id, + "client_order_ref": self.order_data.client_order_ref, + } + ) + with self.assertRaisesRegex( + exceptions.UserError, self.err_msg_already_imported + ): + self.exc_record_in.with_context( + _edi_process_break_on_error=True + ).action_exchange_process() + self.assertEqual(self.exc_record_in.edi_exchange_state, "input_received") + + def test_existing_order(self): + self.assertEqual(self.exc_record_in.edi_exchange_state, "input_received") + self.env["sale.order"].create( + { + "partner_id": self.order_data.partner.id, + "client_order_ref": self.order_data.client_order_ref, + } + ) + # Test w/ error handling + self.exc_record_in.action_exchange_process() + self.assertEqual(self.exc_record_in.edi_exchange_state, "input_processed_error") + err_msg = "Sales order has already been imported before" + self.assertIn(err_msg, self.exc_record_in.exchange_error) + + def test_new_order(self): + self.assertEqual(self.exc_record_in.edi_exchange_state, "input_received") + order = self._find_order() + self.assertFalse(order) + # Test w/ error handling + # .with_context(_edi_process_break_on_error=True) + self.exc_record_in.action_exchange_process() + self.assertEqual(self.exc_record_in.edi_exchange_state, "input_processed") + order = self._find_order() + self.assertEqual(self.exc_record_in.record, order) + order_msg = order.message_ids[0] + self.assertIn("Exchange processed successfully", order_msg.body) + self.assertIn(self.exc_record_in.identifier, order_msg.body) + self.assertIn( + f"/web#id={self.exc_record_in.id}&model=edi.exchange.record&view_type=form", + order_msg.body, + ) + # TODO: test order data. To do so, first add such tests to sale_order_import + self.assertEqual(order.order_line.mapped("edi_id"), ["1", "2"]) + self.assertTrue(order.edi_state_id.code, order.EDI_STATE_ORDER_ACCEPTED) + self.assertTrue( + order.mapped("order_line.edi_state_id").code, + order.EDI_STATE_ORDER_LINE_ACCEPTED, + ) + + def test_cancel(self): + self.assertEqual(self.exc_record_in.edi_exchange_state, "input_received") + order = self.env["sale.order"].create( + { + "partner_id": self.order_data.partner.id, + "client_order_ref": self.order_data.client_order_ref, + "origin_exchange_record_id": self.exc_record_in.id, + } + ) + self.exc_record_in._set_related_record(order) + order.action_confirm() + self.assertTrue(order.edi_state_id.code, order.EDI_STATE_ORDER_ACCEPTED) + order.action_cancel() + self.assertTrue(order.edi_state_id.code, order.EDI_STATE_ORDER_REJECTED) + + def _find_order(self): + return self.env["sale.order"].search( + [ + ("client_order_ref", "=", self.order_data.client_order_ref), + ("commercial_partner_id", "=", self.order_data.partner.parent_id.id), + ] + ) diff --git a/edi_sale_ubl_oca/tests/test_order_in_full_flow.py b/edi_sale_ubl_oca/tests/test_order_in_full_flow.py new file mode 100644 index 0000000000..1adbb47d40 --- /dev/null +++ b/edi_sale_ubl_oca/tests/test_order_in_full_flow.py @@ -0,0 +1,69 @@ +# Copyright 2022 Camptocamp SA +# @author: Simone Orsi +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from unittest import mock + +from odoo.tests.common import SavepointCase + +from odoo.addons.edi_oca.tests.common import EDIBackendTestMixin + +from .common import OrderInboundTestMixin, get_xml_handler + +# TODO: split in different tests w/ SingleTransaction + + +class TestOrderInboundFull(SavepointCase, EDIBackendTestMixin, OrderInboundTestMixin): + + _schema_path = "base_ubl:data/xsd-2.2/maindoc/UBL-OrderResponse-2.2.xsd" + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls._setup_env() + cls.backend = cls._get_backend() + cls._setup_inbound_order(cls.backend) + cls.edi_conf = cls.env.ref( + "edi_sale_ubl_oca.demo_ubl_edi_configuration_confirmed" + ) + + @classmethod + def _get_backend(cls): + return cls.env.ref("edi_ubl_oca.edi_backend_ubl_demo") + + # No need to test sending data + @mock.patch("odoo.addons.edi_oca.models.edi_backend.EDIBackend._exchange_send") + def test_new_order(self, mock_send): + self.backend._check_input_exchange_sync() + self.assertEqual(self.exc_record_in.edi_exchange_state, "input_processed") + order = self._find_order() + order.partner_id.edi_sale_conf_ids = self.edi_conf + self.assertEqual(self.exc_record_in.record, order) + order_msg = order.message_ids[0] + self.assertIn("Exchange processed successfully", order_msg.body) + self.assertIn(self.exc_record_in.identifier, order_msg.body) + order.invalidate_cache() + # Test relations + self.assertEqual(len(order.exchange_record_ids), 1) + exc_record = order.exchange_record_ids.filtered( + lambda x: x.type_id == self.exc_type_in + ) + self.assertEqual(exc_record, self.exc_record_in) + # Confirm the order + order.action_confirm() + # Should give us a valid order response ack record + ack_exc_record = order.exchange_record_ids.filtered( + lambda x: x.type_id == self.exc_type_out + ) + file_content = ack_exc_record._get_file_content() + self.assertTrue(file_content) + # TMP / + # path = "/tmp/order.response.test.xml" + # with open(path, "w") as out: + # out.write(file_content) + # / TMP + handler = get_xml_handler(self.backend, self._schema_path) + # Test is a valid file + err = handler.validate(file_content) + self.assertEqual(err, None, err) + # TODO: test data diff --git a/edi_sale_ubl_oca/tests/test_order_response_out.py b/edi_sale_ubl_oca/tests/test_order_response_out.py new file mode 100644 index 0000000000..ba7f164493 --- /dev/null +++ b/edi_sale_ubl_oca/tests/test_order_response_out.py @@ -0,0 +1,79 @@ +# Copyright 2022 Camptocamp SA +# @author: Simone Orsi +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from freezegun import freeze_time + +from odoo.addons.edi_sale_oca.tests.common import OrderMixin + +from .common import XMLBaseTestCase, get_xml_handler + + +class TestOrderResponseOutbound(XMLBaseTestCase, OrderMixin): + + maxDiff = None + + _schema_path = "base_ubl:data/xsd-2.2/maindoc/UBL-OrderResponse-2.2.xsd" + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls._setup_order() + cls.exc_type_in = cls.env.ref("edi_sale_ubl_oca.demo_edi_exc_type_order_in") + cls.exc_type_out = cls.env.ref( + "edi_sale_ubl_oca.demo_edi_exc_type_order_response_out" + ) + cls.exc_tmpl = cls.env.ref( + "edi_sale_ubl_oca.demo_edi_exc_template_order_response_out" + ) + vals = { + "model": cls.sale._name, + "res_id": cls.sale.id, + "type_id": cls.exc_type_out.id, + } + cls.record = cls.backend.create_record(cls.exc_type_out.code, vals) + cls.sale.origin_exchange_record_id = cls.record + cls.sale.order_line.origin_exchange_record_id = cls.record + cls.sale._edi_update_state() + + @classmethod + def _get_backend(cls): + return cls.env.ref("edi_ubl_oca.edi_backend_ubl_demo") + + def test_get_template(self): + template = self.backend._get_output_template(self.record) + self.assertEqual(template, self.exc_tmpl) + self.assertEqual( + template.template_id.key, + "edi_sale_ubl_oca.qwb_tmpl_ubl_order_response_out", + ) + + def test_render_values(self): + # TODO: test w/ some identifiers + def make_party(record): + return dict( + name=record.name, + identifiers=[], + endpoint={}, + ) + + values = self.exc_tmpl._get_render_values(self.record) + expected = [ + ("seller_party", make_party(self.sale.company_id)), + ("buyer_party", make_party(self.sale.partner_id)), + ] + for k, v in expected: + self.assertEqual(values[k], v, f"{k} is wrong") + + @freeze_time("2022-07-28 10:30:00") + def test_xml(self): + self.record.action_exchange_generate() + file_content = self.record._get_file_content() + with open("/tmp/ordrsp.test.xml", "w") as ff: + ff.write(file_content) + handler = get_xml_handler(self.backend, self._schema_path) + err = handler.validate(file_content) + self.assertEqual(err, None, err) + data = handler.parse_xml(file_content) + # TODO: test all main data + self.assertEqual(data["cbc:OrderResponseCode"], "CA") diff --git a/edi_sale_ubl_oca/tests/test_order_state.py b/edi_sale_ubl_oca/tests/test_order_state.py new file mode 100644 index 0000000000..51ed23d1bd --- /dev/null +++ b/edi_sale_ubl_oca/tests/test_order_state.py @@ -0,0 +1,165 @@ +# Copyright 2022 Camptocamp SA +# @author: Simone Orsi +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo.tests.common import SavepointCase + +from odoo.addons.edi_oca.tests.common import EDIBackendTestMixin +from odoo.addons.edi_sale_oca.tests.common import OrderMixin + + +class TestOrderInbound(SavepointCase, EDIBackendTestMixin, OrderMixin): + @classmethod + def setUpClass(cls): + super().setUpClass() + # force metadata storage w/ proper key + cls.env = cls.env(context=dict(cls.env.context, edi_framework_action=True)) + cls.backend = cls._get_backend() + cls.exc_type_in = cls.env.ref("edi_sale_ubl_oca.demo_edi_exc_type_order_in") + cls.exc_record_in = cls.backend.create_record( + cls.exc_type_in.code, {"edi_exchange_state": "input_received"} + ) + cls._setup_order( + origin_exchange_record_id=cls.exc_record_in.id, + line_defaults=dict(origin_exchange_record_id=cls.exc_record_in.id), + ) + + @classmethod + def _get_backend(cls): + return cls.env.ref("edi_ubl_oca.edi_backend_ubl_demo") + + def test_state_accepted(self): + order = self.sale + self.assertEqual(order.edi_state_id.code, order.EDI_STATE_ORDER_ACCEPTED) + self.assertTrue( + order.mapped("order_line.edi_state_id").code, + order.EDI_STATE_ORDER_LINE_ACCEPTED, + ) + + def test_state_partially_accepted(self): + order = self.sale + orig_qties = {} + for line in order.order_line: + orig_qties[line.id] = line.product_uom_qty + line1, line2, line3 = order.order_line + # change line 1 + line1.product_uom_qty = orig_qties[line1.id] - 1 + self.assertEqual( + order.edi_state_id.code, order.EDI_STATE_ORDER_CONDITIONALLY_ACCEPTED + ) + self.assertEqual( + [x.edi_state_id.code for x in order.order_line], + [ + order.EDI_STATE_ORDER_LINE_CHANGED, + order.EDI_STATE_ORDER_LINE_ACCEPTED, + order.EDI_STATE_ORDER_LINE_ACCEPTED, + ], + ) + # change line 2 + line2.product_uom_qty = orig_qties[line2.id] - 1 + self.assertEqual( + order.edi_state_id.code, order.EDI_STATE_ORDER_CONDITIONALLY_ACCEPTED + ) + self.assertEqual( + [x.edi_state_id.code for x in order.order_line], + [ + order.EDI_STATE_ORDER_LINE_CHANGED, + order.EDI_STATE_ORDER_LINE_CHANGED, + order.EDI_STATE_ORDER_LINE_ACCEPTED, + ], + ) + # change line 3 + line3.product_uom_qty = orig_qties[line3.id] - 1 + self.assertEqual( + order.edi_state_id.code, order.EDI_STATE_ORDER_CONDITIONALLY_ACCEPTED + ) + self.assertEqual( + [x.edi_state_id.code for x in order.order_line], + [ + order.EDI_STATE_ORDER_LINE_CHANGED, + order.EDI_STATE_ORDER_LINE_CHANGED, + order.EDI_STATE_ORDER_LINE_CHANGED, + ], + ) + # restore line 1 + line1.product_uom_qty = orig_qties[line1.id] + self.assertEqual( + order.edi_state_id.code, order.EDI_STATE_ORDER_CONDITIONALLY_ACCEPTED + ) + self.assertEqual( + [x.edi_state_id.code for x in order.order_line], + [ + order.EDI_STATE_ORDER_LINE_ACCEPTED, + order.EDI_STATE_ORDER_LINE_CHANGED, + order.EDI_STATE_ORDER_LINE_CHANGED, + ], + ) + # get more qty for line 1 + line1.product_uom_qty = orig_qties[line1.id] + 1 + self.assertEqual( + order.edi_state_id.code, order.EDI_STATE_ORDER_CONDITIONALLY_ACCEPTED + ) + self.assertEqual( + [x.edi_state_id.code for x in order.order_line], + [ + order.EDI_STATE_ORDER_LINE_CHANGED, + order.EDI_STATE_ORDER_LINE_CHANGED, + order.EDI_STATE_ORDER_LINE_CHANGED, + ], + ) + # discard line 1 + line1.product_uom_qty = 0 + self.assertEqual( + order.edi_state_id.code, order.EDI_STATE_ORDER_CONDITIONALLY_ACCEPTED + ) + self.assertEqual( + [x.edi_state_id.code for x in order.order_line], + [ + order.EDI_STATE_ORDER_LINE_NOT_ACCEPTED, + order.EDI_STATE_ORDER_LINE_CHANGED, + order.EDI_STATE_ORDER_LINE_CHANGED, + ], + ) + + def test_state_rejected(self): + order = self.sale + order.action_cancel() + self.assertEqual(order.edi_state_id.code, order.EDI_STATE_ORDER_REJECTED) + + def test_state_accepted_add_line(self): + order = self.sale + order.write({"state": "sale"}) + self.assertEqual(order.edi_state_id.code, order.EDI_STATE_ORDER_ACCEPTED) + self.assertTrue( + order.mapped("order_line.edi_state_id").code, + order.EDI_STATE_ORDER_LINE_ACCEPTED, + ) + lines = order.order_line + order.write( + { + "order_line": [ + ( + 0, + 0, + { + "product_id": self.product_a.id, + "product_uom_qty": 300, + "edi_id": 4000, + "origin_exchange_record_id": self.exc_record_in.id, + }, + ) + ] + } + ) + new_line = order.order_line - lines + self.assertEqual(new_line.edi_state_id.code, order.EDI_STATE_ORDER_LINE_ADDED) + new_line = order.order_line.create( + { + "order_id": order.id, + "product_id": self.product_a.id, + "product_uom_qty": 300, + "edi_id": 4000, + "origin_exchange_record_id": self.exc_record_in.id, + } + ) + self.assertEqual(new_line.edi_state_id.code, order.EDI_STATE_ORDER_LINE_ADDED) diff --git a/edi_sale_ubl_oca/views/sale_order.xml b/edi_sale_ubl_oca/views/sale_order.xml new file mode 100644 index 0000000000..fee6d36636 --- /dev/null +++ b/edi_sale_ubl_oca/views/sale_order.xml @@ -0,0 +1,77 @@ + + + + + sale.order.form (in edi_sale_UBL) + sale.order + + + + + + + + + + + + + sale.order + + + + + + + + + + sale.order.tree (in edi_sale) + sale.order + + + + + + + + + + + sale.order.search (in edi_sale) + sale.order + + + + + + + + + + + + + + + + diff --git a/setup/edi_sale_ubl_oca/odoo/addons/edi_sale_ubl_oca b/setup/edi_sale_ubl_oca/odoo/addons/edi_sale_ubl_oca new file mode 120000 index 0000000000..8bed21b906 --- /dev/null +++ b/setup/edi_sale_ubl_oca/odoo/addons/edi_sale_ubl_oca @@ -0,0 +1 @@ +../../../../edi_sale_ubl_oca \ No newline at end of file diff --git a/setup/edi_sale_ubl_oca/setup.py b/setup/edi_sale_ubl_oca/setup.py new file mode 100644 index 0000000000..28c57bb640 --- /dev/null +++ b/setup/edi_sale_ubl_oca/setup.py @@ -0,0 +1,6 @@ +import setuptools + +setuptools.setup( + setup_requires=['setuptools-odoo'], + odoo_addon=True, +) diff --git a/test-requirements.txt b/test-requirements.txt index 98a1ae8d5b..25edc1c692 100644 --- a/test-requirements.txt +++ b/test-requirements.txt @@ -6,3 +6,8 @@ PyYAML xmlunittest # tests use python 3.6 and pypdf dropped support for python 3.6-3.7 in 5.0 pypdf==4.3.1 + +# [14.0][IMP] edi_oca: Add new model edi.configuration #1035 +odoo14-addon-edi_oca @ git+https://github.com/OCA/edi@refs/pull/1035/head#subdirectory=setup/edi_oca +# [14.0] edi_sale_oca: use edi.configuration #1067 +odoo14-addon-edi_sale_oca @ git+https://github.com/OCA/edi@refs/pull/1067/head#subdirectory=setup/edi_sale_oca