Files
enterprise-addons/l10n_co_dian/models/account_move.py
T

534 lines
24 KiB
Python

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))