1
0
forked from Mapan/odoo17e
odoo17e-kedaikipas58/addons/l10n_cl_edi/models/fetchmail_server.py
2024-12-10 09:04:09 +07:00

633 lines
35 KiB
Python

# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import base64
import email
import logging
import os
from lxml import etree
from markupsafe import Markup
from xmlrpc import client as xmlrpclib
from odoo import api, fields, models, _, Command
from odoo.exceptions import ValidationError
from odoo.tools import float_compare
from odoo.tools.misc import formatLang
_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'
}
DEFAULT_DOC_NUMBER_PADDING = 6
class FetchmailServer(models.Model):
_name = 'fetchmail.server'
_inherit = 'fetchmail.server'
l10n_cl_is_dte = fields.Boolean(
'DTE server', help='By checking this option, this email account will be used to receive the electronic\n'
'invoices from the suppliers, and communications from the SII regarding the electronic\n'
'invoices issued. In this case, this email should match both emails declared on the SII\n'
'site in the section: "ACTUALIZACION DE DATOS DEL CONTRIBUYENTE", "Mail Contacto SII"\n'
'and "Mail Contacto Empresas".')
l10n_cl_last_uid = fields.Integer(
string='Last read message ID (CL)', default=1,
help='This value is pointing to the number of the last message read by odoo '
'in the inbox. This value will be updated by the system during its normal'
'operation.')
@api.constrains('l10n_cl_is_dte', 'server_type')
def _check_server_type(self):
for record in self:
if record.l10n_cl_is_dte and record.server_type not in ('imap', 'outlook', 'gmail'):
raise ValidationError(_('The server must be of type IMAP.'))
def fetch_mail(self):
for server in self.filtered(lambda s: s.l10n_cl_is_dte):
_logger.info('Start checking for new emails on %s IMAP server %s', server.server_type, server.name)
# prevents the process from timing out when connecting for the first time
# to an edi email server with too many new emails to process
# e.g over 5k emails. We will only fetch the next 50 "new" emails
# based on their IMAP uid
default_batch_size = 50
count, failed = 0, 0
imap_server = None
try:
imap_server = server.connect()
imap_server.select()
result, data = imap_server.uid('search', None, '(UID %s:*)' % server.l10n_cl_last_uid)
new_max_uid = server.l10n_cl_last_uid
for uid in data[0].split()[:default_batch_size]:
if int(uid) <= server.l10n_cl_last_uid:
# We get always minimum 1 message. If no new message, we receive the newest already managed.
continue
result, data = imap_server.uid('fetch', uid, '(RFC822)')
if not data[0]:
continue
message = data[0][1]
# To leave the mail in the state in which they were.
if 'Seen' not in data[1].decode('UTF-8'):
imap_server.uid('STORE', uid, '+FLAGS', '(\\Seen)')
else:
imap_server.uid('STORE', uid, '-FLAGS', '(\\Seen)')
# See details in message_process() in mail_thread.py
if isinstance(message, xmlrpclib.Binary):
message = bytes(message.data)
if isinstance(message, str):
message = message.encode('utf-8')
msg_txt = email.message_from_bytes(message, policy=email.policy.SMTP)
try:
server._process_incoming_email(msg_txt)
new_max_uid = max(new_max_uid, int(uid))
server.write({'l10n_cl_last_uid': new_max_uid})
self._cr.commit()
except Exception:
_logger.info('Failed to process mail from %s server %s.', server.server_type, server.name,
exc_info=True)
failed += 1
count += 1
server.write({'l10n_cl_last_uid': new_max_uid})
_logger.info('Fetched %d email(s) on %s server %s; %d succeeded, %d failed.', count, server.server_type,
server.name, (count - failed), failed)
except Exception:
_logger.info('General failure when trying to fetch mail from %s server %s.', server.server_type,
server.name, exc_info=True)
finally:
if imap_server:
try:
imap_server.close()
imap_server.logout()
except Exception: # pylint: disable=broad-except
_logger.warning('Failed to properly finish connection: %s.', server.name, exc_info=True)
server.write({'date': fields.Datetime.now()})
return super(FetchmailServer, self.filtered(lambda s: not s.l10n_cl_is_dte)).fetch_mail()
def _process_incoming_email(self, msg_txt):
parsed_values = self.env['mail.thread']._message_parse_extract_payload(msg_txt, {})
body, attachments = parsed_values['body'], parsed_values['attachments']
from_address = msg_txt.get('from')
for attachment in attachments:
_logger.info('Processing attachment %s' % attachment.fname)
attachment_ext = os.path.splitext(attachment.fname)[1]
format_content = attachment.content.encode() if isinstance(attachment.content, str) else attachment.content
if attachment_ext.lower() != '.xml' or not self._is_dte_email(format_content):
_logger.info('Attachment %s has been discarded! It is not a xml file or is not a DTE email' %
attachment.fname)
continue
xml_content = etree.fromstring(format_content)
origin_type = self._get_xml_origin_type(xml_content)
if origin_type == 'not_classified':
_logger.info('Attachment %s has been discarded! Origin type: %s' % (attachment.fname, origin_type))
continue
company = self._get_dte_recipient_company(xml_content, origin_type)
if not company or not self._is_dte_enabled_company(company):
_logger.info('Attachment %s has been discarded! It is not a valid company (id: %s)' % (
attachment.fname, company.id))
continue
self._process_attachment_content(format_content, attachment.fname, from_address, origin_type, company.id)
def _process_attachment_content(self, att_content, att_name, from_address, origin_type, company_id):
"""
This could be called from a button if there is a need to be processed manually
"""
if origin_type == 'incoming_supplier_document':
for move in self._create_document_from_attachment(att_content, att_name, from_address, company_id):
if move.partner_id:
try:
move._l10n_cl_send_receipt_acknowledgment()
except Exception as error:
move.message_post(body=str(error))
elif origin_type == 'incoming_sii_dte_result':
self._process_incoming_sii_dte_result(att_content)
elif origin_type in ['incoming_acknowledge', 'incoming_commercial_accept', 'incoming_commercial_reject']:
self._process_incoming_customer_claim(company_id, att_content, att_name, origin_type)
def _process_incoming_sii_dte_result(self, att_content):
xml_content = etree.fromstring(att_content)
track_id = xml_content.findtext('.//TRACKID').zfill(10)
moves = self.env['account.move'].search([('l10n_cl_sii_send_ident', '=', track_id)])
status = xml_content.findtext('IDENTIFICACION/ESTADO')
error_status = xml_content.findtext('REVISIONENVIO/REVISIONDTE/ESTADO')
if error_status is not None:
msg = _('Incoming SII DTE result:<br/> '
'<li><b>ESTADO</b>: %s</li>'
'<li><b>REVISIONDTE/ESTADO</b>: %s</li>'
'<li><b>REVISIONDTE/DETALLE</b>: %s</li>',
status, error_status, xml_content.findtext('REVISIONENVIO/REVISIONDTE/DETALLE'))
else:
msg = _('Incoming SII DTE result:<br/><li><b>ESTADO</b>: %s</li>', status)
for move in moves:
move.message_post(body=msg)
def _process_incoming_customer_claim(self, company_id, att_content, att_name, origin_type):
dte_tag = 'RecepcionDTE' if origin_type == 'incoming_acknowledge' else 'ResultadoDTE'
xml_content = etree.fromstring(att_content)
for dte in xml_content.xpath('//ns0:%s' % dte_tag, namespaces=XML_NAMESPACES):
document_number = self._get_document_number(dte)
issuer_vat = self._get_dte_receptor_vat(dte)
partner = self._get_partner(issuer_vat, company_id)
if not partner:
_logger.warning('Partner for incoming customer claim has not been found for %s', issuer_vat)
continue
document_type_code = self._get_document_type_from_xml(dte)
document_type = self.env['l10n_latam.document.type'].search(
[('code', '=', document_type_code), ('country_id.code', '=', 'CL')], limit=1)
move = self.env['account.move'].sudo().search([
('partner_id', '=', partner.id),
('move_type', 'in', ['out_invoice', 'out_refund']),
('l10n_latam_document_type_id', '=', document_type.id),
('l10n_cl_dte_status', '=', 'accepted'),
('name', '=ilike', f'{document_type.doc_code_prefix}%{document_number}'),
('company_id', '=', company_id),
]).filtered(lambda m: m.name.split()[1].lstrip('0') == document_number)
if not move:
_logger.warning('Move not found with partner: %s, document_number: %s, l10n_latam_document_type: %s, '
'company_id: %s', partner.id, document_number, document_type.id, company_id)
continue
if len(move) > 1:
_logger.warning('Multiple moves found for partner: %s, document_number: %s, l10n_latam_document_type: %s, '
'company_id: %s. Expected only one move.', partner.id, document_number, document_type.id, company_id)
continue
status = {'incoming_acknowledge': 'received', 'incoming_commercial_accept': 'accepted'}.get(
origin_type, 'claimed')
move.write({'l10n_cl_dte_acceptation_status': status})
move.with_context(no_new_invoice=True).message_post(
body=_('DTE reception status established as <b>%s</b> by incoming email', status),
attachments=[(att_name, att_content)])
def _check_document_number_exists(self, partner_id, document_number, document_type, company_id):
to_check_documents = self.env['account.move'].sudo().search(
[('move_type', 'in', ['in_invoice', 'in_refund']),
('name', 'ilike', document_number),
('partner_id', '=', partner_id),
('company_id', '=', company_id)])
return len(to_check_documents.filtered(
lambda x: x.l10n_latam_document_type_id.code == document_type.code and
x.l10n_latam_document_number.lstrip('0') == document_number.lstrip('0')
)) > 0
def _check_document_number_exists_no_partner(self, document_number, document_type, company_id, vat):
""" This is a separate method for the no partner case to not modify the other method in stable.
If the partner is not found, we put its vat in the narration field, so we avoid to import twice.
"""
to_check_documents = self.env['account.move'].sudo().search([
('move_type', 'in', ['in_invoice', 'in_refund']),
('name', 'ilike', document_number),
('partner_id', '=', False),
('narration', '=', vat),
('company_id', '=', company_id)])
return len(to_check_documents.filtered(
lambda x: x.l10n_latam_document_type_id.code == document_type.code and
x.l10n_latam_document_number.lstrip('0') == document_number.lstrip('0')
)) > 0
def _create_document_from_attachment(self, att_content, att_name, from_address, company_id):
moves = []
xml_content = etree.fromstring(att_content)
for dte_xml in xml_content.xpath('//ns0:DTE', namespaces=XML_NAMESPACES):
document_number = self._get_document_number(dte_xml)
document_type_code = self._get_document_type_from_xml(dte_xml)
xml_total_amount = float(dte_xml.findtext('.//ns0:MntTotal', 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:
_logger.info('DTE has been discarded! Document type %s not found' % document_type_code)
continue
if document_type and document_type.internal_type not in ['invoice', 'debit_note', 'credit_note']:
_logger.info('DTE has been discarded! The document type %s is not a vendor bill' % document_type_code)
continue
issuer_vat = self._get_dte_issuer_vat(dte_xml)
partner = self._get_partner(issuer_vat, company_id)
if partner and self._check_document_number_exists(partner.id, document_number, document_type, company_id) \
or (not partner and self._check_document_number_exists_no_partner(document_number, document_type,
company_id, issuer_vat)):
_logger.info('E-invoice already exist: %s', document_number)
continue
default_move_type = 'in_invoice' if document_type_code != '61' else 'in_refund'
msgs = []
try:
invoice_form, msgs = self._get_invoice_form(
company_id, partner, default_move_type, from_address, dte_xml, document_number, document_type, msgs)
except Exception as error:
_logger.info(error)
with self.env.cr.savepoint(), self.env['account.move'].with_context(
default_move_type=default_move_type, allowed_company_ids=[company_id])._get_edi_creation() as invoice_form:
msgs.append(str(error))
invoice_form.partner_id = partner
invoice_form.l10n_latam_document_type_id = document_type
invoice_form.l10n_latam_document_number = document_number
if not partner:
invoice_form.narration = issuer_vat or ''
move = invoice_form
dte_attachment = self.env['ir.attachment'].create({
'name': 'DTE_{}.xml'.format(document_number),
'res_model': move._name,
'res_id': move.id,
'type': 'binary',
'datas': base64.b64encode(etree.tostring(dte_xml))
})
move.l10n_cl_dte_file = dte_attachment.id
for msg in msgs:
move.with_context(no_new_invoice=True).message_post(body=msg)
msg = _('Vendor Bill DTE has been generated for the following vendor:') if partner else \
_('Vendor not found: You can generate this vendor manually with the following information:')
msg += Markup('<br/>')
move.with_context(no_new_invoice=True).message_post(
body=msg + Markup(_(
'<li><b>Name</b>: %(name)s</li><li><b>RUT</b>: %(vat)s</li><li>'
'<b>Address</b>: %(address)s</li>')) % {
'vat': self._get_dte_issuer_vat(xml_content) or '',
'name': self._get_dte_partner_name(xml_content) or '',
'address': self._get_dte_issuer_address(xml_content) or ''}, attachment_ids=[dte_attachment.id])
if float_compare(move.amount_total, xml_total_amount, precision_digits=move.currency_id.decimal_places) != 0:
move.message_post(
body=Markup(_('<strong>Warning:</strong> The total amount of the DTE\'s XML is %s and the total amount '
'calculated by Odoo is %s. Typically this is caused by additional lines in the detail or '
'by unidentified taxes, please check if a manual correction is needed.'))
% (formatLang(self.env, xml_total_amount, currency_obj=move.currency_id),
formatLang(self.env, move.amount_total, currency_obj=move.currency_id)))
move.l10n_cl_dte_acceptation_status = 'received'
moves.append(move)
_logger.info('New move has been created from DTE %s with id: %s', att_name, move.id)
return moves
def _get_invoice_form(self, company_id, partner, default_move_type, from_address, dte_xml, document_number,
document_type, msgs):
"""
This method creates a draft vendor bill from the attached xml in the incoming email.
"""
with self.env.cr.savepoint(), self.env['account.move'].with_context(
default_invoice_source_email=from_address,
default_move_type=default_move_type, allowed_company_ids=[company_id])._get_edi_creation() as invoice_form:
journal = self._get_dte_purchase_journal(company_id)
if journal:
invoice_form.journal_id = journal
invoice_form.partner_id = partner
invoice_date = dte_xml.findtext('.//ns0:FchEmis', namespaces=XML_NAMESPACES)
if invoice_date is not None:
invoice_form.invoice_date = fields.Date.from_string(invoice_date)
# Set the date after invoice_date to avoid the onchange
invoice_form.date = fields.Date.context_today(
self.with_context(tz='America/Santiago'))
invoice_date_due = dte_xml.findtext('.//ns0:FchVenc', namespaces=XML_NAMESPACES)
if invoice_date_due is not None:
invoice_form.invoice_date_due = fields.Date.from_string(invoice_date_due)
currency = self._get_dte_currency(dte_xml)
if currency:
invoice_form.currency_id = currency
invoice_form.l10n_latam_document_type_id = document_type
invoice_form.l10n_latam_document_number = document_number
dte_lines = self._get_dte_lines(dte_xml, company_id, partner.id)
invoice_form.write({
'invoice_line_ids': [
Command.create({
'product_id': dte_line.get('product', self.env['product.product']).id,
'name': dte_line.get('name'),
'quantity': dte_line.get('quantity'),
'price_unit': dte_line.get('price_unit'),
'discount': dte_line.get('discount', 0),
'tax_ids': [Command.set([tax.id for tax in dte_line.get('taxes', [])])],
})
for dte_line in dte_lines
],
'l10n_cl_reference_ids': [
Command.create({
'origin_doc_number': reference_line['origin_doc_number'],
'reference_doc_code': reference_line['reference_doc_code'],
'l10n_cl_reference_doc_type_id': reference_line['l10n_cl_reference_doc_type_id'].id,
'reason': reference_line['reason'],
'date': reference_line['date'],
})
for reference_line in self._get_invoice_references(dte_xml)
],
})
for line, dte_line in zip(invoice_form.invoice_line_ids, dte_lines):
if dte_line.get('default_tax'):
default_tax = line._get_computed_taxes()
if default_tax not in line.tax_ids:
line.tax_ids += default_tax
return invoice_form, msgs
def _is_dte_email(self, attachment_content):
return b'http://www.sii.cl/SiiDte' in attachment_content or b'<RESULTADO_ENVIO>' in attachment_content
def _get_dte_recipient_company(self, xml_content, origin_type):
xml_tag_by_type = {
'incoming_supplier_document': '//ns0:RutReceptor',
'incoming_sii_dte_result': '//RUTEMISOR',
'incoming_acknowledge': '//ns0:RutRecibe',
'incoming_commercial_accept': '//ns0:RutRecibe',
'incoming_commercial_reject': '//ns0:RutRecibe',
}
receiver_rut = xml_content.xpath(
xml_tag_by_type.get(origin_type), namespaces=XML_NAMESPACES)
if not receiver_rut:
return None
return self.env['res.company'].sudo().search([('vat', '=', receiver_rut[0].text)])
def _is_dte_enabled_company(self, company):
return False if not company.l10n_cl_dte_service_provider else True
def _get_xml_origin_type(self, xml_content):
tag = etree.QName(xml_content.tag).localname
if tag == 'EnvioDTE':
return 'incoming_supplier_document'
if tag == 'RespuestaDTE':
if xml_content.findtext('.//ns0:EstadoRecepDTE', namespaces=XML_NAMESPACES) == '0':
return 'incoming_acknowledge'
if xml_content.findtext('.//ns0:EstadoDTE', namespaces=XML_NAMESPACES) == '0':
return 'incoming_commercial_accept'
return 'incoming_commercial_reject'
if tag == 'RESULTADO_ENVIO':
return 'incoming_sii_dte_result'
return 'not_classified'
def _get_partner(self, partner_rut, company_id):
return self.env["res.partner"].search(
[
("vat", "=", partner_rut),
"|",
("company_id", "=", company_id),
("company_id", "=", False),
],
limit=1,
)
def _get_dte_issuer_vat(self, xml_content):
return (xml_content.findtext('.//ns0:RUTEmisor', namespaces=XML_NAMESPACES).upper() or
xml_content.findtext('.//ns0:RutEmisor', namespaces=XML_NAMESPACES).upper())
def _get_dte_receptor_vat(self, xml_content):
return (xml_content.findtext('.//ns0:RUTRecep', namespaces=XML_NAMESPACES).upper() or
xml_content.findtext('.//ns0:RutReceptor', namespaces=XML_NAMESPACES).upper())
def _get_dte_partner_name(self, xml_content):
return xml_content.findtext('.//ns0:RznSoc', namespaces=XML_NAMESPACES)
def _get_dte_issuer_address(self, xml_content):
return xml_content.findtext('.//ns0:DirOrigen', default='', namespaces=XML_NAMESPACES)
def _get_dte_purchase_journal(self, company_id):
return self.env['account.journal'].search([
*self.env['account.journal']._check_company_domain(company_id),
('type', '=', 'purchase'),
('l10n_latam_use_documents', '=', True),
], limit=1)
def _get_document_number(self, xml_content):
return xml_content.findtext('.//ns0:Folio', namespaces=XML_NAMESPACES)
def _get_document_type_from_xml(self, xml_content):
return xml_content.findtext('.//ns0:TipoDTE', namespaces=XML_NAMESPACES)
def _get_doc_number_padding(self, company_id):
"""Returns the document number padding used to create the name of the account move"""
move = self.env['account.move'].sudo().search([
('company_id', '=', company_id),
('name', 'not in', (False, '/', ''))
], order='create_date desc', limit=1)
if not move:
return DEFAULT_DOC_NUMBER_PADDING
doc_number = move.name.split(' ')[1]
return len(doc_number)
def _use_default_tax(self, dte_xml):
"""We use the default tax if the DTE has the tag TasaIVA"""
return dte_xml.findtext('.//ns0:TasaIVA', namespaces=XML_NAMESPACES) is not None
def _get_withholding_taxes(self, company_id, dte_line):
# Get withholding taxes from DTE line
tax_codes = [int(element.text) for element in dte_line.findall('.//ns0:CodImpAdic', namespaces=XML_NAMESPACES)]
return set(self.env['account.tax'].search([
*self.env['account.tax']._check_company_domain(company_id),
('type_tax_use', '=', 'purchase'),
('l10n_cl_sii_code', 'in', tax_codes)
]))
def _get_dte_currency(self, dte_xml):
currency_name = dte_xml.findtext('.//ns0:Moneda', namespaces=XML_NAMESPACES)
if currency_name is None: # If the currency of the DTE is CLP then the tag doesn't exist
currency_name = 'CLP'
return self.env['res.currency'].with_context(active_test=False).search([('name', '=', currency_name)])
def _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 _get_dte_lines(self, dte_xml, company_id, partner_id):
"""
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 = dte_xml.findtext('.//ns0:MntBruto', namespaces=XML_NAMESPACES) is not None
default_purchase_tax = self.env['account.tax'].search([
*self.env['account.tax']._check_company_domain(company_id),
('l10n_cl_sii_code', '=', 14),
('type_tax_use', '=', 'purchase'),
], limit=1)
currency = self._get_dte_currency(dte_xml)
invoice_lines = []
for dte_line in dte_xml.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._get_vendor_product(product_code, product_name, company_id, 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.
values = {
'product': product,
'name': product.name if product else dte_line.findtext('.//ns0:NmbItem', namespaces=XML_NAMESPACES),
'quantity': quantity,
'price_unit': price_unit,
'default_tax': False
}
if (dte_xml.findtext('.//ns0:TasaIVA', namespaces=XML_NAMESPACES) is not None and
dte_line.findtext('.//ns0:IndExe', namespaces=XML_NAMESPACES) is None):
values['default_tax'] = True
values['taxes'] = set(default_purchase_tax) | self._get_withholding_taxes(company_id, dte_line)
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
values['price_unit'] = default_purchase_tax.with_context(
force_price_include=True).compute_all(price_unit, currency)['total_excluded']
invoice_lines.append(values)
for desc_rcg_global in dte_xml.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))
values = {
'name': 'DESCUENTO' if line_type == 'D' else 'RECARGO',
'quantity': 1,
}
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 = (dte_xml.findtext('.//ns0:MntNetoOtrMnda', namespaces=XML_NAMESPACES) or
dte_xml.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)
values['price_unit'] = - price_unit_multiplier * dte_amount_before_discount * percent_dr
values['default_tax'] = self._use_default_tax(dte_xml)
elif inde_exe_dr == '2': # Applied to items not billable
dte_amount_tag = dte_xml.findtext('.//ns0:MontoNF', namespaces=XML_NAMESPACES)
dte_amount = dte_amount_tag is not None and int(dte_amount_tag) or 0
values['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 = (dte_xml.findtext('.//ns0:MntExeOtrMnda', namespaces=XML_NAMESPACES) or
dte_xml.findtext('.//ns0:MntExe', namespaces=XML_NAMESPACES))
dte_amount = dte_amount_tag is not None and int(dte_amount_tag) or 0
values['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']
values['price_unit'] = amount_dr * -1 * price_unit_multiplier
if desc_rcg_global.findtext('.//ns0:IndExeDR', namespaces=XML_NAMESPACES) not in ['1', '2']:
values['default_tax'] = self._use_default_tax(dte_xml)
invoice_lines.append(values)
return invoice_lines
def _get_invoice_references(self, dte_xml):
invoice_reference_ids = []
for reference in dte_xml.findall('.//ns0:Referencia', namespaces=XML_NAMESPACES):
new_reference = {
'reference_doc_type': reference.findtext('.//ns0:TpoDocRef', namespaces=XML_NAMESPACES),
'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),
}
new_reference['l10n_cl_reference_doc_type_id'] = self.env['l10n_latam.document.type'].search(
[('code', '=', new_reference['reference_doc_type'])], limit=1)
if not new_reference['l10n_cl_reference_doc_type_id']:
new_reference['reason'] = '%s: %s' % (new_reference['reference_doc_type'], new_reference['reason'])
invoice_reference_ids.append(new_reference)
return invoice_reference_ids