from pytz import timezone from lxml import etree from collections import defaultdict from datetime import datetime, timedelta import re from odoo import api, fields, models, modules from odoo.addons.l10n_co_dian import xml_utils from odoo.addons.l10n_co_dian.models.l10n_co_dian_document import COMMERCIAL_STATE_SELECTION, EVENT_FILE_SEQUENCE_CODE from odoo.exceptions import UserError, ValidationError, AccessError, RedirectWarning DESCRIPTION_CREDIT_CODE = [ ("1", "Devolución parcial de los bienes y/o no aceptación parcial del servicio"), ("2", "Anulación de factura electrónica"), ("3", "Rebaja total aplicada"), ("4", "Ajuste de precio"), ("5", "Descuento comercial por pronto pago"), ("6", "Descuento comercial por volumen de ventas") ] DESCRIPTION_DEBIT_CODE = [ ('1', 'Intereses'), ('2', 'Gastos por cobrar'), ('3', 'Cambio del valor'), ('4', 'Otros'), ] class AccountMove(models.Model): _inherit = 'account.move' l10n_co_dian_show_support_doc_button = fields.Boolean(compute='_compute_l10n_co_dian_show_support_doc_button') l10n_co_dian_post_time = fields.Datetime(readonly=True, copy=False) l10n_co_dian_document_ids = fields.One2many( comodel_name='l10n_co_dian.document', inverse_name='move_id', ) l10n_co_edi_cufe_cude_ref = fields.Char( string="CUFE/CUDE/CUDS", compute='_compute_l10n_co_dian_cufe', store=True, readonly=True, copy=False, help="Unique ID used by the DIAN to identify the invoice.", ) l10n_co_dian_state = fields.Selection( selection=[ ('invoice_sending_failed', "Sending Failed"), ('invoice_pending', "Pending"), ('invoice_rejected', "Rejected"), ('invoice_accepted', "Accepted"), ], compute='_compute_l10n_co_dian_states', store=True, copy=False, ) l10n_co_dian_attachment_id = fields.Many2one( comodel_name='ir.attachment', compute='_compute_l10n_co_dian_attachment_id', ) l10n_co_dian_identifier_type = fields.Selection( selection=[ ('cufe', 'CUFE'), ('cude', 'CUDE'), ('cuds', 'CUDS'), ], compute="_compute_l10n_co_dian_identifier_type", ) l10n_co_dian_is_enabled = fields.Boolean(compute="_compute_l10n_co_dian_is_enabled") l10n_co_dian_processed_by_get_event_status_cron = fields.Boolean() l10n_co_dian_commercial_state = fields.Selection( string="Commercial Status", default='pending', selection=COMMERCIAL_STATE_SELECTION, compute='_compute_l10n_co_dian_states', copy=False, store=True, ) l10n_co_dian_claim_reason = fields.Selection( string="Claim Reason", selection=[ ('01', "Document with inconsistencies"), ('02', "Undelivered merchandise"), ('03', "Merchandise partially delivered"), ('04', "Service not provided"), ], copy=False, ) l10n_co_dian_update_commercial_event_enabled = fields.Boolean(compute='_compute_l10n_co_dian_update_commercial_event_enabled') # ------------------------------------------------------------------------- # Compute # ------------------------------------------------------------------------- @api.depends( 'move_type', 'l10n_co_edi_is_support_document', 'l10n_co_dian_document_ids.state', 'l10n_co_dian_document_ids.commercial_state', ) def _compute_l10n_co_dian_cufe(self): for move in self: if move.move_type in ('in_invoice', 'in_refund') and not move.l10n_co_edi_is_support_document: move.l10n_co_edi_cufe_cude_ref = move.l10n_co_edi_cufe_cude_ref continue move.l10n_co_edi_cufe_cude_ref = move.l10n_co_edi_cufe_cude_ref documents = move.l10n_co_dian_document_ids.sorted() is_accepted_by_issuer = False for document in documents: if document.state not in ('invoice_pending', 'invoice_accepted'): continue # In case a document has been accepted by the issuer, we report the identifier of the first send document when the # commercial state was pending. if document.commercial_state == 'accepted_by_issuer': is_accepted_by_issuer = True if is_accepted_by_issuer and document.commercial_state == 'pending': move.l10n_co_edi_cufe_cude_ref = document.identifier break # Otherwise, report the identifier for the 'invoice_accepted' document. if not is_accepted_by_issuer and document.state == 'invoice_accepted': move.l10n_co_edi_cufe_cude_ref = document.identifier break @api.depends('l10n_co_dian_document_ids', 'l10n_co_dian_document_ids.state', 'l10n_co_dian_document_ids.commercial_state') def _compute_l10n_co_dian_states(self): for move in self: if accepted_doc := move._l10n_co_dian_get_last_accepted_document(): move.l10n_co_dian_state = accepted_doc.state move.l10n_co_dian_commercial_state = accepted_doc.commercial_state continue move.l10n_co_dian_commercial_state = False doc = move.l10n_co_dian_document_ids.sorted()[:1] move.l10n_co_dian_state = doc.state if doc else False @api.depends('l10n_co_dian_document_ids', 'l10n_co_dian_document_ids.state') def _compute_l10n_co_dian_attachment_id(self): for move in self: move.l10n_co_dian_attachment_id = False documents = move.l10n_co_dian_document_ids.sorted() for document in documents: if document.state == 'invoice_accepted': move.l10n_co_dian_attachment_id = document.attachment_id break @api.depends('journal_id', 'move_type') def _compute_l10n_co_dian_identifier_type(self): for move in self: if move.journal_id.l10n_co_edi_debit_note or move.move_type == 'out_refund': move.l10n_co_dian_identifier_type = 'cude' # Debit Notes, Credit Notes elif move.l10n_co_edi_is_support_document: move.l10n_co_dian_identifier_type = 'cuds' # Support Documents (Vendor Bills) else: move.l10n_co_dian_identifier_type = 'cufe' # Invoices @api.depends('state', 'move_type', 'l10n_co_dian_state') def _compute_l10n_co_dian_show_support_doc_button(self): for move in self: move.l10n_co_dian_show_support_doc_button = ( move.l10n_co_dian_is_enabled and move.l10n_co_dian_state != 'invoice_accepted' and move.move_type in ('in_refund', 'in_invoice') and move.state == 'posted' and move.journal_id.l10n_co_edi_is_support_document ) @api.depends('country_code', 'company_currency_id', 'move_type', 'company_id.l10n_co_dian_provider') def _compute_l10n_co_dian_is_enabled(self): """ Check whether or not the DIAN is needed on this invoice. """ for move in self: move.l10n_co_dian_is_enabled = ( move.country_code == "CO" and move.company_currency_id.name == "COP" and move.is_invoice() and move.company_id.l10n_co_dian_provider == 'dian' ) @api.depends('l10n_co_dian_document_ids.state', 'l10n_co_dian_commercial_state') def _compute_l10n_co_dian_update_commercial_event_enabled(self): for move in self: move.l10n_co_dian_update_commercial_event_enabled = ( any(doc.state == 'invoice_accepted' for doc in move.l10n_co_dian_document_ids) and move.l10n_co_dian_commercial_state not in ('claimed', 'accepted', 'accepted_by_issuer') ) # ------------------------------------------------------------------------- # Extends # ------------------------------------------------------------------------- def _post(self, soft=True): # EXTENDS account res = super()._post(soft=soft) for move in self.filtered('l10n_co_dian_is_enabled'): # naive local colombian datetime now = fields.Datetime.to_string(datetime.now(tz=timezone('America/Bogota'))) move.l10n_co_dian_post_time = now if move.is_purchase_document() and not move.l10n_co_edi_is_support_document and not move.l10n_co_dian_document_ids: self.env['l10n_co_dian.document']._create_document( xml=None, move=move, state='invoice_accepted', commercial_state='pending', message_json={'status': ''}, ) return res @api.depends('l10n_co_dian_state') def _compute_show_reset_to_draft_button(self): # EXTENDS 'account' super()._compute_show_reset_to_draft_button() for move in self.filtered(lambda m: m.is_sale_document()): # Reset to draft is not possible for invoices validated by DIAN if any(d.state in ('invoice_pending', 'invoice_accepted') and d.commercial_state == 'pending' for d in move.l10n_co_dian_document_ids): move.show_reset_to_draft_button = False def _get_name_invoice_report(self): # EXTENDS account self.ensure_one() if self.env.ref('l10n_co_dian.report_vendor_document', raise_if_not_found=False) and \ self.l10n_co_edi_is_support_document and \ self.move_type in ('in_refund', 'in_invoice'): return 'l10n_co_dian.report_vendor_document' elif self.l10n_co_dian_state == 'invoice_accepted' and self.l10n_co_dian_attachment_id: return 'l10n_co_dian.report_invoice_document' return super()._get_name_invoice_report() def _get_import_file_type(self, file_data): """ Identify DIAN UBL files. """ # EXTENDS 'account' if ( file_data['xml_tree'] is not None and etree.QName(file_data['xml_tree']).localname != 'AttachedDocument' and (ubl_profile := file_data['xml_tree'].findtext('{*}ProfileID')) and ubl_profile.startswith('DIAN 2.1:') ): return 'account.edi.xml.ubl_dian' return super()._get_import_file_type(file_data) @api.model def _get_mail_template(self): # EXTENDS 'account' self.ensure_one() mail_template = super()._get_mail_template() if self.country_code == 'CO': xmlid = 'l10n_co_dian.email_template_edi_credit_note' if self.move_type == 'out_refund' else 'l10n_co_dian.email_template_edi_invoice' return self.env.ref(xmlid, raise_if_not_found=False) or mail_template return mail_template # ------------------------------------------------------------------------- # Helpers # ------------------------------------------------------------------------- def l10n_co_dian_action_update_event_status(self): self.l10n_co_dian_document_ids.filtered(lambda doc: doc.state == 'invoice_rejected').unlink() for move in self.try_lock_for_update(): track_id = move._l10n_co_dian_get_last_accepted_document().identifier if not track_id: continue self.env['l10n_co_dian.document']._send_get_status_event(move, track_id) if not modules.module.current_test: self.env.cr.commit() # unlink duplicate documents and only keep the most recent ones # for this process we exclude the original document containing the invoice data documents = move.l10n_co_dian_document_ids.sorted()[:-1] grouped_documents = documents.grouped(key='commercial_state') for commercial_state, duplicate_documents in grouped_documents.items(): duplicate_documents.sorted()[1:].unlink() def l10n_co_dian_send_event_update_status_received(self): self._l10n_co_dian_send_event_update_status('received') def l10n_co_dian_send_event_update_status_claimed(self): self._l10n_co_dian_send_event_update_status('claimed') def l10n_co_dian_send_event_update_status_goods_received(self): self._l10n_co_dian_send_event_update_status('goods_received') def l10n_co_dian_send_event_update_status_accepted(self): self._l10n_co_dian_send_event_update_status('accepted') def l10n_co_dian_send_event_update_status_accepted_by_issuer(self): self._l10n_co_dian_send_event_update_status('accepted_by_issuer') def _l10n_co_dian_send_event_update_status(self, commercial_state_next): if not self.env.user.has_group('account.group_account_invoice'): raise AccessError(self.env._("Only invoicing users can update the DIAN commercial status.")) self.ensure_one() self._l10n_co_dian_validate_send_event_update_data() document = self.env['l10n_co_dian.document']._send_commercial_event(self, commercial_state_next) if document.state == 'invoice_accepted': # Send mail AccountMoveSend = self.env['account.move.send'] mail_template = self.env.ref('l10n_co_dian.email_template_commercial_event') mail_lang = AccountMoveSend._get_default_mail_lang(self, mail_template) self.with_context( email_notification_allow_footer=True, ).message_post( message_type='comment', subtype_id=self.env.ref('mail.mt_comment').id, body=AccountMoveSend._get_default_mail_body(self, mail_template, mail_lang), subject=AccountMoveSend._get_default_mail_subject(self, mail_template, mail_lang), partner_ids=self.partner_id.ids, attachments=[(self.l10n_co_dian_attachment_id.name, self.l10n_co_dian_attachment_id.raw)], ) def _l10n_co_dian_validate_send_event_update_data(self): errors = [] # Validate required data for vendor bills if self.move_type in ('in_invoice', 'in_refund'): if not self.ref: errors.append(self.env._("The Bill Reference is required to send commercial events.")) if not self.l10n_co_edi_cufe_cude_ref: errors.append(self.env._("The Bill CUFE/CUDE is required to send commercial events.")) if errors: raise ValidationError('\n'.join(errors)) # Validate required data for invoices and vendor bills if self.partner_id and not self.partner_id.vat: raise RedirectWarning( message=self.env._("The receiving partner's identification number is required to send commercial events."), action={ 'type': 'ir.actions.act_window', 'res_model': 'res.partner', 'context': {'create': False}, 'view_mode': 'form', 'views': [[self.env.ref('base.view_partner_form').id, 'form']], 'res_id': self.partner_id.id, }, button_text=self.env._("Go to Partner"), ) def _l10n_co_dian_get_mail_commercial_state_label(self): self.ensure_one() commercial_states = dict(self._fields['l10n_co_dian_commercial_state']._description_selection(self.with_context(lang=self.partner_id.lang).env)) return commercial_states.get(self.l10n_co_dian_commercial_state, '') def _l10n_co_dian_get_electronic_document_number(self): self.ensure_one() if not self.l10n_co_dian_attachment_id: return None if self.move_type in ('in_invoice', 'in_refund') and not self.l10n_co_edi_is_support_document: root = etree.fromstring(xml_utils._unzip(self.l10n_co_dian_attachment_id.raw)) else: root = etree.fromstring(self.l10n_co_dian_attachment_id.raw) nsmap = {k: v for k, v in root.nsmap.items() if k} # empty namespace prefix is not supported for XPaths return root.findtext('./cbc:ID', namespaces=nsmap) def l10n_co_dian_action_send_bill_support_document(self): self.ensure_one() xml, errors = self.env['account.edi.xml.ubl_dian']._export_invoice(self) if errors: raise UserError(self.env._("Error(s) when generating the UBL attachment:\n- %s", '\n- '.join(errors))) doc = self._l10n_co_dian_send_invoice_xml(xml) if doc.state == 'invoice_rejected': if self.env['account.move.send']._can_commit(): self.env.cr.commit() raise UserError(self.env._("Error(s) when sending the document to the DIAN:\n- %s", "\n- ".join(doc.message_json['errors']) or doc.message_json['status'])) def _l10n_co_dian_get_invoice_report_qr_code_value(self): """ Returns the value to be embedded inside the QR Code on the PDF report. For Support Documents, see section 12.2 ('Anexo-Tecnico-Documento-Soporte[...].pdf'). Otherwise, see section 11.7 ('Anexo-Tecnico-[...]-1-9.pdf'). """ self.ensure_one() attachment = self.l10n_co_dian_attachment_id if self.move_type in ('in_invoice', 'in_refund') and not self.l10n_co_edi_is_support_document: xml_bytes = xml_utils._unzip(attachment.raw) root = etree.fromstring(etree.fromstring(xml_bytes).findtext('.//{*}Description') or xml_bytes) else: root = etree.fromstring(attachment.raw) return xml_utils._get_qr_code_value(root, self.currency_id, self.l10n_co_edi_is_support_document) def _l10n_co_dian_get_extra_invoice_report_values(self): """ Get the values used to render the PDF """ self.ensure_one() document = self._l10n_co_dian_get_last_accepted_document() return { 'barcode_src': f'/report/barcode/?barcode_type=QR&value="{self._l10n_co_dian_get_invoice_report_qr_code_value()}"&width=180&height=180&quiet=0', 'signing_datetime': document.datetime.replace(microsecond=0), 'identifier': document.identifier, } def _l10n_co_dian_get_invoice_prepayments(self): """ Collect the prepayments linked to an account.move (based on the partials) :returns: a list of dict of the form: [{'name', 'amount', 'date'}] """ if not self.is_sale_document(): return [] lines = self.line_ids.filtered(lambda l: l.display_type == 'payment_term') prepayment_by_move = defaultdict(float) source_exchange_move = {} for field in ('debit', 'credit'): for partial in lines[f'matched_{field}_ids'].sorted('exchange_move_id.id'): counterpart_line = partial[f'{field}_move_id'] # Aggregate the exchange difference amount if partial.exchange_move_id: source_exchange_move[partial.exchange_move_id] = counterpart_line elif counterpart_line.move_id in source_exchange_move: counterpart_line = source_exchange_move[counterpart_line.move_id] if counterpart_line not in prepayment_by_move: continue # Exclude the partials created after creating a credit note from an existing move if ( (counterpart_line.move_id.move_type == 'out_refund' and lines.move_type == 'out_invoice') or (counterpart_line.move_id.move_type == 'out_invoice' and lines.move_type == 'out_refund') ): continue prepayment_by_move[counterpart_line] += partial.amount return [ { 'name': line.name, 'date': line.date, 'amount': amount, } for line, amount in prepayment_by_move.items() ] def _l10n_co_dian_send_invoice_xml(self, xml): """ Main method called by the Send & Print wizard / on a Support Document It unlinks the previous rejected documents, create a new one, send it to DIAN and logs in the chatter if it is accepted. """ self.ensure_one() self.l10n_co_dian_document_ids.filtered(lambda doc: doc.state == 'invoice_rejected').unlink() document = self.env['l10n_co_dian.document']._send_to_dian(xml=xml, move=self) if document.state == 'invoice_accepted': self.message_post( body=self.env._( "The %s was accepted by the DIAN.", dict(self._fields['move_type']._description_selection(self.env))[document.move_id.move_type], ) if not document.move_id.company_id.l10n_co_dian_demo_mode else self.env._( "The %s was validated locally in Demo Mode.", dict(self._fields['move_type']._description_selection(self.env))[document.move_id.move_type], ), attachment_ids=document.attachment_id.copy().ids, ) return document def _l10n_co_dian_get_attached_document_filename(self): self.ensure_one() # remove every non-word char or underscore, keep only the alphanumeric characters return re.sub(r'[\W_]', '', self.name) def _l10n_co_dian_get_commercial_event_document_filename(self, file_ext): self.ensure_one() prefix = 'ar' if file_ext == 'xml' else 'z' vat = self.company_id.partner_id._get_vat_without_verification_code().zfill(10) year = fields.Datetime.now().strftime("%y") suffix = self.with_company(self.company_id).env['ir.sequence'].next_by_code(EVENT_FILE_SEQUENCE_CODE) if not suffix: sequence = self.env['ir.sequence'].sudo().create([{ 'name': f"Commercial Event File Name ({self.company_id.name})", 'code': EVENT_FILE_SEQUENCE_CODE, 'company_id': self.company_id.id, 'implementation': 'no_gap', 'use_date_range': True, }]) suffix = sequence.next_by_id() return f'{prefix}{vat}000{year}{int(suffix):0{8}X}' def _l10n_co_dian_get_last_accepted_document(self): self.ensure_one() return next( (d for d in self.l10n_co_dian_document_ids.sorted() if d.state == 'invoice_accepted'), self.env['l10n_co_dian.document'], ) def _l10n_co_dian_cron_update_event_status(self, *, limit=3): date_limit = fields.Datetime.now().date() - timedelta(days=30) # There is no clear-cut way to distinguish processed and non-processed moves # so we need an extra field to prevent multiple batches from processing the same record to_process_domain = [ ('move_type', '=', 'out_invoice'), ('state', '=', 'posted'), ('l10n_co_dian_commercial_state', 'in', ('pending', 'received', 'goods_received')), ('l10n_co_edi_cufe_cude_ref', '!=', False), ('invoice_date', '>=', date_limit), ('l10n_co_dian_processed_by_get_event_status_cron', '=', False), ] records = self.search(domain=to_process_domain, limit=limit) records.l10n_co_dian_action_update_event_status() records.l10n_co_dian_processed_by_get_event_status_cron = True remaining = self.search_count(domain=to_process_domain) if not remaining: # reset processed by cron field processed_records = self.search([('l10n_co_dian_processed_by_get_event_status_cron', '=', True)]) processed_records.l10n_co_dian_processed_by_get_event_status_cron = False self.env['ir.cron']._commit_progress(len(records), remaining=remaining) class AccountMoveLine(models.Model): _inherit = 'account.move.line' def _l10n_co_dian_net_price_subtotal(self): """ Returns the price subtotal after discount in company currency. """ self.ensure_one() return self.move_id.direction_sign * self.balance def _l10n_co_dian_gross_price_subtotal(self): """ Returns the price subtotal without discount in company currency. """ self.ensure_one() if self.discount == 100.0: return 0.0 else: net_price_subtotal = self._l10n_co_dian_net_price_subtotal() return self.company_id.currency_id.round(net_price_subtotal / (1.0 - (self.discount or 0.0) / 100.0))