import uuid from markupsafe import Markup from urllib.parse import quote, urlencode, urlparse from odoo import _, api, fields, models from odoo.exceptions import UserError from odoo.addons.l10n_tr_nilvera.lib.nilvera_client import _get_nilvera_client MOVE_TYPE_CATEGORY_MAP = { "out_invoice": { "earchive": "invoices", "einvoice": "sale", }, "in_invoice": { "einvoice": "purchase", }, } CATEGORY_MOVE_TYPE_MAP = { "invoices": "out_invoice", "sale": "out_invoice", "purchase": "in_invoice", } class AccountMove(models.Model): _name = 'account.move' _inherit = ['account.move'] l10n_tr_nilvera_uuid = fields.Char( string="Nilvera Document UUID", copy=False, readonly=True, default=lambda self: str(uuid.uuid4()), help="Universally unique identifier of the Invoice", ) l10n_tr_nilvera_send_status = fields.Selection( selection=[ ('error', "Error (check chatter)"), ('not_sent', "Not sent"), ('sent', "Sent and waiting response"), ('succeed', "Successful"), ('waiting', "Waiting"), ('unknown', "Unknown"), ], string="Nilvera Status", readonly=True, copy=False, default='not_sent', ) def _l10n_tr_types_to_update_status(self): return list(MOVE_TYPE_CATEGORY_MAP) def _l10n_tr_get_document_category(self, invoice_channel): return MOVE_TYPE_CATEGORY_MAP.get(self.move_type, {}).get(invoice_channel) def _l10n_tr_get_category_move_type(self, document_category): return CATEGORY_MOVE_TYPE_MAP.get(document_category.lower()) @api.model def _get_ubl_cii_builder_from_xml_tree(self, tree): customization_id = tree.find('{*}CustomizationID') if customization_id is not None and 'TR1.2' in customization_id.text: return self.env['account.edi.xml.ubl.tr'] return super()._get_ubl_cii_builder_from_xml_tree(tree) def button_draft(self): # EXTENDS account for move in self: if move.l10n_tr_nilvera_uuid and move.l10n_tr_nilvera_send_status != 'not_sent': raise UserError(_("You cannot reset to draft an entry that has been sent to Nilvera.")) super().button_draft() def _l10n_tr_nilvera_submit_einvoice(self, xml_file, customer_alias): self._l10n_tr_nilvera_submit_document( xml_file=xml_file, endpoint=f"/einvoice/Send/Xml?{urlencode({'Alias': customer_alias})}", ) def _l10n_tr_nilvera_submit_earchive(self, xml_file): self._l10n_tr_nilvera_submit_document( xml_file=xml_file, endpoint="/earchive/Send/Xml", ) def _l10n_tr_nilvera_submit_document(self, xml_file, endpoint, post_series=True): """ Submits an e-invoice or e-archive document to Nilvera for processing. :param xml_file: The XML file to be submitted. :type xml_file: file-like object :param endpoint: The Nilvera API endpoint for submission. :type endpoint: str :param post_series: Whether to attempt posting the series/sequence to Nilvera if it is missing. Defaults to True. Useful for avoiding an infinite loop. :type post_series: bool :raises UserError: If the API key lacks necessary rights (401 or 403 responses), if the response indicates a client error (4xx), or if a server error occurs (500). :return: None """ with _get_nilvera_client(self.env.company) as client: response = client.request( "POST", endpoint, files={'file': (xml_file.name, xml_file, 'application/xml')}, handle_response=False, ) if response.status_code == 200: self.is_move_sent = True self.l10n_tr_nilvera_send_status = 'sent' elif response.status_code in {401, 403}: raise UserError(_("Oops, seems like you're unauthorised to do this. Try another API key with more rights or contact Nilvera.")) elif 400 <= response.status_code < 500: error_message, error_codes = self._l10n_tr_nilvera_einvoice_get_error_messages_from_response(response) # If the sequence/series is not found on Nilvera, add it then retry. if 3009 in error_codes and post_series: self._l10n_tr_nilvera_post_series(endpoint, client) return self._l10n_tr_nilvera_submit_document(xml_file, endpoint, post_series=False) raise UserError(error_message) elif response.status_code == 500: raise UserError(_("Server error from Nilvera, please try again later.")) self.message_post(body=_("The invoice has been successfully sent to Nilvera.")) def _l10n_tr_nilvera_post_series(self, endpoint, client): """Post the series to Nilvera based on the endpoint.""" path = urlparse(endpoint).path # Remove query params from te endpoint. if path == "/einvoice/Send/Xml": series_endpoint = "/einvoice/Series" elif path == "/earchive/Send/Xml": series_endpoint = "/earchive/Series" else: # Return early if endpoint couldn't be matched. return if not self.sequence_prefix: return series = self.sequence_prefix.split('/', 1)[0] client.request( "POST", series_endpoint, json={ 'Name': series, 'IsActive': True, 'IsDefault': False, }, ) def _l10n_tr_nilvera_get_submitted_document_status(self): with _get_nilvera_client(self.env.company) as client: for invoice in self: invoice_channel = invoice.partner_id.l10n_tr_nilvera_customer_status document_category = invoice._l10n_tr_get_document_category(invoice_channel) if not document_category or not invoice_channel: continue response = client.request( "GET", f"/{invoice_channel}/{quote(document_category)}/{invoice.l10n_tr_nilvera_uuid}/Status", ) nilvera_status = response.get('InvoiceStatus', {}).get('Code') or response.get('StatusCode') if nilvera_status in dict(invoice._fields['l10n_tr_nilvera_send_status'].selection): invoice.l10n_tr_nilvera_send_status = nilvera_status if nilvera_status == 'error': invoice.message_post( body=Markup( "%s
%s - %s
" ) % ( _("The invoice couldn't be sent to the recipient."), response.get('InvoiceStatus', {}).get('Description') or response.get('StatusDetail'), response.get('InvoiceStatus', {}).get('DetailDescription') or response.get('ReportStatus'), ) ) else: invoice.message_post(body=_("The invoice status couldn't be retrieved from Nilvera.")) def _l10n_tr_nilvera_get_documents(self, invoice_channel="einvoice", document_category="Purchase", journal_type="in_invoice"): with _get_nilvera_client(self.env.company) as client: response = client.request("GET", f"/{invoice_channel}/{quote(document_category)}", params={"StatusCode": ["succeed"]}) if not response.get('Content'): return journal = self._l10n_tr_get_nilvera_invoice_journal(journal_type) document_uuids, document_uuids_records, document_uuids_references = self._l10n_tr_build_document_uuids_list(response) for document_uuid in document_uuids: move = document_uuids_records.get(document_uuid) # If an account.move doesn't exist, create it and attach the document if not move: move = self._l10n_tr_nilvera_get_invoice_from_uuid(client, journal, document_uuid, document_category, invoice_channel) self._l10n_tr_nilvera_add_pdf_to_invoice(client, move, document_uuid, document_category, invoice_channel) # If account.move exists, but doesn't have a reference and its reference is found in the nilvera document references, attach the document elif not move.ref and (nilvera_reference := document_uuids_references.get(document_uuid)): move.ref = nilvera_reference self._l10n_tr_nilvera_add_pdf_to_invoice(client, move, document_uuid, document_category, invoice_channel) self._cr.commit() def _l10n_tr_get_nilvera_invoice_journal(self, journal_type): journal = self._l10n_tr_get_document_category_default_journal(journal_type) if not journal: journal = self.env['account.journal'].search([ *self.env['account.journal']._check_company_domain(self.env.company), ('type', '=', f'{journal_type}'), ], limit=1) return journal def _l10n_tr_get_document_category_default_journal(self, journal_type): if journal_type == "purchase": return self.env.company.l10n_tr_nilvera_purchase_journal_id return None def _l10n_tr_build_document_uuids_list(self, response): contents = response.get("Content", []) document_uuids = [content.get("UUID") for content in contents if content.get("UUID")] # Should be unique per invoice so we get the records with the invoice to use the records document_uuids_records = dict(self.env["account.move"]._read_group([("l10n_tr_nilvera_uuid", "in", document_uuids)], groupby=["l10n_tr_nilvera_uuid", "id"])) document_uuids_references = { content["UUID"]: content["InvoiceNumber"] for content in contents if content.get("UUID") and content.get("InvoiceNumber") } return document_uuids, document_uuids_records, document_uuids_references def _l10n_tr_nilvera_get_invoice_from_uuid(self, client, journal, document_uuid, document_category="Purchase", invoice_channel="einvoice"): response = client.request( "GET", f"/{invoice_channel}/{quote(document_category)}/{quote(document_uuid)}/xml", params={"StatusCode": ["succeed"]}, ) attachment_vals = { 'name': 'attachment.xml', 'raw': response, 'type': 'binary', 'mimetype': 'application/xml', } attachment = self.env['ir.attachment'].create(attachment_vals) move_type = self._l10n_tr_get_category_move_type(document_category) try: move = journal.with_context( default_move_type=move_type, default_l10n_tr_nilvera_uuid=document_uuid, default_message_main_attachment_id=attachment.id, default_l10n_tr_nilvera_send_status='succeed', )._create_document_from_attachment(attachment.id) # If move creation was successful, update the attachment name with the bill reference. if move.ref: attachment.name = f'{move.ref}.xml' move._message_log(body=_("Nilvera document has been received successfully")) except Exception: # noqa: BLE001 # If the invoice creation fails, create an empty invoice with the attachment. The PDF will be # added in a later step as well. Nilvera only returns uuid of the successful attachments. move = self.env['account.move'].create({ 'move_type': move_type, 'company_id': self.env.company.id, 'l10n_tr_nilvera_uuid': document_uuid, 'l10n_tr_nilvera_send_status': 'succeed', 'message_main_attachment_id': attachment.id, }) attachment.write({ 'res_model': 'account.move', 'res_id': move.id, }) return move def _l10n_tr_nilvera_add_pdf_to_invoice(self, client, invoice, document_uuid, document_category="Purchase", invoice_channel="einvoice"): response = client.request( "GET", f"/{invoice_channel}/{quote(document_category)}/{quote(document_uuid)}/pdf", ) filename = f'{invoice.ref}.pdf' if invoice.ref else 'Nilvera PDF.pdf' attachment = self.env['ir.attachment'].create({ 'name': filename, 'res_id': invoice.id, 'res_model': 'account.move', 'datas': response, 'type': 'binary', 'mimetype': 'application/pdf', }) # The created attachement coming form Nilvera should be the main attachment invoice.message_main_attachment_id = attachment invoice.with_context(no_new_invoice=True).message_post(attachment_ids=attachment.ids) def _l10n_tr_nilvera_einvoice_get_error_messages_from_response(self, response): msg = "" error_codes = [] response_json = response.json() if errors := response_json.get('Errors'): msg += _("The invoice couldn't be sent due to the following errors:\n") for error in errors: msg += "%s - %s: %s\n" % (error.get('Code'), error.get('Description'), error.get('Detail')) error_codes.append(error.get('Code')) return msg, error_codes def _l10n_tr_nilvera_einvoice_check_invalid_subscription_dates(self): if 'deferred_start_date' not in self.invoice_line_ids._fields: return False # Ensure that either no lines have the start and end dates or all lines have the same start and end dates. lines_to_check = self.invoice_line_ids.filtered(lambda line: line.display_type == 'product') if not (subscription_lines := lines_to_check.filtered('deferred_start_date')): return False return len(subscription_lines) != len(lines_to_check) or len(set(subscription_lines.mapped( lambda aml: (aml.deferred_start_date, aml.deferred_end_date)), )) > 1 def _l10n_tr_nilvera_einvoice_check_negative_lines(self): return any( line.display_type not in {'line_note', 'line_section'} and (line.quantity < 0 or line.price_unit < 0) for line in self.invoice_line_ids ) def _get_partner_l10n_tr_nilvera_customer_alias_name(self): # Allows overriding the default customer alias with a custom one. self.ensure_one() return self.partner_id.l10n_tr_nilvera_customer_alias_id.name # ------------------------------------------------------------------------- # CRONS # ------------------------------------------------------------------------- def _l10n_tr_nilvera_company_get_documents(self, invoice_channel, category, journal_type): for company in self.env.companies: if company.country_code != "TR" or not company.l10n_tr_nilvera_api_key: continue self.with_company(company)._l10n_tr_nilvera_get_documents(invoice_channel, category, journal_type) def _cron_nilvera_get_new_einvoice_purchase_documents(self): self._l10n_tr_nilvera_company_get_documents("einvoice", "Purchase", "purchase") def _cron_nilvera_get_new_einvoice_sale_documents(self): self._l10n_tr_nilvera_company_get_documents("einvoice", "Sale", "sale") def _cron_nilvera_get_new_earchive_sale_documents(self): self._l10n_tr_nilvera_company_get_documents("earchive", "Invoices", "sale") def _cron_nilvera_get_invoice_status(self): invoices_to_update = self.env['account.move'].search([ ('l10n_tr_nilvera_send_status', 'in', ['waiting', 'sent']), ('move_type', 'in', self._l10n_tr_types_to_update_status()), ]) invoices_to_update._l10n_tr_nilvera_get_submitted_document_status()