From 31e5620f9f3606f621ed91ab6f1cd36709ca2cd3 Mon Sep 17 00:00:00 2001 From: Alexis de Lattre Date: Fri, 3 Jan 2025 21:15:28 +0100 Subject: [PATCH] [MIG] l10n_fr_chorus_*: migrate to v18 Switch to new native field invoice_sending_method Add test button on config page to test API Large code cleanup --- l10n_fr_chorus_account/__manifest__.py | 8 +- .../data/{cron.xml => ir_cron.xml} | 6 - .../data/transmit_method.xml | 13 -- l10n_fr_chorus_account/demo/demo.xml | 4 +- l10n_fr_chorus_account/models/account_move.py | 108 +++++++-------- l10n_fr_chorus_account/models/chorus_flow.py | 30 ++-- .../models/chorus_partner_service.py | 34 +---- l10n_fr_chorus_account/models/res_company.py | 131 +++++++++--------- l10n_fr_chorus_account/models/res_partner.py | 40 +++--- l10n_fr_chorus_account/security/group.xml | 8 +- .../security/ir.model.access.csv | 1 + l10n_fr_chorus_account/views/account_move.xml | 53 +++++-- l10n_fr_chorus_account/views/chorus_flow.xml | 96 +++++++------ .../views/chorus_partner_service.xml | 12 +- .../views/res_config_settings.xml | 10 +- l10n_fr_chorus_account/views/res_partner.xml | 78 ++++++----- .../wizard/account_invoice_chorus_send.py | 60 ++++---- .../account_invoice_chorus_send_view.xml | 6 - .../wizard/res_config_settings.py | 36 ++++- l10n_fr_chorus_facturx/__manifest__.py | 2 +- l10n_fr_chorus_facturx/models/account_move.py | 6 +- l10n_fr_chorus_sale/__manifest__.py | 4 +- l10n_fr_chorus_sale/models/sale_order.py | 13 +- l10n_fr_chorus_sale/views/sale_order.xml | 19 --- 24 files changed, 400 insertions(+), 378 deletions(-) rename l10n_fr_chorus_account/data/{cron.xml => ir_cron.xml} (86%) delete mode 100644 l10n_fr_chorus_account/data/transmit_method.xml delete mode 100644 l10n_fr_chorus_sale/views/sale_order.xml diff --git a/l10n_fr_chorus_account/__manifest__.py b/l10n_fr_chorus_account/__manifest__.py index eead14225..2a35d346d 100644 --- a/l10n_fr_chorus_account/__manifest__.py +++ b/l10n_fr_chorus_account/__manifest__.py @@ -6,23 +6,21 @@ "name": "L10n FR Chorus", "summary": "Generate Chorus-compliant e-invoices and transmit them " "via the Chorus API", - "version": "17.0.1.0.1", + "version": "18.0.1.0.0", "category": "French Localization", "author": "Akretion,Odoo Community Association (OCA)", "maintainers": ["alexis-via"], "website": "https://github.com/OCA/l10n-france", "license": "AGPL-3", "depends": [ - "l10n_fr_siret", - "account_invoice_transmit_method", + "l10n_fr_siret_account", "server_environment", ], "external_dependencies": {"python": ["requests_oauthlib"]}, "data": [ "security/group.xml", "security/ir.model.access.csv", - "data/transmit_method.xml", - "data/cron.xml", + "data/ir_cron.xml", "data/mail_template.xml", "wizard/account_invoice_chorus_send_view.xml", "views/chorus_flow.xml", diff --git a/l10n_fr_chorus_account/data/cron.xml b/l10n_fr_chorus_account/data/ir_cron.xml similarity index 86% rename from l10n_fr_chorus_account/data/cron.xml rename to l10n_fr_chorus_account/data/ir_cron.xml index 356d6b01f..0b4b37697 100644 --- a/l10n_fr_chorus_account/data/cron.xml +++ b/l10n_fr_chorus_account/data/ir_cron.xml @@ -11,8 +11,6 @@ 1 days - -1 - code model.chorus_cron() @@ -23,8 +21,6 @@ 1 weeks - -1 - code model.chorus_cron() @@ -35,8 +31,6 @@ 1 days - -1 - code model.chorus_api_expiry_reminder_cron() diff --git a/l10n_fr_chorus_account/data/transmit_method.xml b/l10n_fr_chorus_account/data/transmit_method.xml deleted file mode 100644 index 31cdd1e0d..000000000 --- a/l10n_fr_chorus_account/data/transmit_method.xml +++ /dev/null @@ -1,13 +0,0 @@ - - - - - fr-chorus - Chorus Pro - - - diff --git a/l10n_fr_chorus_account/demo/demo.xml b/l10n_fr_chorus_account/demo/demo.xml index 7f02dfbce..1cf5d2535 100644 --- a/l10n_fr_chorus_account/demo/demo.xml +++ b/l10n_fr_chorus_account/demo/demo.xml @@ -17,7 +17,7 @@ http://www.education.gouv.fr/ 110043015 00012 - + fr_chorus engagement @@ -56,7 +56,7 @@ http://www.aphp.fr/ 267500452 00011 - + fr_chorus engagement diff --git a/l10n_fr_chorus_account/models/account_move.py b/l10n_fr_chorus_account/models/account_move.py index 77531d852..1c6cc5388 100644 --- a/l10n_fr_chorus_account/models/account_move.py +++ b/l10n_fr_chorus_account/models/account_move.py @@ -62,8 +62,18 @@ class AccountMove(models.Model): _inherit = "account.move" + # The related field below should be native... I hope we won't have conflict issues + # if another module defines the same related field. + invoice_sending_method = fields.Selection( + related="commercial_partner_id.invoice_sending_method", store=True + ) chorus_flow_id = fields.Many2one( - "chorus.flow", string="Chorus Flow", readonly=True, copy=False, tracking=True + "chorus.flow", + string="Chorus Flow", + readonly=True, + copy=False, + tracking=True, + check_company=True, ) chorus_identifier = fields.Integer( string="Chorus Invoice Identifier", readonly=True, copy=False, tracking=True @@ -79,15 +89,16 @@ class AccountMove(models.Model): "account_move_chorus_ir_attachment_rel", string="Chorus Attachments", copy=False, + check_company=True, ) - @api.constrains("chorus_attachment_ids", "transmit_method_id") + @api.constrains("chorus_attachment_ids", "invoice_sending_method") def _check_chorus_attachments(self): # https://communaute.chorus-pro.gouv.fr/pieces-jointes-dans-chorus-pro-quelques-regles-a-respecter/ # noqa: B950,E501 for move in self: if ( move.move_type in ("out_invoice", "out_refund") - and move.transmit_method_code == "fr-chorus" + and move.invoice_sending_method == "fr_chorus" ): total_size = 0 for attach in move.chorus_attachment_ids: @@ -98,13 +109,11 @@ def _check_chorus_attachments(self): " is %(filename_max)s caracters maximum" " (extension included)." " The filename '%(filename)s' has %(filename_size)s" - " caracters." + " caracters.", + filename_max=CHORUS_FILENAME_MAX, + filename=attach.name, + filename_size=len(attach.name), ) - % { - "filename_max": CHORUS_FILENAME_MAX, - "filename": attach.name, - "filename_size": len(attach.name), - } ) filename, file_extension = os.path.splitext(attach.name) if not file_extension: @@ -122,12 +131,10 @@ def _check_chorus_attachments(self): "On Chorus Pro, the allowed formats for the " "attachments are the following: %(extension_list)s.\n" "The attachment '%(filename)s'" - " is not part of this list." + " is not part of this list.", + extension_list=", ".join(CHORUS_ALLOWED_FORMATS), + filename=attach.name, ) - % { - "extension_list": ", ".join(CHORUS_ALLOWED_FORMATS), - "filename": attach.name, - } ) if not attach.file_size: raise ValidationError( @@ -140,13 +147,11 @@ def _check_chorus_attachments(self): _( "On Chorus Pro, each attachment" " cannot exceed %(size_max)s Mb. " - "The attachment '%(filename)s' weights %(size)s Mb." + "The attachment '%(filename)s' weights %(size)s Mb.", + size_max=CHORUS_FILESIZE_MAX_MO, + filename=attach.name, + size=formatLang(self.env, filesize_mo), ) - % { - "size_max": CHORUS_FILESIZE_MAX_MO, - "filename": attach.name, - "size": formatLang(self.env, filesize_mo), - } ) if total_size: total_size_mo = round(total_size / (1024 * 1024), 1) @@ -156,23 +161,21 @@ def _check_chorus_attachments(self): "On Chorus Pro, an invoice with its attachments " "cannot exceed %(size_max)s Mb, so we set a limit of " "%(attach_size_max)s Mb for the attachments. " - "The attachments have a total size of %(size)s Mb." + "The attachments have a total size of %(size)s Mb.", + size_max=CHORUS_TOTAL_FILESIZE_MAX_MO, + attach_size_max=CHORUS_TOTAL_ATTACHMENTS_MAX_MO, + size=formatLang(self.env, total_size_mo), ) - % { - "size_max": CHORUS_TOTAL_FILESIZE_MAX_MO, - "attach_size_max": CHORUS_TOTAL_ATTACHMENTS_MAX_MO, - "size": formatLang(self.env, total_size_mo), - } ) - def action_post(self): + def _post(self, soft=True): """Check validity of Chorus invoices""" - for inv in self.filtered( + for move in self.filtered( lambda x: x.move_type in ("out_invoice", "out_refund") - and x.transmit_method_code == "fr-chorus" + and x.invoice_sending_method == "fr_chorus" ): - inv._chorus_validation_checks() - return super().action_post() + move._chorus_validation_checks() + return super()._post(soft=soft) def _chorus_validation_checks(self): self.ensure_one() @@ -180,21 +183,21 @@ def _chorus_validation_checks(self): self, self.partner_id, self.ref ) if self.move_type == "out_invoice": - if not self.payment_mode_id: + if not self.preferred_payment_method_line_id: raise UserError( _( - "Missing Payment Mode on invoice '%s'. " + "Missing Payment Method on invoice '%s'. " "This information is required for Chorus Pro." ) % self.display_name ) payment_means_code = ( - self.payment_mode_id.payment_method_id.unece_code or "30" + self.preferred_payment_method_line_id.payment_method_id.unece_code + or "30" ) if payment_means_code in CREDIT_TRF_CODES: partner_bank_id = self.partner_bank_id or ( - self.payment_mode_id.bank_account_link == "fixed" - and self.payment_mode_id.fixed_journal_id.bank_account_id + self.preferred_payment_method_line_id.journal_id.bank_account_id ) if not partner_bank_id: raise UserError( @@ -207,36 +210,34 @@ def _chorus_validation_checks(self): "'fixed' and the related bank journal should have " "a 'Bank Account' set, or the field " "'Bank Account' should be set on the customer " - "invoice." + "invoice.", + invoice=self.display_name, + company=self.company_id.display_name, ) - % { - "invoice": self.display_name, - "company": self.company_id.display_name, - } ) if partner_bank_id.acc_type != "iban": raise UserError( _( "Chorus Pro only accepts IBAN. But the bank account " - "'%(acc_number)s' of %(company)s is not an IBAN." + "'%(acc_number)s' of %(company)s is not an IBAN.", + acc_number=partner_bank_id.acc_number, + company=self.company_id.display_name, ) - % { - "acc_number": partner_bank_id.acc_number, - "company": self.company_id.display_name, - } ) elif self.move_type == "out_refund": - if self.payment_mode_id: + if self.preferred_payment_method_line_id: raise UserError( _( - "The Payment Mode must be empty on %s " + "The Payment Method must be empty on %s " "because customer refunds sent to Chorus Pro mustn't " - "have a Payment Mode." + "have a Payment Method." ) % self.display_name ) - def chorus_get_invoice(self, chorus_invoice_format): + def _chorus_get_invoice(self, chorus_invoice_format): + """Method inherited in format-specific modules, + such as l10n_fr_chorus_facturx""" self.ensure_one() return False @@ -256,7 +257,7 @@ def _prepare_chorus_deposer_flux_payload(self): chorus_invoice_format ] if len(self) == 1: - chorus_file_content = self.chorus_get_invoice(chorus_invoice_format) + chorus_file_content = self._chorus_get_invoice(chorus_invoice_format) inv_name = self.name.replace("/", "-") filename = f"{short_format}_chorus_facture_{inv_name}.{file_ext}" else: @@ -264,7 +265,7 @@ def _prepare_chorus_deposer_flux_payload(self): tarfileobj = BytesIO() with tarfile.open(fileobj=tarfileobj, mode="w:gz") as tar: for inv in self: - inv_file_data = inv.chorus_get_invoice(chorus_invoice_format) + inv_file_data = inv._chorus_get_invoice(chorus_invoice_format) invfileio = BytesIO(inv_file_data) inv_name = inv.name.replace("/", "-") invfilename = f"{short_format}_chorus_facture_{inv_name}.{file_ext}" @@ -272,7 +273,6 @@ def _prepare_chorus_deposer_flux_payload(self): tarinfo.size = len(inv_file_data) tarinfo.mtime = int(time.time()) tar.addfile(tarinfo=tarinfo, fileobj=invfileio) - tar.close() tarfileobj.seek(0) chorus_file_content = tarfileobj.read() payload = { @@ -344,7 +344,7 @@ def _fr_chorus_send(self): for invoice in self: assert invoice.state == "posted" assert invoice.move_type in ("out_invoice", "out_refund") - assert invoice.transmit_method_code == "fr-chorus" + assert invoice.invoice_sending_method == "fr_chorus" assert not invoice.chorus_flow_id assert invoice.company_id == company company._check_chorus_invoice_format() diff --git a/l10n_fr_chorus_account/models/chorus_flow.py b/l10n_fr_chorus_account/models/chorus_flow.py index e1f37b5f6..d3a496c18 100644 --- a/l10n_fr_chorus_account/models/chorus_flow.py +++ b/l10n_fr_chorus_account/models/chorus_flow.py @@ -4,6 +4,8 @@ import logging +from markupsafe import Markup + from odoo import _, api, fields, models from odoo.exceptions import UserError @@ -14,11 +16,16 @@ class ChorusFlow(models.Model): _name = "chorus.flow" _description = "Chorus Flow" _order = "id desc" + _check_company_auto = True name = fields.Char("Flow Ref", readonly=True, copy=False, required=True) date = fields.Date("Flow Date", readonly=True, copy=False, required=True) attachment_id = fields.Many2one( - "ir.attachment", string="File Sent to Chorus", readonly=True, copy=False + "ir.attachment", + string="File Sent to Chorus", + readonly=True, + copy=False, + check_company=True, ) status = fields.Char(string="Flow Status (raw value)", readonly=True, copy=False) status_display = fields.Char( @@ -46,6 +53,7 @@ class ChorusFlow(models.Model): "move_id", string="Initial Invoices", readonly=True, + check_company=True, help="Invoices in the flow before potential rejections", ) invoice_ids = fields.One2many( @@ -138,14 +146,16 @@ def _chorus_api_consulter_cr(self, api_params, session=None): ) if invoice: invoice.message_post( - body=_( - "This invoice has been " - "rejected by Chorus Pro " - "for the following reason:
%s
" - "You should fix the error and send this invoice to " - "Chorus Pro again." + body=Markup( + _( + "This invoice has been " + "rejected by Chorus Pro " + "for the following reason:
%s
" + "You should fix the error and send this " + "invoice to Chorus Pro again." + ) + % error.get("libelleErreurDP") ) - % error.get("libelleErreurDP") ) invoice.sudo().write({"chorus_flow_id": False}) if not notes and answer.get("libelle") != "TRA_MSG_00.000": @@ -221,7 +231,7 @@ def get_invoice_identifiers(self): "On flow %s, the status is not 'INTEGRE' " "nor 'INTEGRE PARTIEL'." ) - % (flow.name, flow.status) + % flow.name ) logger.warning( "Skipping flow %s: chorus flow status should be " @@ -281,7 +291,7 @@ def chorus_cron(self): [ ("state", "=", "posted"), ("move_type", "in", ("out_invoice", "out_refund")), - ("transmit_method_code", "=", "fr-chorus"), + ("invoice_sending_method", "=", "fr_chorus"), ("chorus_identifier", "!=", False), ("chorus_status", "not in", ("MANDATEE", "MISE_EN_PAIEMENT")), ] diff --git a/l10n_fr_chorus_account/models/chorus_partner_service.py b/l10n_fr_chorus_account/models/chorus_partner_service.py index 92ace0af8..ae816af9d 100644 --- a/l10n_fr_chorus_account/models/chorus_partner_service.py +++ b/l10n_fr_chorus_account/models/chorus_partner_service.py @@ -15,6 +15,7 @@ class ChorusPartnerService(models.Model): _name = "chorus.partner.service" _description = "Chorus Services attached to a partner" _order = "partner_id, code" + _rec_names_search = ["name", "code"] partner_id = fields.Many2one( "res.partner", @@ -61,31 +62,6 @@ def _compute_display_name(self): ) ] - @api.model - def _name_search(self, name, domain=None, operator="ilike", limit=None, order=None): - if domain is None: - domain = [] - if name and operator == "ilike": - ids = list( - self._search( - [("code", "=ilike", name)] + domain, limit=limit, order=order - ) - ) - if ids: - return ids - ids = list( - self._search( - ["|", ("code", "ilike", name), ("name", "ilike", name)] + domain, - limit=limit, - order=order, - ) - ) - if ids: - return ids - return super()._name_search( - name, domain=domain, operator=operator, limit=limit, order=order - ) - def _api_consulter_service(self, api_params, session): assert self.chorus_identifier url_path = "structures/v1/consulter/service" @@ -118,12 +94,10 @@ def service_update(self): raise UserError( _( "Missing Chorus Identifier on service '%(service_name)s' " - "of partner '%(partner_name)s'." + "of partner '%(partner_name)s'.", + service_name=service.display_name, + partner_name=partner.display_name, ) - % { - "service_name": service.display_name, - "partner_name": partner.display_name, - } ) else: logger.warning( diff --git a/l10n_fr_chorus_account/models/res_company.py b/l10n_fr_chorus_account/models/res_company.py index 56afebb81..fcb1dfc71 100644 --- a/l10n_fr_chorus_account/models/res_company.py +++ b/l10n_fr_chorus_account/models/res_company.py @@ -12,6 +12,7 @@ from odoo import _, api, fields, models, tools from odoo.exceptions import UserError +from odoo.tools.misc import format_date logger = logging.getLogger(__name__) @@ -37,9 +38,6 @@ class ResCompany(models.Model): fr_chorus_api_password = fields.Char( string="Chorus Technical User Password", groups="base.group_system" ) - fr_chorus_qualif = fields.Boolean( - "Chorus Test Mode", help="Use the Chorus Pro qualification website" - ) # The values of the selection field below should # start with either 'xml_' or 'pdf_' fr_chorus_invoice_format = fields.Selection([], string="Chorus Invoice Format") @@ -69,7 +67,6 @@ def _server_env_fields(self): { "fr_chorus_api_login": {}, "fr_chorus_api_password": {}, - "fr_chorus_qualif": {}, } ) return env_fields @@ -101,6 +98,15 @@ def _chorus_get_piste_api_oauth_identifiers(self, raise_if_ko=False): return False return (oauth_id, oauth_secret) + def _chorus_qualif(self): + self.ensure_one() + # Warning: with the OCA module server_environment, + # when there is no key 'running_env' in the odoo server config file, + # it will return "test" as a "safe default" + running_env = tools.config.get("running_env", "prod") + qualif = running_env in ("test", "dev") + return qualif + def _chorus_get_api_params(self, raise_if_ko=False): self.ensure_one() api_params = {} @@ -116,7 +122,7 @@ def _chorus_get_api_params(self, raise_if_ko=False): api_params = { "login": self.sudo().fr_chorus_api_login, "password": self.sudo().fr_chorus_api_password, - "qualif": self.fr_chorus_qualif, + "qualif": self._chorus_qualif(), "oauth_id": oauth_identifiers[0], "oauth_secret": oauth_identifiers[1], } @@ -139,9 +145,9 @@ def _chorus_get_api_params(self, raise_if_ko=False): "Chorus API is %s. You should login to Chorus Pro, " "generate a new password for the technical user and " "update it in the menu Accounting > Configuration > " - "Configuration." + "Settings." ) - % self.fr_chorus_pwd_expiry_date + % format_date(self.env, self.fr_chorus_pwd_expiry_date) ) else: logger.warning( @@ -173,9 +179,10 @@ def _get_new_token(self, oauth_id, oauth_secret, qualif): _( "Connection to PISTE (URL %(url)s) failed. " "Check the internet connection of the Odoo server.\n\n" - "Error details: %(error)s" + "Error details: %(error)s", + url=url, + error=e, ) - % {"url": url, "error": e} ) from e except requests.exceptions.RequestException as e: logger.error("PISTE request for new token failed. Error: %s", e) @@ -209,13 +216,11 @@ def _get_new_token(self, oauth_id, oauth_secret, qualif): _( "Error in the request to get a new token via PISTE.\n\n" "HTTP error code: %(status_code)s. Error type: %(error_type)s. " - "Error description: %(error_description)s." + "Error description: %(error_description)s.", + status_code=r.status_code, + error_type=token.get("error"), + error_description=token.get("error_description"), ) - % { - "status_code": r.status_code, - "error_type": token.get("error"), - "error_description": token.get("error_description"), - } ) from None # {'access_token': 'xxxxxxxxxxxxxxxxx', # 'token_type': 'Bearer', 'expires_in': 3600, 'scope': 'openid'} @@ -279,9 +284,10 @@ def _chorus_post(self, api_params, url_path, payload, session=None): _( "Connection to Chorus API (URL %(url)s) failed. " "Check the Internet connection of the Odoo server.\n\n" - "Error details: %(error)s" + "Error details: %(error)s", + url=url, + error=e, ) - % {"url": url, "error": e} ) from e except requests.exceptions.RequestException as e: logger.error("Chorus POST request failed. Error: %s", e) @@ -299,12 +305,21 @@ def _chorus_post(self, api_params, url_path, payload, session=None): r.status_code, r.text, ) + error_label = "" + if r.headers.get("Content-Type").startswith("application/json"): + try: + error_dict = r.json() + error_label = error_dict.get("libelle") + except Exception: + error_label = r.text raise UserError( _( "Wrong request on %(url)s. HTTP error code received from " - "Chorus: %(status_code)s." + "Chorus: %(status_code)s.\nError: %(error)s", + url=url, + status_code=r.status_code, + error=error_label, ) - % {"url": url, "status_code": r.status_code} ) from None answer = r.json() @@ -320,8 +335,8 @@ def chorus_expiry_remind_user_list(self): @api.model def chorus_api_expiry_reminder_cron(self): logger.info("Starting the Chorus Pro API expiry reminder cron") - today_dt = fields.Date.from_string(fields.Date.context_today(self)) - limit_date = fields.Date.to_string(today_dt + relativedelta(days=15)) + today_dt = fields.Date.context_today(self) + limit_date_dt = today_dt + relativedelta(days=15) companies = ( self.env["res.company"] .sudo() @@ -330,7 +345,7 @@ def chorus_api_expiry_reminder_cron(self): ("fr_chorus_api_password", "!=", False), ("fr_chorus_api_login", "!=", False), ("fr_chorus_pwd_expiry_date", "!=", False), - ("fr_chorus_pwd_expiry_date", "<=", limit_date), + ("fr_chorus_pwd_expiry_date", "<=", limit_date_dt), ] ) ) @@ -339,18 +354,16 @@ def chorus_api_expiry_reminder_cron(self): ) for company in companies: if company.fr_chorus_expiry_remind_user_ids: - expiry_date_dt = fields.Date.from_string( - company.fr_chorus_pwd_expiry_date - ) + expiry_date_dt = company.fr_chorus_pwd_expiry_date pwd_days = (expiry_date_dt - today_dt).days mail_tpl.with_context(pwd_days=pwd_days).send_mail(company.id) logger.info( - "The Chorus API expiry reminder has been sent " "for company %s", + "The Chorus API expiry reminder has been sent for company %s", company.name, ) else: logger.warning( - "The Chorus API credentials or certificate will " + "The Chorus API credentials will " "soon expire for company %s but the field " "fr_chorus_expiry_remind_user_ids is empty!", company.name, @@ -378,12 +391,10 @@ def _chorus_common_validation_checks( raise UserError( _( "Missing SIRET on partner '%(partner)s'" - " linked to company '%(company)s'." + " linked to company '%(company)s'.", + partner=company_partner.display_name, + company=self.display_name, ) - % { - "partner": company_partner.display_name, - "company": self.display_name, - } ) cpartner = invoice_partner.commercial_partner_id if not cpartner.siren or not cpartner.nic: @@ -404,13 +415,11 @@ def _chorus_common_validation_checks( "Chorus Pro, so you must select a contact as %(partner_field)s " "for %(obj_display_name)s and this contact should have a name " "and a Chorus service and the Chorus service must " - "be active." + "be active.", + partner=cpartner.display_name, + obj_display_name=obj_display_name, + partner_field=partner_field, ) - % { - "partner": cpartner.display_name, - "obj_display_name": obj_display_name, - "partner_field": partner_field, - } ) if cpartner.fr_chorus_required in ("engagement", "service_and_engagement"): if not client_order_ref: @@ -420,12 +429,10 @@ def _chorus_common_validation_checks( "as Engagement required for " "Chorus Pro, so the 'Customer Reference' " "of %(obj_display_name)s must " - "contain a commitment number." + "contain a commitment number.", + partner=cpartner.display_name, + obj_display_name=obj_display_name, ) - % { - "partner": cpartner.display_name, - "obj_display_name": obj_display_name, - } ) self._chorus_check_commitment_number(source_object, client_order_ref) elif ( @@ -439,13 +446,11 @@ def _chorus_common_validation_checks( "is linked to Chorus service '%(service)s' " "which is configured with 'Engagement Required', so the " "'Customer Reference' of %(obj_display_name)s must " - "contain a commitment number." + "contain a commitment number.", + partner=invoice_partner.display_name, + service=invoice_partner.fr_chorus_service_id.code, + obj_display_name=obj_display_name, ) - % { - "partner": invoice_partner.display_name, - "service": invoice_partner.fr_chorus_service_id.code, - "obj_display_name": obj_display_name, - } ) self._chorus_check_commitment_number(source_object, client_order_ref) if cpartner.fr_chorus_required == "service_or_engagement": @@ -459,13 +464,11 @@ def _chorus_common_validation_checks( "is not set and the '%(partner_field)s' " "is not correctly configured as a service " "(should be a contact with a Chorus service " - "and a name)." + "and a name).", + partner=cpartner.display_name, + partner_field=partner_field, + obj_display_name=obj_display_name, ) - % { - "partner": cpartner.display_name, - "partner_field": partner_field, - "obj_display_name": obj_display_name, - } ) self._chorus_check_commitment_number(source_object, client_order_ref) @@ -493,13 +496,11 @@ def _chorus_check_commitment_number( _( "On %(obj_display_name)s, the Customer Reference " "'%(client_order_ref)s' is %(size)s caracters long. " - "The maximum is 50. Please update the Customer Reference." + "The maximum is 50. Please update the Customer Reference.", + obj_display_name=source_object.display_name, + client_order_ref=client_order_ref, + size=len(client_order_ref), ) - % { - "obj_display_name": source_object.display_name, - "client_order_ref": client_order_ref, - "size": len(client_order_ref), - } ) return self._chorus_api_check_commitment_number( source_object, @@ -542,12 +543,10 @@ def _chorus_api_check_commitment_number( _( "%(obj_display_name)s: Customer Reference " "'%(client_order_ref)s' not found in Chorus Pro. " - "Please check the Customer Reference carefully." + "Please check the Customer Reference carefully.", + obj_display_name=source_object.display_name, + client_order_ref=client_order_ref, ) - % { - "obj_display_name": source_object.display_name, - "client_order_ref": client_order_ref, - } ) logger.warning( "%s: commitment number %s not found in Chorus Pro.", diff --git a/l10n_fr_chorus_account/models/res_partner.py b/l10n_fr_chorus_account/models/res_partner.py index fdd1274b2..ae20f7d4d 100644 --- a/l10n_fr_chorus_account/models/res_partner.py +++ b/l10n_fr_chorus_account/models/res_partner.py @@ -26,6 +26,9 @@ class ResPartner(models.Model): ], string="Info Required for Chorus", tracking=True, + compute="_compute_fr_chorus_required", + store=True, + readonly=False, ) fr_chorus_identifier = fields.Integer("Chorus Identifier", readonly=True) fr_chorus_service_count = fields.Integer( @@ -44,6 +47,16 @@ class ResPartner(models.Model): fr_chorus_service_ids = fields.One2many( "chorus.partner.service", "partner_id", string="Chorus Services" ) + # inherit field of the account module (field added in odoo 18.0) + invoice_sending_method = fields.Selection( + selection_add=[("fr_chorus", "Chorus Pro")], ondelete={"fr_chorus": "set null"} + ) + + @api.depends("invoice_sending_method") + def _compute_fr_chorus_required(self): + self.filtered( + lambda x: x.invoice_sending_method != "fr_chorus" + ).fr_chorus_required = False def _compute_fr_chorus_service_count(self): rg_res = self.env["chorus.partner.service"]._read_group( @@ -65,12 +78,10 @@ def _check_fr_chorus_service(self): "Chorus service codes can only be set on contacts, " "not on parent partners. Chorus service code " "'%(service_code)s' has been set on " - "partner %(partner_name)s that has no parent." + "partner %(partner_name)s that has no parent.", + service_code=partner.fr_chorus_service_id.code, + partner_name=partner.display_name, ) - % { - "service_code": partner.fr_chorus_service_id.code, - "partner_name": partner.display_name, - } ) if not partner.name: raise ValidationError( @@ -87,13 +98,11 @@ def _check_fr_chorus_service(self): _( "The Chorus Service '%(service_name)s' configured on " "contact '%(partner_name)s' is attached to another partner " - "(%(other_partner_name)s)." + "(%(other_partner_name)s).", + service_name=partner.fr_chorus_service_id.display_name, + partner_name=partner.display_name, + other_partner_name=chorus_service_partner.display_name, ) - % { - "service_name": partner.fr_chorus_service_id.display_name, - "partner_name": partner.display_name, - "other_partner_name": chorus_service_partner.display_name, - } ) def _fr_chorus_api_structures_rechercher(self, api_params, session=None): @@ -145,21 +154,20 @@ def fr_chorus_identifier_get(self): ) continue if ( - partner.customer_invoice_transmit_method_code != "fr-chorus" + partner.invoice_sending_method != "fr_chorus" and not self.env.context.get("get_company_identifier") ): if raise_if_ko: raise UserError( _( - "On partner '%s', the invoice transmit method " + "On partner '%s', Invoice Sending " "is not set to 'Chorus Pro'." ) % partner.display_name ) else: logger.warning( - "Skipping partner %s: invoice transmit method " - "not set to fr-chorus", + "Skipping partner %s: invoice sending not set to chorus pro", partner.display_name, ) continue @@ -435,7 +443,7 @@ def chorus_cron(self): to_update_partners = self.search( [ ("parent_id", "=", False), - ("customer_invoice_transmit_method_code", "=", "fr-chorus"), + ("invoice_sending_method", "=", "fr_chorus"), ("siren", "!=", False), ("nic", "!=", False), ] diff --git a/l10n_fr_chorus_account/security/group.xml b/l10n_fr_chorus_account/security/group.xml index 3be6d5727..8c95f652d 100644 --- a/l10n_fr_chorus_account/security/group.xml +++ b/l10n_fr_chorus_account/security/group.xml @@ -9,14 +9,16 @@ Chorus API
+ + Chorus API Debugging + + Chorus Flow multi-company - ['|', ('company_id', '=', False), ('company_id', 'in', company_ids)] + [('company_id', 'in', company_ids)] diff --git a/l10n_fr_chorus_account/security/ir.model.access.csv b/l10n_fr_chorus_account/security/ir.model.access.csv index 128c49d89..603b157fd 100644 --- a/l10n_fr_chorus_account/security/ir.model.access.csv +++ b/l10n_fr_chorus_account/security/ir.model.access.csv @@ -1,4 +1,5 @@ id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_chorus_flow_readonly,Read-only access on chorus.flow to account viewer,model_chorus_flow,account.group_account_readonly,1,0,0,0 access_chorus_flow_user,Read/Write/Create access on chorus.flow to invoicing grp,model_chorus_flow,account.group_account_invoice,1,1,1,0 access_chorus_flow_full,Full access on chorus.flow to system grp,model_chorus_flow,base.group_system,1,1,1,1 access_chorus_partner_service_full,Full access on chorus.partner.service to system grp,model_chorus_partner_service,base.group_system,1,1,1,1 diff --git a/l10n_fr_chorus_account/views/account_move.xml b/l10n_fr_chorus_account/views/account_move.xml index 4e58a5c7e..e90ee8fda 100644 --- a/l10n_fr_chorus_account/views/account_move.xml +++ b/l10n_fr_chorus_account/views/account_move.xml @@ -8,13 +8,13 @@ Chorus support on Customer Invoice form view account.move - + - - + + diff --git a/l10n_fr_chorus_account/wizard/account_invoice_chorus_send.py b/l10n_fr_chorus_account/wizard/account_invoice_chorus_send.py index fa9b2a8e9..ebd69b502 100644 --- a/l10n_fr_chorus_account/wizard/account_invoice_chorus_send.py +++ b/l10n_fr_chorus_account/wizard/account_invoice_chorus_send.py @@ -15,9 +15,22 @@ class AccountInvoiceChorusSend(models.TransientModel): _description = "Send several invoices to Chorus" _check_company_auto = True + invoice_ids = fields.Many2many( + "account.move", + string="Invoices to Send", + readonly=True, + check_company=True, + ) + invoice_count = fields.Integer(string="Number of Invoices", readonly=True) + company_id = fields.Many2one("res.company", string="Company", readonly=True) + chorus_invoice_format = fields.Selection( + related="company_id.fr_chorus_invoice_format" + ) + @api.model def default_get(self, fields_list): res = super().default_get(fields_list) + assert self._context.get("active_model") == "account.move" assert self._context.get("active_ids"), "Missing active_ids in ctx" invoices = self.env["account.move"].browse(self._context.get("active_ids")) company = False @@ -35,43 +48,36 @@ def default_get(self, fields_list): _( "The state of invoice '%(invoice)s' is " "'%(invoice_state)s'. You can only send to Chorus Pro invoices " - "in posted state." - ) - % { - "invoice": invoice.display_name, - "invoice_state": invoice._fields["state"].convert_to_export( + "in posted state.", + invoice=invoice.display_name, + invoice_state=invoice._fields["state"].convert_to_export( invoice.state, invoice ), - } + ) ) - if invoice.transmit_method_code != "fr-chorus": + if invoice.invoice_sending_method != "fr_chorus": raise UserError( _( - "On invoice '%(invoice)s', the transmit method is " - "'%(transmit_method)s'. To be able " - "to send it to Chorus Pro, the transmit method must be " - "'Chorus Pro'." + "Invoice '%(invoice)s': partner '%(partner)s' is " + "not configured with invoice sending set " + "to 'Chorus Pro'.", + invoice=invoice.display_name, + partner=invoice.commercial_partner_id.display_name, ) - % { - "invoice": invoice.display_name, - "transmit_method": invoice.transmit_method_id.name or _("None"), - } ) if invoice.chorus_flow_id: raise UserError( _( "The invoice '%(invoice)s' has already been sent: " - "it is linked to Chorus Flow %(flow)s." + "it is linked to Chorus Flow %(flow)s.", + invoice=invoice.display_name, + flow=invoice.chorus_flow_id.display_name, ) - % { - "invoice": invoice.display_name, - "flow": invoice.chorus_flow_id.display_name, - } ) if company: if company != invoice.company_id: raise UserError( - _("All the selected invoices must be in the same company") + _("All the selected invoices must be in the same company.") ) else: company = invoice.company_id @@ -86,18 +92,6 @@ def default_get(self, fields_list): ) return res - invoice_ids = fields.Many2many( - "account.move", - string="Invoices to Send", - readonly=True, - check_company=True, - ) - invoice_count = fields.Integer(string="Number of Invoices", readonly=True) - company_id = fields.Many2one("res.company", string="Company", readonly=True) - chorus_invoice_format = fields.Selection( - related="company_id.fr_chorus_invoice_format" - ) - def run(self): self.ensure_one() action = {} diff --git a/l10n_fr_chorus_account/wizard/account_invoice_chorus_send_view.xml b/l10n_fr_chorus_account/wizard/account_invoice_chorus_send_view.xml index 7fd9df68b..836af3aec 100644 --- a/l10n_fr_chorus_account/wizard/account_invoice_chorus_send_view.xml +++ b/l10n_fr_chorus_account/wizard/account_invoice_chorus_send_view.xml @@ -36,11 +36,5 @@ account.invoice.chorus.send form new - - - list diff --git a/l10n_fr_chorus_account/wizard/res_config_settings.py b/l10n_fr_chorus_account/wizard/res_config_settings.py index 79144eba1..5efb87555 100644 --- a/l10n_fr_chorus_account/wizard/res_config_settings.py +++ b/l10n_fr_chorus_account/wizard/res_config_settings.py @@ -2,7 +2,11 @@ # @author: Alexis de Lattre # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). -from odoo import fields, models +import logging + +from odoo import _, fields, models + +logger = logging.getLogger(__name__) class ResConfigSettings(models.TransientModel): @@ -21,7 +25,9 @@ class ResConfigSettings(models.TransientModel): related="company_id.fr_chorus_api_password", readonly=False ) fr_chorus_qualif = fields.Boolean( - related="company_id.fr_chorus_qualif", readonly=False + string="Use Chorus Qualification Platform", + readonly=True, + default=lambda self: self.env.company._chorus_qualif(), ) fr_chorus_invoice_format = fields.Selection( related="company_id.fr_chorus_invoice_format", readonly=False @@ -35,3 +41,29 @@ class ResConfigSettings(models.TransientModel): fr_chorus_expiry_remind_user_ids = fields.Many2many( related="company_id.fr_chorus_expiry_remind_user_ids", readonly=False ) + + def fr_chorus_test_api(self): + self.ensure_one() + # The code will raise UserError if the test fails + logger.info("Start test Chorus Pro API for company %s", self.company_id.name) + api_params = self.company_id._chorus_get_api_params(raise_if_ko=True) + url_path = "structures/v1/rechercher" + payload = {"structure": {"nomStructure": "This is just a test"}} + answer, session = self.env["res.company"]._chorus_post( + api_params, url_path, payload + ) + message = _( + "Successful test of the Chorus Pro API for company '%(company)s'!", + company=self.company_id.display_name, + ) + logger.info(message) + action = { + "type": "ir.actions.client", + "tag": "display_notification", + "params": { + "message": message, + "type": "success", + "sticky": False, + }, + } + return action diff --git a/l10n_fr_chorus_facturx/__manifest__.py b/l10n_fr_chorus_facturx/__manifest__.py index 0310e7e1d..fc54b45a0 100644 --- a/l10n_fr_chorus_facturx/__manifest__.py +++ b/l10n_fr_chorus_facturx/__manifest__.py @@ -5,7 +5,7 @@ { "name": "L10n FR Chorus Factur-X", "summary": "Generate Chorus-compliant Factur-X invoices", - "version": "17.0.1.0.0", + "version": "18.0.1.0.0", "category": "French Localization", "author": "Akretion,Odoo Community Association (OCA)", "maintainers": ["alexis-via"], diff --git a/l10n_fr_chorus_facturx/models/account_move.py b/l10n_fr_chorus_facturx/models/account_move.py index 46703f941..ec04d1eee 100644 --- a/l10n_fr_chorus_facturx/models/account_move.py +++ b/l10n_fr_chorus_facturx/models/account_move.py @@ -20,7 +20,7 @@ def _cii_trade_agreement_buyer_ref(self, partner): return partner.fr_chorus_service_id.code return super()._cii_trade_agreement_buyer_ref(partner) - def chorus_get_invoice(self, chorus_invoice_format): + def _chorus_get_invoice(self, chorus_invoice_format): self.ensure_one() if chorus_invoice_format == "xml_cii": chorus_file_content = self.with_context( @@ -28,11 +28,11 @@ def chorus_get_invoice(self, chorus_invoice_format): ).generate_facturx_xml()[0] elif chorus_invoice_format == "pdf_factur-x": chorus_file_content, filetype = self.env["ir.actions.report"]._render( - "account.report_invoice", [self.id] + "account.report_invoice_with_payments", [self.id] ) assert filetype == "pdf", "wrong filetype" else: - chorus_file_content = super().chorus_get_invoice(chorus_invoice_format) + chorus_file_content = super()._chorus_get_invoice(chorus_invoice_format) return chorus_file_content def _prepare_facturx_attachments(self): diff --git a/l10n_fr_chorus_sale/__manifest__.py b/l10n_fr_chorus_sale/__manifest__.py index 279bbf0de..1b580ffeb 100644 --- a/l10n_fr_chorus_sale/__manifest__.py +++ b/l10n_fr_chorus_sale/__manifest__.py @@ -5,14 +5,14 @@ { "name": "L10n FR Chorus Sale", "summary": "Add checks on sale orders for Chorus Pro", - "version": "17.0.1.0.0", + "version": "18.0.1.0.0", "category": "French Localization", "author": "Akretion,Odoo Community Association (OCA)", "maintainers": ["alexis-via"], "website": "https://github.com/OCA/l10n-france", "license": "AGPL-3", "depends": ["l10n_fr_chorus_account", "sale"], - "data": ["views/sale_order.xml"], + "data": [], "installable": True, "auto_install": True, } diff --git a/l10n_fr_chorus_sale/models/sale_order.py b/l10n_fr_chorus_sale/models/sale_order.py index 68e256b0d..8b932d44f 100644 --- a/l10n_fr_chorus_sale/models/sale_order.py +++ b/l10n_fr_chorus_sale/models/sale_order.py @@ -2,24 +2,17 @@ # @author: Alexis de Lattre # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). -from odoo import fields, models +from odoo import models class SaleOrder(models.Model): _inherit = "sale.order" - invoice_transmit_method_id = fields.Many2one( - related="partner_invoice_id.customer_invoice_transmit_method_id", - string="Invoice Transmission Method", - ) - invoice_transmit_method_code = fields.Char( - related="partner_invoice_id.customer_invoice_transmit_method_id.code", - ) - def action_confirm(self): """Check validity of Chorus orders""" for order in self.filtered( - lambda so: so.invoice_transmit_method_code == "fr-chorus" + lambda x: x.partner_invoice_id.commercial_partner_id.invoice_sending_method + == "fr_chorus" ): order._chorus_validation_checks() return super().action_confirm() diff --git a/l10n_fr_chorus_sale/views/sale_order.xml b/l10n_fr_chorus_sale/views/sale_order.xml deleted file mode 100644 index 61dc8db77..000000000 --- a/l10n_fr_chorus_sale/views/sale_order.xml +++ /dev/null @@ -1,19 +0,0 @@ - - - - - chorus.sale.order.form - sale.order - - - - - - - - -