diff --git a/addons/account_edi_ubl_cii/models/account_edi_common.py b/addons/account_edi_ubl_cii/models/account_edi_common.py index be6b6987e8a5d1..70468b66f64382 100644 --- a/addons/account_edi_ubl_cii/models/account_edi_common.py +++ b/addons/account_edi_ubl_cii/models/account_edi_common.py @@ -427,9 +427,8 @@ def _import_invoice_ubl_cii(self, invoice, file_data, new=False): # Update the invoice. invoice.move_type = move_type - with invoice._get_edi_creation() as invoice: + with invoice.with_context(disable_onchange_name_predictive=True)._get_edi_creation() as invoice: logs = self._import_fill_invoice(invoice, tree, qty_factor) - if invoice: body = Markup("%s") % \ _("Format used to import the invoice: %s", @@ -444,7 +443,7 @@ def _import_invoice_ubl_cii(self, invoice, file_data, new=False): # For UBL, we should override the computed tax amount if it is less than 0.05 different of the one in the xml. # In order to support use case where the tax total is adapted for rounding purpose. # This has to be done after the first import in order to let Odoo compute the taxes before overriding if needed. - with invoice._get_edi_creation() as invoice: + with invoice.with_context(disable_onchange_name_predictive=True)._get_edi_creation() as invoice: self._correct_invoice_tax_amount(tree, invoice) attachments = self._import_attachments(invoice, tree) @@ -890,7 +889,7 @@ def _retrieve_taxes(self, record, line_values, tax_type, tax_exigibility=False): ] tax = self.env['account.tax'] if hasattr(record, '_get_specific_tax'): - tax = record._get_specific_tax(line_values['name'], 'percent', amount, tax_type) + tax = record._get_specific_tax(line_values['name'], 'percent', amount, tax_type).filtered_domain(domain)[:1] if tax_exigibility: if not tax and tax_exigibility: tax = self.env['account.tax'].search(domain + [('price_include', '=', False), ('tax_exigibility', '=', tax_exigibility)], limit=1) diff --git a/addons/account_edi_ubl_cii/models/account_edi_xml_ubl_20.py b/addons/account_edi_ubl_cii/models/account_edi_xml_ubl_20.py index 6ba93fd6c459b9..e4c39b6deb7946 100644 --- a/addons/account_edi_ubl_cii/models/account_edi_xml_ubl_20.py +++ b/addons/account_edi_ubl_cii/models/account_edi_xml_ubl_20.py @@ -1121,6 +1121,7 @@ def _add_invoice_config_vals(self, vals): 'currency_id': invoice.currency_id, 'company_currency_id': invoice.company_id.currency_id, 'company': invoice.company_id, + 'journal': invoice.journal_id, 'use_company_currency': False, # If true, use the company currency for the amounts instead of the invoice currency 'fixed_taxes_as_allowance_charges': True, # If true, include fixed taxes as AllowanceCharges on lines instead of as taxes diff --git a/addons/account_edi_ubl_cii/models/account_move_send.py b/addons/account_edi_ubl_cii/models/account_move_send.py index 4c6567dad512fe..120bccce09e398 100644 --- a/addons/account_edi_ubl_cii/models/account_move_send.py +++ b/addons/account_edi_ubl_cii/models/account_move_send.py @@ -146,7 +146,7 @@ def _hook_invoice_document_after_pdf_report_render(self, invoice, invoice_data): super()._hook_invoice_document_after_pdf_report_render(invoice, invoice_data) # Add PDF to XML - if 'ubl_cii_xml_options' in invoice_data and invoice_data['ubl_cii_xml_options']['ubl_cii_format'] != 'facturx': + if self._needs_ubl_postprocessing(invoice_data): self._postprocess_invoice_ubl_xml(invoice, invoice_data) # Always silently generate a Factur-X and embed it inside the PDF for inter-portability @@ -204,6 +204,10 @@ def _hook_invoice_document_after_pdf_report_render(self, invoice, invoice_data): reader_buffer.close() writer_buffer.close() + @api.model + def _needs_ubl_postprocessing(self, invoice_data): + return 'ubl_cii_xml_options' in invoice_data and invoice_data['ubl_cii_xml_options']['ubl_cii_format'] != 'facturx' + @api.model def _postprocess_invoice_ubl_xml(self, invoice, invoice_data): # Adding the PDF to the XML diff --git a/addons/account_peppol/models/account_move_send.py b/addons/account_peppol/models/account_move_send.py index f4dc85cb1cf22d..b96dfc6d7b476d 100644 --- a/addons/account_peppol/models/account_move_send.py +++ b/addons/account_peppol/models/account_move_send.py @@ -200,6 +200,11 @@ def _call_web_service_after_invoice_pdf_render(self, invoices_data): ) continue + if invoice.invoice_pdf_report_id and self._needs_ubl_postprocessing(invoice_data): + self._postprocess_invoice_ubl_xml(invoice, invoice_data) + xml_file = invoice_data['ubl_cii_xml_attachment_values']['raw'] + filename = invoice_data['ubl_cii_xml_attachment_values']['name'] + if len(xml_file) > 64000000: invoice_data['error'] = _("Invoice %s is too big to send via peppol (64MB limit)", invoice.name) continue diff --git a/addons/account_peppol/models/res_partner.py b/addons/account_peppol/models/res_partner.py index 90eef3ef349ca3..a5a401f1ade24a 100644 --- a/addons/account_peppol/models/res_partner.py +++ b/addons/account_peppol/models/res_partner.py @@ -39,6 +39,9 @@ class ResPartner(models.Model): @api.onchange('invoice_edi_format', 'peppol_endpoint', 'peppol_eas') def _onchange_verify_peppol_status(self): + if not self.commercial_partner_id: + # avoid issue when commercial_partner_id is on the view + self._compute_commercial_partner() self.button_account_peppol_check_partner_endpoint() # ------------------------------------------------------------------------- diff --git a/addons/account_peppol/tests/test_peppol_messages.py b/addons/account_peppol/tests/test_peppol_messages.py index 2328e980ee1072..dd781c71be3120 100644 --- a/addons/account_peppol/tests/test_peppol_messages.py +++ b/addons/account_peppol/tests/test_peppol_messages.py @@ -1,6 +1,7 @@ import json from base64 import b64encode from contextlib import contextmanager +from lxml import etree from unittest.mock import patch from urllib import parse @@ -613,3 +614,31 @@ def test_automatic_invoicing_auto_update_partner_peppol_status(self): transaction.sudo()._post_process() self.assertRecordValues(partner, [{'peppol_verification_state': 'valid'}]) + + def test_send_email_then_peppol(self): + """ + Test that the PDF is correctly embedded in the Peppol XML even if the PDF + was already generated by a previous 'Send by Email' action. + """ + move = self.create_move(self.valid_partner) + move.action_post() + wizard_email = self.create_send_and_print(move, sending_methods=['email']) + wizard_email.action_send_and_print() + self.assertTrue(move.invoice_pdf_report_id, "PDF should be generated after sending by email") + + wizard_peppol = self.create_send_and_print(move, sending_methods=['peppol']) + self.assertEqual(wizard_peppol.invoice_edi_format, 'ubl_bis3') + wizard_peppol.action_send_and_print() + self.assertTrue(move.ubl_cii_xml_id, "UBL XML should be generated") + + root = etree.fromstring(move.ubl_cii_xml_id.raw) + embedded_pdfs = root.xpath( + '//cbc:EmbeddedDocumentBinaryObject[@mimeCode="application/pdf"]', + namespaces={ + 'cbc': "urn:oasis:names:specification:ubl:schema:xsd:CommonBasicComponents-2", + } + ) + self.assertTrue( + embedded_pdfs and embedded_pdfs[0].text, + "Peppol XML must embed the already-generated PDF" + ) diff --git a/addons/account_peppol/tests/test_peppol_participant.py b/addons/account_peppol/tests/test_peppol_participant.py index 51f9f8576a4634..312ec9f12471f6 100644 --- a/addons/account_peppol/tests/test_peppol_participant.py +++ b/addons/account_peppol/tests/test_peppol_participant.py @@ -2,6 +2,7 @@ from odoo import Command from odoo.exceptions import ValidationError +from odoo.tests.form import Form from odoo.tests.common import tagged, TransactionCase, freeze_time from odoo.tools import mute_logger from odoo.tools.misc import file_open @@ -341,3 +342,45 @@ def test_deregister_with_client_gone_error(self): # Should successfully deregister despite Exception self.assertEqual(self.env.company.account_peppol_proxy_state, 'not_registered') + + def test_peppol_commercial_entity(self): + receivable = self.env["account.account"].create({ + "account_type": "income", + "name": "test_receiv", + "code": "TESTR" + }) + payable = self.env["account.account"].create({ + "account_type": "expense", + "name": "test_pay", + "code": "TESTP" + }) + company_peppol = self.env["res.company"].create({ + "name": "test_be", + "country_id": self.env.ref("base.be").id, + }) + partner_view = self.env.ref("base.view_partner_form") + self.env["ir.ui.view"].create({ + "name": "test_inherit", + "inherit_id": partner_view.id, + "model": "res.partner", + "type": "form", + "arch": """ + + """ + }) + env_partner = (self.env["res.partner"] + .with_company(company_peppol) + .with_context( + default_property_account_receivable_id=receivable.id, + default_property_account_payable_id=payable.id + )) + with Form(env_partner, view=partner_view) as partner_form: + self.assertEqual(partner_form.peppol_verification_state, "not_verified") + partner_form.name = "test" + partner_form.vat = "BE0477472701" + partner_form.peppol_eas = "odemo" + self.assertFalse(partner_form.commercial_partner_id) + p_rec = partner_form.save() + self.assertEqual(partner_form.peppol_verification_state, "not_valid") + self.assertEqual(p_rec.commercial_partner_id, p_rec) + self.assertEqual(p_rec.commercial_partner_id.name, "test") diff --git a/addons/barcodes_gs1_nomenclature/views/barcodes_view.xml b/addons/barcodes_gs1_nomenclature/views/barcodes_view.xml index 044eba6ac016ef..852a2d327575ac 100644 --- a/addons/barcodes_gs1_nomenclature/views/barcodes_view.xml +++ b/addons/barcodes_gs1_nomenclature/views/barcodes_view.xml @@ -23,6 +23,7 @@ parent.is_gs1_nomenclature + @@ -47,12 +48,12 @@ - parent.is_gs1_nomenclature or type == 'alias' + is_gs1_nomenclature or type == 'alias' - - - + + + diff --git a/addons/delivery/static/src/js/location_selector/location_schedule/location_schedule.js b/addons/delivery/static/src/js/location_selector/location_schedule/location_schedule.js index aa4c4454701653..f5922004862e2c 100644 --- a/addons/delivery/static/src/js/location_selector/location_schedule/location_schedule.js +++ b/addons/delivery/static/src/js/location_selector/location_schedule/location_schedule.js @@ -24,7 +24,8 @@ export class LocationSchedule extends Component { * @return {Object} the localized name of the day (long version). */ getWeekDay(weekday) { - return luxon.Info.weekdays()[weekday] + const dayName = luxon.Info.weekdays()[weekday]; + return dayName.charAt(0).toUpperCase() + dayName.slice(1); } get closedLabel() { diff --git a/addons/hw_drivers/tools/helpers.py b/addons/hw_drivers/tools/helpers.py index 6b2472508f063e..ae2c82a7759a43 100644 --- a/addons/hw_drivers/tools/helpers.py +++ b/addons/hw_drivers/tools/helpers.py @@ -1,6 +1,7 @@ # -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. +import certifi import configparser import contextlib import datetime @@ -503,7 +504,7 @@ def download_iot_handlers(auto=True): server = get_odoo_server_url() if server: urllib3.disable_warnings() - pm = urllib3.PoolManager() + pm = urllib3.PoolManager(cert_reqs='CERT_REQUIRED', ca_certs=certifi.where()) server = server + '/iot/get_handlers' try: resp = pm.request('POST', server, fields={'mac': get_mac_address(), 'auto': auto}, timeout=8) diff --git a/addons/l10n_es_edi_facturae/data/facturae_templates.xml b/addons/l10n_es_edi_facturae/data/facturae_templates.xml index 8ca52377cc176b..12ddc251986cbb 100644 --- a/addons/l10n_es_edi_facturae/data/facturae_templates.xml +++ b/addons/l10n_es_edi_facturae/data/facturae_templates.xml @@ -135,13 +135,13 @@ - + - + @@ -150,11 +150,11 @@ - + - + diff --git a/addons/l10n_es_edi_facturae/tests/data/expected_ac_document.xml b/addons/l10n_es_edi_facturae/tests/data/expected_ac_document.xml index ff5dcf631d5a9c..a5f24d7d139619 100644 --- a/addons/l10n_es_edi_facturae/tests/data/expected_ac_document.xml +++ b/addons/l10n_es_edi_facturae/tests/data/expected_ac_document.xml @@ -136,8 +136,8 @@ 1.0 01 100.00000000 - 100.00000000 - 100.00000000 + 100.00 + 100.00 01 diff --git a/addons/l10n_es_edi_facturae/tests/data/expected_in_invoice_document.xml b/addons/l10n_es_edi_facturae/tests/data/expected_in_invoice_document.xml index 7e21326411ef56..65c09bf3ff8049 100644 --- a/addons/l10n_es_edi_facturae/tests/data/expected_in_invoice_document.xml +++ b/addons/l10n_es_edi_facturae/tests/data/expected_in_invoice_document.xml @@ -137,8 +137,8 @@ 1.0 01 100.00000000 - 100.00000000 - 100.00000000 + 100.00 + 100.00 01 @@ -158,8 +158,8 @@ 1.0 01 100.00000000 - 100.00000000 - 100.00000000 + 100.00 + 100.00 01 @@ -179,8 +179,8 @@ 1.0 01 200.00000000 - 200.00000000 - 200.00000000 + 200.00 + 200.00 01 @@ -200,15 +200,15 @@ 1.0 01 1000.00000000 - 1000.00000000 + 1000.00 / 10.00 - 100.00000000 + 100.00 - 900.00000000 + 900.00 01 @@ -228,15 +228,15 @@ 1.0 01 1000.00000000 - 1000.00000000 + 1000.00 / 10.00 - 100.00000000 + 100.00 - 1100.00000000 + 1100.00 01 diff --git a/addons/l10n_es_edi_facturae/tests/data/expected_invoice_payment_means.xml b/addons/l10n_es_edi_facturae/tests/data/expected_invoice_payment_means.xml index 908a89a9df9a7b..edb9205a56d8f7 100644 --- a/addons/l10n_es_edi_facturae/tests/data/expected_invoice_payment_means.xml +++ b/addons/l10n_es_edi_facturae/tests/data/expected_invoice_payment_means.xml @@ -97,8 +97,8 @@ 1.0 01 100.00000000 - 100.00000000 - 100.00000000 + 100.00 + 100.00 01 diff --git a/addons/l10n_es_edi_facturae/tests/data/expected_invoice_period_document.xml b/addons/l10n_es_edi_facturae/tests/data/expected_invoice_period_document.xml index d94bdedbe1f8f2..77e28c0dc3be81 100644 --- a/addons/l10n_es_edi_facturae/tests/data/expected_invoice_period_document.xml +++ b/addons/l10n_es_edi_facturae/tests/data/expected_invoice_period_document.xml @@ -101,8 +101,8 @@ 1.0 01 100.00000000 - 100.00000000 - 100.00000000 + 100.00 + 100.00 01 diff --git a/addons/l10n_es_edi_facturae/tests/data/expected_out_invoice_4_decimals.xml b/addons/l10n_es_edi_facturae/tests/data/expected_out_invoice_4_decimals.xml index 25698324f25f4b..409c88ec822371 100644 --- a/addons/l10n_es_edi_facturae/tests/data/expected_out_invoice_4_decimals.xml +++ b/addons/l10n_es_edi_facturae/tests/data/expected_out_invoice_4_decimals.xml @@ -97,8 +97,8 @@ 22.0 01 2.05909091 - 45.30000000 - 45.30000000 + 45.30 + 45.30 01 diff --git a/addons/l10n_es_edi_facturae/tests/data/expected_out_invoice_negative.xml b/addons/l10n_es_edi_facturae/tests/data/expected_out_invoice_negative.xml index e3c13ed5a56acf..9359fdadbdfd79 100644 --- a/addons/l10n_es_edi_facturae/tests/data/expected_out_invoice_negative.xml +++ b/addons/l10n_es_edi_facturae/tests/data/expected_out_invoice_negative.xml @@ -129,8 +129,8 @@ 1.0 01 1000.00000000 - 1000.00000000 - 1000.00000000 + 1000.00 + 1000.00 04 @@ -162,8 +162,8 @@ 1.0 01 -100.00000000 - -100.00000000 - -100.00000000 + -100.00 + -100.00 04 diff --git a/addons/l10n_es_edi_facturae/tests/data/expected_out_invoice_round_glob.xml b/addons/l10n_es_edi_facturae/tests/data/expected_out_invoice_round_glob.xml index 408f0e41f03f30..dde39a2f94980a 100644 --- a/addons/l10n_es_edi_facturae/tests/data/expected_out_invoice_round_glob.xml +++ b/addons/l10n_es_edi_facturae/tests/data/expected_out_invoice_round_glob.xml @@ -127,8 +127,8 @@ 25.0 01 2.02000000 - 50.50000000 - 50.50000000 + 50.50 + 50.50 01 @@ -148,8 +148,8 @@ 2.0 01 7.29000000 - 14.58000000 - 14.58000000 + 14.58 + 14.58 01 @@ -169,8 +169,8 @@ 5.0 01 7.32000000 - 36.60000000 - 36.60000000 + 36.60 + 36.60 01 @@ -190,8 +190,8 @@ 25.0 01 9.50000000 - 237.50000000 - 237.50000000 + 237.50 + 237.50 01 diff --git a/addons/l10n_es_edi_facturae/tests/data/expected_out_invoice_withhold.xml b/addons/l10n_es_edi_facturae/tests/data/expected_out_invoice_withhold.xml index 3cdb6853991d46..2ff531d4e27d43 100644 --- a/addons/l10n_es_edi_facturae/tests/data/expected_out_invoice_withhold.xml +++ b/addons/l10n_es_edi_facturae/tests/data/expected_out_invoice_withhold.xml @@ -97,8 +97,8 @@ 1.0 01 100.00000000 - 100.00000000 - 100.00000000 + 100.00 + 100.00 04 diff --git a/addons/l10n_es_edi_facturae/tests/data/expected_refund_document.xml b/addons/l10n_es_edi_facturae/tests/data/expected_refund_document.xml index be4cb775d2d91d..434a9eb9fb1355 100644 --- a/addons/l10n_es_edi_facturae/tests/data/expected_refund_document.xml +++ b/addons/l10n_es_edi_facturae/tests/data/expected_refund_document.xml @@ -124,8 +124,8 @@ 1.0 01 -100.00000000 - -100.00000000 - -100.00000000 + -100.00 + -100.00 01 @@ -148,8 +148,8 @@ 1.0 01 -100.00000000 - -100.00000000 - -100.00000000 + -100.00 + -100.00 01 diff --git a/addons/l10n_es_edi_facturae/tests/data/expected_signed_document.xml b/addons/l10n_es_edi_facturae/tests/data/expected_signed_document.xml index 9b0c5782572ba6..97afeba517bc57 100644 --- a/addons/l10n_es_edi_facturae/tests/data/expected_signed_document.xml +++ b/addons/l10n_es_edi_facturae/tests/data/expected_signed_document.xml @@ -137,8 +137,8 @@ 1.0 01 100.00000000 - 100.00000000 - 100.00000000 + 100.00 + 100.00 01 @@ -158,8 +158,8 @@ 1.0 01 100.00000000 - 100.00000000 - 100.00000000 + 100.00 + 100.00 01 @@ -179,8 +179,8 @@ 1.0 01 200.00000000 - 200.00000000 - 200.00000000 + 200.00 + 200.00 01 @@ -200,15 +200,15 @@ 1.0 01 1000.00000000 - 1000.00000000 + 1000.00 / 10.00 - 100.00000000 + 100.00 - 900.00000000 + 900.00 01 @@ -228,15 +228,15 @@ 1.0 01 1000.00000000 - 1000.00000000 + 1000.00 / 10.00 - 100.00000000 + 100.00 - 1100.00000000 + 1100.00 01 diff --git a/addons/l10n_es_edi_facturae/tests/data/expected_simplified_document.xml b/addons/l10n_es_edi_facturae/tests/data/expected_simplified_document.xml index c611c7d53a7315..7f3b5930bf90d3 100644 --- a/addons/l10n_es_edi_facturae/tests/data/expected_simplified_document.xml +++ b/addons/l10n_es_edi_facturae/tests/data/expected_simplified_document.xml @@ -97,8 +97,8 @@ 1.0 01 100.00000000 - 100.00000000 - 100.00000000 + 100.00 + 100.00 01 diff --git a/addons/l10n_es_edi_facturae/tests/data/import_multiple_invoices.xml b/addons/l10n_es_edi_facturae/tests/data/import_multiple_invoices.xml index 3dbe827b83bea4..099975b17a6223 100644 --- a/addons/l10n_es_edi_facturae/tests/data/import_multiple_invoices.xml +++ b/addons/l10n_es_edi_facturae/tests/data/import_multiple_invoices.xml @@ -113,8 +113,8 @@ 1.0 01 320.00000000 - 320.00000000 - 320.00000000 + 320.00 + 320.00 01 @@ -133,8 +133,8 @@ 1.0 01 1799.00000000 - 1799.00000000 - 1799.00000000 + 1799.00 + 1799.00 01 @@ -197,8 +197,8 @@ 3.0 01 320.00000000 - 960.00000000 - 960.00000000 + 960.00 + 960.00 01 diff --git a/addons/l10n_es_edi_facturae/tests/data/import_withholding_invoice.xml b/addons/l10n_es_edi_facturae/tests/data/import_withholding_invoice.xml index 11b69ae7267f10..2d65207122f578 100644 --- a/addons/l10n_es_edi_facturae/tests/data/import_withholding_invoice.xml +++ b/addons/l10n_es_edi_facturae/tests/data/import_withholding_invoice.xml @@ -115,8 +115,8 @@ 1.0 01 320.00000000 - 320.00000000 - 320.00000000 + 320.00 + 320.00 01 diff --git a/addons/l10n_hr_edi/models/account_edi_xml_ubl_hr.py b/addons/l10n_hr_edi/models/account_edi_xml_ubl_hr.py index d7b104f7bef217..011fb6b588d55d 100644 --- a/addons/l10n_hr_edi/models/account_edi_xml_ubl_hr.py +++ b/addons/l10n_hr_edi/models/account_edi_xml_ubl_hr.py @@ -63,7 +63,11 @@ def _get_document_nsmap(self, vals): return nsmap def _export_invoice_constraints_new(self, invoice, vals): - constraints = super()._export_invoice_constraints_new(invoice, vals) + # OVERRIDE 'account.edi.xml.ubl_bis3': don't apply Peppol rules + constraints = self.env['account.edi.xml.ubl_20']._export_invoice_constraints(invoice, vals) + constraints.update( + self._invoice_constraints_cen_en16931_ubl_new(invoice, vals) + ) constraints.update( self._invoice_constraints_eracun_new(invoice, vals) ) diff --git a/addons/l10n_jo_edi/models/account_move.py b/addons/l10n_jo_edi/models/account_move.py index ba06c736c04ed7..b206b06106fbfc 100644 --- a/addons/l10n_jo_edi/models/account_move.py +++ b/addons/l10n_jo_edi/models/account_move.py @@ -1,13 +1,10 @@ import base64 -import requests import uuid from werkzeug.urls import url_encode from odoo import _, api, fields, models from odoo.exceptions import ValidationError -JOFOTARA_URL = "https://backend.jofotara.gov.jo/core/invoices/" - class AccountMove(models.Model): _inherit = 'account.move' @@ -154,27 +151,10 @@ def _get_name_invoice_report(self): return super()._get_name_invoice_report() def _l10n_jo_build_jofotara_headers(self): - self.ensure_one() - return { - 'Client-Id': self.sudo().company_id.l10n_jo_edi_client_identifier, - 'Secret-Key': self.sudo().company_id.l10n_jo_edi_secret_key, - } + return self.company_id._l10n_jo_build_jofotara_headers() def _send_l10n_jo_edi_request(self, params, headers): - try: - response = requests.post(JOFOTARA_URL, json=params, headers=headers, timeout=50) - except requests.exceptions.Timeout: - return {'error': _("Request timeout! Please try again.")} - except requests.exceptions.RequestException as e: - return {'error': _("Invalid request: %s", e)} - - if not response.ok: - content = response.content.decode() - if response.status_code == 403: - content = _("Access forbidden. Please verify your JoFotara credentials.") - return {'error': _("Request failed: %s", content)} - dict_response = response.json() - return dict_response + return self.company_id._send_l10n_jo_edi_request(params, headers) def _submit_to_jofotara(self): self.ensure_one() @@ -200,18 +180,7 @@ def _l10n_jo_edi_get_xml_attachment_name(self): return f"{self.name.replace('/', '_')}_edi.xml" def _l10n_jo_validate_config(self): - error_msgs = [] - if not self.sudo().company_id.l10n_jo_edi_client_identifier: - error_msgs.append(_("Client ID is missing.")) - if not self.sudo().company_id.l10n_jo_edi_secret_key: - error_msgs.append(_("Secret key is missing.")) - if not self.company_id.l10n_jo_edi_taxpayer_type: - error_msgs.append(_("Taxpayer type is missing.")) - if not self.company_id.l10n_jo_edi_sequence_income_source: - error_msgs.append(_("Activity number (Sequence of income source) is missing.")) - - if error_msgs: - return _("%s \nTo set: Configuration > Settings > Electronic Invoicing (Jordan)", "\n".join(error_msgs)) + return self.company_id._l10n_jo_validate_config() def _l10n_jo_validate_fields(self): def has_non_digit_vat(partner, partner_type, error_msgs): diff --git a/addons/l10n_jo_edi/models/res_company.py b/addons/l10n_jo_edi/models/res_company.py index 9f0ab7ce448bc9..dfc2eecb562f2d 100644 --- a/addons/l10n_jo_edi/models/res_company.py +++ b/addons/l10n_jo_edi/models/res_company.py @@ -1,5 +1,9 @@ +import requests + from odoo import fields, models +JOFOTARA_URL = "https://backend.jofotara.gov.jo/core/invoices/" + class ResCompany(models.Model): _inherit = 'res.company' @@ -12,3 +16,41 @@ class ResCompany(models.Model): ('sales', "Registered in the sales tax"), ('special', "Registered in the special sales tax"), ], default='sales') + + def _l10n_jo_validate_config(self): + self.ensure_one() + error_msgs = [] + if not self.sudo().l10n_jo_edi_client_identifier: + error_msgs.append(self.env._("Client ID is missing.")) + if not self.sudo().l10n_jo_edi_secret_key: + error_msgs.append(self.env._("Secret key is missing.")) + if not self.l10n_jo_edi_taxpayer_type: + error_msgs.append(self.env._("Taxpayer type is missing.")) + if not self.l10n_jo_edi_sequence_income_source: + error_msgs.append(self.env._("Activity number (Sequence of income source) is missing.")) + + if error_msgs: + return self.env._("%s \nTo set: Configuration > Settings > Electronic Invoicing (Jordan)", "\n".join(error_msgs)) + + def _l10n_jo_build_jofotara_headers(self): + self.ensure_one() + return { + 'Client-Id': self.sudo().l10n_jo_edi_client_identifier, + 'Secret-Key': self.sudo().l10n_jo_edi_secret_key, + } + + def _send_l10n_jo_edi_request(self, params, headers): + try: + response = requests.post(JOFOTARA_URL, json=params, headers=headers, timeout=50) + except requests.exceptions.Timeout: + return {'error': self.env._("Request timeout! Please try again.")} + except requests.exceptions.RequestException as e: + return {'error': self.env._("Invalid request: %s", e)} + + if not response.ok: + content = response.content.decode() + if response.status_code == 403: + content = self.env._("Access forbidden. Please verify your JoFotara credentials.") + return {'error': self.env._("Request failed: %s", content)} + dict_response = response.json() + return dict_response diff --git a/addons/l10n_jo_edi/tests/test_jo_edi_precision.py b/addons/l10n_jo_edi/tests/test_jo_edi_precision.py index 09da7629c25b04..8eb2acc08d3c22 100644 --- a/addons/l10n_jo_edi/tests/test_jo_edi_precision.py +++ b/addons/l10n_jo_edi/tests/test_jo_edi_precision.py @@ -47,7 +47,7 @@ def _extract_vals_from_subtotals(self, subtotals, defaults): def _sum_max_dp(self, iterable): return self.env['account.edi.xml.ubl_21.jo']._sum_max_dp(iterable) - def _validate_jo_edi_numbers(self, xml_string, invoice): + def _validate_jo_edi_numbers(self, xml_string, amount_total): """ TLDR: This method checks that units sum up to total values. =================================================================================================== @@ -80,7 +80,7 @@ def _validate_jo_edi_numbers(self, xml_string, invoice): tax_exclusive_amount = float(root.findtext('./{*}LegalMonetaryTotal/{*}TaxExclusiveAmount')) tax_inclusive_amount = float(root.findtext('./{*}LegalMonetaryTotal/{*}TaxInclusiveAmount')) - self.assertEqual(float_compare(tax_inclusive_amount, invoice.amount_total, 2), 0, f'{tax_inclusive_amount} != {invoice.amount_total}') + self.assertEqual(float_compare(tax_inclusive_amount, amount_total, 2), 0, f'{tax_inclusive_amount} != {amount_total}') monetary_values_discount = float(root.findtext('./{*}LegalMonetaryTotal/{*}AllowanceTotalAmount')) payable_amount = float(root.findtext('./{*}LegalMonetaryTotal/{*}PayableAmount')) @@ -162,7 +162,7 @@ def _validate_invoice_vals_jo_edi_numbers(self, invoice_vals): with self.subTest(sub_test_name=invoice_vals['name']): invoice = self._l10n_jo_create_invoice(invoice_vals) generated_file = self.env['account.edi.xml.ubl_21.jo']._export_invoice(invoice)[0] - errors = self._validate_jo_edi_numbers(generated_file, invoice) + errors = self._validate_jo_edi_numbers(generated_file, invoice.amount_total) self.assertFalse(errors, errors) def test_jo_sales_invoice_precision(self): diff --git a/addons/l10n_jo_edi_pos/__init__.py b/addons/l10n_jo_edi_pos/__init__.py new file mode 100644 index 00000000000000..0650744f6bc69b --- /dev/null +++ b/addons/l10n_jo_edi_pos/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/addons/l10n_jo_edi_pos/__manifest__.py b/addons/l10n_jo_edi_pos/__manifest__.py new file mode 100644 index 00000000000000..6b27fb5375b195 --- /dev/null +++ b/addons/l10n_jo_edi_pos/__manifest__.py @@ -0,0 +1,26 @@ +{ + "name": "Jordan Accounting EDI for POS", + "countries": ["jo"], + "version": "1.0", + "description": """ +Jordan Accounting EDI for POS +============================= +Provides electronic invoicing for Jordan in the POS. +""", + "category": "Accounting/Localizations/EDI", + "license": "OEEL-1", + "depends": ["l10n_jo_edi_extended", "pos_edi_ubl"], + "demo": ["demo/demo_company.xml"], + "data": [ + "views/pos_order_views.xml", + "views/pos_payment_method_views.xml", + "views/res_config_settings_views.xml", + ], + "auto_install": True, + "assets": { + "point_of_sale._assets_pos": ["l10n_jo_edi_pos/static/src/**/*"], + "web.assets_tests": [ + "l10n_jo_edi_pos/static/tests/tours/**/*", + ], + }, +} diff --git a/addons/l10n_jo_edi_pos/demo/demo_company.xml b/addons/l10n_jo_edi_pos/demo/demo_company.xml new file mode 100644 index 00000000000000..cb751296779d40 --- /dev/null +++ b/addons/l10n_jo_edi_pos/demo/demo_company.xml @@ -0,0 +1,9 @@ + + + + + True + True + + + diff --git a/addons/l10n_jo_edi_pos/models/__init__.py b/addons/l10n_jo_edi_pos/models/__init__.py new file mode 100644 index 00000000000000..35adf85ebf76da --- /dev/null +++ b/addons/l10n_jo_edi_pos/models/__init__.py @@ -0,0 +1,6 @@ +from . import account_move +from . import pos_edi_ubl_21_jo +from . import pos_order +from . import pos_payment_method +from . import res_company +from . import res_config_settings diff --git a/addons/l10n_jo_edi_pos/models/account_move.py b/addons/l10n_jo_edi_pos/models/account_move.py new file mode 100644 index 00000000000000..01bbd9ab39abd9 --- /dev/null +++ b/addons/l10n_jo_edi_pos/models/account_move.py @@ -0,0 +1,11 @@ +from odoo import models + + +class AccountMove(models.Model): + _inherit = 'account.move' + + def _compute_l10n_jo_edi_is_needed(self): + super()._compute_l10n_jo_edi_is_needed() + # moves linked to pos.orders should be synchronized through the pos.order + for move in self.filtered(lambda m: m.l10n_jo_edi_is_needed and m.pos_order_ids): + move.l10n_jo_edi_is_needed = False diff --git a/addons/l10n_jo_edi_pos/models/pos_edi_ubl_21_jo.py b/addons/l10n_jo_edi_pos/models/pos_edi_ubl_21_jo.py new file mode 100644 index 00000000000000..671c5c76a046a2 --- /dev/null +++ b/addons/l10n_jo_edi_pos/models/pos_edi_ubl_21_jo.py @@ -0,0 +1,385 @@ +import functools +from types import SimpleNamespace + +from odoo import models +from odoo.tools import float_round, float_is_zero +from odoo.addons.l10n_jo_edi.models.account_edi_xml_ubl_21_jo import JO_MAX_DP + + +class HashableNamespace(SimpleNamespace): + """ We need to use a hashable namespace to be able to use it as a key in a dictionary. + This is because the `SimpleNamespace` class is not hashable by default. + """ + + def __hash__(self): + return hash(self.name) + + +class PosEdiXmlUBL21Jo(models.AbstractModel): + _name = 'pos.edi.xml.ubl_21.jo' + _inherit = ['account.edi.xml.ubl_21.jo', 'pos.edi.xml.ubl_21'] + _description = 'UBL 2.1 (JoFotara) for PoS Orders' + + def _get_tax_category_code(self, customer, supplier, tax): + if tax: + if tax._l10n_jo_is_exempt_tax(): + return "Z" + if tax.amount: + return "S" + return "O" + + def _get_pos_order_node(self, vals): + document_node = super()._get_pos_order_node(vals) + self._add_pos_order_seller_supplier_party_nodes(document_node, vals) + return document_node + + def _add_pos_order_config_vals(self, vals): + super()._add_pos_order_config_vals(vals) + vals.update({ + 'document_type': 'invoice', + 'fixed_taxes_as_allowance_charges': False, + 'is_refund': vals['document_type'] == 'credit_note', + 'is_sales': vals['pos_order'].company_id.l10n_jo_edi_taxpayer_type == 'sales', + 'is_income': vals['pos_order'].company_id.l10n_jo_edi_taxpayer_type == 'income', + }) + # We cannot use a new `res.currency` record to round to 9 decimals, because the + # `res.currency.rounding` field definition prevents rounding to more than 6 decimal places. + vals['currency_id'] = HashableNamespace( + name='JOD', + round=functools.partial(float_round, precision_digits=JO_MAX_DP), + is_zero=functools.partial(float_is_zero, precision_digits=JO_MAX_DP), + rounding=10**-JO_MAX_DP, + decimal_places=JO_MAX_DP, + ) + + def _add_pos_order_currency_vals(self, vals): + super()._add_pos_order_currency_vals(vals) + vals['currency_name'] = 'JO' + + def _add_pos_order_base_lines_vals(self, vals): + # OVERRIDE account_edi_xml_ubl_20.py + currency_9_dp = vals['currency_id'] + pos_order = vals['pos_order'] + + # Compute values for order lines. In Jordan, because the web-service has absolutely no tolerance, + # what we do is: use round per line with 9 decimals (yes!) + base_lines = pos_order._prepare_tax_base_line_values() + + AccountTax = self.env['account.tax'] + for base_line in base_lines: + base_line['currency_id'] = currency_9_dp + AccountTax._add_tax_details_in_base_line(base_line, base_line['record'].company_id, 'round_per_line') + + # Round to 9 decimals + AccountTax._round_base_lines_tax_details(base_lines, company=pos_order.company_id) + + # raw_total_* values need to calculated with `round_globally` because these values should not be rounded per line + # However, taxes need to be calculated with `round_per_line` so that _round_base_lines_tax_details does not + # end up generating lines taxes using unrounded base amounts + new_base_lines = [base_line.copy() for base_line in base_lines] + for base_line, new_base_line in zip(base_lines, new_base_lines): + AccountTax._add_tax_details_in_base_line(new_base_line, new_base_line['record'].company_id, 'round_globally') + for key in [ + 'raw_total_excluded_currency', + 'raw_total_excluded', + 'raw_total_included_currency', + 'raw_total_included', + ]: + base_line['tax_details'][key] = new_base_line['tax_details'][key] + + vals['base_lines'] = base_lines + self._add_pos_order_discount_vals(vals) + + def _add_document_line_gross_subtotal_and_discount_vals(self, vals): + """ In JO, because of the precision requirements, we first compute an exact + gross unit price, rounded to 9 decimals, and then use it to compute the discount amounts. + """ + base_line = vals['base_line'] + currency_9_dp = vals['currency_id'] + + # OVERRIDE account_edi_xml_ubl_20.py + discount_factor = 1 - (base_line['discount'] / 100.0) + if discount_factor != 0.0: + gross_subtotal_currency = base_line['tax_details']['raw_total_excluded_currency'] / discount_factor + else: + gross_subtotal_currency = base_line['currency_id'].round(base_line['price_unit'] * base_line['quantity']) + + # Start by getting a gross unit price rounded to 9 decimals + vals['gross_price_unit_currency'] = currency_9_dp.round(gross_subtotal_currency / base_line['quantity']) if base_line['quantity'] else 0.0 + + # Then compute the gross subtotal from the rounded unit price + vals['gross_subtotal_currency'] = currency_9_dp.round(vals['gross_price_unit_currency'] * base_line['quantity']) + + # Then compute the discount from the gross subtotal + vals['discount_amount_currency'] = vals['gross_subtotal_currency'] - base_line['tax_details']['total_excluded_currency'] + + def _add_pos_order_discount_vals(self, vals): + discount_amount_currency = 0 + for base_line in vals['base_lines']: + new_vals = {**vals, 'base_line': base_line} + self._add_document_line_gross_subtotal_and_discount_vals(new_vals) + discount_amount_currency += abs(new_vals['discount_amount_currency']) + vals['discount_amount_currency'] = discount_amount_currency + + def _get_payment_method_code(self, order): + return order._get_order_scope_code() + order._get_order_payment_method_code() + order._get_order_tax_payer_type_code() + + def _add_pos_order_header_nodes(self, document_node, vals): + pos_order = vals['pos_order'] + document_node.update({ + 'cbc:ProfileID': {'_text': 'reporting:1.0'}, + 'cbc:ID': {'_text': (pos_order.name or '').replace('/', '_')}, + 'cbc:UUID': {'_text': pos_order.l10n_jo_edi_pos_uuid}, + 'cbc:IssueDate': {'_text': '2020-01-01' if pos_order.company_id.l10n_jo_edi_pos_testing_mode else pos_order.date_order.date()}, + 'cbc:InvoiceTypeCode': {'_text': 381 if vals['is_refund'] else 388, 'name': self._get_payment_method_code(pos_order)}, + 'cbc:Note': {'_text': pos_order.general_note}, + 'cbc:DocumentCurrencyCode': {'_text': pos_order.currency_id.name}, + 'cbc:TaxCurrencyCode': {'_text': pos_order.currency_id.name}, + 'cac:BillingReference': { + 'cac:InvoiceDocumentReference': { + 'cbc:ID': {'_text': (pos_order.refunded_order_id.name or '').replace('/', '_')}, + 'cbc:UUID': {'_text': pos_order.refunded_order_id.l10n_jo_edi_pos_uuid}, + 'cbc:DocumentDescription': { + '_text': self.format_float(abs(pos_order.refunded_order_id.amount_total), vals['currency_dp']), + }, + }, + } if vals['is_refund'] else None, + 'cac:AdditionalDocumentReference': { + 'cbc:ID': {'_text': 'ICV'}, + 'cbc:UUID': {'_text': pos_order.id}, + }, + }) + + def _add_pos_order_accounting_customer_party_nodes(self, document_node, vals): + super()._add_pos_order_accounting_customer_party_nodes(document_node, vals) + if not vals['is_refund']: + document_node['cac:AccountingCustomerParty'].update({ + 'cac:AccountingContact': { + 'cbc:Telephone': {'_text': self._sanitize_phone(vals['customer'].phone or vals['customer'].mobile)} + }, + }) + + def _add_pos_order_seller_supplier_party_nodes(self, document_node, vals): + document_node['cac:SellerSupplierParty'] = { + 'cac:Party': { + 'cac:PartyIdentification': { + 'cbc:ID': {'_text': vals['pos_order'].company_id.l10n_jo_edi_sequence_income_source}, + }, + }, + } + + def _add_pos_order_payment_means_nodes(self, document_node, vals): + if vals['is_refund']: + document_node['cac:PaymentMeans'] = { + 'cbc:PaymentMeansCode': {'listID': 'UN/ECE 4461', '_text': 10}, + 'cbc:InstructionNote': {'_text': vals['pos_order'].l10n_jo_edi_pos_return_reason}, + } + + def _get_party_node(self, vals): + partner = vals['partner'] + commercial_partner = partner.commercial_partner_id + is_customer = vals['role'] == 'customer' + return { + 'cac:PartyIdentification': { + 'cbc:ID': {'_text': commercial_partner.vat, 'schemeID': 'TN' if partner.country_code == 'JO' else 'PN'}, + } if not vals['is_refund'] and is_customer else None, + 'cac:PostalAddress': self._get_address_node(vals), + 'cac:PartyTaxScheme': { + 'cbc:CompanyID': {'_text': commercial_partner.vat} if not vals['is_refund'] or not is_customer else None, + 'cac:TaxScheme': { + 'cbc:ID': {'_text': 'VAT'} + }, + }, + 'cac:PartyLegalEntity': { + 'cbc:RegistrationName': {'_text': commercial_partner.name}, + } if not vals['is_refund'] or not is_customer else None, + } + + def _get_address_node(self, vals): + partner = vals['partner'] + country = partner['country_id'] + state = partner['state_id'] + + return { + 'cbc:PostalZone': {'_text': partner.zip} if not vals['is_refund'] else None, + 'cbc:CountrySubentityCode': {'_text': state.code} if not vals['is_refund'] else None, + 'cac:Country': { + 'cbc:IdentificationCode': {'_text': country.code}, + }, + } + + def _add_pos_order_allowance_charge_nodes(self, document_node, vals): + currency_suffix = vals['currency_suffix'] + + document_node['cac:AllowanceCharge'] = { + 'cbc:ChargeIndicator': {'_text': 'false'}, + 'cbc:AllowanceChargeReason': {'_text': 'discount'}, + 'cbc:Amount': { + '_text': self.format_float(vals[f'discount_amount{currency_suffix}'], vals['currency_dp']), + 'currencyID': vals['currency_name'], + }, + } + + def _add_pos_order_tax_total_nodes(self, document_node, vals): + # income docs should have no taxes + if not vals['is_income']: + super()._add_pos_order_tax_total_nodes(document_node, vals) + + def _add_pos_order_monetary_total_nodes(self, document_node, vals): + monetary_total_tag = self._get_tags_for_document_type(vals)['monetary_total'] + currency_suffix = vals['currency_suffix'] + + document_node[monetary_total_tag] = { + 'cbc:TaxExclusiveAmount': { + '_text': self.format_float(vals[f'tax_exclusive_amount{currency_suffix}'] + vals[f'discount_amount{currency_suffix}'], vals['currency_dp']), + 'currencyID': vals['currency_name'], + }, + 'cbc:TaxInclusiveAmount': { + '_text': self.format_float(vals[f'tax_inclusive_amount{currency_suffix}'], vals['currency_dp']), + 'currencyID': vals['currency_name'], + }, + 'cbc:AllowanceTotalAmount': { + '_text': self.format_float(vals[f'discount_amount{currency_suffix}'], vals['currency_dp']), + 'currencyID': vals['currency_name'], + }, + 'cbc:PrepaidAmount': { + '_text': self.format_float(0, vals['currency_dp']), + 'currencyID': vals['currency_name'], + } if vals['is_refund'] and vals['is_sales'] else None, + 'cbc:PayableAmount': { + '_text': self.format_float(vals[f'tax_inclusive_amount{currency_suffix}'], vals['currency_dp']), + 'currencyID': vals['currency_name'], + }, + } + + def _get_pos_order_line_node(self, vals): + self._add_pos_order_line_vals(vals) + + line_node = {} + self._add_pos_order_line_id_nodes(line_node, vals) + self._add_pos_order_line_amount_nodes(line_node, vals) + self._add_pos_order_line_item_nodes(line_node, vals) + self._add_pos_order_line_tax_total_nodes(line_node, vals) + self._add_pos_order_line_price_nodes(line_node, vals) + self._add_pos_order_line_allowance_charge_nodes(line_node, vals) + return line_node + + def _get_pos_order_line_id(self, vals): + if not vals['is_refund']: + return vals['line_idx'] + + line_id = -1 + refund_line = vals['base_line']['record'] + order_lines = vals['pos_order'].refunded_order_id.lines + for order_line_id, order_line in enumerate(order_lines, 1): + if refund_line.product_id == order_line.product_id \ + and refund_line.price_unit == order_line.price_unit \ + and refund_line.discount == order_line.discount: + line_id = order_line_id + break + if line_id == -1: + line_id = len(order_lines) + vals['line_idx'] + + return line_id + + def _add_pos_order_line_id_nodes(self, line_node, vals): + line_id = self._get_pos_order_line_id(vals) + line_node['cbc:ID'] = {'_text': line_id} + + def _add_pos_order_line_item_nodes(self, line_node, vals): + product = vals['base_line']['product_id'] + line_node['cac:Item'] = { + 'cbc:Name': {'_text': product.name}, + } + + def _add_pos_order_line_tax_total_nodes(self, line_node, vals): + # income docs should have no taxes + if not vals['is_income']: + super()._add_pos_order_line_tax_total_nodes(line_node, vals) + + def _sum_tax_details(self, vals, key, include_fixed=False): + currency_suffix = vals['currency_suffix'] + + return sum( + tax_details[f'{key}{currency_suffix}'] + for grouping_key, tax_details in vals['aggregated_tax_details'].items() + if grouping_key and (include_fixed or grouping_key['amount_type'] != 'fixed') + ) + + def _get_tax_total_node(self, vals): + aggregated_tax_details = vals['aggregated_tax_details'] + total_tax_amount = self._sum_tax_details(vals, 'raw_tax_amount') + rounding_amount = self._sum_tax_details(vals, 'raw_total_excluded') + self._sum_tax_details(vals, 'raw_tax_amount', True) + return { + 'cbc:TaxAmount': { + '_text': self.format_float(total_tax_amount, vals['currency_dp']), + 'currencyID': vals['currency_name'] + }, + 'cbc:RoundingAmount': { + '_text': self.format_float(rounding_amount, vals['currency_dp']), + 'currencyID': vals['currency_name'], + } if vals['role'] == 'line' else None, + 'cac:TaxSubtotal': [ + self._get_tax_subtotal_node({ + **vals, + 'tax_details': tax_details, + 'grouping_key': grouping_key, + }) + for grouping_key, tax_details in aggregated_tax_details.items() + if grouping_key + ] if vals['role'] == 'line' or (vals['is_refund'] and vals['is_sales']) else None, + } + + def _get_tax_subtotal_node(self, vals): + tax_details = vals['tax_details'] + currency_suffix = vals['currency_suffix'] + return { + 'cbc:TaxableAmount': { + '_text': self.format_float(tax_details[f'raw_total_excluded{currency_suffix}'], vals['currency_dp']), + 'currencyID': vals['currency_name'] + }, + 'cbc:TaxAmount': { + '_text': self.format_float(tax_details[f'raw_tax_amount{currency_suffix}'], vals['currency_dp']), + 'currencyID': vals['currency_name'] + }, + 'cac:TaxCategory': self._get_tax_category_node(vals) + } + + def _get_tax_category_node(self, vals): + grouping_key = vals['grouping_key'] + return { + 'cbc:ID': {'_text': grouping_key['tax_category_code'], 'schemeAgencyID': 6, 'schemeID': 'UN/ECE 5305'}, + 'cbc:Percent': {'_text': grouping_key['amount']} if grouping_key['amount_type'] == 'percent' else None, + 'cac:TaxScheme': { + 'cbc:ID': { + '_text': 'VAT' if grouping_key['amount_type'] == 'percent' else 'OTH', + 'schemeAgencyID': 6, + 'schemeID': 'UN/ECE 5153', + }, + } + } + + def _add_pos_order_line_price_nodes(self, line_node, vals): + currency_suffix = vals['currency_suffix'] + + line_node['cac:Price'] = { + 'cbc:PriceAmount': { + '_text': self.format_float( + vals[f'gross_price_unit{currency_suffix}'], + vals['currency_dp'], + ), + 'currencyID': vals['currency_name'], + }, + } + + def _add_pos_order_line_allowance_charge_nodes(self, line_node, vals): + currency_suffix = vals['currency_suffix'] + + line_node['cac:Price']['cac:AllowanceCharge'] = { + 'cbc:ChargeIndicator': {'_text': 'false'}, + 'cbc:AllowanceChargeReason': {'_text': 'DISCOUNT'}, + 'cbc:Amount': { + '_text': self.format_float(vals[f'discount_amount{currency_suffix}'], vals['currency_dp']), + 'currencyID': vals['currency_name'], + }, + } diff --git a/addons/l10n_jo_edi_pos/models/pos_order.py b/addons/l10n_jo_edi_pos/models/pos_order.py new file mode 100644 index 00000000000000..8a4ebd1ee7ee90 --- /dev/null +++ b/addons/l10n_jo_edi_pos/models/pos_order.py @@ -0,0 +1,202 @@ +import base64 +import uuid +from urllib.parse import urlencode + +from odoo import api, fields, models +from odoo.exceptions import ValidationError +from odoo.tools.sql import create_column, column_exists + + +class PosOrder(models.Model): + _inherit = 'pos.order' + + l10n_jo_edi_pos_return_reason = fields.Char(string="Return Reason", help="Return Reason reported to JoFotara") + l10n_jo_edi_pos_enabled = fields.Boolean(related='company_id.l10n_jo_edi_pos_enabled') + l10n_jo_edi_pos_uuid = fields.Char(string="Order UUID", copy=False, compute='_compute_l10n_jo_edi_pos_uuid', store=True) + l10n_jo_edi_pos_qr = fields.Char(string="QR", copy=False) + l10n_jo_edi_pos_state = fields.Selection( + selection=[('to_send', 'To Send'), ('sent', 'Sent'), ('demo', 'Sent (Demo)')], + string="JoFotara State", + tracking=True, + copy=False, + ) + l10n_jo_edi_pos_error = fields.Text( + string="JoFotara Error", + copy=False, + readonly=True, + ) + l10n_jo_edi_pos_computed_xml = fields.Binary( + string="Jordan E-Invoice computed XML File", + compute='_compute_l10n_jo_edi_pos_computed_xml', + help="Jordan: technical field computing e-invoice XML data, useful at submission failure scenarios.", + ) + l10n_jo_edi_pos_xml_attachment_id = fields.Many2one( + comodel_name='ir.attachment', + string="Jordan E-Invoice XML", + help="Jordan: e-invoice XML.", + ) + + def _auto_init(self): + if not column_exists(self.env.cr, 'pos_order', 'l10n_jo_edi_pos_uuid'): + create_column(self.env.cr, 'pos_order', 'l10n_jo_edi_pos_uuid', 'char') + return super()._auto_init() + + @api.depends('country_code') + def _compute_l10n_jo_edi_pos_uuid(self): + for order in self: + if order.country_code == 'JO' and not order.l10n_jo_edi_pos_uuid: + order.l10n_jo_edi_pos_uuid = uuid.uuid4() + + def _get_order_scope_code(self): + return '0' + + def _get_order_payment_method_code(self): + return '1' if any(self.payment_ids.payment_method_id.mapped('l10n_jo_edi_pos_is_cash')) else '2' + + def _get_order_tax_payer_type_code(self): + return { + 'income': '1', + 'sales': '2', + 'special': '3', + }.get(self.company_id.l10n_jo_edi_taxpayer_type, '1') + + def _submit_to_jofotara(self): + self.ensure_one() + headers = self.company_id._l10n_jo_build_jofotara_headers() + xml_order = self.env['pos.edi.xml.ubl_21.jo']._export_pos_order(self)[0] + params = {'invoice': base64.b64encode(xml_order).decode()} + dict_response = {'EINV_QR': "Demo JoFotara QR"}\ + if self.env.company.l10n_jo_edi_demo_mode\ + else self.company_id._send_l10n_jo_edi_request(params, headers) + if 'error' in dict_response and len(dict_response) == 1: + return dict_response['error'] + self.l10n_jo_edi_pos_qr = str(dict_response.get('EINV_QR', '')) + self.l10n_jo_edi_pos_xml_attachment_id = self.env['ir.attachment'].create( + { + 'res_model': 'pos.order', + 'res_id': self.id, + 'name': self._l10n_jo_edi_pos_get_xml_attachment_name(), + 'raw': xml_order, + } + ) + + def _l10n_jo_edi_pos_get_xml_attachment_name(self): + return f"{self.name.replace('/', '_')}_edi.xml" + + def _l10n_jo_validate_fields(self): + error_msgs = [] + if self.refunded_order_id: + if self.refunded_order_id.l10n_jo_edi_pos_state not in ['sent', 'demo']: + error_msgs.append(self.env._("Refunded order was not sent to JoFotara. Please submit the original order to JoFotara first and try again.")) + if not self.l10n_jo_edi_pos_return_reason: + error_msgs.append(self.env._("Refund order must have a return reason")) + if any(line.price_unit < 0 for line in self.lines) or (not self.refunded_order_id and any(line.qty < 0 for line in self.lines)): + error_msgs.append(self.env._("Downpayments, global discounts, and negative lines are not supported at the moment. To revert this order, please go to Orders > Select the Order > Refund or create a Return from the backend by going to Orders > Select the Order > Return")) + if len(self.payment_ids.payment_method_id.mapped('l10n_jo_edi_pos_is_cash')) > 1: + error_msgs.append(self.env._("Please select the payment methods that are consistent with the value set in 'JoFotara Cash'. If set, the payment method is Cash. If empty, then it is Receivable.")) + + for line in self.lines: + if self.company_id.l10n_jo_edi_taxpayer_type == 'income' and len(line.tax_ids) != 0: + error_msgs.append(self.env._("No taxes are allowed on order lines for taxpayers unregistered in the sales tax")) + elif self.company_id.l10n_jo_edi_taxpayer_type == 'sales' and len(line.tax_ids) != 1: + error_msgs.append(self.env._("One general tax per order line is expected for taxpayers registered in the sales tax")) + elif self.company_id.l10n_jo_edi_taxpayer_type == 'special' and len(line.tax_ids) != 2: + error_msgs.append(self.env._("One special and one general tax per order line are expected for taxpayers registered in the special tax")) + + return "\n".join(error_msgs) + + def _l10n_jo_edi_send(self): + for order in self: + if error_messages := order.company_id._l10n_jo_validate_config() or order._l10n_jo_validate_fields() or order._submit_to_jofotara(): + order.l10n_jo_edi_pos_state = 'to_send' + order.l10n_jo_edi_pos_error = error_messages + # avoid creating an invoice in case of JoFotara sync failure + order.to_invoice = False + return error_messages + else: + order.l10n_jo_edi_pos_state = 'demo' if order.env.company.l10n_jo_edi_demo_mode else 'sent' + order.l10n_jo_edi_pos_error = False + order._link_xml_and_qr_to_invoice(order.account_move) + order.message_post( + body=order.env._("E-invoice (JoFotara) submitted successfully."), + attachment_ids=order.l10n_jo_edi_pos_xml_attachment_id.ids, + ) + + def action_pos_order_paid(self): + # EXTENDS 'point_of_sale' + """ + Once an order is paid, sync it with JoFotara if possible + """ + result = super().action_pos_order_paid() + if self.country_code == 'JO' and self.l10n_jo_edi_pos_enabled: + self._l10n_jo_edi_send() + return result + + def button_l10n_jo_edi_pos(self): + if error_message := self._l10n_jo_edi_send(): + return { + 'type': 'ir.actions.client', + 'tag': 'display_notification', + 'params': { + 'type': 'danger', + 'message': error_message, + 'next': { + 'type': 'ir.actions.act_window_close', + }, + } + } + + @api.depends('country_code', 'l10n_jo_edi_pos_error') + def _compute_l10n_jo_edi_pos_computed_xml(self): + for order in self: + if order.country_code == 'JO' and not order.l10n_jo_edi_pos_error: + xml_content = order.env['pos.edi.xml.ubl_21.jo']._export_pos_order(order)[0] + order.l10n_jo_edi_pos_computed_xml = base64.b64encode(xml_content) + else: + order.l10n_jo_edi_pos_computed_xml = False + + def download_l10n_jo_edi_pos_computed_xml(self): + if error_message := self.company_id._l10n_jo_validate_config() or self._l10n_jo_validate_fields(): + raise ValidationError(self.env._("The following errors have to be fixed in order to create an XML:\n") + error_message) + params = urlencode({ + 'model': self._name, + 'id': self.id, + 'field': 'l10n_jo_edi_pos_computed_xml', + 'filename': self._l10n_jo_edi_pos_get_xml_attachment_name(), + 'mimetype': 'application/xml', + 'download': 'true', + }) + return {'type': 'ir.actions.act_url', 'url': '/web/content/?' + params, 'target': 'new'} + + def _prepare_invoice_vals(self): + # EXTENDS 'point_of_sale' + vals = super()._prepare_invoice_vals() + return { + **vals, + 'ref': self.l10n_jo_edi_pos_return_reason, + 'l10n_jo_edi_uuid': self.l10n_jo_edi_pos_uuid, + 'l10n_jo_edi_state': self.l10n_jo_edi_pos_state, + 'l10n_jo_edi_error': self.l10n_jo_edi_pos_error, + 'l10n_jo_edi_invoice_type': 'local', # pos order invoices are always of type local + 'preferred_payment_method_line_id': self.env['account.payment.method.line'].search([], limit=1).id, + } + + def _create_invoice(self, move_vals): + # EXTENDS 'point_of_sale' + invoice = super()._create_invoice(move_vals) + self._link_xml_and_qr_to_invoice(invoice) + return invoice + + def _link_xml_and_qr_to_invoice(self, invoice): + if invoice and self.l10n_jo_edi_pos_xml_attachment_id: + self.env["ir.attachment"].create( + { + "res_model": "account.move", + "res_id": invoice.id, + "res_field": "l10n_jo_edi_xml_attachment_file", + "name": f'{self.name}_edi.xml', + "datas": self.l10n_jo_edi_pos_xml_attachment_id.datas, + } + ) + if invoice and self.l10n_jo_edi_pos_qr: + invoice.l10n_jo_edi_qr = self.l10n_jo_edi_pos_qr diff --git a/addons/l10n_jo_edi_pos/models/pos_payment_method.py b/addons/l10n_jo_edi_pos/models/pos_payment_method.py new file mode 100644 index 00000000000000..d79c303099dc02 --- /dev/null +++ b/addons/l10n_jo_edi_pos/models/pos_payment_method.py @@ -0,0 +1,18 @@ +from odoo import api, fields, models + + +class PosPaymentMethod(models.Model): + _inherit = 'pos.payment.method' + + country_code = fields.Char(related='company_id.country_id.code', depends=['company_id.country_id']) + l10n_jo_edi_pos_is_cash = fields.Boolean( + string="JoFotara Cash", + help="If checked, this payment method will reported as a cash payment method to JoFotara.", + compute='_compute_l10n_jo_edi_pos_is_cash', + store=True, readonly=False, + ) + + @api.depends('journal_id.type') + def _compute_l10n_jo_edi_pos_is_cash(self): + for pm in self: + pm.l10n_jo_edi_pos_is_cash = pm.journal_id.type == 'cash' diff --git a/addons/l10n_jo_edi_pos/models/res_company.py b/addons/l10n_jo_edi_pos/models/res_company.py new file mode 100644 index 00000000000000..4b4bbe4f58b97d --- /dev/null +++ b/addons/l10n_jo_edi_pos/models/res_company.py @@ -0,0 +1,15 @@ +from odoo import api, fields, models + + +class ResCompany(models.Model): + _inherit = 'res.company' + + l10n_jo_edi_pos_enabled = fields.Boolean() + l10n_jo_edi_pos_testing_mode = fields.Boolean() + + @api.model + def _load_pos_data_fields(self, config_id): + params = super()._load_pos_data_fields(config_id) + if self.env.company.account_fiscal_country_id.code == 'JO': + params += ["l10n_jo_edi_pos_enabled"] + return params diff --git a/addons/l10n_jo_edi_pos/models/res_config_settings.py b/addons/l10n_jo_edi_pos/models/res_config_settings.py new file mode 100644 index 00000000000000..e24d63baf434d8 --- /dev/null +++ b/addons/l10n_jo_edi_pos/models/res_config_settings.py @@ -0,0 +1,8 @@ +from odoo import models, fields + + +class ResConfigSettings(models.TransientModel): + _inherit = 'res.config.settings' + + l10n_jo_edi_pos_enabled = fields.Boolean(related='company_id.l10n_jo_edi_pos_enabled', readonly=False) + l10n_jo_edi_pos_testing_mode = fields.Boolean(related='company_id.l10n_jo_edi_pos_testing_mode', readonly=False) diff --git a/addons/l10n_jo_edi_pos/static/src/overrides/components/order_receipt.xml b/addons/l10n_jo_edi_pos/static/src/overrides/components/order_receipt.xml new file mode 100644 index 00000000000000..bc1b2a5c7c2852 --- /dev/null +++ b/addons/l10n_jo_edi_pos/static/src/overrides/components/order_receipt.xml @@ -0,0 +1,22 @@ + + + + + RECEIPT WITHOUT FISCAL VALUE + + + + + + + -------------------------------- + + JoFotara QR code + + + + + + + + diff --git a/addons/l10n_jo_edi_pos/static/src/overrides/models/invoice_button.js b/addons/l10n_jo_edi_pos/static/src/overrides/models/invoice_button.js new file mode 100644 index 00000000000000..47d7643cce22d1 --- /dev/null +++ b/addons/l10n_jo_edi_pos/static/src/overrides/models/invoice_button.js @@ -0,0 +1,22 @@ +import { InvoiceButton } from "@point_of_sale/app/screens/ticket_screen/invoice_button/invoice_button"; +import { WarningDialog } from "@web/core/errors/error_dialogs"; +import { _t } from "@web/core/l10n/translation"; +import { patch } from "@web/core/utils/patch"; + +patch(InvoiceButton.prototype, { + click() { + if ( + this.pos.config.company_id.l10n_jo_edi_pos_enabled && + !["sent", "demo"].includes(this.props.order.l10n_jo_edi_pos_state) + ) { + this.dialog.add(WarningDialog, { + title: _t("Odoo Warning"), + message: _t( + "Please synchronize this order with JoFotara first by clicking on Details > JoFotara (Jordan)" + ), + }); + return; + } + return super.click(); + }, +}); diff --git a/addons/l10n_jo_edi_pos/static/src/overrides/models/pos_order.js b/addons/l10n_jo_edi_pos/static/src/overrides/models/pos_order.js new file mode 100644 index 00000000000000..374cf6897ba9e7 --- /dev/null +++ b/addons/l10n_jo_edi_pos/static/src/overrides/models/pos_order.js @@ -0,0 +1,18 @@ +import { PosOrder } from "@point_of_sale/app/models/pos_order"; +import { qrCodeSrc } from "@point_of_sale/utils"; +import { patch } from "@web/core/utils/patch"; + +patch(PosOrder.prototype, { + export_for_printing() { + const result = super.export_for_printing(...arguments); + if (this.company.account_fiscal_country_id?.code !== "JO") { + return result; + } + + result.headerData.l10n_jo_edi_pos_error = this.l10n_jo_edi_pos_error; + result.l10n_jo_edi_pos_qr = this.l10n_jo_edi_pos_qr + ? qrCodeSrc(this.l10n_jo_edi_pos_qr) + : false; + return result; + }, +}); diff --git a/addons/l10n_jo_edi_pos/static/src/overrides/models/receipt_screen.js b/addons/l10n_jo_edi_pos/static/src/overrides/models/receipt_screen.js new file mode 100644 index 00000000000000..79191443af1738 --- /dev/null +++ b/addons/l10n_jo_edi_pos/static/src/overrides/models/receipt_screen.js @@ -0,0 +1,28 @@ +import { _t } from "@web/core/l10n/translation"; +import { onMounted } from "@odoo/owl"; +import { patch } from "@web/core/utils/patch"; +import { AlertDialog } from "@web/core/confirmation_dialog/confirmation_dialog"; + +import { ReceiptScreen } from "@point_of_sale/app/screens/receipt_screen/receipt_screen"; + +patch(ReceiptScreen.prototype, { + setup() { + super.setup(); + onMounted(() => { + const error = this.currentOrder.l10n_jo_edi_pos_error; + if (error) { + this.dialog.add( + AlertDialog, + { + title: _t("JoFotara Error"), + body: + _t( + `The receipt is stuck due to an Error.\nTo send it, go to Orders > Select the Order > Details > JoFotara or Backend > Orders > Select the Order > JoFotara.\n\nError message:\n` + ) + error, + }, + {} + ); + } + }); + }, +}); diff --git a/addons/l10n_jo_edi_pos/static/src/overrides/screens/ticket_screen.js b/addons/l10n_jo_edi_pos/static/src/overrides/screens/ticket_screen.js new file mode 100644 index 00000000000000..02182e3204b4e9 --- /dev/null +++ b/addons/l10n_jo_edi_pos/static/src/overrides/screens/ticket_screen.js @@ -0,0 +1,34 @@ +import { TicketScreen } from "@point_of_sale/app/screens/ticket_screen/ticket_screen"; +import { makeAwaitable } from "@point_of_sale/app/store/make_awaitable_dialog"; +import { TextInputPopup } from "@point_of_sale/app/utils/input_popups/text_input_popup"; +import { _t } from "@web/core/l10n/translation"; +import { patch } from "@web/core/utils/patch"; + +patch(TicketScreen.prototype, { + get showJoEdiStatus() { + return this.pos.config.company_id.l10n_jo_edi_pos_enabled; + }, + get joEdiStatus() { + switch (this.order.l10n_jo_edi_pos_state) { + case "to_send": + return _t("To Send"); + case "sent": + return _t("Sent"); + case "demo": + return _t("Sent (Demo)"); + default: + return ""; + } + }, + async addAdditionalRefundInfo(order, destinationOrder) { + if (this.pos.config.company_id.country_id.code === "JO") { + const payload = await makeAwaitable(this.dialog, TextInputPopup, { + title: _t("JoFotara Return Reason"), + }); + if (payload) { + destinationOrder.l10n_jo_edi_pos_return_reason = payload; + } + } + return super.addAdditionalRefundInfo(...arguments); + }, +}); diff --git a/addons/l10n_jo_edi_pos/static/src/overrides/screens/ticket_screen.xml b/addons/l10n_jo_edi_pos/static/src/overrides/screens/ticket_screen.xml new file mode 100644 index 00000000000000..39c262a2483f3a --- /dev/null +++ b/addons/l10n_jo_edi_pos/static/src/overrides/screens/ticket_screen.xml @@ -0,0 +1,12 @@ + + + + + + JoFotara Status + + + + + + diff --git a/addons/l10n_jo_edi_pos/static/tests/tours/l10n_jo_edi_pos_tour.js b/addons/l10n_jo_edi_pos/static/tests/tours/l10n_jo_edi_pos_tour.js new file mode 100644 index 00000000000000..7f4568979bdc27 --- /dev/null +++ b/addons/l10n_jo_edi_pos/static/tests/tours/l10n_jo_edi_pos_tour.js @@ -0,0 +1,23 @@ +import * as Chrome from "@point_of_sale/../tests/tours/utils/chrome_util"; +import * as Dialog from "@point_of_sale/../tests/tours/utils/dialog_util"; +import * as PaymentScreen from "@point_of_sale/../tests/tours/utils/payment_screen_util"; +import * as ProductScreen from "@point_of_sale/../tests/tours/utils/product_screen_util"; +import * as ReceiptScreen from "@point_of_sale/../tests/tours/utils/receipt_screen_util"; +import { registry } from "@web/core/registry"; + +registry.category("web_tour.tours").add("L10nJoEdiPosTour", { + steps: () => + [ + Chrome.startPoS(), + Dialog.confirm("Open Register"), + ProductScreen.addOrderline("Desk Pad", "1"), + ProductScreen.clickPayButton(), + PaymentScreen.clickPaymentMethod("Bank"), + PaymentScreen.clickValidate(), + ReceiptScreen.receiptIsThere(), + { + content: "Check for JoFotara QR code", + trigger: "div:contains('JoFotara QR code') + div img.pos-receipt-qrcode", + }, + ].flat(), +}); diff --git a/addons/l10n_jo_edi_pos/tests/__init__.py b/addons/l10n_jo_edi_pos/tests/__init__.py new file mode 100644 index 00000000000000..780798f4e4d79c --- /dev/null +++ b/addons/l10n_jo_edi_pos/tests/__init__.py @@ -0,0 +1,3 @@ +from . import test_jo_edi_pos_precision +from . import test_jo_edi_pos_types +from . import test_l10n_jo_edi_pos_tour diff --git a/addons/l10n_jo_edi_pos/tests/jo_edi_pos_common.py b/addons/l10n_jo_edi_pos/tests/jo_edi_pos_common.py new file mode 100644 index 00000000000000..00c8d8cdb59662 --- /dev/null +++ b/addons/l10n_jo_edi_pos/tests/jo_edi_pos_common.py @@ -0,0 +1,47 @@ +from odoo import Command +from odoo.tests import tagged +from odoo.addons.l10n_jo_edi.tests.jo_edi_common import JoEdiCommon +from odoo.addons.point_of_sale.tests.common import TestPoSCommon +from odoo.addons.point_of_sale.tests.test_frontend import TestPointOfSaleHttpCommon + + +@tagged('post_install_l10n', 'post_install', '-at_install') +class JoEdiPosCommon(JoEdiCommon, TestPoSCommon, TestPointOfSaleHttpCommon): + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.company_data['company'].write({ + 'country_id': cls.env.ref('base.jo').id, + }) + + def _l10n_jo_create_order(self, order_vals): + self.main_pos_config.with_user(self.pos_user).open_ui() + order_vals.update({ + 'company_id': self.company.id, + 'session_id': self.main_pos_config.current_session_id.id, + 'partner_id': self.partner_jo.id, + 'date_order': order_vals.get('date_order', '2019-01-01'), + }) + order_vals.setdefault('amount_tax', 0) + order_vals.setdefault('amount_paid', 0) + order_vals.setdefault('amount_total', 0) + order_vals.setdefault('amount_return', 0) + order_vals['lines'] = [Command.create({'price_subtotal': 0, 'price_subtotal_incl': 0, **line}) for line in order_vals['lines']] + order = self.env['pos.order'].create(order_vals) + order._compute_prices() + + if 'currency_id' in order_vals: + order.currency_id = order_vals['currency_id'] + + return order + + def _l10n_jo_create_order_refund(self, order, refund_vals): + order = self._l10n_jo_create_order(order) if isinstance(order, dict) else order + order_refund = order._refund() + if 'lines' in refund_vals: + for order_line, line_write_vals in zip(order_refund.lines, refund_vals['lines']): + order_line.write(line_write_vals) + del refund_vals['lines'] + order_refund.write(refund_vals) + return order_refund diff --git a/addons/l10n_jo_edi_pos/tests/test_jo_edi_pos_precision.py b/addons/l10n_jo_edi_pos/tests/test_jo_edi_pos_precision.py new file mode 100644 index 00000000000000..bdcb72099e74b9 --- /dev/null +++ b/addons/l10n_jo_edi_pos/tests/test_jo_edi_pos_precision.py @@ -0,0 +1,141 @@ +from odoo import Command +from odoo.tests import tagged +from odoo.addons.l10n_jo_edi.tests.test_jo_edi_precision import TestJoEdiPrecision +from odoo.addons.l10n_jo_edi_pos.tests.jo_edi_pos_common import JoEdiPosCommon + + +@tagged('post_install_l10n', 'post_install', '-at_install') +class TestJoEdiPosPrecision(JoEdiPosCommon, TestJoEdiPrecision): + def _validate_order_vals_jo_edi_pos_numbers(self, order_vals): + with self.subTest(sub_test_name=order_vals['name']): + order = self._l10n_jo_create_order(order_vals) + generated_file = self.env['pos.edi.xml.ubl_21.jo']._export_pos_order(order)[0] + errors = self._validate_jo_edi_numbers(generated_file, order.amount_total) + self.assertFalse(errors, errors) + + def test_jo_pos_sales_invoice_precision(self): + eur = self.env.ref('base.EUR') + self.setup_currency_rate(eur, 1.41) + self.company.l10n_jo_edi_taxpayer_type = 'sales' + self.company.l10n_jo_edi_sequence_income_source = '16683693' + + self._validate_order_vals_jo_edi_pos_numbers({ + 'name': 'TestEIN022', + 'currency_id': eur.id, + 'date_order': '2023-11-12', + 'lines': [ + { + 'product_id': self.product_a.id, + 'qty': 3.48, + 'price_unit': 1.56, + 'discount': 2.5, + 'tax_ids': [Command.set(self.jo_general_tax_16_included.ids)], + }, + { + 'product_id': self.product_b.id, + 'qty': 6.02, + 'price_unit': 2.79, + 'discount': 2.5, + 'tax_ids': [Command.set(self.jo_general_tax_16_included.ids)], + }, + ], + }) + + def test_jo_pos_special_invoice_precision(self): + self.company.l10n_jo_edi_taxpayer_type = 'special' + self.company.l10n_jo_edi_sequence_income_source = '16683693' + self._validate_order_vals_jo_edi_pos_numbers({ + 'name': 'TestEIN014', + 'date_order': '2023-11-10', + 'lines': [ + { + 'product_id': self.product_b.id, + 'price_unit': 100, + 'qty': 1, + 'tax_ids': [Command.set((self.jo_general_tax_10 | self.jo_special_tax_10).ids)], + }, + { + 'product_id': self.product_a.id, + 'price_unit': 100, + 'qty': 1, + 'tax_ids': [Command.set((self.jo_general_tax_10 | self.jo_special_tax_5).ids)], + }, + { + 'product_id': self.product_a.id, + 'price_unit': 100, + 'qty': 1, + 'tax_ids': [Command.set((self.jo_general_tax_16 | self.jo_special_tax_5).ids)], + }, + { + 'product_id': self.product_b.id, + 'price_unit': 100, + 'qty': 1, + 'tax_ids': [Command.set((self.jo_general_tax_16 | self.jo_special_tax_10).ids)], + }, + ], + }) + + def test_jo_pos_credit_notes_price_unit(self): + def get_price_units(xml_string): + root = self.get_xml_tree_from_string(xml_string) + for xml_line in root.findall('./{*}InvoiceLine'): + yield float(xml_line.findtext('{*}Price/{*}PriceAmount')) + self.company.l10n_jo_edi_taxpayer_type = 'sales' + self.company.l10n_jo_edi_sequence_income_source = '16683693' + order = self._l10n_jo_create_order({ + 'name': 'TestEIN014', + 'date_order': '2023-11-10', + 'lines': [ + { + 'product_id': self.product_b.id, + 'price_unit': 11.11, + 'qty': 9833, + 'discount': 3.12, + 'tax_ids': [Command.set((self.jo_general_tax_16_included).ids)], + }, + { + 'product_id': self.product_a.id, + 'price_unit': 10000.01, + 'qty': 93333, + 'discount': 99.71, + 'tax_ids': [Command.set((self.jo_general_tax_16_included).ids)], + }, + { + 'product_id': self.product_a.id, + 'price_unit': 0.01, + 'qty': 0.11, + 'discount': 2, + 'tax_ids': [Command.set((self.jo_general_tax_16_included).ids)], + }, + ], + }) + refund = self._l10n_jo_create_order_refund(order, { + 'l10n_jo_edi_pos_return_reason': 'return reason', + 'lines': [ + { + 'product_id': self.product_b.id, + 'price_unit': 11.11, + 'qty': 3.11, + 'discount': 3.12, + 'tax_ids': [Command.set((self.jo_general_tax_16_included).ids)], + }, + { + 'product_id': self.product_a.id, + 'price_unit': 10000.01, + 'qty': 2.02, + 'discount': 99.71, + 'tax_ids': [Command.set((self.jo_general_tax_16_included).ids)], + }, + { + 'product_id': self.product_a.id, + 'price_unit': 0.01, + 'qty': 0.1, + 'discount': 2, + 'tax_ids': [Command.set((self.jo_general_tax_16_included).ids)], + }, + ], + }) + invoice_file = self.env['pos.edi.xml.ubl_21.jo']._export_pos_order(order)[0] + refund_file = self.env['pos.edi.xml.ubl_21.jo']._export_pos_order(refund)[0] + for invoice_price_unit, refund_price_unit in zip(get_price_units(invoice_file), get_price_units(refund_file)): + self.assertEqual(invoice_price_unit, refund_price_unit) diff --git a/addons/l10n_jo_edi_pos/tests/test_jo_edi_pos_types.py b/addons/l10n_jo_edi_pos/tests/test_jo_edi_pos_types.py new file mode 100644 index 00000000000000..642461d8b405b6 --- /dev/null +++ b/addons/l10n_jo_edi_pos/tests/test_jo_edi_pos_types.py @@ -0,0 +1,352 @@ +from odoo import Command +from odoo.tests import tagged +from odoo.addons.l10n_jo_edi_pos.tests.jo_edi_pos_common import JoEdiPosCommon + + +@tagged('post_install_l10n', 'post_install', '-at_install') +class TestJoEdiPosTypes(JoEdiPosCommon): + def test_jo_pos_income_invoice(self): + self.company.l10n_jo_edi_taxpayer_type = 'income' + self.company.l10n_jo_edi_sequence_income_source = '4419618' + + order_vals = { + 'name': 'EIN/998833/0', + 'date_order': '2022-09-27', + 'general_note': 'ملاحظات 2', + 'lines': [ + { + 'product_id': self.product_a.id, + 'price_unit': 3, + 'qty': 44, + 'discount': 1, + }, + ], + } + order = self._l10n_jo_create_order(order_vals) + + expected_file = self._read_xml_test_file('type_1') + generated_file = self.env['pos.edi.xml.ubl_21.jo']._export_pos_order(order)[0] + self.assertXmlTreeEqual( + self.get_xml_tree_from_string(generated_file), + self.get_xml_tree_from_string(expected_file) + ) + + def test_jo_pos_income_refund(self): + self.company.l10n_jo_edi_taxpayer_type = 'income' + self.company.l10n_jo_edi_sequence_income_source = '4419618' + + order_vals = { + 'name': 'EIN00017', + 'lines': [ + { + 'product_id': self.product_a.id, + 'price_unit': 18.85, + 'qty': 10, + 'discount': 20, + }, + ], + } + refund_vals = { + 'name': 'EIN998833', + 'date_order': '2022-09-27', + 'general_note': 'ملاحظات 2', + 'l10n_jo_edi_pos_return_reason': 'Reversal of: EIN00017, change price', + 'lines': [ + { + 'price_unit': 3, + 'qty': -44, + 'discount': 1, + }, + ], + } + refund = self._l10n_jo_create_order_refund(order_vals, refund_vals) + + expected_file = self._read_xml_test_file('type_2') + generated_file = self.env['pos.edi.xml.ubl_21.jo']._export_pos_order(refund)[0] + self.assertXmlTreeEqual( + self.get_xml_tree_from_string(generated_file), + self.get_xml_tree_from_string(expected_file) + ) + + def test_jo_pos_sales_invoice(self): + self.company.l10n_jo_edi_taxpayer_type = 'sales' + self.company.l10n_jo_edi_sequence_income_source = '16683693' + + order_vals = { + 'name': 'TestEIN022', + 'date_order': '2023-11-10', + 'general_note': 'Test General for Documentation', + 'lines': [ + { + 'product_id': self.product_a.id, + 'price_unit': 10, + 'qty': 100, + 'discount': 10, + 'tax_ids': [Command.set(self.jo_general_tax_10.ids)], + }, + ], + } + order = self._l10n_jo_create_order(order_vals) + + expected_file = self._read_xml_test_file('type_3') + generated_file = self.env['pos.edi.xml.ubl_21.jo']._export_pos_order(order)[0] + self.assertXmlTreeEqual( + self.get_xml_tree_from_string(generated_file), + self.get_xml_tree_from_string(expected_file) + ) + + def test_jo_pos_sales_refund(self): + self.company.l10n_jo_edi_taxpayer_type = 'sales' + self.company.l10n_jo_edi_sequence_income_source = '16683693' + + order_vals = { + 'name': 'TestEIN022', + 'currency_id': self.usd.id, # should not affect values as they are reported in order currency + 'lines': [ + { + 'product_id': self.product_a.id, + 'price_unit': 10, + 'qty': 100, + 'discount': 10, + 'tax_ids': [Command.set(self.jo_general_tax_10.ids)], + }, + ], + } + refund_vals = { + 'name': 'TestEIN022R', + 'currency_id': self.usd.id, # should not affect values as they are reported in order currency + 'date_order': '2023-11-10', + 'l10n_jo_edi_pos_return_reason': 'Reversal of: TestEIN022, Test_Return', + } + refund = self._l10n_jo_create_order_refund(order_vals, refund_vals) + + expected_file = self._read_xml_test_file('type_4') + generated_file = self.env['pos.edi.xml.ubl_21.jo']._export_pos_order(refund)[0] + self.assertXmlTreeEqual( + self.get_xml_tree_from_string(generated_file), + self.get_xml_tree_from_string(expected_file) + ) + + def test_jo_pos_special_invoice(self): + self.company.l10n_jo_edi_taxpayer_type = 'special' + self.company.l10n_jo_edi_sequence_income_source = '16683696' + + order_vals = { + 'name': 'TestEIN013', + 'currency_id': self.usd.id, # should not affect values as they are reported in order currency + 'date_order': '2023-11-10', + 'lines': [ + { + 'product_id': self.product_b.id, + 'price_unit': 100, + 'qty': 1, + 'tax_ids': [Command.set((self.jo_general_tax_10 | self.jo_special_tax_10).ids)], + }, + ], + } + order = self._l10n_jo_create_order(order_vals) + + expected_file = self._read_xml_test_file('type_5') + generated_file = self.env['pos.edi.xml.ubl_21.jo']._export_pos_order(order)[0] + self.assertXmlTreeEqual( + self.get_xml_tree_from_string(generated_file), + self.get_xml_tree_from_string(expected_file) + ) + + def test_jo_pos_special_refund(self): + self.company.l10n_jo_edi_taxpayer_type = 'special' + self.company.l10n_jo_edi_sequence_income_source = '16683696' + + order_vals = { + 'name': 'TestEIN013', + 'lines': [ + { + 'product_id': self.product_b.id, + 'price_unit': 100, + 'qty': 1, + 'tax_ids': [Command.set((self.jo_general_tax_10 | self.jo_special_tax_10).ids)], + }, + ], + } + refund_vals = { + 'name': 'TestEINReturn013', + 'date_order': '2023-11-10', + 'l10n_jo_edi_pos_return_reason': 'Reversal of: TestEIN013, Test Return', + } + refund = self._l10n_jo_create_order_refund(order_vals, refund_vals) + + expected_file = self._read_xml_test_file('type_6') + generated_file = self.env['pos.edi.xml.ubl_21.jo']._export_pos_order(refund)[0] + self.assertXmlTreeEqual( + self.get_xml_tree_from_string(generated_file), + self.get_xml_tree_from_string(expected_file) + ) + + def test_jo_pos_no_vat_customer(self): + self.company.l10n_jo_edi_taxpayer_type = 'income' + self.company.l10n_jo_edi_sequence_income_source = '4419618' + self.partner_jo.vat = False + + order_vals = { + 'name': 'EIN/998833/0', + 'date_order': '2022-09-27', + 'general_note': 'ملاحظات 2', + 'lines': [ + { + 'product_id': self.product_a.id, + 'price_unit': 3, + 'qty': 44, + 'discount': 1, + 'tax_ids': [Command.clear()], + }, + ], + } + order = self._l10n_jo_create_order(order_vals) + + expected_file = self._read_xml_test_file('type_7') + generated_file = self.env['pos.edi.xml.ubl_21.jo']._export_pos_order(order)[0] + self.assertXmlTreeEqual( + self.get_xml_tree_from_string(generated_file), + self.get_xml_tree_from_string(expected_file) + ) + + def test_jo_pos_no_country_customer(self): + self.company.l10n_jo_edi_taxpayer_type = 'income' + self.company.l10n_jo_edi_sequence_income_source = '4419618' + self.partner_jo.country_id = False + + order_vals = { + 'name': 'EIN/998833/0', + 'date_order': '2022-09-27', + 'general_note': 'ملاحظات 2', + 'lines': [ + { + 'product_id': self.product_a.id, + 'price_unit': 3, + 'qty': 44, + 'discount': 1, + 'tax_ids': [Command.clear()], + }, + ], + } + + order = self._l10n_jo_create_order(order_vals) + + expected_file = self._read_xml_test_file('type_8') + generated_file = self.env['pos.edi.xml.ubl_21.jo']._export_pos_order(order)[0] + self.assertXmlTreeEqual( + self.get_xml_tree_from_string(generated_file), + self.get_xml_tree_from_string(expected_file) + ) + + def test_credit_notes_lines_matching(self): + self.company.l10n_jo_edi_taxpayer_type = 'income' + self.company.l10n_jo_edi_sequence_income_source = '4419618' + + order_vals = { + 'name': 'EIN00017', + 'lines': [ + { # id = 1 + 'product_id': self.product_a.id, + 'price_unit': 10, + 'qty': 10, + 'discount': 10, + }, + { # id = 2 + 'product_id': self.product_a.id, + 'price_unit': 10, + 'qty': 10, + 'discount': 20, + }, + { # id = 3 + 'product_id': self.product_b.id, + 'price_unit': 10, + 'qty': 10, + }, + { # id = 4 + 'product_id': self.product_b.id, + 'price_unit': 20, + 'qty': 10, + }, + ], + } + refund_vals = { + 'name': 'EIN998833', + 'date_order': '2022-09-27', + 'general_note': 'ملاحظات 2', + 'l10n_jo_edi_pos_return_reason': 'change price', + 'lines': [ + { # id should be 4 + 'product_id': self.product_b.id, + 'price_unit': 20, + 'qty': -3, + 'discount': 0, + }, + { # id should be 1 + 'product_id': self.product_a.id, + 'price_unit': 10, + 'qty': -10, + 'discount': 10, + }, + { # id should be 2 + 'product_id': self.product_a.id, + 'price_unit': 10, + 'qty': -1, + 'discount': 20, + }, + { # id should be > 4 + 'product_id': self.product_b.id, + 'price_unit': 30, + 'qty': -10, + }, + ], + } + refund = self._l10n_jo_create_order_refund(order_vals, refund_vals) + xml_string = self.env['pos.edi.xml.ubl_21.jo']._export_pos_order(refund)[0] + xml_tree = self.get_xml_tree_from_string(xml_string) + for xml_line, expected_line_id in zip(xml_tree.findall('./{*}InvoiceLine'), [4, 1, 2]): + self.assertEqual(int(xml_line.findtext('{*}ID')), expected_line_id) + + self.assertGreater(int(xml_tree.findall('./{*}InvoiceLine')[-1].findtext('{*}ID')), 4) + + def test_different_payment_methods(self): + def get_xml_order_type(order, amount_cash, amount_bank): + cash_pm = order.config_id.payment_method_ids.filtered(lambda pm: pm.l10n_jo_edi_pos_is_cash)[0] + bank_pm = order.config_id.payment_method_ids.filtered(lambda pm: not pm.l10n_jo_edi_pos_is_cash)[0] + + if amount_cash: + self.make_payment(order, cash_pm, amount_cash) + if amount_bank: + self.make_payment(order, bank_pm, amount_bank) + if order._l10n_jo_validate_fields(): # conflicting payment methods + return False + + generated_file = self.env['pos.edi.xml.ubl_21.jo']._export_pos_order(order)[0] + xml_tree = self.get_xml_tree_from_string(generated_file) + return xml_tree.find(".//{urn:oasis:names:specification:ubl:schema:xsd:CommonBasicComponents-2}InvoiceTypeCode").get('name') + + self.company.l10n_jo_edi_taxpayer_type = 'income' + self.company.l10n_jo_edi_sequence_income_source = '4419618' + + for (cash_amount, bank_amount, expected_type) in [ + (100, 0, '011'), + (0, 100, '021'), + (50, 50, False), + ]: + order_vals = { + 'name': 'EIN/998833/0', + 'date_order': '2022-09-27', + 'general_note': 'ملاحظات 2', + 'lines': [ + { + 'product_id': self.product_a.id, + 'price_unit': 100, + 'qty': 1, + 'discount': 0, + 'tax_ids': [Command.clear()], + }, + ], + } + order = self._l10n_jo_create_order(order_vals) + order_type = get_xml_order_type(order, cash_amount, bank_amount) + self.assertEqual(order_type, expected_type) diff --git a/addons/l10n_jo_edi_pos/tests/test_l10n_jo_edi_pos_tour.py b/addons/l10n_jo_edi_pos/tests/test_l10n_jo_edi_pos_tour.py new file mode 100644 index 00000000000000..00d951347817e5 --- /dev/null +++ b/addons/l10n_jo_edi_pos/tests/test_l10n_jo_edi_pos_tour.py @@ -0,0 +1,23 @@ +from odoo.tests import tagged +from odoo.addons.l10n_jo_edi_pos.tests.jo_edi_pos_common import JoEdiPosCommon + + +@tagged('post_install_l10n', 'post_install', '-at_install') +class TestL10nJoEdiPosTour(JoEdiPosCommon): + + def test_l10n_jo_edi_pos_tour(self): + self.company.write({ + 'l10n_jo_edi_pos_enabled': True, + 'l10n_jo_edi_pos_testing_mode': True, + 'l10n_jo_edi_demo_mode': True, + 'l10n_jo_edi_sequence_income_source': '12345', + 'l10n_jo_edi_secret_key': 'demo_secret_key', + 'l10n_jo_edi_client_identifier': 'demo_client_identifier', + 'l10n_jo_edi_taxpayer_type': 'income', + }) + + self.main_pos_config.with_user(self.pos_user).open_ui() + self.start_tour( + "/pos/ui?config_id=%d" % self.main_pos_config.id, + "L10nJoEdiPosTour", login="pos_user", + ) diff --git a/addons/l10n_jo_edi_pos/views/pos_order_views.xml b/addons/l10n_jo_edi_pos/views/pos_order_views.xml new file mode 100644 index 00000000000000..4e8f7648b4b39e --- /dev/null +++ b/addons/l10n_jo_edi_pos/views/pos_order_views.xml @@ -0,0 +1,56 @@ + + + + pos.order.form + pos.order + + + + + + + + + + + + + E-invoicing (Jordan) + Download XML + + + + + + + + + + + + + + pos.order + + + + + + + + + + + pos.order.list.select.inherit + pos.order + + + + + + + + + diff --git a/addons/l10n_jo_edi_pos/views/pos_payment_method_views.xml b/addons/l10n_jo_edi_pos/views/pos_payment_method_views.xml new file mode 100644 index 00000000000000..7f6c7e86f3a72d --- /dev/null +++ b/addons/l10n_jo_edi_pos/views/pos_payment_method_views.xml @@ -0,0 +1,13 @@ + + + + pos.payment.method.form + pos.payment.method + + + + + + + + diff --git a/addons/l10n_jo_edi_pos/views/res_config_settings_views.xml b/addons/l10n_jo_edi_pos/views/res_config_settings_views.xml new file mode 100644 index 00000000000000..1ca62a6e8f28ed --- /dev/null +++ b/addons/l10n_jo_edi_pos/views/res_config_settings_views.xml @@ -0,0 +1,25 @@ + + + + res.config.settings.view.form + res.config.settings + + + + + + + + + + Sets Orders Dates to 1 January 2020 in the XMLs which enables testing the submission of orders to the ISTD + + + + + + diff --git a/addons/l10n_pl_edi/data/ir_cron_data.xml b/addons/l10n_pl_edi/data/ir_cron_data.xml index 784e4fbbf287c1..2ec49404630d79 100644 --- a/addons/l10n_pl_edi/data/ir_cron_data.xml +++ b/addons/l10n_pl_edi/data/ir_cron_data.xml @@ -12,5 +12,15 @@ weeks + + + Polish eInvoice: Download vendor bills from KSeF + + code + model._cron_l10n_pl_edi_download_bills() + + 3 + hours + diff --git a/addons/l10n_pl_edi/exceptions.py b/addons/l10n_pl_edi/exceptions.py new file mode 100644 index 00000000000000..d6e39ae64a841e --- /dev/null +++ b/addons/l10n_pl_edi/exceptions.py @@ -0,0 +1,8 @@ +from odoo.exceptions import UserError + + +class KSeFRateLimitError(UserError): + + def __init__(self, message, retry_after=None): + super().__init__(message) + self.retry_after = int(retry_after) if retry_after else None diff --git a/addons/l10n_pl_edi/models/account_move.py b/addons/l10n_pl_edi/models/account_move.py index fb3b3358a4014d..0f3887ac916a99 100644 --- a/addons/l10n_pl_edi/models/account_move.py +++ b/addons/l10n_pl_edi/models/account_move.py @@ -2,9 +2,11 @@ import re from xml.dom.minidom import parseString +from dateutil.relativedelta import relativedelta +from lxml import etree from stdnum.pl.nip import compact -from odoo import api, fields, models +from odoo import Command, api, fields, models from odoo.exceptions import UserError from odoo.tools import float_compare, float_is_zero, float_repr, OrderedSet @@ -45,6 +47,10 @@ class AccountMove(models.Model): depends=['l10n_pl_edi_upo_file'], ) + _sql_constraints = [ + ('unique_l10n_pl_edi_number', 'UNIQUE(l10n_pl_edi_number)', 'The KSeF number must be unique'), + ] + def _l10n_pl_edi_check_mandatory_fields(self): errors = {} if not self: @@ -436,3 +442,203 @@ def _compute_show_reset_to_draft_button(self): def _get_fields_to_detach(self): # EXTENDS account return super()._get_fields_to_detach() + ['l10n_pl_edi_attachment_file', 'l10n_pl_edi_upo_file'] + + @api.model + def l10n_pl_edi_get_ksef_bill_vals_from_xml(self, xml_content): + + p12_to_tax_xml_id_map = { + "23": "vz_kraj_23", + "8": "vz_kraj_8", + "5": "vz_kraj_5", + "0 KR": "vz_kraj_0", + "zw": "vz_kraj_zw", + "oo": "vz_stal", + "0 WDT": "vz_unia", + "np II": "vz_nabu", + "0 EX": "vz_imp_tow", + "np I": "vz_impu", + } + + def parse_fa3_bill_xml(xml_content): + root = etree.fromstring(xml_content) + + def get_value(node, xpath): + res_node = node.find(xpath) + return res_node.text if res_node is not None else None + + invoice_node = root.find('{*}Fa') + vendor_node = root.find('{*}Podmiot1') + + vendor_nip = get_value(vendor_node, '{*}DaneIdentyfikacyjne/{*}NIP') + vendor_name = get_value(vendor_node, '{*}DaneIdentyfikacyjne/{*}Nazwa') + vendor_country = get_value(vendor_node, '{*}Adres/{*}KodKraju') + + invoice_date = get_value(invoice_node, '{*}P_1') + invoice_date_due = get_value(invoice_node, '{*}Platnosc/{*}TerminPlatnosci/{*}Termin') + invoice_number = get_value(invoice_node, '{*}P_2') + currency_code = get_value(invoice_node, '{*}KodWaluty') + move_line_nodes = invoice_node.findall("{*}FaWiersz") + + lines = [ + { + 'name': get_value(line_node, '{*}P_7') or '/', + 'uom_name': get_value(line_node, '{*}P_8A') or '', + 'quantity': float(get_value(line_node, '{*}P_8B') or 0.0), + 'price_unit': float(get_value(line_node, '{*}P_9A') or 0.0), + 'tax_name': get_value(line_node, '{*}P_12') or '', + } + for line_node in move_line_nodes + ] + + return { + 'vendor_nip': vendor_nip, + 'vendor_name': vendor_name, + 'vendor_country': vendor_country, + 'invoice_date': invoice_date, + 'invoice_date_due': invoice_date_due, + 'invoice_number': invoice_number, + 'currency_code': currency_code, + 'lines': lines, + } + + def get_ksef_bill_vals(data): + partner_vat_domain_vals = (data['vendor_nip'], f"{data['vendor_country']}{data['vendor_nip']}") + partner = self.env['res.partner'].search( + [ + ('vat', 'in', partner_vat_domain_vals), + *self.env['res.partner']._check_company_domain(self.env.company), + '|', + ('country_id.code', '=', data['vendor_country']), + ('country_id', '=', False), + ], limit=1, + ) + if not partner: + partner = self.env['res.partner'].create( + { + 'name': data['vendor_name'], + 'vat': data['vendor_nip'], + 'country_id': self.env['res.country'].search([('code', '=', data['vendor_country'])]).id, + }, + ) + + currency = self.env['res.currency'].search([('name', '=', data['currency_code'])], limit=1) + + move_vals = { + 'move_type': 'in_invoice', + 'partner_id': partner.id, + 'invoice_date': data['invoice_date'], + 'invoice_date_due': data['invoice_date_due'], + 'ref': data['invoice_number'], + 'currency_id': currency.id, + 'invoice_line_ids': [], + } + + for line in data['lines']: + + tax_ids = [] + if xml_id := p12_to_tax_xml_id_map.get(line['tax_name']): + if tax := self.env['account.chart.template'].ref(xml_id, raise_if_not_found=False): + tax_ids.append(Command.set(tax.ids)) + else: + raise UserError(self.env._("Purchase tax corresponding to '%s' required for the KSeF import was not found in the system.", line['tax_name'])) + + move_vals['invoice_line_ids'].append( + Command.create( + { + 'name': line['name'], + 'quantity': line['quantity'], + 'price_unit': line['price_unit'], + 'tax_ids': tax_ids, + } + ) + ) + + return move_vals + + return get_ksef_bill_vals(parse_fa3_bill_xml(xml_content)) + + @api.model + def _cron_l10n_pl_edi_download_bills(self): + for company in self.env['res.company'].search([('l10n_pl_edi_access_token', '!=', False)]): + blocking_error = self.with_company(company)._l10n_pl_edi_download_bills_from_ksef() + if blocking_error: + break + + @api.model + def _l10n_pl_edi_download_bills_from_ksef(self): + + def handle_download_bills_from_ksef_error(error): + if not (delay := error.get('retry_after')): + raise UserError(error.get('message')) + + cron = self.env.ref('l10n_pl_edi.cron_l10n_pl_edi_ksef_download_bills') + cron._trigger(at=fields.Datetime.now() + relativedelta(seconds=delay)) + return True + + service = KsefApiService(self.env.company) + + last_processed_move = self.search([ + ('l10n_pl_edi_number', '!=', False), + ('move_type', '=', 'in_invoice'), + *self._check_company_domain(self.env.company) + ], order='invoice_date DESC', limit=1) + + if last_processed_move: + date_from = fields.Datetime.to_datetime(last_processed_move.invoice_date) + else: + date_from = fields.Datetime.now() - relativedelta(months=1) + + query = { + 'subjectType': 'Subject2', + 'dateRange': { + 'from': date_from.isoformat(), + 'to': fields.Datetime.now().isoformat(), + 'dateType': 'Invoicing', + }, + } + + # Rate Limiting of get_invoice_by_ksef_number + # + # req/s | req/m | req/h + # ----------------------------- + # 8 | 16 | 64 + # + # Page size shouldn't be more than 64. + + page_offset = 0 + page_size = 64 + + has_more = True + + invoice_numbers = [] + blocking_error = False + + while has_more: + response = service.query_invoice_metadata(query, page_size, page_offset) + if response.get('error'): + blocking_error = handle_download_bills_from_ksef_error(response['error']) + break + invoice_numbers.extend(invoice['ksefNumber'] for invoice in response['invoices']) + has_more = response['hasMore'] + page_offset += 1 + + already_processed = set(self.env['account.move'].search([ + ('l10n_pl_edi_number', 'in', invoice_numbers) + ]).mapped('l10n_pl_edi_number')) + + to_process = [invoice_nr for invoice_nr in invoice_numbers if invoice_nr not in already_processed] + + bills_vals_list = [] + + for invoice_nr in to_process: + response = service.get_invoice_by_ksef_number(invoice_nr) + if response.get('error'): + blocking_error = handle_download_bills_from_ksef_error(response['error']) + break + bill_data = self.l10n_pl_edi_get_ksef_bill_vals_from_xml(response['xml_content']) + bill_data['l10n_pl_edi_number'] = invoice_nr + bills_vals_list.append(bill_data) + + self.create(bills_vals_list) + + return blocking_error diff --git a/addons/l10n_pl_edi/tests/export_xmls/fa3_bill.xml b/addons/l10n_pl_edi/tests/export_xmls/fa3_bill.xml new file mode 100644 index 00000000000000..b08d010e064998 --- /dev/null +++ b/addons/l10n_pl_edi/tests/export_xmls/fa3_bill.xml @@ -0,0 +1,100 @@ + + + + FA + 3 + 2026-02-10T10:50:29Z + Odoo + + + + 7492091229 + HADRON FOR BUSINESS SP Z O O + + + PL + 250 Executive Park Blvd, Suite 3400 94134 Kędzierzyn-Koźle Polska + + + it@hadron.eu.com + 112 + + + + + 5795955811 + PL Company + + + PL + 47-400 Racibórz Polska + + + info@company.plexample.com + +48 512 345 678 + + 2 + 2 + + + PLN + 2026-02-10 + Kędzierzyn-Koźle + FV/2026/00001-demo-test-005 + 3.19 + 0.73 + 38.42 + 1.0 + + 2 + 2 + 2 + 2 + + 1 + + + 1 + + 2 + + 1 + + + VAT + + 1 + [FURN_0006] Podstawka pod monitor + szt. + 1.0 + 3.19 + 3.19 + 23 + + + 2 + [FOOD_0001] Chleb pszenny + szt. + 2.5 + 5.00 + 12.50 + 8 + + + 3 + [BOOK_0001] Podręcznik szkolny + szt. + 4.0 + 5.00 + 20.00 + 5 + + + + 2026-02-10 + + + + diff --git a/addons/l10n_pl_edi/tests/test_l10n_pl_edi.py b/addons/l10n_pl_edi/tests/test_l10n_pl_edi.py index 8037b923924450..ef977387847933 100644 --- a/addons/l10n_pl_edi/tests/test_l10n_pl_edi.py +++ b/addons/l10n_pl_edi/tests/test_l10n_pl_edi.py @@ -1,4 +1,6 @@ import base64 +from datetime import timedelta + from lxml import etree from odoo import Command, fields, tools @@ -6,6 +8,7 @@ from odoo.tests import freeze_time, patch, tagged from odoo.addons.account.tests.common import AccountTestInvoicingCommon +from odoo.addons.base.tests.test_ir_cron import CronMixinCase from odoo.addons.l10n_pl_edi.tools.ksef_api_service import KsefApiService @@ -14,7 +17,7 @@ def attachment_to_dict(attachment): @tagged('post_install', '-at_install', 'post_install_l10n') -class TestL10nPlEdi(AccountTestInvoicingCommon): +class TestL10nPlEdi(AccountTestInvoicingCommon, CronMixinCase): @classmethod @AccountTestInvoicingCommon.setup_country('pl') @@ -486,3 +489,132 @@ def send_invoice_raise(xml_content): self.assertEqual(invoice.l10n_pl_edi_session_id, False) self.assertEqual(invoice.l10n_pl_edi_ref, False) self.assertEqual(invoice.l10n_pl_edi_attachment_id.name, False) + + def test_l10n_pl_edi_download_bill_success(self): + + def query_invoice_metadata(query_criteria, page_size=100, page_offset=0): + return { + 'hasMore': False, + 'invoices': [ + { + 'acquisitionDate': '2026-02-10T10:50:29.348439+00:00', + 'buyer': {'identifier': {'type': 'Nip', 'value': '5795955811'}, 'name': 'PL Company'}, + 'currency': 'PLN', + 'formCode': {'schemaVersion': '1-0E', 'systemCode': 'FA (3)', 'value': 'FA'}, + 'hasAttachment': False, + 'invoiceHash': 'DOFApZsfUkl3BLgW1nd7frNq4IVHvYoXHEudpyCFbpg=', + 'invoiceNumber': 'FV/2026/00001-demo-test-005', + 'invoiceType': 'Vat', + 'invoicingDate': '2026-02-10T10:50:29.189345+00:00', + 'invoicingMode': 'Online', + 'isSelfInvoicing': False, + 'issueDate': '2026-02-10', + 'ksefNumber': '7492091229-20260210-0700A043714A-5E', + 'permanentStorageDate': '2026-02-10T10:50:30.40494+00:00', + 'seller': {'name': 'HADRON FOR BUSINESS SP Z O O', 'nip': '7492091229'}, + }, + ], + } + + def get_invoice_by_ksef_number(ksef_number): + path = 'l10n_pl_edi/tests/export_xmls/fa3_bill.xml' + with tools.file_open(path, mode='rb') as file: + return {'xml_content': file.read()} + + with ( + patch.object(KsefApiService, 'query_invoice_metadata', side_effect=query_invoice_metadata), + patch.object(KsefApiService, 'get_invoice_by_ksef_number', side_effect=get_invoice_by_ksef_number), + ): + self.env['account.move'].with_company(self.company)._l10n_pl_edi_download_bills_from_ksef() + + created_move = self.env['account.move'].search([('l10n_pl_edi_number', '=', '7492091229-20260210-0700A043714A-5E')]) + self.assertTrue(created_move) + self.assertEqual(created_move.partner_id.vat, '7492091229') + self.assertEqual(len(created_move), 1) + self.assertRecordValues( + created_move, [ + { + 'state': 'draft', + 'move_type': 'in_invoice', + 'invoice_date': fields.Date.to_date('2026-02-10'), + 'invoice_date_due': fields.Date.to_date('2026-02-10'), + 'ref': 'FV/2026/00001-demo-test-005', + 'currency_id': self.env['res.currency'].search([('name', '=', 'PLN')]).id, + }, + ], + ) + self.assertRecordValues( + created_move.invoice_line_ids, [ + { + 'name': "[FURN_0006] Podstawka pod monitor", + 'quantity': 1.0, + 'price_unit': 3.19, + }, + { + 'name': "[FOOD_0001] Chleb pszenny", + 'quantity': 2.5, + 'price_unit': 5.00, + }, + { + 'name': "[BOOK_0001] Podręcznik szkolny", + 'quantity': 4.0, + 'price_unit': 5.00, + }, + ], + ) + + self.assertEqual( + created_move.invoice_line_ids.tax_ids.ids, + self.env['account.tax'].search( + [ + ('name', 'in', ('23% G', '8%', '5%')), + ('type_tax_use', '=', 'purchase'), + *self.env['account.tax']._check_company_domain(self.company), + ], + ).ids, + ) + + def test_l10n_pl_edi_download_bill_retry_after(self): + """Test that when a rate limit error occurs the progress is preserved and the cron is rescheduled.""" + + def query_invoice_metadata(query_criteria, page_size=100, page_offset=0): + return { + 'hasMore': False, + 'invoices': [ + { + 'ksefNumber': 'KSEF-BILL-001', + }, + { + 'ksefNumber': 'KSEF-BILL-002', + }, + ], + } + + call_count = 0 + + def get_invoice_by_ksef_number(ksef_number): + nonlocal call_count + call_count += 1 + if call_count == 1: + path = 'l10n_pl_edi/tests/export_xmls/fa3_bill.xml' + with tools.file_open(path, mode='rb') as file: + return {'xml_content': file.read()} + return {'error': {'retry_after': 120, 'message': 'Too Many Requests'}} + + with ( + patch.object(KsefApiService, 'query_invoice_metadata', side_effect=query_invoice_metadata), + patch.object(KsefApiService, 'get_invoice_by_ksef_number', side_effect=get_invoice_by_ksef_number), + self.capture_triggers() as capt, + ): + cron_runs_before = len(capt.records) + self.env['account.move'].with_company(self.company)._l10n_pl_edi_download_bills_from_ksef() + + bill_1 = self.env['account.move'].search([('l10n_pl_edi_number', '=', 'KSEF-BILL-001')]) + self.assertTrue(bill_1) + + bill_2 = self.env['account.move'].search([('l10n_pl_edi_number', '=', 'KSEF-BILL-002')]) + self.assertFalse(bill_2) + + self.assertEqual(len(capt.records), cron_runs_before + 1) + self.assertGreaterEqual(capt.records[-1].call_at, fields.Datetime.now() + timedelta(seconds=120)) + self.assertLessEqual(capt.records[-1].call_at, fields.Datetime.now() + timedelta(seconds=240)) diff --git a/addons/l10n_pl_edi/tools/ksef_api_service.py b/addons/l10n_pl_edi/tools/ksef_api_service.py index 14130a4e459190..599802d48e7a45 100644 --- a/addons/l10n_pl_edi/tools/ksef_api_service.py +++ b/addons/l10n_pl_edi/tools/ksef_api_service.py @@ -13,6 +13,9 @@ from odoo.exceptions import UserError +from odoo.addons.l10n_pl_edi.exceptions import KSeFRateLimitError + + _logger = logging.getLogger(__name__) TIMEOUT = 10 @@ -58,6 +61,9 @@ def _make_request(self, method, endpoint, is_auth_retry=False, **kwargs): self.refresh_access_token() # Pass is_auth_retry=True to prevent looping return self._make_request(method, endpoint, is_auth_retry=True, **kwargs) + elif response.status_code == 429: + retry_after = response.headers.get('Retry-After') + raise KSeFRateLimitError("Too Many Requests", retry_after=retry_after) else: response.raise_for_status() return response @@ -337,3 +343,20 @@ def redeem_token(self, temp_token): return response.json() except requests.exceptions.RequestException as e: raise UserError(self.env._("Failed to redeem token: %s", e.response.text if e.response else e)) + + def query_invoice_metadata(self, query_criteria, page_size=100, page_offset=0): + endpoint = f"{self.api_url}/invoices/query/metadata" + params = {'pageSize': page_size, 'pageOffset': page_offset} + try: + response = self._make_request('POST', endpoint, json=query_criteria, params=params) + return response.json() + except KSeFRateLimitError as e: + return {'error': {'retry_after': e.retry_after, 'message': e.message}} + + def get_invoice_by_ksef_number(self, ksef_number): + endpoint = f"{self.api_url}/invoices/ksef/{ksef_number}" + try: + response = self._make_request('GET', endpoint) + return {'xml_content': response.content} + except KSeFRateLimitError as e: + return {'error': {'retry_after': e.retry_after, 'message': e.message}} diff --git a/addons/l10n_ro_edi/models/account_edi_xml_ubl_ciusro.py b/addons/l10n_ro_edi/models/account_edi_xml_ubl_ciusro.py index d66716f8dcdf13..57256aa440609d 100644 --- a/addons/l10n_ro_edi/models/account_edi_xml_ubl_ciusro.py +++ b/addons/l10n_ro_edi/models/account_edi_xml_ubl_ciusro.py @@ -239,6 +239,10 @@ def _ubl_add_accounting_customer_party_legal_entity_nodes(self, vals): }] def _export_invoice_constraints_new(self, invoice, vals): - constraints = super()._export_invoice_constraints_new(invoice, vals) + # OVERRIDE 'account.edi.xml.ubl_bis3': don't apply Peppol rules + constraints = self.env['account.edi.xml.ubl_20']._export_invoice_constraints(invoice, vals) + constraints.update( + self._invoice_constraints_cen_en16931_ubl_new(invoice, vals) + ) constraints.update(self._export_invoice_constraints_ciusro(vals)) return constraints diff --git a/addons/l10n_ro_edi/tests/test_files/from_odoo/ciusro_out_invoice_no_prefix_vat.xml b/addons/l10n_ro_edi/tests/test_files/from_odoo/ciusro_out_invoice_no_prefix_vat.xml index 88e2d5d7979004..fbc6740e5c4863 100644 --- a/addons/l10n_ro_edi/tests/test_files/from_odoo/ciusro_out_invoice_no_prefix_vat.xml +++ b/addons/l10n_ro_edi/tests/test_files/from_odoo/ciusro_out_invoice_no_prefix_vat.xml @@ -52,7 +52,6 @@ - ___ignore___ ref_partner_a diff --git a/addons/l10n_ro_edi/tests/test_xml_ubl_ro.py b/addons/l10n_ro_edi/tests/test_xml_ubl_ro.py index 430569b2a5de24..db4f5a767db546 100644 --- a/addons/l10n_ro_edi/tests/test_xml_ubl_ro.py +++ b/addons/l10n_ro_edi/tests/test_xml_ubl_ro.py @@ -178,7 +178,7 @@ def test_export_no_vat_but_have_company_registry_new(self): def test_export_no_vat_but_have_company_registry_without_prefix(self): self.company_data['company'].write({'vat': False, 'company_registry': '1234567897'}) - self.partner_a.write({'vat': False}) + self.partner_a.write({'vat': False, 'peppol_eas': False, 'peppol_endpoint': False}) invoice = self.create_move("out_invoice", currency_id=self.company.currency_id.id) attachment = self.get_attachment(invoice) self._assert_invoice_attachment(attachment, xpaths=None, expected_file_path='from_odoo/ciusro_out_invoice_no_prefix_vat.xml') diff --git a/addons/mass_mailing/static/src/js/mass_mailing_html_field.js b/addons/mass_mailing/static/src/js/mass_mailing_html_field.js index 1d09fced2e9a27..c0aff4c0cdd3f2 100644 --- a/addons/mass_mailing/static/src/js/mass_mailing_html_field.js +++ b/addons/mass_mailing/static/src/js/mass_mailing_html_field.js @@ -357,6 +357,7 @@ export class MassMailingHtmlField extends HtmlField { template: values.body_arch, userId: values.user_id[0], userName: values.user_id[1], + is_favorite: true, }; }); @@ -674,41 +675,56 @@ export class MassMailingHtmlField extends HtmlField { const old_layout = this.wysiwyg.$editable.find('.o_layout')[0]; - let $newWrapper; - let $newWrapperContent; - if (themeParams.nowrap) { - $newWrapper = $('', { - class: 'oe_structure' - }); - $newWrapperContent = $newWrapper; + const templateDoc = new DOMParser().parseFromString(themeParams.template, "text/html"); + const templateEl = templateDoc.body.firstElementChild; + const expectedClasses = [ + 'o_layout', + 'oe_unremovable', + 'oe_unmovable', + themeParams.className, + ].filter(Boolean); + const hasAllClasses = templateEl && expectedClasses.every(cls => templateEl.classList.contains(cls)); + if (themeParams?.is_favorite && hasAllClasses) { + // Avoid applying multiple time the wrapper elements + old_layout && old_layout.remove(); + this.wysiwyg.odooEditor.resetContent(templateEl.outerHTML); } else { - // This wrapper structure is the only way to have a responsive - // and centered fixed-width content column on all mail clients - $newWrapper = $('', { - class: 'container o_mail_wrapper o_mail_regular oe_unremovable', - }); - $newWrapperContent = $('', { - class: 'col o_mail_no_options o_mail_wrapper_td bg-white oe_structure o_editable' - }); - $newWrapper.append($('').append($newWrapperContent)); + let $newWrapper; + let $newWrapperContent; + if (themeParams.nowrap) { + $newWrapper = $('', { + class: 'oe_structure' + }); + $newWrapperContent = $newWrapper; + } else { + // This wrapper structure is the only way to have a responsive + // and centered fixed-width content column on all mail clients + $newWrapper = $('', { + class: 'container o_mail_wrapper o_mail_regular oe_unremovable', + }); + $newWrapperContent = $('', { + class: 'col o_mail_no_options o_mail_wrapper_td bg-white oe_structure o_editable' + }); + $newWrapper.append($('').append($newWrapperContent)); + } + const $newLayout = $('', { + class: 'o_layout oe_unremovable oe_unmovable bg-200 ' + themeParams.className, + style: themeParams.layoutStyles, + 'data-name': 'Mailing', + }).append($newWrapper); + + $newWrapperContent.append(themeParams.template); + + this._switchImages(themeParams, $newWrapperContent); + old_layout && old_layout.remove(); + this.wysiwyg.odooEditor.resetContent($newLayout[0].outerHTML); + + $newWrapperContent.find('*').addBack() + .contents() + .filter(function () { + return this.nodeType === 3 && this.textContent.match(/\S/); + }).parent().addClass('o_default_snippet_text'); } - const $newLayout = $('', { - class: 'o_layout oe_unremovable oe_unmovable bg-200 ' + themeParams.className, - style: themeParams.layoutStyles, - 'data-name': 'Mailing', - }).append($newWrapper); - - const $contents = themeParams.template; - $newWrapperContent.append($contents); - this._switchImages(themeParams, $newWrapperContent); - old_layout && old_layout.remove(); - this.wysiwyg.odooEditor.resetContent($newLayout[0].outerHTML); - - $newWrapperContent.find('*').addBack() - .contents() - .filter(function () { - return this.nodeType === 3 && this.textContent.match(/\S/); - }).parent().addClass('o_default_snippet_text'); if (themeParams.name === 'basic') { this.wysiwyg.$editable[0].focus(); diff --git a/addons/point_of_sale/models/pos_session.py b/addons/point_of_sale/models/pos_session.py index 54727ba2b0badc..aa270e9d99d6d6 100644 --- a/addons/point_of_sale/models/pos_session.py +++ b/addons/point_of_sale/models/pos_session.py @@ -220,6 +220,26 @@ def get_pos_ui_product_pricelist_item_by_product(self, product_tmpl_ids, product return {'product.pricelist.item': pricelist_item.read(pricelist_item_fields, load=False)} + def get_pos_uis_product_pricelist_item_by_pricelist(self, config_id, pricelist_ids): + self.ensure_one() + pricelist_fields = self.env['product.pricelist']._load_pos_data_fields(config_id) + pricelist_domain = [ + '&', + ('id', 'in', pricelist_ids), + *self.env['product.pricelist']._check_company_domain(self.company_id), + ] + pricelists = self.env['product.pricelist'].search(pricelist_domain) + + pricelist_item_fields = self.env['product.pricelist.item']._load_pos_data_fields(config_id) + pricelist_item_domain = [ + '&', + ('pricelist_id', 'in', pricelist_ids), + *self.env['product.pricelist.item']._check_company_domain(self.company_id), + ] + + pricelist_item = self.env['product.pricelist.item'].search(pricelist_item_domain) + return {'product.pricelist.item': pricelist_item.read(pricelist_item_fields, load=False), 'product.pricelist': pricelists.read(pricelist_fields, load=False)} + @api.depends('currency_id', 'company_id.currency_id') def _compute_is_in_company_currency(self): for session in self: diff --git a/addons/point_of_sale/models/res_partner.py b/addons/point_of_sale/models/res_partner.py index e1af0efa19d6f3..7953defa453c30 100644 --- a/addons/point_of_sale/models/res_partner.py +++ b/addons/point_of_sale/models/res_partner.py @@ -13,6 +13,20 @@ class ResPartner(models.Model): groups="point_of_sale.group_pos_user", ) pos_order_ids = fields.One2many('pos.order', 'partner_id', readonly=True) + pos_property_product_pricelist_id = fields.Integer( + compute="_compute_pos_property_product_pricelist_id", + string="Pricelist ID" + ) + + @api.depends_context( + "company" + ) + @api.depends( + "property_product_pricelist" + ) + def _compute_pos_property_product_pricelist_id(self): + for rec in self: + rec.pos_property_product_pricelist_id = rec.property_product_pricelist.id @api.model def _load_pos_data_domain(self, data): @@ -33,7 +47,7 @@ def _load_pos_data_fields(self, config_id): return [ 'id', 'name', 'street', 'city', 'state_id', 'country_id', 'vat', 'lang', 'phone', 'zip', 'mobile', 'email', 'barcode', 'write_date', 'property_account_position_id', 'property_product_pricelist', 'parent_name', 'contact_address', - 'company_type', + 'company_type', 'pos_property_product_pricelist_id' ] def _compute_pos_order(self): diff --git a/addons/point_of_sale/static/src/app/models/pos_order.js b/addons/point_of_sale/static/src/app/models/pos_order.js index e7c698fb410f0e..e460e3ec4922d1 100644 --- a/addons/point_of_sale/static/src/app/models/pos_order.js +++ b/addons/point_of_sale/static/src/app/models/pos_order.js @@ -1014,7 +1014,7 @@ export class PosOrder extends Base { ) : defaultFiscalPosition; newPartnerPricelist = - this.config.available_pricelist_ids.find( + this.models["product.pricelist"].find( (pricelist) => pricelist.id === newPartner.property_product_pricelist?.id ) || this.config.pricelist_id; } else { diff --git a/addons/point_of_sale/static/src/app/store/pos_store.js b/addons/point_of_sale/static/src/app/store/pos_store.js index 2eb63482960b8a..3c1641f9f119df 100644 --- a/addons/point_of_sale/static/src/app/store/pos_store.js +++ b/addons/point_of_sale/static/src/app/store/pos_store.js @@ -12,6 +12,7 @@ import { random5Chars, uuidv4, computeProductPricelistCache, + computeProductPricelistCacheOfProductAndPricelist, loadPricelistItems } from "@point_of_sale/utils"; import { Reactive } from "@web/core/utils/reactive"; import { HWPrinter } from "@point_of_sale/app/printer/hw_printer"; @@ -722,6 +723,10 @@ export class PosStore extends Reactive { vals.product_id = this.data.models["product.product"].get(vals.product_id); } const product = vals.product_id; + const partner = order.partner_id; + if (partner && partner.pos_property_product_pricelist_id) { + await computeProductPricelistCacheOfProductAndPricelist(this, [], product, partner.pos_property_product_pricelist_id); + } const values = { price_type: "price_unit" in vals ? "manual" : "original", @@ -2041,6 +2046,39 @@ export class PosStore extends Reactive { getPayload: (newPartner) => currentOrder.set_partner(newPartner), }); + try { + if (payload && Boolean(payload.pos_property_product_pricelist_id)) { + let pricelistId = payload.pos_property_product_pricelist_id; + let pricelist = this.models["product.pricelist"].find((item) => item.id === pricelistId); + if (!Boolean(pricelist)) { + await loadPricelistItems(this, pricelistId); + pricelist = this.models["product.pricelist"].find((item) => item.id === pricelistId); + } + if (Boolean(pricelist)) { + payload.property_product_pricelist = pricelist; + const orderLines = currentOrder.get_orderlines(); + for (const lineIndex in orderLines) { + let line = orderLines[lineIndex]; + await computeProductPricelistCacheOfProductAndPricelist(this, [], line.product_id, pricelistId); + } + } + + } + } catch (error) { + console.log(error) + let message; + if (error instanceof ConnectionLostError) { + message = _t( + "Connection to the server has been lost. Please check your internet connection." + ); + } else { + message = error.data.message; + } + this.env.services.dialog.add(AlertDialog, { + title: _t("Failure to apply pricelist of the customer"), + body: message, + }); + } if (payload) { currentOrder.set_partner(payload); } else { diff --git a/addons/point_of_sale/static/src/utils.js b/addons/point_of_sale/static/src/utils.js index d94249098aafd8..0b97408ee916cc 100644 --- a/addons/point_of_sale/static/src/utils.js +++ b/addons/point_of_sale/static/src/utils.js @@ -256,3 +256,84 @@ export function computeProductPricelistCache(service, data = []) { service._loadMissingPricelistItems(products); } } + +export async function loadPricelistItems(service, pricelist_id) { + await service.data.read("pos.config", [service.config.id], ["id"]); // dirty fix to check connectivity + await service.data.callRelated("pos.session", "get_pos_uis_product_pricelist_item_by_pricelist", [ + odoo.pos_session_id, + service.config.id, + [pricelist_id] + ]); +} + +export function computeProductPricelistCacheOfProductAndPricelist(service, data = [], product, pricelistId) { + if (product.cachedPricelistRules[pricelistId]) { + return; + } + + const date = luxon.DateTime.now(); + let pricelistItems = service.models["product.pricelist.item"].filter((item) => item.pricelist_id.id === pricelistId); + + const pushItem = (targetArray, key, item) => { + if (!targetArray[key]) { + targetArray[key] = []; + } + targetArray[key].push(item); + }; + + const pricelistRules = {}; + + for (const item of pricelistItems) { + if ( + (item.date_start && deserializeDateTime(item.date_start) > date) || + (item.date_end && deserializeDateTime(item.date_end) < date) + ) { + continue; + } + const pricelistId = item.pricelist_id.id; + + if (!pricelistRules[pricelistId]) { + pricelistRules[pricelistId] = { + productItems: {}, + productTmlpItems: {}, + categoryItems: {}, + globalItems: [], + }; + } + + const productId = item.raw.product_id; + if (productId) { + pushItem(pricelistRules[pricelistId].productItems, productId, item); + continue; + } + const productTmplId = item.raw.product_tmpl_id; + if (productTmplId) { + pushItem(pricelistRules[pricelistId].productTmlpItems, productTmplId, item); + continue; + } + const categId = item.raw.categ_id; + if (categId) { + pushItem(pricelistRules[pricelistId].categoryItems, categId, item); + } else { + pricelistRules[pricelistId].globalItems.push(item); + } + } + + const applicableRules = product.getApplicablePricelistRules(pricelistRules); + for (const pricelistId in applicableRules) { + if (product.cachedPricelistRules[pricelistId]) { + const existingRuleIds = product.cachedPricelistRules[pricelistId].map( + (rule) => rule.id + ); + const newRules = applicableRules[pricelistId].filter( + (rule) => !existingRuleIds.includes(rule.id) + ); + product.cachedPricelistRules[pricelistId] = [ + ...newRules, + ...product.cachedPricelistRules[pricelistId], + ]; + } else { + product.cachedPricelistRules[pricelistId] = applicableRules[pricelistId]; + } + } +} \ No newline at end of file diff --git a/addons/point_of_sale/static/tests/tours/ticket_screen_tour.js b/addons/point_of_sale/static/tests/tours/ticket_screen_tour.js index ea0ed046350893..1328201c20cd1a 100644 --- a/addons/point_of_sale/static/tests/tours/ticket_screen_tour.js +++ b/addons/point_of_sale/static/tests/tours/ticket_screen_tour.js @@ -375,22 +375,3 @@ registry.category("web_tour.tours").add("test_serial_number_do_not_duplicate_aft }), ].flat(), }); - -registry.category("web_tour.tours").add("test_not_available_pricelist_not_set_on_order", { - steps: () => - [ - Chrome.startPoS(), - Dialog.confirm("Open Register"), - Chrome.clickMenuOption("Orders"), - TicketScreen.selectFilter("Paid"), - TicketScreen.clickDiscard(), - ProductScreen.isShown(), - ProductScreen.addOrderline("Desk Pad", "2", "3"), - ProductScreen.clickPartnerButton(), - ProductScreen.clickCustomer("AA Customer"), - ProductScreen.clickPayButton(), - PaymentScreen.clickPaymentMethod("Bank"), - PaymentScreen.clickValidate(), - ReceiptScreen.isShown(), - ].flat(), -}); diff --git a/addons/pos_edi_ubl/__init__.py b/addons/pos_edi_ubl/__init__.py new file mode 100644 index 00000000000000..0650744f6bc69b --- /dev/null +++ b/addons/pos_edi_ubl/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/addons/pos_edi_ubl/__manifest__.py b/addons/pos_edi_ubl/__manifest__.py new file mode 100644 index 00000000000000..f0eac36e337104 --- /dev/null +++ b/addons/pos_edi_ubl/__manifest__.py @@ -0,0 +1,14 @@ +{ + 'name': 'Point of Sale UBL', + 'version': '1.0', + 'category': 'Sales/Point of Sale', + 'sequence': 6, + 'summary': 'UBL in the Point of Sale ', + 'description': """ + +This module provides helpers for exporting POS orders in various UBL 2.1 formats. + +""", + 'depends': ['point_of_sale', 'account_edi_ubl_cii'], + 'license': 'LGPL-3', +} diff --git a/addons/pos_edi_ubl/models/__init__.py b/addons/pos_edi_ubl/models/__init__.py new file mode 100644 index 00000000000000..460892381cae75 --- /dev/null +++ b/addons/pos_edi_ubl/models/__init__.py @@ -0,0 +1 @@ +from . import pos_edi_ubl_21 diff --git a/addons/pos_edi_ubl/models/pos_edi_ubl_21.py b/addons/pos_edi_ubl/models/pos_edi_ubl_21.py new file mode 100644 index 00000000000000..b4e3649353a0e0 --- /dev/null +++ b/addons/pos_edi_ubl/models/pos_edi_ubl_21.py @@ -0,0 +1,203 @@ +from lxml import etree + +from odoo import models +from odoo.addons.account.tools import dict_to_xml + + +class PosEdiXmlUBL21(models.AbstractModel): + _name = 'pos.edi.xml.ubl_21' + _description = 'PoS Order UBL 2.1 builder' + _inherit = ['account.edi.xml.ubl_21'] + + def _export_pos_order(self, pos_order): + vals = {'pos_order': pos_order.with_context(lang=pos_order.partner_id.lang)} + document_node = self._get_pos_order_node(vals) + + errors = {constraint for constraint in self._export_pos_order_constraints(pos_order, vals).values() if constraint} + + template = self._get_document_template(vals) + nsmap = self._get_document_nsmap(vals) + + xml_tree = dict_to_xml(document_node, nsmap=nsmap, template=template) + return etree.tostring(xml_tree, xml_declaration=True, encoding='UTF-8'), set(errors) + + def _get_pos_order_node(self, vals): + self._add_pos_order_config_vals(vals) + self._add_pos_order_base_lines_vals(vals) + self._add_pos_order_currency_vals(vals) + self._add_pos_order_tax_grouping_function_vals(vals) + self._add_pos_order_monetary_totals_vals(vals) + + document_node = {} + self._add_pos_order_header_nodes(document_node, vals) + self._add_pos_order_accounting_supplier_party_nodes(document_node, vals) + self._add_pos_order_accounting_customer_party_nodes(document_node, vals) + self._add_pos_order_payment_means_nodes(document_node, vals) + + self._add_pos_order_allowance_charge_nodes(document_node, vals) + self._add_pos_order_tax_total_nodes(document_node, vals) + self._add_pos_order_monetary_total_nodes(document_node, vals) + self._add_pos_order_line_nodes(document_node, vals) + return document_node + + def _add_pos_order_config_vals(self, vals): + pos_order = vals['pos_order'] + supplier = pos_order.company_id.partner_id.commercial_partner_id + customer = pos_order.partner_id + + vals.update({ + 'document_type': 'invoice' if pos_order.amount_total >= 0 else 'credit_note', + + 'company': pos_order.company_id, + 'journal': pos_order.config_id.invoice_journal_id, + 'name': pos_order.name, + + 'supplier': supplier, + 'customer': customer, + + 'currency_id': pos_order.currency_id, + 'company_currency_id': pos_order.company_id.currency_id, + + 'use_company_currency': False, # If true, use the company currency for the amounts instead of the invoice currency + 'fixed_taxes_as_allowance_charges': True, # If true, include fixed taxes as AllowanceCharges on lines instead of as taxes + }) + + def _add_pos_order_base_lines_vals(self, vals): + pos_order = vals['pos_order'] + + base_lines = pos_order._prepare_tax_base_line_values() + AccountTax = self.env['account.tax'] + AccountTax._add_tax_details_in_base_lines(base_lines, pos_order.company_id) + AccountTax._round_base_lines_tax_details(base_lines, pos_order.company_id) + + vals['base_lines'] = base_lines + + def _add_pos_order_currency_vals(self, vals): + self._add_document_currency_vals(vals) + + def _add_pos_order_tax_grouping_function_vals(self, vals): + self._add_document_tax_grouping_function_vals(vals) + + def _add_pos_order_monetary_totals_vals(self, vals): + self._add_document_monetary_total_vals(vals) + + def _add_pos_order_header_nodes(self, document_node, vals): + pos_order = vals['pos_order'] + document_node.update({ + 'cbc:UBLVersionID': {'_text': '2.0'}, + 'cbc:ID': {'_text': vals['name']}, + 'cbc:IssueDate': {'_text': pos_order.date_order}, + 'cbc:InvoiceTypeCode': {'_text': 380} if vals['document_type'] == 'invoice' else None, + 'cbc:Note': {'_text': pos_order.general_note}, + 'cbc:DocumentCurrencyCode': {'_text': pos_order.currency_id.name}, + 'cac:OrderReference': { + 'cbc:ID': {'_text': vals['name']}, + } + }) + + def _add_pos_order_accounting_supplier_party_nodes(self, document_node, vals): + document_node['cac:AccountingSupplierParty'] = { + 'cac:Party': self._get_party_node({**vals, 'partner': vals['supplier'], 'role': 'supplier'}), + } + + def _add_pos_order_accounting_customer_party_nodes(self, document_node, vals): + document_node['cac:AccountingCustomerParty'] = { + 'cac:Party': self._get_party_node({**vals, 'partner': vals['customer'], 'role': 'customer'}), + } + + def _add_pos_order_payment_means_nodes(self, document_node, vals): + pass + + def _add_pos_order_allowance_charge_nodes(self, document_node, vals): + self._add_document_allowance_charge_nodes(document_node, vals) + + def _add_pos_order_tax_total_nodes(self, document_node, vals): + self._add_document_tax_total_nodes(document_node, vals) + + def _add_pos_order_monetary_total_nodes(self, document_node, vals): + self._add_document_monetary_total_nodes(document_node, vals) + monetary_total_tag = self._get_tags_for_document_type(vals)['monetary_total'] + pos_order = vals['pos_order'] + total_included = vals[f'tax_exclusive_amount{vals["currency_suffix"]}'] + document_node[monetary_total_tag].update({ + 'cbc:PrepaidAmount': { + '_text': self.format_float(total_included - pos_order.amount_paid, vals['currency_dp']), + 'currencyID': vals['currency_name'], + }, + 'cbc:PayableAmount': { + '_text': self.format_float(pos_order.amount_paid, vals['currency_dp']), + 'currencyID': vals['currency_name'], + }, + }) + + def _add_pos_order_line_nodes(self, document_node, vals): + line_idx = 1 + + line_tag = self._get_tags_for_document_type(vals)['document_line'] + document_node[line_tag] = line_nodes = [] + for base_line in vals['base_lines']: + # Only use product lines to generate the UBL InvoiceLines. + # Other lines should be represented as AllowanceCharges. + if not self._is_document_allowance_charge(base_line): + line_vals = { + **vals, + 'line_idx': line_idx, + 'base_line': base_line, + } + line_node = self._get_pos_order_line_node(line_vals) + line_nodes.append(line_node) + line_idx += 1 + + def _get_pos_order_line_node(self, vals): + self._add_pos_order_line_vals(vals) + + line_node = {} + self._add_pos_order_line_id_nodes(line_node, vals) + self._add_pos_order_line_note_nodes(line_node, vals) + self._add_pos_order_line_amount_nodes(line_node, vals) + self._add_pos_order_line_period_nodes(line_node, vals) + self._add_pos_order_line_allowance_charge_nodes(line_node, vals) + self._add_pos_order_line_tax_total_nodes(line_node, vals) + self._add_pos_order_line_item_nodes(line_node, vals) + self._add_pos_order_line_tax_category_nodes(line_node, vals) + self._add_pos_order_line_price_nodes(line_node, vals) + return line_node + + def _add_pos_order_line_vals(self, vals): + self._add_document_line_vals(vals) + + def _add_pos_order_line_id_nodes(self, line_node, vals): + self._add_document_line_id_nodes(line_node, vals) + + def _add_pos_order_line_note_nodes(self, line_node, vals): + self._add_document_line_note_nodes(line_node, vals) + + def _add_pos_order_line_amount_nodes(self, line_node, vals): + self._add_document_line_amount_nodes(line_node, vals) + + def _add_pos_order_line_period_nodes(self, line_node, vals): + pass + + def _add_pos_order_line_allowance_charge_nodes(self, line_node, vals): + self._add_document_line_allowance_charge_nodes(line_node, vals) + + def _add_pos_order_line_tax_total_nodes(self, line_node, vals): + self._add_document_line_tax_total_nodes(line_node, vals) + + def _add_pos_order_line_item_nodes(self, line_node, vals): + self._add_document_line_item_nodes(line_node, vals) + + line = vals['base_line']['record'] + if line_name := line.name and line.name.replace('\n', ' '): + line_node['cac:Item']['cbc:Description']['_text'] = line_name + if not line_node['cac:Item']['cbc:Name']['_text']: + line_node['cac:Item']['cbc:Name']['_text'] = line_name + + def _add_pos_order_line_tax_category_nodes(self, line_node, vals): + self._add_document_line_tax_category_nodes(line_node, vals) + + def _add_pos_order_line_price_nodes(self, line_node, vals): + self._add_document_line_price_nodes(line_node, vals) + + def _export_pos_order_constraints(self, pos_order, vals): + return {} diff --git a/addons/sale_project/models/project_task.py b/addons/sale_project/models/project_task.py index c255c0be3a9d83..16b25236df2ad3 100644 --- a/addons/sale_project/models/project_task.py +++ b/addons/sale_project/models/project_task.py @@ -58,7 +58,7 @@ def _group_expand_sales_order(self, sales_orders, domain): @api.depends('sale_line_id', 'project_id', 'allow_billable') def _compute_sale_order_id(self): for task in self: - if not task.allow_billable: + if not (task.allow_billable and task.sale_line_id): task.sale_order_id = False continue sale_order = ( diff --git a/addons/spreadsheet/static/src/list/index.js b/addons/spreadsheet/static/src/list/index.js index 6effddfd307077..0169e3e2afe3aa 100644 --- a/addons/spreadsheet/static/src/list/index.js +++ b/addons/spreadsheet/static/src/list/index.js @@ -56,6 +56,7 @@ inverseCommandRegistry .add("UPDATE_ODOO_LIST", identity) .add("RE_INSERT_ODOO_LIST", identity) .add("RENAME_ODOO_LIST", identity) - .add("REMOVE_ODOO_LIST", identity); + .add("REMOVE_ODOO_LIST", identity) + .add("DUPLICATE_ODOO_LIST", identity); export { ListCorePlugin, ListUIPlugin }; diff --git a/addons/spreadsheet/static/src/o_spreadsheet/o_spreadsheet.js b/addons/spreadsheet/static/src/o_spreadsheet/o_spreadsheet.js index 800c6ee2b28b8c..84b45cebf137a9 100644 --- a/addons/spreadsheet/static/src/o_spreadsheet/o_spreadsheet.js +++ b/addons/spreadsheet/static/src/o_spreadsheet/o_spreadsheet.js @@ -2,9 +2,9 @@ /** * This file is generated by o-spreadsheet build tools. Do not edit it. * @see https://github.com/odoo/o-spreadsheet - * @version 18.0.56 - * @date 2026-02-06T07:14:56.532Z - * @hash 4587c3416 + * @version 18.0.57 + * @date 2026-02-19T18:17:34.409Z + * @hash ce74496c9 */ import { useEnv, useSubEnv, onWillUnmount, useComponent, status, Component, useRef, onMounted, useEffect, useState, onPatched, onWillPatch, onWillUpdateProps, useExternalListener, onWillStart, xml, useChildSubEnv, markRaw, toRaw } from '@odoo/owl'; @@ -47318,7 +47318,11 @@ class CellComposerStore extends AbstractComposerStore { if (!content.startsWith("=")) { return; } - const evaluated = this.getters.evaluateFormula(this.sheetId, content); + const evaluated = this.getters.evaluateFormula(this.sheetId, content, { + sheetId: this.sheetId, + col: this.col, + row: this.row, + }); if (!isMatrix(evaluated)) { return; } @@ -59017,7 +59021,16 @@ class Evaluator { this.compilationParams.ensureRange(range); } } - updateCompilationParameters() { + updateCompilationParameters(originCellPosition) { + this.compilationParams = buildCompilationParameters(this.context, this.getters, this.computeAndSave.bind(this)); + this.compilationParams.evalContext.__originCellPosition = originCellPosition; + this.compilationParams.evalContext.lookupCaches = this.compilationParams.evalContext + .lookupCaches || { + forwardSearch: new Map(), + reverseSearch: new Map(), + }; + } + updateCompilationParametersForGridEvaluation() { // rebuild the compilation parameters (with a clean cache) this.compilationParams = buildCompilationParameters(this.context, this.getters, this.computeAndSave.bind(this)); this.compilationParams.evalContext.updateDependencies = this.updateDependencies.bind(this); @@ -59092,10 +59105,10 @@ class Evaluator { this.evaluate(this.getAllCells()); console.debug("evaluate all cells", performance.now() - start, "ms"); } - evaluateFormulaResult(sheetId, formulaString) { + evaluateFormulaResult(sheetId, formulaString, originCellPosition) { const compiledFormula = compile(formulaString); const ranges = compiledFormula.dependencies.map((xc) => this.getters.getRangeFromSheetXC(sheetId, xc)); - this.updateCompilationParameters(); + this.updateCompilationParameters(originCellPosition); return this.evaluateCompiledFormula(sheetId, { ...compiledFormula, dependencies: ranges, @@ -59142,7 +59155,7 @@ class Evaluator { this.nextPositionsToUpdate = positions; let currentIteration = 0; while (!this.nextPositionsToUpdate.isEmpty() && currentIteration++ < MAX_ITERATION) { - this.updateCompilationParameters(); + this.updateCompilationParametersForGridEvaluation(); const positions = this.nextPositionsToUpdate.clear(); for (let i = 0; i < positions.length; ++i) { this.evaluatedCells.delete(positions[i]); @@ -59549,15 +59562,15 @@ class EvaluationPlugin extends UIPlugin { // --------------------------------------------------------------------------- // Getters // --------------------------------------------------------------------------- - evaluateFormula(sheetId, formulaString) { - const result = this.evaluateFormulaResult(sheetId, formulaString); + evaluateFormula(sheetId, formulaString, originCellPosition) { + const result = this.evaluateFormulaResult(sheetId, formulaString, originCellPosition); if (isMatrix(result)) { return matrixMap(result, (cell) => cell.value); } return result.value; } - evaluateFormulaResult(sheetId, formulaString) { - return this.evaluator.evaluateFormulaResult(sheetId, formulaString); + evaluateFormulaResult(sheetId, formulaString, originCellPosition) { + return this.evaluator.evaluateFormulaResult(sheetId, formulaString, originCellPosition); } evaluateCompiledFormula(sheetId, compiledFormula, getSymbolValue) { return this.evaluator.evaluateCompiledFormula(sheetId, compiledFormula, getSymbolValue); @@ -74850,7 +74863,7 @@ const constants = { export { AbstractCellClipboardHandler, AbstractChart, AbstractFigureClipboardHandler, CellErrorType, CommandResult, CorePlugin, DispatchResult, EvaluationError, Model, PivotRuntimeDefinition, Registry, Revision, SPREADSHEET_DIMENSIONS, Spreadsheet, SpreadsheetPivotTable, UIPlugin, __info__, addFunction, addRenderingLayer, astToFormula, compile, compileTokens, components, constants, convertAstNodes, coreTypes, findCellInNewZone, functionCache, helpers, hooks, invalidateCFEvaluationCommands, invalidateDependenciesCommands, invalidateEvaluationCommands, iterateAstNodes, links, load, parse, parseTokens, readonlyAllowedCommands, registries, setDefaultSheetViewSize, setTranslationMethod, stores, tokenColors, tokenize }; -__info__.version = "18.0.56"; -__info__.date = "2026-02-06T07:14:56.532Z"; -__info__.hash = "4587c3416"; +__info__.version = "18.0.57"; +__info__.date = "2026-02-19T18:17:34.409Z"; +__info__.hash = "ce74496c9"; //# sourceMappingURL=o_spreadsheet.js.map diff --git a/addons/spreadsheet/static/src/o_spreadsheet/o_spreadsheet.xml b/addons/spreadsheet/static/src/o_spreadsheet/o_spreadsheet.xml index ba837429da740a..82a6484d496ffd 100644 --- a/addons/spreadsheet/static/src/o_spreadsheet/o_spreadsheet.xml +++ b/addons/spreadsheet/static/src/o_spreadsheet/o_spreadsheet.xml @@ -1,9 +1,9 @@ @@ -2716,13 +2716,12 @@ Add range - + Reset Confirm diff --git a/addons/spreadsheet/static/tests/lists/list_plugin.test.js b/addons/spreadsheet/static/tests/lists/list_plugin.test.js index c889db851bdec6..2b26044174ceb8 100644 --- a/addons/spreadsheet/static/tests/lists/list_plugin.test.js +++ b/addons/spreadsheet/static/tests/lists/list_plugin.test.js @@ -1043,6 +1043,10 @@ test("Can duplicate a list", async () => { const listIds = model.getters.getListIds(); expect(model.getters.getListIds().length).toBe(2); + undo(model); + expect(model.getters.getListIds().length).toBe(1); + redo(model); + const expectedDuplicatedDefinition = { ...model.getters.getListDefinition(listId), id: "2", diff --git a/addons/survey/controllers/main.py b/addons/survey/controllers/main.py index 3adba1836f8b4c..4fd486924155dd 100644 --- a/addons/survey/controllers/main.py +++ b/addons/survey/controllers/main.py @@ -243,7 +243,9 @@ def survey_start(self, survey_token, answer_token=None, email=False, **post): else: return request.render("survey.survey_403_page", {'survey': survey_sudo}) - return request.redirect('/survey/%s/%s' % (survey_sudo.access_token, answer_sudo.access_token)) + response = request.redirect('/survey/%s' % survey_sudo.access_token) + response.set_cookie('survey_%s' % survey_sudo.access_token, answer_sudo.access_token, max_age=60 * 60 * 24) + return response def _prepare_survey_data(self, survey_sudo, answer_sudo, **post): """ This method prepares all the data needed for template rendering, in function of the survey user input state. @@ -387,8 +389,13 @@ def _prepare_question_html(self, survey_sudo, answer_sudo, **post): 'background_image_url': background_image_url } - @http.route('/survey//', type='http', auth='public', website=True) - def survey_display_page(self, survey_token, answer_token, **post): + @http.route([ + '/survey/', + '/survey//', + ], type='http', auth='public', website=True) + def survey_display_page(self, survey_token, answer_token=None, **post): + if not answer_token: + answer_token = request.httprequest.cookies.get('survey_%s' % survey_token) access_data = self._get_access_data(survey_token, answer_token, ensure_token=True) if access_data['validity_code'] is not True: return self._redirect_with_error(access_data, access_data['validity_code']) diff --git a/addons/survey/tests/test_survey_controller.py b/addons/survey/tests/test_survey_controller.py index e640cfe44261e7..0c59cc6ae91dd3 100644 --- a/addons/survey/tests/test_survey_controller.py +++ b/addons/survey/tests/test_survey_controller.py @@ -79,9 +79,17 @@ def test_submit_route_scoring_after_page(self): if layout == 'page_per_section': page0, _ = self.env['survey.question'].create(pages) + cookie_key = f'survey_{survey.access_token}' + # clear the cookie to start a new survey + self.opener.cookies.pop(cookie_key, None) + response = self._access_start(survey) - user_input = self.env['survey.user_input'].search([('access_token', '=', response.url.split('/')[-1])]) + self.assertTrue(response.history, "Survey start should redirect") + cookie_token = response.history[0].cookies.get(cookie_key) + user_input = self.env['survey.user_input'].search([('access_token', '=', cookie_token)]) answer_token = user_input.access_token + self.assertTrue(cookie_token) + self.assertTrue(user_input) r = self._access_page(survey, answer_token) self.assertResponse(r, 200) diff --git a/addons/website_event/controllers/main.py b/addons/website_event/controllers/main.py index 97a54b38531d09..81d6ab9d36a8a4 100644 --- a/addons/website_event/controllers/main.py +++ b/addons/website_event/controllers/main.py @@ -299,7 +299,10 @@ def _process_attendees_form(self, event, form_details): registration_index, field_name = key_values if field_name not in registration_fields: continue - registrations.setdefault(registration_index, dict())[field_name] = int(value) or False + # Only cast when needed, as it might crash here for custom inputs in overrides + if isinstance(registration_fields[field_name], (fields.Many2one, fields.Integer)): + value = int(value) or False + registrations.setdefault(registration_index, dict())[field_name] = value continue if len(key_values) != 3: diff --git a/addons/website_forum/static/src/js/website_forum.js b/addons/website_forum/static/src/js/website_forum.js index 8eefe734a58207..0d3b825fc7732f 100644 --- a/addons/website_forum/static/src/js/website_forum.js +++ b/addons/website_forum/static/src/js/website_forum.js @@ -135,7 +135,7 @@ publicWidget.registry.websiteForum = publicWidget.Widget.extend({ await attachComponent(this, selectMenuWrapperEl, WebsiteForumTagsWrapper, { defaulValue: defaulValue, - disabled: isReadOnly, + isReadOnly: isReadOnly, }); } diff --git a/addons/website_project/controllers/main.py b/addons/website_project/controllers/main.py index c3b9b6ef0a51ef..b41e2d07764a65 100644 --- a/addons/website_project/controllers/main.py +++ b/addons/website_project/controllers/main.py @@ -27,7 +27,9 @@ def insert_record(self, request, model, values, custom, meta=None): custom_label = nl2br_enclose(_("Other Information"), 'h4') # Title for custom fields default_field = model.website_form_default_field_id default_field_data = values.get(default_field.name, '') - default_field_content = nl2br_enclose(default_field.name.capitalize(), 'h4') + nl2br_enclose(html2plaintext(default_field_data), 'p') + default_field_content = nl2br_enclose(html2plaintext(default_field_data), 'p') + if default_field.name and default_field.name != 'description': + default_field_content = nl2br_enclose(default_field.name.capitalize(), 'h4') + default_field_content custom_content = (default_field_content if default_field_data else '') \ + (custom_label + custom if custom else '') \ + (self._meta_label + meta if meta else '') diff --git a/addons/website_sale_collect/models/delivery_carrier.py b/addons/website_sale_collect/models/delivery_carrier.py index ebf1beee38b245..c61fe4eee7558e 100644 --- a/addons/website_sale_collect/models/delivery_carrier.py +++ b/addons/website_sale_collect/models/delivery_carrier.py @@ -87,9 +87,9 @@ def _in_store_get_close_locations(self, partner_address, product_id=None): try: pickup_location_values = { 'id': wh.id, - 'name': wh_location['name'].title(), - 'street': wh_location['street'].title(), - 'city': wh_location.city.title(), + 'name': wh_location['name'], + 'street': wh_location['street'], + 'city': wh_location.city, 'zip_code': wh_location.zip or '', 'country_code': wh_location.country_code, 'state': wh_location.state_id.code, diff --git a/odoo/tests/common.py b/odoo/tests/common.py index 089ba01aeae3a8..041e99b9764c09 100644 --- a/odoo/tests/common.py +++ b/odoo/tests/common.py @@ -1020,7 +1020,7 @@ def signal_changes(): cls.startClassPatcher(cls._signal_changes_patcher) cls.attrs_before = { - model: { + model._name: { *vars(model), # __annotations__ pops up during testing on *some* models '__annotations__', @@ -1115,7 +1115,7 @@ def check_attrs(self): } with self._outcome.testPartExecutor(self, isTest=False): # need defaults for custom models created during the test - default_attrs = self.attrs_before[self.registry['base']] | {'_rec_name', '_active_name'} + default_attrs = self.attrs_before['base'] | {'_rec_name', '_active_name'} # TODO: maybe retrieve all abstractmodels and either create a big # set of mixin attributes to always remove or have a mapping # of mixin: attributes to remove on a per-model basis? @@ -1130,7 +1130,7 @@ def check_attrs(self): # registry in place, adding fields on leaf classes if not (f.startswith('x_') and f in model._fields) if (model, f) not in modelClassPatches - }.difference(self.attrs_before.get(model, default_attrs)) + }.difference(self.attrs_before.get(model._name, default_attrs)) if extras: sets = "\n\n".join( f"======== {k} ========\n{v}:\n{tb}\n"