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

1436 lines
77 KiB
Python

# Part of Odoo. See LICENSE file for full copyright and licensing details.
import base64
import logging
import re
from collections import namedtuple
import contextlib
from datetime import datetime
from io import BytesIO
from lxml import etree
from markupsafe import Markup
from psycopg2 import OperationalError
from odoo import fields, models, Command
from odoo.addons.l10n_cl_edi.models.l10n_cl_edi_util import UnexpectedXMLResponse, InvalidToken
from odoo.exceptions import LockError, UserError, ValidationError
from odoo.tools.misc import formatLang
from odoo.tools.translate import _, LazyTranslate
from odoo.tools.float_utils import float_repr
_lt = LazyTranslate(__name__)
_logger = logging.getLogger(__name__)
XML_NAMESPACES = {
'ns0': 'http://www.sii.cl/SiiDte',
'ns1': 'http://www.w3.org/2000/09/xmldsig#',
'xml_schema': 'http://www.sii.cl/XMLSchema'
}
CURRENCY_CODES = {
'001': 'ARS',
'036': 'AUD',
'004': 'BOB',
'005': 'BRL',
'006': 'CAD',
'999': 'CLP',
'048': 'CNY',
'129': 'COP',
'051': 'DKK',
'139': 'AED',
'013': 'USD',
'127': 'HKD',
'137': 'INR',
'135': 'IQD',
'072': 'JPY',
'132': 'MXN',
'096': 'NOK',
'097': 'NZD',
'023': 'PYG',
'024': 'PEN',
'102': 'GBP',
'136': 'SGD',
'128': 'ZAR',
'113': 'SEK',
'082': 'CHF',
'143': 'THB',
'138': 'TWD',
'026': 'UYU',
'134': 'VEF',
'142': 'EUR',
'146': 'CZK',
'166': 'ILS',
'144': 'KRW',
'682': 'SAR',
}
try:
import pdf417gen
except ImportError:
pdf417gen = None
_logger.error('Could not import library pdf417gen')
class AccountMove(models.Model):
_name = 'account.move'
_inherit = ['l10n_cl.edi.util', 'account.move']
l10n_cl_sii_barcode = fields.Char(
string='SII Barcode', readonly=True, copy=False,
help='This XML contains the portion of the DTE XML that should be coded in PDF417 '
'and printed in the invoice barcode should be present in the printed invoice report to be valid')
l10n_cl_dte_status = fields.Selection([
('not_sent', 'Pending To Be Sent'),
('ask_for_status', 'Ask For Status'),
('accepted', 'Accepted'),
('objected', 'Accepted With Objections'),
('rejected', 'Rejected'),
('cancelled', 'Cancelled'),
('manual', 'Manual'),
], string='SII DTE status', copy=False, tracking=True, help="""Status of sending the DTE to the SII:
- Not sent: the DTE has not been sent to SII but it has created.
- Ask For Status: The DTE is asking for its status to the SII.
- Accepted: The DTE has been accepted by SII.
- Accepted With Objections: The DTE has been accepted with objections by SII.
- Rejected: The DTE has been rejected by SII.
- Cancelled: The DTE has been deleted by the user.
- Manual: The DTE is sent manually, i.e.: the DTE will not be sending manually.""")
l10n_cl_dte_partner_status = fields.Selection([
('not_sent', 'Not Sent'),
('sent', 'Sent'),
], string='Partner DTE status', copy=False, readonly=True, help="""
Status of sending the DTE to the partner:
- Not sent: the DTE has not been sent to the partner but it has sent to SII.
- Sent: The DTE has been sent to the partner.""")
l10n_cl_dte_acceptation_status = fields.Selection([
('received', 'Received'),
('ack_sent', 'Acknowledge Sent'),
('claimed', 'Claimed'),
('accepted', 'Accepted'),
('goods', 'Reception 19983'),
('accepted_goods', 'Accepted and RG 19983'),
], string='DTE Accept status', copy=False, help="""The status of the DTE Acceptation
Received: the DTE was received by us for vendor bills, by our customers for customer invoices.
Acknowledge Sent: the Acknowledge has been sent to the vendor.
Claimed: the DTE was claimed by us for vendor bills, by our customers for customer invoices.
Accepted: the DTE was accepted by us for vendor bills, by our customers for customer invoices.
Reception 19983: means that the merchandise or services reception has been created and sent.
Accepted and RG 19983: means that both the content of the document has been accepted and the merchandise or
services reception has been received as well.
""")
l10n_cl_claim = fields.Selection([
('ACD', 'Accept the Content of the Document'),
('RCD', 'Claim the Content of the Document'),
('ERM', 'Provide Receipt of Merchandise or Services'),
('RFP', 'Claim for Partial Lack of Merchandise'),
('RFT', 'Claim for Total Lack of Merchandise'),
('NCA', 'Reception of Cancellation that References Document'),
('ENC', 'Reception of Credit note different to Cancellation'),
('PAG', 'DTE Paid with Cash'),
('ERG', 'Merchandise and Service Receipt Acknowledge in Delivery Guide Previous Month'),
('ERI', 'Merchandise and Service Receipt Acknowledge Printed in Previous Month'),
('CED', 'Yielded DTE'),
], string='Claim', copy=False, help='The reason why the DTE was accepted or claimed by the customer')
l10n_cl_claim_description = fields.Char(string='Claim Detail', readonly=True, copy=False)
l10n_cl_sii_send_file = fields.Many2one('ir.attachment', string='SII Send file', copy=False, groups='base.group_system', index='btree_not_null')
l10n_cl_dte_file = fields.Many2one('ir.attachment', string='DTE file', copy=False, groups='base.group_system', index='btree_not_null')
l10n_cl_sii_send_ident = fields.Text(string='SII Send Identification(Track ID)', copy=False, tracking=True)
l10n_cl_journal_point_of_sale_type = fields.Selection(related='journal_id.l10n_cl_point_of_sale_type')
l10n_cl_reference_ids = fields.One2many('l10n_cl.edi.reference', 'move_id', string='Reference Records')
def button_cancel(self):
for record in self.filtered(lambda x: x.company_id.country_id.code == "CL"):
# The move cannot be modified once the DTE has been accepted, objected or sent to the SII
if record.l10n_cl_dte_status in ['accepted', 'objected']:
l10n_cl_dte_status = dict(record._fields['l10n_cl_dte_status']._description_selection(
self.env)).get(record.l10n_cl_dte_status)
raise UserError(_('This %(document_type)s is in SII status: %(status)s. It cannot be cancelled. '
'Instead you should revert it.',
document_type=record.l10n_latam_document_type_id.name, status=l10n_cl_dte_status))
elif record.l10n_cl_dte_status == 'ask_for_status':
raise UserError(_('This %s is in the intermediate state: \'Ask for Status in the SII\'. '
'You will be able to cancel it only when the document has reached the state '
'of rejection. Otherwise, if it were accepted or objected you should revert it '
'with a suitable document instead of cancelling it.') %
record.l10n_latam_document_type_id.name)
record.l10n_cl_dte_status = 'cancelled'
return super().button_cancel()
def button_draft(self):
for record in self.filtered(lambda x: x.company_id.country_id.code == "CL"):
# The move cannot be modified once the DTE has been accepted, objected or sent to the SII
if record.l10n_cl_dte_status in ['accepted', 'objected']:
l10n_cl_dte_status = dict(record._fields['l10n_cl_dte_status']._description_selection(
self.env)).get(record.l10n_cl_dte_status)
raise UserError(_('This %(document_type)s is in SII status %(status)s. It cannot be reset to draft state. '
'Instead you should revert it.',
document_type=record.l10n_latam_document_type_id.name, status=l10n_cl_dte_status))
elif record.l10n_cl_dte_status == 'ask_for_status':
raise UserError(_('This %s is in the intermediate state: \'Ask for Status in the SII\'. '
'You will be able to reset it to draft only when the document has reached the state '
'of rejection. Otherwise, if it were accepted or objected you should revert it '
'with a suitable document instead of cancelling it.') %
record.l10n_latam_document_type_id.name)
record.l10n_cl_dte_status = None
return super().button_draft()
def _post(self, soft=True):
res = super(AccountMove, self)._post(soft=soft)
# Avoid to post a vendor bill with a inactive currency created from the incoming mail
for move in self.filtered(
lambda x: x.company_id.account_fiscal_country_id.code == "CL" and
x.company_id.l10n_cl_dte_service_provider in ['SII', 'SIITEST', 'SIIDEMO'] and
x.l10n_latam_use_documents):
msg_demo = _(' in DEMO mode.') if move.company_id.l10n_cl_dte_service_provider == 'SIIDEMO' else '.'
for line in move.invoice_line_ids:
line.name = line.product_id.display_name if not line.name else line.name
# check if we have the currency active, in order to receive vendor bills correctly.
if move.move_type in ['in_invoice', 'in_refund'] and not move.currency_id.active:
raise UserError(
_('Invoice %(invoice)s has the currency %(currency)s inactive. Please activate the currency and try again.',
invoice=move.name, currency=move.currency_id.name))
# generation of customer invoices
if ((move.move_type in ['out_invoice', 'out_refund'] and move.journal_id.type == 'sale')
or (move.move_type in ['in_invoice', 'in_refund'] and move.l10n_latam_document_type_id._is_doc_type_vendor())):
if move.journal_id.l10n_cl_point_of_sale_type != 'online' and not move.l10n_latam_document_type_id._is_doc_type_vendor():
move.l10n_cl_dte_status = 'manual'
continue
move._l10n_cl_edi_post_validation()
move._l10n_cl_create_dte()
move.l10n_cl_dte_status = 'not_sent'
dte_signed, file_name = move._l10n_cl_create_dte_envelope()
attachment = self.env['ir.attachment'].create({
'name': 'SII_{}'.format(file_name),
'res_id': move.id,
'res_model': 'account.move',
'datas': base64.b64encode(dte_signed.encode('ISO-8859-1', 'replace')),
'type': 'binary',
})
move.sudo().l10n_cl_sii_send_file = attachment.id
move.message_post(
body=_('DTE has been created%s', msg_demo),
attachment_ids=attachment.ids)
return res
def action_reverse(self):
for record in self.filtered(lambda x: x.company_id.account_fiscal_country_id.code == "CL"):
if record.l10n_cl_dte_status == 'rejected':
raise UserError(_('This %s is rejected by SII. Instead of creating a reverse, you should set it to '
'draft state, correct it and post it again.') %
record.l10n_latam_document_type_id.name)
return super().action_reverse()
def _reverse_moves(self, default_values_list=None, cancel=False):
reverse_moves = super(AccountMove, self)._reverse_moves(default_values_list, cancel)
# The reverse move lines of the reverse moves created to correct the original text are replaced by
# a line with the quantity to 1, the amount to 0 and the original text and correct text as the name
# since sii regulations stipulate the option to use this kind of document with an amount_untaxed
# and amount_total equal to $0.0 just in order to inform this is only a text correction.
# for example, for a bad address or a bad activity description in the originating document.
if self.env.context.get('default_l10n_cl_edi_reference_doc_code') == '2':
for move in reverse_moves:
move.invoice_line_ids = [[5, 0], [0, 0, {
'account_id': move.journal_id.default_account_id.id,
'name': _('Where it says: %(original_text)s should say: %(corrected_text)s',
original_text=self.env.context.get('default_l10n_cl_original_text'),
corrected_text=self.env.context.get('default_l10n_cl_corrected_text')),
'quantity': 1,
'price_unit': 0.0,
}, ], ]
return reverse_moves
def _compute_l10n_latam_document_type(self):
"""
Extension to ensure the document type for customer invoices is computed based on the partner's taxpayer type and allows
the SII, the fiscal authority of Chile, to properly validate the record.
"""
taxpayer_moves = self.filtered(lambda m: m.partner_id.l10n_cl_sii_taxpayer_type in ['1', '3', '4'] and m.move_type == 'out_invoice')
grouped_doc_types = {doc_type.code: doc_type for doc_type in self.l10n_latam_available_document_type_ids._origin}
for move in taxpayer_moves:
taxpayer_type = move.partner_id.l10n_cl_sii_taxpayer_type
if taxpayer_type == '1':
if move.debit_origin_id:
move.l10n_latam_document_type_id = grouped_doc_types.get('56', False)
else:
move.l10n_latam_document_type_id = grouped_doc_types.get('33', False)
elif taxpayer_type == '3':
move.l10n_latam_document_type_id = grouped_doc_types.get('39', False)
else:
if move.debit_origin_id:
move.l10n_latam_document_type_id = grouped_doc_types.get('111', False)
else:
move.l10n_latam_document_type_id = grouped_doc_types.get('110', False)
super(AccountMove, self - taxpayer_moves)._compute_l10n_latam_document_type()
# SII Customer Invoice Buttons
def l10n_cl_send_dte_to_sii(self, retry_send=True):
if self.l10n_latam_document_type_id._is_doc_type_ticket():
return self._l10n_cl_send_dte_to_sii_ticket()
else:
return self._l10n_cl_send_dte_to_sii_non_ticket(retry_send=retry_send)
def _l10n_cl_send_dte_to_sii_ticket(self):
try:
self.lock_for_update()
except LockError:
if not self.env.context.get('cron_skip_connection_errs'):
raise UserError(_('This electronic document is being processed already.')) from None
return
except OperationalError as e:
raise UserError(_('An error occurred while processing this document.')) from None
# To avoid double send on double-click
if self.l10n_cl_dte_status != "not_sent":
return None
digital_signature_sudo = self.company_id.sudo()._get_digital_signature(user_id=self.env.user.id)
if self.company_id.l10n_cl_dte_service_provider == 'SIIDEMO':
self.message_post(body=_('This DTE has been generated in DEMO Mode. It is considered as accepted and it won\'t be sent to SII.'))
self.l10n_cl_dte_status = 'accepted'
return None
response = self._send_xml_to_sii_rest(
self.company_id.l10n_cl_dte_service_provider,
self.company_id.vat,
self.sudo().l10n_cl_sii_send_file.name,
base64.b64decode(self.sudo().l10n_cl_sii_send_file.datas),
digital_signature_sudo,
)
if not response:
return None
self.l10n_cl_sii_send_ident = response.get('trackid')
sii_response_status = response.get('estado')
self.l10n_cl_dte_status = 'ask_for_status' if sii_response_status == 'REC' else 'rejected'
self.message_post(body=_('DTE has been sent to SII with response: %s', self._l10n_cl_get_sii_reception_status_message_rest(sii_response_status)))
def _l10n_cl_send_dte_to_sii_non_ticket(self, retry_send=True):
"""
Send the DTE to the SII.
"""
try:
self.lock_for_update()
except LockError:
if not self.env.context.get('cron_skip_connection_errs'):
raise UserError(_('This invoice is being processed already.')) from None
return
# To avoid double send on double-click
if self.l10n_cl_dte_status != "not_sent":
return None
_logger.info('Sending DTE for invoice with ID %s (name: %s)', self.id, self.name)
digital_signature_sudo = self.company_id.sudo()._get_digital_signature(user_id=self.env.user.id)
if not digital_signature_sudo.subject_serial_number:
raise UserError(_("Please set the subject serial number in the certificate: %s", digital_signature_sudo.name))
if self.company_id.l10n_cl_dte_service_provider == 'SIIDEMO':
self.message_post(body=_('This DTE has been generated in DEMO Mode. It is considered as accepted and '
'it won\'t be sent to SII.'))
self.l10n_cl_dte_status = 'accepted'
return None
params = {
'rutSender': digital_signature_sudo.subject_serial_number[:-2],
'dvSender': digital_signature_sudo.subject_serial_number[-1],
'rutCompany': self._l10n_cl_format_vat(self.company_id.vat)[:-2],
'dvCompany': self._l10n_cl_format_vat(self.company_id.vat)[-1],
'archivo': (
self.sudo().l10n_cl_sii_send_file.name,
base64.b64decode(self.sudo().l10n_cl_sii_send_file.datas),
'application/xml'),
}
response = self._send_xml_to_sii(
self.company_id.l10n_cl_dte_service_provider,
self.company_id.website,
params,
digital_signature_sudo
)
if not response:
return None
response_parsed = etree.fromstring(response)
self.l10n_cl_sii_send_ident = response_parsed.findtext('TRACKID')
sii_response_status = response_parsed.findtext('STATUS')
if sii_response_status == '5':
digital_signature_sudo.last_token = False
_logger.warning('The response status is %s. Clearing the token.',
self._l10n_cl_get_sii_reception_status_message(sii_response_status))
if retry_send:
_logger.info('Retrying send DTE to SII')
self.l10n_cl_send_dte_to_sii(retry_send=False)
# cleans the token and keeps the l10n_cl_dte_status until new attempt to connect
# would like to resend from here, because we cannot wait till tomorrow to attempt
# a new send
else:
self.l10n_cl_dte_status = 'ask_for_status' if sii_response_status == '0' else 'rejected'
self.message_post(body=_('DTE has been sent to SII with response: %s.') %
self._l10n_cl_get_sii_reception_status_message(sii_response_status))
def l10n_cl_verify_dte_status(self, send_dte_to_partner=True):
if self.l10n_latam_document_type_id._is_doc_type_ticket():
return self._l10n_cl_verify_dte_status_ticket(send_dte_to_partner=send_dte_to_partner)
else:
return self._l10n_cl_verify_dte_status_non_ticket(send_dte_to_partner=send_dte_to_partner)
def _l10n_cl_verify_dte_status_ticket(self, send_dte_to_partner=True):
digital_signature_sudo = self.company_id.sudo()._get_digital_signature(user_id=self.env.user.id)
response = self._get_send_status_rest(
self.company_id.l10n_cl_dte_service_provider,
self.l10n_cl_sii_send_ident,
self._l10n_cl_format_vat(self.company_id.vat),
digital_signature_sudo,
)
if not response:
self.l10n_cl_dte_status = 'ask_for_status'
return None
self.l10n_cl_dte_status = self._analyze_sii_result_rest(response)
message_body = self._l10n_cl_get_verify_status_msg_rest(response)
if self.l10n_cl_dte_status in ['accepted', 'objected']:
self.l10n_cl_dte_partner_status = 'not_sent'
if send_dte_to_partner:
self._l10n_cl_send_dte_to_partner()
self.message_post(body=message_body)
def _l10n_cl_verify_dte_status_non_ticket(self, send_dte_to_partner=True):
digital_signature_sudo = self.company_id.sudo()._get_digital_signature(user_id=self.env.user.id)
response = self._get_send_status(
self.company_id.l10n_cl_dte_service_provider,
self.l10n_cl_sii_send_ident,
self._l10n_cl_format_vat(self.company_id.vat),
digital_signature_sudo)
if not response:
self.l10n_cl_dte_status = 'ask_for_status'
digital_signature_sudo.last_token = False
return None
response_parsed = etree.fromstring(response.encode('utf-8'))
if response_parsed.findtext('{http://www.sii.cl/XMLSchema}RESP_HDR/ESTADO') in ['001', '002', '003']:
digital_signature_sudo.last_token = False
_logger.error('Token is invalid.')
return
try:
self.l10n_cl_dte_status = self._analyze_sii_result(response_parsed)
except UnexpectedXMLResponse:
# The assumption here is that the unexpected input is intermittent,
# so we'll retry later. If the same input appears regularly, it should
# be handled properly in _analyze_sii_result.
_logger.error("Unexpected XML response:\n%s", response)
return
if self.l10n_cl_dte_status in ['accepted', 'objected']:
self.l10n_cl_dte_partner_status = 'not_sent'
if send_dte_to_partner:
self._l10n_cl_send_dte_to_partner()
self.message_post(
body=_('Asking for DTE status with response:') +
Markup('<br /><li><b>ESTADO</b>: %s</li><li><b>GLOSA</b>: %s</li><li><b>NUM_ATENCION</b>: %s</li>') % (
response_parsed.findtext('{http://www.sii.cl/XMLSchema}RESP_HDR/ESTADO'),
response_parsed.findtext('{http://www.sii.cl/XMLSchema}RESP_HDR/GLOSA'),
response_parsed.findtext('{http://www.sii.cl/XMLSchema}RESP_HDR/NUM_ATENCION')))
def l10n_cl_verify_claim_status(self):
if self.company_id.l10n_cl_dte_service_provider in ['SIITEST', 'SIIDEMO']:
raise UserError(_('This feature is not available in certification/test mode'))
response = self._get_dte_claim(
self.company_id.l10n_cl_dte_service_provider,
self.company_id.vat,
self.company_id.sudo()._get_digital_signature(user_id=self.env.user.id),
self.l10n_latam_document_type_id.code,
self.l10n_latam_document_number
)
if not response:
return None
try:
events = response['listaEventosDoc']
# listaEventosDoc can be either a list or a CompoundValue (which behaves like but isn't a dict)
if not isinstance(events, list):
events = [events]
# listaEventosDoc can also be falsy and empty, for example if codResp is 16 and the client didn't accept or
# claim the invoice yet. In that case we keep checking until it is and keep the response code unset.
response_code = False
# The purpose of this loop is to check each event has a `codEvento` value. If it doesn't something's wrong
# and we log that as an error. We store the value of the last event in l10n_cl_claim, to mark the claim as
# successfully parsed. It would make more sense to have it be a list instead of a single value, since a list
# of events can be returned, but l10n_cl_claim was defined to only hold a single value and that's not
# something we can change in stable. It's currently not used anywhere in the standard code except to check
# if we already parsed the claim and if not to retrieve its status from the SII.
for event in events:
response_code = event['codEvento']
self.l10n_cl_claim = response_code
except Exception as error:
_logger.error(error)
if not self.env.context.get('cron_skip_connection_errs'):
self.message_post(body=_('Asking for claim status with response:') + Markup('<br/>: %s <br/>') % response +
_('failed due to:') + Markup('<br/> %s') % error)
else:
self.message_post(body=_('Asking for claim status with response:') + Markup('<br/> %s') % response)
# SII Vendor Bill Buttons
def _l10n_cl_send_dte_reception_status(self, status_type):
"""
Send to the supplier the acceptance or claim of the bill received.
"""
response_id = self.env['ir.sequence'].browse(self.env.ref('l10n_cl_edi.response_sequence').id).next_by_id()
StatusType = namedtuple(
'StatusType',
['dte_status', 'dte_glosa_status', 'code_rejected', 'email_template', 'response_type', 'env_type'])
status_types = {
'accepted': StatusType(0, 'DTE Aceptado OK', None, 'email_template_receipt_commercial_accept',
'response_dte', 'env_resp'),
'goods': StatusType(
1, 'El acuse de recibo que se declara en este acto, de acuerdo a lo dispuesto en la letra b) '
'del Art. 4, y la letra c) del Art. 5 de la Ley 19.983, acredita que la entrega de '
'mercaderias o servicio(s) prestado(s) ha(n) sido recibido(s).',
None, 'email_template_receipt_goods', 'receipt_dte', 'recep'),
'claimed': StatusType(2, 'DTE Rechazado', -1, 'email_template_claimed_ack', 'response_dte', 'env_resp'),
}
if not int(self.l10n_latam_document_number):
raise UserError(_('Please check the document number'))
digital_signature_sudo = self.company_id.sudo()._get_digital_signature(user_id=self.env.user.id)
response = self.env['ir.qweb']._render('l10n_cl_edi.%s' % status_types[status_type].response_type, {
'move': self,
'doc_id': f'F{int(self.l10n_latam_document_number)}T{self.l10n_latam_document_type_id.code}',
'format_vat': self._l10n_cl_format_vat,
'time_stamp': self._get_cl_current_strftime(),
'response_id': response_id,
'dte_status': status_types[status_type].dte_status,
'dte_glosa_status': status_types[status_type].dte_glosa_status,
'code_rejected': status_types[status_type].code_rejected,
'signer_rut': digital_signature_sudo.subject_serial_number,
'rec_address': f'{self.company_id.street}, {self.company_id.street2} {self.company_id.city}',
'__keep_empty_lines': True,
})
signed_response = self._sign_full_xml(
response, digital_signature_sudo, '', status_types[status_type].env_type,
self.l10n_latam_document_type_id._is_doc_type_voucher())
if status_type == 'goods':
response = self.env['ir.qweb']._render('l10n_cl_edi.envio_receipt_dte', {
'move': self,
'format_vat': self._l10n_cl_format_vat,
'receipt_section': signed_response.replace(Markup('<?xml version="1.0" encoding="ISO-8859-1" ?>'), ''),
'time_stamp': self._get_cl_current_strftime(),
'__keep_empty_lines': True,
})
signed_response = self._sign_full_xml(
response, digital_signature_sudo, '', 'env_recep',
self.l10n_latam_document_type_id._is_doc_type_voucher())
dte_attachment = self.env['ir.attachment'].create({
'name': 'DTE_{}_{}.xml'.format(status_type, self.name),
'res_model': self._name,
'res_id': self.id,
'type': 'binary',
'datas': base64.b64encode(bytes(signed_response, 'utf-8')),
})
self.sudo().l10n_cl_dte_file = dte_attachment.id
# If we are sending a reception of goods or services we must use an envelope and sign it the same
# way we do with the invoice (DTE / EnvioDTE) in this case we use the tags: DocumentoRecibo / EnvioRecibos
email_template = self.env.ref('l10n_cl_edi.%s' % status_types[status_type].email_template)
email_template.send_mail(self.id, force_send=True, email_values={'attachment_ids': [dte_attachment.id]})
def l10n_cl_reprocess_acknowledge(self):
if not self.partner_id:
raise UserError(_('Please assign a partner before sending the acknowledgement'))
try:
self._l10n_cl_send_receipt_acknowledgment()
except UserError as error:
self.message_post(body=str(error))
def _l10n_cl_send_receipt_acknowledgment(self):
"""
This method sends an xml with the acknowledgement of the reception of the invoice
by email to the vendor.
"""
attch_name = 'DTE_{}.xml'.format(self.l10n_latam_document_number)
dte_attachment = self.sudo().l10n_cl_dte_file
if not dte_attachment:
raise UserError(_('DTE attachment not found => %s') % attch_name)
xml_dte = base64.b64decode(dte_attachment.datas)
xml_content = etree.fromstring(xml_dte)
response_id = self.env['ir.sequence'].browse(self.env.ref('l10n_cl_edi.response_sequence').id).next_by_id()
xml_ack_template = self.env['ir.qweb']._render('l10n_cl_edi.ack_template', {
'move': self,
'format_vat': self._l10n_cl_format_vat,
'get_cl_current_strftime': self._get_cl_current_strftime,
'response_id': response_id,
'nmb_envio': 'RESP_%s' % attch_name,
'envio_dte_id': self._l10n_cl_get_set_dte_id(xml_content),
'digest_value': xml_content.findtext(
'.//ns1:DigestValue', namespaces={'ns1': 'http://www.w3.org/2000/09/xmldsig#'}),
'__keep_empty_lines': True,
})
xml_ack_template = xml_ack_template.replace(
'&lt;', '<').replace('&gt;', '>').replace('&quot;', '"').replace(
'<?xml version="1.0" encoding="ISO-8859-1" ?>', '')
try:
digital_signature_sudo = self.company_id.sudo()._get_digital_signature(user_id=self.env.user.id)
except UserError:
raise UserError(_('There is no signature available to send acknowledge or acceptation of this DTE. '
'Please setup your digital signature'))
xml_ack = self._sign_full_xml(xml_ack_template, digital_signature_sudo, str(response_id),
'env_resp', self.l10n_latam_document_type_id._is_doc_type_voucher())
attachment = self.env['ir.attachment'].create({
'name': 'receipt_acknowledgment_{}.xml'.format(response_id),
'res_model': self._name,
'res_id': self.id,
'type': 'binary',
'datas': base64.b64encode(bytes(xml_ack, 'utf-8')),
})
self.env.ref('l10n_cl_edi.email_template_receipt_ack').send_mail(self.id, force_send=True, email_values={
'attachment_ids': attachment.ids})
self.l10n_cl_dte_acceptation_status = 'ack_sent'
def _l10n_cl_action_response(self, status_type):
"""
This method is intended to manage the claim/acceptation/receipt of goods for vendor bill
"""
accepted_statuses = {'accepted_goods', 'accepted', 'goods'}
ActionResponse = namedtuple('ActionResponse', ['code', 'category', 'status', 'description'])
action_response = {
'accepted': ActionResponse('ACD', _('Claim status'), 'accepted', _('acceptance')),
'goods': ActionResponse('ERM', _('Reception law 19983'), 'received',
_('reception of goods or services RG 19.983')),
'claimed': ActionResponse('RCD', _('Claim status'), 'claimed', _('claim')),
}
if self.company_id.l10n_cl_dte_service_provider == 'SIIDEMO':
self.message_post(body=_(
'<strong>WARNING: Simulating %s in Demo Mode</strong>') % action_response[status_type].description)
self.l10n_cl_dte_acceptation_status = 'accepted_goods' if \
self.l10n_cl_dte_acceptation_status in accepted_statuses and status_type in accepted_statuses \
else status_type
self._l10n_cl_send_dte_reception_status(status_type)
if not self.l10n_latam_document_type_id._is_doc_type_acceptance():
raise UserError(_('The document type with code %(code)s cannot be %(status)s',
code=self.l10n_latam_document_type_id.code, status=action_response[status_type].status))
try:
response = self._send_sii_claim_response(
self.company_id.l10n_cl_dte_service_provider, self.partner_id.vat,
self.company_id.sudo()._get_digital_signature(user_id=self.env.user.id), self.l10n_latam_document_type_id.code,
self.l10n_latam_document_number, action_response[status_type].code)
except InvalidToken:
digital_signature_sudo = self.company_id.sudo()._get_digital_signature(user_id=self.env.user.id)
digital_signature_sudo.last_token = None
return self._l10n_cl_action_response(status_type)
if not response:
return None
try:
cod_response = response['codResp']
description_response = response['descResp']
except Exception as error:
_logger.error(error)
self.message_post(body=_('Exception error parsing the response: %s') % response)
return None
if cod_response in [0, 1]:
# accepted_goods only when both acceptation of the invoice and receipt of goods have been done
self.l10n_cl_dte_acceptation_status = 'accepted_goods' if \
self.l10n_cl_dte_acceptation_status in accepted_statuses and status_type in accepted_statuses \
else status_type
self._l10n_cl_send_dte_reception_status(status_type)
msg = _('Document %s was accepted with the following response:') % (
action_response[status_type].description) + '<br/><strong>%s: %s.</strong>' % (
cod_response, description_response)
if self.company_id.l10n_cl_dte_service_provider == 'SIIDEMO':
msg += _(' -- Response simulated in Demo Mode')
else:
msg = _('Document %s failed with the following response:') % (action_response[status_type].description) + \
Markup('<br/><strong>%s: %s.</strong>') % (cod_response, description_response)
if cod_response == 9 and self.company_id.l10n_cl_dte_service_provider == 'SIITEST':
msg += Markup(_('<br/><br/>If you are trying to test %(status_type)s of documents, you should send this %(document_type)s as a vendor '
'to %(company)s before doing the test.')) % {
"status_type": action_response[status_type].description,
"document_type": self.l10n_latam_document_type_id.name,
"company": self.company_id.name,
}
self.message_post(body=msg)
def l10n_cl_accept_document(self):
self._l10n_cl_action_response('accepted')
def l10n_cl_receipt_service_or_merchandise(self):
self._l10n_cl_action_response('goods')
def l10n_cl_claim_document(self):
self._l10n_cl_action_response('claimed')
# DTE creation
def _get_last_sequence(self, relaxed=False, with_prefix=None):
"""
This method extends the base sequence retrieval to handle Chilean electronic documents.
It checks if there's an available CAF (Código de Autorización de Folios)
for the current folio number and adjusts the sequence accordingly.
"""
res = super()._get_last_sequence(relaxed=relaxed, with_prefix=with_prefix)
if res and self.country_code == "CL" and self.is_sale_document() and self.l10n_latam_document_type_id:
match = re.search(r'(\d+)$', res)
if match:
folio = int(match.group(1))
available_caf = self.env['l10n_cl.dte.caf'].sudo().search([
('final_nb', '>=', folio), ('start_nb', '<=', folio), ('l10n_latam_document_type_id', '=', self.l10n_latam_document_type_id.id),
('status', '=', 'in_use'), ('company_id', '=', self.company_id.id)], limit=1)
if not available_caf:
start_nb = self.l10n_latam_document_type_id._get_start_number()
if start_nb:
res = f"{self.l10n_latam_document_type_id.doc_code_prefix} {start_nb - 1:06d}"
return res
def _l10n_cl_create_dte(self):
folio = int(self.l10n_latam_document_number)
doc_id_number = 'F{}T{}'.format(folio, self.l10n_latam_document_type_id.code)
dte_barcode_xml = self._l10n_cl_get_dte_barcode_xml()
self.l10n_cl_sii_barcode = dte_barcode_xml['barcode']
dte = self.env['ir.qweb']._render('l10n_cl_edi.dte_template', {
'move': self,
'format_vat': self._l10n_cl_format_vat,
'get_cl_current_strftime': self._get_cl_current_strftime,
'format_length': self._format_length,
'format_uom': self._format_uom,
'float_repr': float_repr,
'float_rr': self._float_repr_float_round,
'doc_id': doc_id_number,
'caf': self.l10n_latam_document_type_id._get_caf_file(self.company_id.id, int(self.l10n_latam_document_number)),
'amounts': self._l10n_cl_get_amounts(),
'withholdings': self._l10n_cl_get_withholdings(),
'dte': dte_barcode_xml['ted'],
'__keep_empty_lines': True,
})
digital_signature_sudo = self.company_id.sudo()._get_digital_signature(user_id=self.env.user.id)
signed_dte = self._sign_full_xml(
dte, digital_signature_sudo, doc_id_number, 'doc', self.l10n_latam_document_type_id._is_doc_type_voucher())
dte_attachment = self.env['ir.attachment'].create({
'name': 'DTE_{}.xml'.format(self.name),
'res_model': self._name,
'res_id': self.id,
'type': 'binary',
'datas': base64.b64encode(signed_dte.encode('ISO-8859-1', 'replace'))
})
self.sudo().l10n_cl_dte_file = dte_attachment.id
def _l10n_cl_create_partner_dte(self):
dte_signed, file_name = self._l10n_cl_create_dte_envelope(
'55555555-5' if self.partner_id.l10n_cl_sii_taxpayer_type == '4' else self.partner_id.vat)
dte_partner_attachment = self.env['ir.attachment'].create({
'name': file_name,
'res_model': self._name,
'res_id': self.id,
'type': 'binary',
'datas': base64.b64encode(dte_signed.encode('ISO-8859-1', 'replace'))
})
self.message_post(
body=_('Partner DTE has been generated'),
attachment_ids=[dte_partner_attachment.id])
return dte_partner_attachment
def _l10n_cl_create_dte_envelope(self, receiver_rut='60803000-K'):
file_name = 'F{}T{}.xml'.format(self.l10n_latam_document_number, self.l10n_latam_document_type_id.code)
digital_signature_sudo = self.company_id.sudo()._get_digital_signature(user_id=self.env.user.id)
template = self.l10n_latam_document_type_id._is_doc_type_voucher() and self.env.ref(
'l10n_cl_edi.envio_boleta') or self.env.ref('l10n_cl_edi.envio_dte')
dte = self.sudo().l10n_cl_dte_file.raw.decode('ISO-8859-1')
dte = Markup(dte.replace('<?xml version="1.0" encoding="ISO-8859-1" ?>', ''))
dte_rendered = self.env['ir.qweb']._render(template.id, {
'move': self,
'RutEmisor': self._l10n_cl_format_vat(self.company_id.vat),
'RutEnvia': digital_signature_sudo.subject_serial_number,
'RutReceptor': receiver_rut,
'FchResol': self.company_id.l10n_cl_dte_resolution_date,
'NroResol': self.company_id.l10n_cl_dte_resolution_number,
'TmstFirmaEnv': self._get_cl_current_strftime(),
'dte': dte,
'__keep_empty_lines': True,
})
dte_rendered = dte_rendered.replace('<?xml version="1.0" encoding="ISO-8859-1" ?>', '')
dte_signed = self._sign_full_xml(
dte_rendered, digital_signature_sudo, 'SetDoc',
self.l10n_latam_document_type_id._is_doc_type_voucher() and 'bol' or 'env',
self.l10n_latam_document_type_id._is_doc_type_voucher()
)
return dte_signed, file_name
# DTE sending
def _l10n_cl_send_dte_to_partner(self):
# We need a DTE with the partner vat as RutReceptor to be sent to the partner
dte_partner_attachment = self._l10n_cl_create_partner_dte()
self.env.ref('l10n_cl_edi.l10n_cl_edi_email_template_invoice').send_mail(
self.id, force_send=True, email_values={'attachment_ids': [dte_partner_attachment.id]})
self.l10n_cl_dte_partner_status = 'sent'
self.message_post(body=_('DTE has been sent to the partner'))
# Helpers
def _l10n_cl_edi_currency_validation(self):
# commented to allow validation to true for every case of invoices (national invoices in foreign currency)
return True
def _l10n_cl_edi_post_validation(self):
if (self.l10n_cl_journal_point_of_sale_type == 'online' and
not ((self.partner_id.l10n_cl_dte_email or self.commercial_partner_id.l10n_cl_dte_email) and
self.company_id.l10n_cl_dte_email) and
not self.l10n_latam_document_type_id._is_doc_type_export() and
not self.l10n_latam_document_type_id._is_doc_type_ticket()):
raise UserError(
_('The %(partner_type)s %(partner)s has not a DTE email defined. This is mandatory for electronic invoicing.',
partner_type=_('partner') if not (self.partner_id.l10n_cl_dte_email or
self.commercial_partner_id.l10n_cl_dte_email) else _('company'), partner=self.partner_id.name))
if datetime.strptime(self._get_cl_current_strftime(), '%Y-%m-%dT%H:%M:%S').date() < self.invoice_date:
raise UserError(
_('The stamp date and time cannot be prior to the invoice issue date and time. TIP: check '
'in your user preferences if the timezone is "America/Santiago"'))
if not self.company_id.l10n_cl_dte_service_provider:
raise UserError(_(
'You have not selected an invoicing service provider for your company. '
'Please go to your company and select one'))
if not self.company_id.l10n_cl_activity_description:
raise UserError(_(
'Your company has not an activity description configured. This is mandatory for electronic '
'invoicing. Please go to your company and set the correct one (www.sii.cl - Mi SII)'))
if not self.company_id.l10n_cl_company_activity_ids:
raise UserError(_(
'There are no activity codes configured in your company. This is mandatory for electronic '
'invoicing. Please go to your company and set the correct activity codes (www.sii.cl - Mi SII)'))
if len(self.company_id.l10n_cl_company_activity_ids) > 4:
raise UserError(self.env._(
'The maximum amount of Activities Names is 4. Please go to your company and select only 4 or less options.'))
if not self.company_id.l10n_cl_sii_regional_office:
raise UserError(_(
'There is no SII Regional Office configured in your company. This is mandatory for electronic '
'invoicing. Please go to your company and set the regional office, according to your company '
'address (www.sii.cl - Mi SII)'))
if self.l10n_latam_document_type_id.code == '39':
if self.line_ids.filtered(lambda x: x.tax_group_id.id in [
self.env['account.chart.template'].with_company(self.company_id).ref('tax_group_ila').id,
self.env['account.chart.template'].with_company(self.company_id).ref('tax_group_retenciones').id,
]):
raise UserError(_('Receipts with withholding taxes are not allowed'))
if self.company_id.currency_id != self.currency_id:
raise UserError(_('It is not allowed to create receipts in a different currency than CLP'))
if (self.l10n_latam_document_type_id.code not in ['39', '41', '110', '111', '112'] and
not (self.partner_id.l10n_cl_activity_description or
self.commercial_partner_id.l10n_cl_activity_description)):
raise UserError(_(
'There is not an activity description configured in the '
'customer %s record. This is mandatory for electronic invoicing for this type of '
'document. Please go to the partner record and set the activity description') % self.partner_id.name)
if not self.l10n_latam_document_type_id._is_doc_type_electronic_ticket() and not self.partner_id.street:
raise UserError(_(
'There is no address configured in your customer %s record. '
'This is mandatory for electronic invoicing for this type of document. '
'Please go to the partner record and set the address') % self.partner_id.name)
if (self.l10n_latam_document_type_id.code in ['34', '41', '110', '111', '112'] and
self.amount_untaxed != self.amount_total):
raise UserError(_('It seems that you are using items with taxes in exempt documents in invoice %(invoice_id)s - %(invoice_name)s.'
' You must either:\n'
' - Change the document type to a not exempt type.\n'
' - Set an exempt fiscal position to remove taxes automatically.\n'
' - Use products without taxes.\n'
' - Remove taxes from product lines.', invoice_id=self.id, invoice_name=self.name))
if self.l10n_latam_document_type_id.code == '33' and self.amount_untaxed == self.amount_total:
raise UserError(_('All the items you are billing in invoice %(invoice_id)s - %(invoice_name)s, have no taxes.\n'
' If you need to bill exempt items you must either use exempt invoice document type (34),'
' or at least one of the items should have vat tax.', invoice_id=self.id, invoice_name=self.name))
def _l10n_cl_get_sii_reception_status_message(self, sii_response_status):
"""
Get the value of the code returns by SII once the DTE has been sent to the SII.
"""
return {
'0': _('Upload OK'),
'1': _('Sender Does Not Have Permission To Send'),
'2': _('File Size Error (Too Big or Too Small)'),
'3': _('Incomplete File (Size <> Parameter size)'),
'5': _('Not Authenticated'),
'6': _('Company Not Authorized to Send Files'),
'7': _('Invalid Schema'),
'8': _('Document Signature'),
'9': _('System Locked'),
'Otro': _('Internal Error'),
}.get(sii_response_status, sii_response_status)
def _l10n_cl_get_sii_reception_status_message_rest(self, sii_response_status):
return {
'REC': _lt('Submission received'),
'EPR': _lt('Submission processed'),
'CRT': _lt('Cover OK'),
'FOK': _lt('Submission signature validated'),
'PRD': _lt('Submission in process'),
'RCH': _lt('Rejected due to information errors'),
'RCO': _lt('Rejected for consistency'),
'VOF': _lt('The .xml file was not found'),
'RFR': _lt('Rejected due to error in signature'),
'RPR': _lt('Accepted with objections'),
'RPT': _lt('Repeat submission rejected'),
'RSC': _lt('Rejected by schema'),
'SOK': _lt('Validated schema'),
'RCT': _lt('Rejected by error in covert'),
}.get(sii_response_status, sii_response_status)
def _l10n_cl_get_verify_status_msg_rest(self, data):
msg = _('Asking for DTE status with response:')
if self.l10n_cl_dte_status in ['rejected', 'objected']:
detail = data['detalle_rep_rech']
if detail:
msg += Markup('<br/><li><b>ESTADO</b>: %s</li>') % detail[0]['estado']
errors = detail[0].get('error', [])
for error in errors:
msg += Markup('<br/><li><b>ERROR</b>: %s %s</li>') % (error['descripcion'], error['detalle'] or "")
return msg
return msg + Markup('<br/><li><b>ESTADO</b>: %s</li>') % data['estado']
def _l10n_cl_normalize_currency_name(self, currency_name):
currency_dict = {
'AED': 'DIRHAM',
'ARS': 'PESO',
'AUD': 'DOLAR AUST',
'BOB': 'BOLIVIANO',
'BRL': 'CRUZEIRO REAL',
'CAD': 'DOLAR CAN',
'CHF': 'FRANCO SZ',
'CLP': 'PESO CL',
'CNY': 'RENMINBI',
'COP': 'PESO COL',
'ECS': 'SUCRE',
'EUR': 'EURO',
'GBP': 'LIBRA EST',
'HKD': 'DOLAR HK',
'INR': 'RUPIA',
'JPY': 'YEN',
'MXN': 'PESO MEX',
'NOK': 'CORONA NOR',
'NZD': 'DOLAR NZ',
'PEN': 'NUEVO SOL',
'PYG': 'GUARANI',
'SEK': 'CORONA SC',
'SGD': 'DOLAR SIN',
'TWD': 'DOLAR TAI',
'USD': 'DOLAR USA',
'UYU': 'PESO URUG',
'VEF': 'BOLIVAR',
'ZAR': 'RAND',
}
return currency_dict.get(currency_name, 'OTRAS MONEDAS')
def _l10n_cl_get_dte_barcode_xml(self):
"""
This method create the "stamp" (timbre). Is the auto-contained information inside the pdf417 barcode, which
consists of a reduced xml version of the invoice, containing: issuer, recipient, folio and the first line
of the invoice, etc.
:return: xml that goes embedded inside the pdf417 code
"""
dd = self.env['ir.qweb']._render('l10n_cl_edi.dd_template', {
'move': self,
'format_vat': self._l10n_cl_format_vat,
'float_repr': float_repr,
'format_length': self._format_length,
'format_uom': self._format_uom,
'time_stamp': self._get_cl_current_strftime(),
'caf': self.l10n_latam_document_type_id._get_caf_file(
self.company_id.id, int(self.l10n_latam_document_number)),
'amounts': self._l10n_cl_get_amounts(),
'__keep_empty_lines': True,
})
caf_file = self.l10n_latam_document_type_id._get_caf_file(
self.company_id.id, int(self.l10n_latam_document_number))
ted = self.env['ir.qweb']._render('l10n_cl_edi.ted_template', {
'dd': dd,
'frmt': self.env['certificate.key']._sign_with_key(
re.sub(b'\n\\s*', b'', dd.encode('ISO-8859-1', 'replace')),
base64.b64encode(caf_file.findtext('RSASK').encode('utf-8')),
hashing_algorithm='sha1',
formatting='base64',
).decode(),
'stamp': self._get_cl_current_strftime(),
'__keep_empty_lines': True,
})
return {
'ted': Markup(re.sub(r'\n\s*$', '', ted, flags=re.MULTILINE)),
'barcode': etree.tostring(etree.fromstring(re.sub(
r'<TmstFirma>.*</TmstFirma>', '', ted), parser=etree.XMLParser(remove_blank_text=True)))
}
def _l10n_cl_get_reverse_doc_type(self):
if self.partner_id.l10n_cl_sii_taxpayer_type == '4' or self.partner_id.country_id.code != "CL":
return self.env['l10n_latam.document.type'].search(
[('code', '=', '112'), ('country_id.code', '=', "CL")], limit=1)
return self.env['l10n_latam.document.type'].search(
[('code', '=', '61'), ('country_id.code', '=', "CL")], limit=1)
def _l10n_cl_get_comuna_recep(self, recep=True):
if self.partner_id._l10n_cl_is_foreign():
if recep:
return self._format_length(
self.partner_id.state_id.name or self.commercial_partner_id.state_id.name or 'N-A', 20)
return self._format_length(self.partner_shipping_id.state_id.name or 'N-A', 20)
if self.l10n_latam_document_type_id._is_doc_type_voucher():
return 'N-A'
if recep:
return self._format_length(self.partner_id.city or self.commercial_partner_id.city, 20) or False
return self._format_length(self.partner_shipping_id.city, 20) or False
def _l10n_cl_get_set_dte_id(self, xml_content):
set_dte = xml_content.find('.//ns0:SetDTE', namespaces={'ns0': 'http://www.sii.cl/SiiDte'})
if set_dte is None:
return ''
return set_dte.attrib.get('ID')
def _l10n_cl_get_report_base_filename(self):
return _("%s COPY", self._get_report_base_filename())
# Cron methods
def _l10n_cl_ask_dte_status(self):
for move in self.search([('l10n_cl_dte_status', '=', 'ask_for_status')]):
move.l10n_cl_verify_dte_status(send_dte_to_partner=False)
self.env.cr.commit()
def _l10n_cl_send_dte_to_partner_multi(self):
for move in self.search([('l10n_cl_dte_status', '=', 'accepted'),
('l10n_cl_dte_partner_status', '=', 'not_sent'),
('partner_id.country_id.code', '=', "CL")]):
_logger.debug('Sending %s DTE to partner' % move.name)
if move.partner_id._l10n_cl_is_foreign():
continue
move._l10n_cl_send_dte_to_partner()
self.env.cr.commit()
def _l10n_cl_ask_claim_status(self):
for move in self.search([('l10n_cl_dte_acceptation_status', 'in', ['accepted', 'claimed']),
('move_type', '=', 'out_invoice'),
('l10n_cl_claim', '=', False)]):
if move.company_id.l10n_cl_dte_service_provider in ['SIITEST', 'SIIDEMO']:
continue
move.l10n_cl_verify_claim_status()
self.env.cr.commit()
def _pdf417_barcode(self, barcode_data):
# This method creates the graphic representation of the barcode
barcode_file = BytesIO()
if pdf417gen is None:
return False
bc = pdf417gen.encode(barcode_data, security_level=5, columns=13)
image = pdf417gen.render_image(bc, padding=15, scale=1)
image.save(barcode_file, 'PNG')
data = barcode_file.getvalue()
return base64.b64encode(data)
# cron jobs
def cron_run_sii_workflow(self):
"""
This method groups all the steps needed to do the SII workflow:
1.- Ask to SII for the status of the DTE sent
2.- Send to the customer the DTE accepted by the SII
3.- Ask the status of the DTE claimed by the customer
"""
_logger.debug('Starting cron SII workflow')
self_skip = self.with_context(cron_skip_connection_errs=True)
self_skip._l10n_cl_ask_dte_status()
self_skip._l10n_cl_send_dte_to_partner_multi()
self_skip._l10n_cl_ask_claim_status()
def cron_send_dte_to_sii(self):
for record in self.search([('l10n_cl_dte_status', '=', 'not_sent')]):
record.with_context(cron_skip_connection_errs=True).l10n_cl_send_dte_to_sii()
self.env.cr.commit()
# ------------------------------------------------------------
# IMPORT
# ------------------------------------------------------------
def _get_import_file_type(self, file_data):
""" Identify DTE files. """
# EXTENDS 'account'
if file_data['xml_tree'] is not None and file_data['xml_tree'].xpath('//ns0:DTE', namespaces=XML_NAMESPACES):
return 'l10n_cl.dte'
return super()._get_import_file_type(file_data)
def _unwrap_attachment(self, file_data, recurse=True):
""" Divide a DTE into constituent invoices and create a new attachment for each invoice after the first. """
# EXTENDS 'account'
if file_data['import_file_type'] != 'l10n_cl.dte':
return super()._unwrap_attachment(file_data, recurse)
embedded = self._split_xml_into_new_attachments(file_data, tag='{*}DTE')
if embedded and recurse:
embedded.extend(self._unwrap_attachments(embedded, recurse=True))
return embedded
def _get_edi_decoder(self, file_data, new=True):
# EXTENDS 'account'
if file_data['import_file_type'] == 'l10n_cl.dte':
return {
'priority': 20,
'decoder': self._l10n_cl_import_dte,
}
return super()._get_edi_decoder(file_data, new=new)
def _l10n_cl_import_dte(self, invoice, file_data, new):
if invoice.invoice_line_ids:
return invoice._reason_cannot_decode_has_invoice_lines()
xml_tree = file_data['xml_tree']
invoice.l10n_cl_dte_file = file_data['attachment']
invoice.l10n_cl_dte_acceptation_status = 'received'
vals = {}
messages = []
origin_type = self.env['fetchmail.server']._get_xml_origin_type(xml_tree)
if origin_type == 'not_classified':
messages.append(_('Failed to determine origin type of the attached document, attempting to process as a vendor bill'))
# The file may contain several DTEs: only the first one belongs to this vendor bill.
dte_nodes = xml_tree.findall('.//ns0:DTE', namespaces=XML_NAMESPACES)
if len(dte_nodes) > 1:
xml_tree = dte_nodes[0]
invoice._l10n_cl_fill_partner_vals_from_xml(xml_tree, vals, messages)
invoice._l10n_cl_fill_document_number_vals_from_xml(xml_tree, vals, messages)
invoice._l10n_cl_fill_document_vals_from_xml(xml_tree, vals, messages)
invoice._l10n_cl_fill_lines_vals_from_xml(xml_tree, vals, messages)
invoice._l10n_cl_fill_references_vals_from_xml(xml_tree, vals, messages)
invoice.message_post(body=Markup('<br/>').join(messages))
invoice.write(vals)
invoice._l10n_cl_adjust_manual_taxes_from_xml(xml_tree)
invoice._l10n_cl_check_total_amount_from_xml(xml_tree)
def _l10n_cl_fill_partner_vals_from_xml(self, xml_tree, vals, messages):
partner_vat = (
xml_tree.findtext('.//ns0:RUTEmisor', namespaces=XML_NAMESPACES).upper() or
xml_tree.findtext('.//ns0:RutEmisor', namespaces=XML_NAMESPACES).upper()
)
if partner_vat and (partner := self.env['res.partner'].search(
[
("vat", "=", partner_vat),
*self.env['res.partner']._check_company_domain(self.company_id.id),
],
limit=1,
)):
vals['partner_id'] = partner.id
return
partner_name = xml_tree.findtext('.//ns0:RznSoc', namespaces=XML_NAMESPACES)
partner_street = xml_tree.findtext('.//ns0:DirOrigen', namespaces=XML_NAMESPACES)
if partner_name and partner_vat and partner_street:
with contextlib.suppress(ValidationError):
vals['partner_id'] = self.env['res.partner'].create({
'name': partner_name,
'vat': partner_vat,
'street': partner_street,
'country_id': self.env.ref('base.cl').id,
'l10n_latam_identification_type_id': self.env.ref('l10n_cl.it_RUT').id,
'l10n_cl_sii_taxpayer_type': '1'
}).id
return
vals['narration'] = partner_vat
msg = Markup(
'%(explanation)s<br/>'
'<li><b>%(name_l)s</b>: %(name)s</li>'
'<li><b>%(rut_l)s</b>: %(rut)s</li>'
'<li><b>%(address_l)s</b>: %(address)s</li>'
) % {
'explanation': _('Vendor not found: You can generate this vendor manually with the following information:'),
'name_l': _("Name"), 'name': partner_name or '',
'rut_l': _("RUT"), 'rut': partner_vat or '',
'address_l': _("Address"), 'address': partner_street or ''
}
messages.append(msg)
def _l10n_cl_fill_document_number_vals_from_xml(self, xml_tree, vals, messages):
document_number = xml_tree.findtext('.//ns0:Folio', namespaces=XML_NAMESPACES)
document_type_code = xml_tree.findtext('.//ns0:TipoDTE', namespaces=XML_NAMESPACES)
document_type = self.env['l10n_latam.document.type'].search(
[('code', '=', document_type_code), ('country_id.code', '=', 'CL')], limit=1,
)
if not document_type:
messages.append(_('Document type %s not found.', document_type_code))
if document_type.internal_type not in ['invoice', 'debit_note', 'credit_note']:
messages.append(_('The document type %s is not a vendor bill.', document_type_code))
if vals.get('partner_id'):
duplicate_domain = [('partner_id', '=', vals['partner_id'])]
else:
duplicate_domain = [
('partner_id', '=', False),
('narration', '=', vals['narration']),
]
if self.search([
*duplicate_domain,
('move_type', 'in', ['in_invoice', 'in_refund']),
('name', 'ilike', document_number),
('l10n_latam_document_type_id', '=', document_type.id),
*self.env['account.move']._check_company_domain(self.company_id.id),
]).filtered(lambda m: m.l10n_latam_document_number.lstrip('0') == document_number.lstrip('0')):
messages.append(_('E-invoice already exist: %s', document_number))
if document_type_code == '61':
vals['move_type'] = 'in_refund'
vals['l10n_latam_document_type_id'] = document_type
vals['l10n_latam_document_number'] = document_number
def _l10n_cl_fill_document_vals_from_xml(self, xml_tree, vals, messages):
invoice_date = xml_tree.findtext('.//ns0:FchEmis', namespaces=XML_NAMESPACES)
if invoice_date is not None:
vals['invoice_date'] = fields.Date.from_string(invoice_date)
vals['date'] = fields.Date.context_today(
self.with_context(tz='America/Santiago')
)
invoice_date_due = xml_tree.findtext('.//ns0:FchVenc', namespaces=XML_NAMESPACES)
if invoice_date_due is not None:
vals['invoice_date_due'] = fields.Date.from_string(invoice_date_due)
currency_name = xml_tree.findtext('.//ns0:Moneda', namespaces=XML_NAMESPACES) or 'CLP'
if currency_name.isnumeric():
currency_name = CURRENCY_CODES.get(currency_name)
if currency := self.env['res.currency'].with_context(active_test=False).search([('name', '=', currency_name)]):
vals['currency_id'] = currency
else:
vals['currency_id'] = self.env.ref('base.CLP')
def _l10n_cl_fill_lines_vals_from_xml(self, xml_tree, vals, messages):
vals['invoice_line_ids'] = [
Command.create(line_vals)
for line_vals in self._l10n_cl_get_lines_vals_from_xml(xml_tree, vals, messages)
]
def _l10n_cl_get_lines_vals_from_xml(self, xml_tree, vals, messages):
"""
This parse DTE invoice detail lines and tries to match lines with existing products.
If no products are found, it puts only the description of the products in the draft invoice lines
"""
gross_amount = xml_tree.findtext('.//ns0:MntBruto', namespaces=XML_NAMESPACES) is not None
use_default_tax = xml_tree.findtext('.//ns0:TasaIVA', namespaces=XML_NAMESPACES) is not None
default_purchase_tax = (self.company_id.account_purchase_tax_id or
self.env['account.chart.template'].with_company(self.company_id).ref('OTAX_19'))
currency = vals['currency_id']
lines_vals_list = []
for dte_line in xml_tree.findall('.//ns0:Detalle', namespaces=XML_NAMESPACES):
product_code = dte_line.findtext('.//ns0:VlrCodigo', namespaces=XML_NAMESPACES)
product_name = dte_line.findtext('.//ns0:NmbItem', namespaces=XML_NAMESPACES)
product = self._l10n_cl_get_vendor_product(product_code, product_name, self.company_id, vals.get('partner_id'))
# the QtyItem tag is not mandatory in certain cases (case 2 in documentation).
# Should be set to 1 if not present.
# See http://www.sii.cl/factura_electronica/formato_dte.pdf row 15 and row 22 of tag table
quantity = float(dte_line.findtext('.//ns0:QtyItem', default=1, namespaces=XML_NAMESPACES))
# in the same case, PrcItem is not mandatory if QtyItem is not present, but MontoItem IS mandatory
# this happens whenever QtyItem is not present in the invoice.
# See http://www.sii.cl/factura_electronica/formato_dte.pdf row 38 of tag table.
qty1 = quantity or 1
price_unit = float(dte_line.findtext('.//ns0:MontoItem', default=0, namespaces=XML_NAMESPACES)) / qty1
# See http://www.sii.cl/factura_electronica/formato_dte.pdf,
# where MontoItem is defined as (price_unit * quantity ) - discount + surcharge
# The amount present in "MontoItem" contains
# the value with discount or surcharge applied, so we don't need to calculate it, just dividing this amount
# by the quantity we get the price unit we should use in Odoo.
if dte_line.findtext('.//ns0:IndExe', namespaces=XML_NAMESPACES) == '6':
# If the exempt code is '6' the amount is negative
price_unit = -price_unit
line_vals = {
'product_id': product.id,
'name': product.name if product else dte_line.findtext('.//ns0:NmbItem', namespaces=XML_NAMESPACES),
'quantity': quantity,
'price_unit': price_unit,
'tax_ids': [],
}
if (xml_tree.findtext('.//ns0:TasaIVA', namespaces=XML_NAMESPACES) is not None and
dte_line.findtext('.//ns0:IndExe', namespaces=XML_NAMESPACES) is None):
taxes = self._l10n_cl_get_default_tax(product) or default_purchase_tax
withholding_tax_codes = [int(element.text) for element in dte_line.findall('.//ns0:CodImpAdic', namespaces=XML_NAMESPACES)]
withholding_taxes = self.env['account.tax'].search([
*self.env['account.tax']._check_company_domain(self.company_id),
('type_tax_use', '=', 'purchase'),
('l10n_cl_sii_code', 'in', withholding_tax_codes)
])
line_vals['tax_ids'] = [Command.set(taxes.ids + withholding_taxes.ids)]
if gross_amount:
# in case the tag MntBruto is included in the IdDoc section, and there are not
# additional taxes (withholdings)
# even if the company has not selected its default tax value, we deduct it
# from the price unit, gathering the value rate of the l10n_cl default purchase tax
line_vals['price_unit'] = default_purchase_tax.with_context(
force_price_include=True).compute_all(price_unit, currency)['total_excluded']
lines_vals_list.append(line_vals)
for desc_rcg_global in xml_tree.findall('.//ns0:DscRcgGlobal', namespaces=XML_NAMESPACES):
line_type = desc_rcg_global.findtext('.//ns0:TpoMov', namespaces=XML_NAMESPACES)
price_type = desc_rcg_global.findtext('.//ns0:TpoValor', namespaces=XML_NAMESPACES)
discount_surcharge_value = (desc_rcg_global.findtext('.//ns0:ValorDROtrMnda', namespaces=XML_NAMESPACES) or
desc_rcg_global.findtext('.//ns0:ValorDR', namespaces=XML_NAMESPACES))
line_vals = {
'name': 'DESCUENTO' if line_type == 'D' else 'RECARGO',
'quantity': 1,
'tax_ids': [],
}
amount_dr = float(discount_surcharge_value)
percent_dr = amount_dr / 100
# The price unit of a discount line should be negative while surcharge should be positive
price_unit_multiplier = 1 if line_type == 'D' else -1
if price_type == '%':
inde_exe_dr = desc_rcg_global.findtext('.//ns0:IndExeDR', namespaces=XML_NAMESPACES)
if inde_exe_dr is None: # Applied to items with tax
dte_amount_tag = (xml_tree.findtext('.//ns0:MntNetoOtrMnda', namespaces=XML_NAMESPACES) or
xml_tree.findtext('.//ns0:MntNeto', namespaces=XML_NAMESPACES))
dte_amount = int(dte_amount_tag or 0)
# as MntNeto value is calculated after discount
# we need to calculate back the amount before discount in order to apply the percentage
# and know the amount of the discount.
dte_amount_before_discount = dte_amount / (1 - percent_dr)
line_vals['price_unit'] = - price_unit_multiplier * dte_amount_before_discount * percent_dr
if use_default_tax:
line_vals['tax_ids'] = [Command.link(default_purchase_tax.id)]
elif inde_exe_dr == '2': # Applied to items not billable
dte_amount_tag = xml_tree.findtext('.//ns0:MontoNF', namespaces=XML_NAMESPACES)
dte_amount = dte_amount_tag is not None and int(dte_amount_tag) or 0
line_vals['price_unit'] = round(
dte_amount - (int(dte_amount) / (1 - amount_dr / 100))) * price_unit_multiplier
elif inde_exe_dr == '1': # Applied to items without taxes
dte_amount_tag = (xml_tree.findtext('.//ns0:MntExeOtrMnda', namespaces=XML_NAMESPACES) or
xml_tree.findtext('.//ns0:MntExe', namespaces=XML_NAMESPACES))
dte_amount = dte_amount_tag is not None and int(dte_amount_tag) or 0
line_vals['price_unit'] = round(
dte_amount - (int(dte_amount) / (1 - amount_dr / 100))) * price_unit_multiplier
else:
if gross_amount:
amount_dr = default_purchase_tax.with_context(force_price_include=True).compute_all(
amount_dr, currency)['total_excluded']
line_vals['price_unit'] = amount_dr * -1 * price_unit_multiplier
if use_default_tax and desc_rcg_global.findtext('.//ns0:IndExeDR', namespaces=XML_NAMESPACES) not in ['1', '2']:
line_vals['tax_ids'] = [Command.link(default_purchase_tax.id)]
lines_vals_list.append(line_vals)
return lines_vals_list
def _l10n_cl_get_default_tax(self, product):
return product.taxes_id.filtered(lambda tax: tax.company_id == self.company_id and tax.type_tax_use == 'purchase')
def _l10n_cl_get_vendor_product(self, product_code, product_name, company_id, partner_id):
"""
This tries to match products specified in the vendor bill with current products in database.
Criteria to attempt a match with existent products:
1) check if product_code in the supplier info is present (if partner_id is established)
2) if (1) fails, check if product supplier info name is present (if partner_id is established)
3) if (1) and (2) fail, check product default_code
4) if 3 previous criteria fail, check product name, and return false if fails
"""
if partner_id:
supplier_info_domain = [
*self.env['product.supplierinfo']._check_company_domain(company_id),
('partner_id', '=', partner_id),
]
if product_code:
# 1st criteria
supplier_info_domain.append(('product_code', '=', product_code))
else:
# 2nd criteria
supplier_info_domain.append(('product_name', '=', product_name))
supplier_info = self.env['product.supplierinfo'].sudo().search(supplier_info_domain, limit=1)
if supplier_info:
return supplier_info.product_id
# 3rd criteria
if product_code:
product = self.env['product.product'].sudo().search([
*self.env['product.product']._check_company_domain(company_id),
'|', ('default_code', '=', product_code), ('barcode', '=', product_code),
], limit=1)
if product:
return product
# 4th criteria
return self.env['product.product'].sudo().search([
*self.env['product.product']._check_company_domain(company_id),
('name', 'ilike', product_name),
], limit=1)
def _l10n_cl_fill_references_vals_from_xml(self, xml_tree, vals, messages):
vals['l10n_cl_reference_ids'] = [
Command.create(reference_line)
for reference_line in self._l10n_cl_get_invoice_references_from_xml(xml_tree)
]
def _l10n_cl_get_invoice_references_from_xml(self, xml_tree):
invoice_reference_ids = []
for reference in xml_tree.findall('.//ns0:Referencia', namespaces=XML_NAMESPACES):
new_reference = {
'origin_doc_number': reference.findtext('.//ns0:FolioRef', namespaces=XML_NAMESPACES),
'reference_doc_code': reference.findtext('.//ns0:CodRef', namespaces=XML_NAMESPACES),
'reason': reference.findtext('.//ns0:RazonRef', namespaces=XML_NAMESPACES),
'date': reference.findtext('.//ns0:FchRef', namespaces=XML_NAMESPACES),
}
if reference_doc_type_code := reference.findtext('.//ns0:TpoDocRef', namespaces=XML_NAMESPACES):
if reference_doc_type := self.env['l10n_latam.document.type'].search(
[('code', '=', reference_doc_type_code)], limit=1
):
new_reference['l10n_cl_reference_doc_type_id'] = reference_doc_type.id
else:
new_reference['reason'] = '%s: %s' % (reference_doc_type_code, new_reference['reason'])
invoice_reference_ids.append(new_reference)
return invoice_reference_ids
def _l10n_cl_adjust_manual_taxes_from_xml(self, xml_tree):
"""
This method adjusts values on automatically created tax lines for taxes with manually read amounts from the imported XML
It should work for other properly setup taxes read from the <ImptoReten> section when their codes are added to the list
"""
if xml_tree.findtext('.//ns0:ImptoReten', namespaces=XML_NAMESPACES) is not None:
manual_tax_lines = self._l10n_cl_get_manual_taxes_from_xml(xml_tree)
else:
return
line_ids_command = []
processed_sii_codes = set()
for sii_code in manual_tax_lines:
sii_code_lines = self.line_ids.filtered(lambda aml: aml.tax_line_id.l10n_cl_sii_code == sii_code)
if len(sii_code_lines) != 1:
# Found more than one tax line corresponding to a manual import SII code, ignoring
processed_sii_codes.add(sii_code)
sign = -1 if self.is_inbound() else 1
delta_payment_term = 0.0
for line in self.line_ids:
sii_code = line.tax_line_id.l10n_cl_sii_code
if sii_code in (35, 28) and sii_code not in processed_sii_codes:
processed_sii_codes.add(sii_code)
new_amount_currency = sign * manual_tax_lines[sii_code]
delta = new_amount_currency - line.amount_currency
delta_payment_term -= delta
line_ids_command.append(Command.update(line.id, {'amount_currency': new_amount_currency}))
payment_term = self.line_ids.filtered(lambda aml: aml.display_type == 'payment_term')[:1]
if not payment_term or not processed_sii_codes:
return
line_ids_command.append(Command.update(payment_term.id, {'amount_currency': payment_term.amount_currency + delta_payment_term}))
self.line_ids = line_ids_command
def _l10n_cl_get_manual_taxes_from_xml(self, xml_tree):
"""
Get information for lines of manually-read taxes from the values listed in ImptoReten elements
"""
tipo_list = [int(element.text) for element in xml_tree.findall('.//ns0:TipoImp', namespaces=XML_NAMESPACES)]
monto_list = [float(element.text) for element in xml_tree.findall('.//ns0:MontoImp', namespaces=XML_NAMESPACES)]
manual_tax_lines = {}
for tipo, monto in zip(tipo_list, monto_list):
manual_tax_lines[tipo] = monto
return manual_tax_lines
def _l10n_cl_check_total_amount_from_xml(self, xml_tree):
xml_total_amount = float(xml_tree.findtext('.//ns0:MntTotal', namespaces=XML_NAMESPACES))
if self.currency_id.compare_amounts(self.amount_total, xml_total_amount) != 0:
self.message_post(
body=Markup("<strong> %s </strong> %s") % (
_("Warning:"),
_("The total amount of the DTE\'s XML is %(xml_amount)s and the total amount calculated by Odoo is %(move_amount)s. Typically this is caused by additional lines in the detail or by unidentified taxes, please check if a manual correction is needed.",
xml_amount=formatLang(self.env, xml_total_amount, currency_obj=self.currency_id),
move_amount=formatLang(self.env, self.amount_total, currency_obj=self.currency_id)
)
)
)