924 lines
41 KiB
Python
924 lines
41 KiB
Python
# -*- coding: utf-8 -*-
|
|
from odoo import _, api, models, fields, Command
|
|
from odoo.addons.l10n_mx_edi.models.l10n_mx_edi_document import CANCELLATION_REASON_SELECTION, CFDI_DATE_FORMAT, USAGE_SELECTION
|
|
from odoo.exceptions import UserError, ValidationError
|
|
from datetime import datetime
|
|
|
|
|
|
class PosOrder(models.Model):
|
|
_inherit = 'pos.order'
|
|
|
|
l10n_mx_edi_is_cfdi_needed = fields.Boolean(
|
|
compute='_compute_l10n_mx_edi_is_cfdi_needed',
|
|
store=True,
|
|
)
|
|
l10n_mx_edi_document_ids = fields.Many2many(
|
|
comodel_name='l10n_mx_edi.document',
|
|
relation='l10n_mx_edi_pos_order_document_ids_rel',
|
|
column1='pos_order_id',
|
|
column2='document_id',
|
|
copy=False,
|
|
readonly=True,
|
|
)
|
|
l10n_mx_edi_cfdi_state = fields.Selection(
|
|
string="CFDI status",
|
|
selection=[
|
|
('sent', 'Signed'),
|
|
('global_sent', 'Signed Global'),
|
|
('global_cancel', 'Cancelled Global'),
|
|
],
|
|
store=True,
|
|
copy=False,
|
|
compute="_compute_l10n_mx_edi_cfdi_state_and_attachment",
|
|
)
|
|
l10n_mx_edi_cfdi_sat_state = fields.Selection(
|
|
string="SAT status",
|
|
selection=[
|
|
('valid', "Validated"),
|
|
('cancelled', "Cancelled"),
|
|
('not_found', "Not Found"),
|
|
('not_defined', "Not Defined"),
|
|
('error', "Error"),
|
|
],
|
|
store=True,
|
|
copy=False,
|
|
compute="_compute_l10n_mx_edi_cfdi_state_and_attachment",
|
|
)
|
|
l10n_mx_edi_cfdi_attachment_id = fields.Many2one(
|
|
comodel_name='ir.attachment',
|
|
string="CFDI",
|
|
store=True,
|
|
copy=False,
|
|
compute='_compute_l10n_mx_edi_cfdi_state_and_attachment',
|
|
)
|
|
# Technical field indicating if the "Update SAT" button needs to be displayed on pos order view.
|
|
l10n_mx_edi_update_sat_needed = fields.Boolean(compute='_compute_l10n_mx_edi_update_sat_needed')
|
|
# Indicate if you send the invoice to the SAT using 'Publico En General' meaning
|
|
# the customer is unknown by the SAT. This is mainly used when the customer doesn't have
|
|
# a VAT number registered to the SAT.
|
|
l10n_mx_edi_cfdi_to_public = fields.Boolean(
|
|
string="CFDI to public",
|
|
compute='_compute_l10n_mx_edi_cfdi_to_public',
|
|
store=True,
|
|
readonly=False,
|
|
help="Send the CFDI with recipient 'publico en general'",
|
|
)
|
|
l10n_mx_edi_usage = fields.Selection(
|
|
selection=USAGE_SELECTION,
|
|
string="Usage",
|
|
default="G03",
|
|
help="The code that corresponds to the use that will be made of the receipt by the recipient.",
|
|
)
|
|
l10n_mx_edi_cfdi_uuid = fields.Char(
|
|
string="Fiscal Folio",
|
|
compute='_compute_l10n_mx_edi_cfdi_uuid',
|
|
copy=False,
|
|
store=True,
|
|
help="Folio in electronic invoice, is returned by SAT when send to stamp.",
|
|
)
|
|
l10n_mx_edi_payment_method_id = fields.Many2one(
|
|
comodel_name='l10n_mx_edi.payment.method',
|
|
string="Payment Way",
|
|
compute='_compute_l10n_mx_edi_payment_method_id',
|
|
)
|
|
|
|
def action_pos_order_invoice(self):
|
|
# EXTENDS 'point_of_sale'
|
|
if self.company_id.country_id.code == 'MX':
|
|
if self.refunded_order_id and not self.refunded_order_id.account_move:
|
|
raise UserError(_("You cannot invoice this refund since the related order is not invoiced yet."))
|
|
action_values = super().action_pos_order_invoice()
|
|
|
|
for order in self:
|
|
if order.l10n_mx_edi_cfdi_state == 'global_sent':
|
|
order._l10n_mx_edi_cfdi_invoice_try_send()
|
|
|
|
return action_values
|
|
|
|
def _l10n_mx_edi_check_autogenerate_cfdi_refund(self):
|
|
for order in self:
|
|
if (
|
|
order.company_id.country_id.code == 'MX'
|
|
and not order.l10n_mx_edi_cfdi_state
|
|
and order.refunded_order_id and order.refunded_order_id.l10n_mx_edi_cfdi_state == 'global_sent'
|
|
):
|
|
order._l10n_mx_edi_cfdi_invoice_try_send()
|
|
|
|
@api.model
|
|
def sync_from_ui(self, orders):
|
|
# EXTENDS 'point_of_sale'
|
|
for order in orders:
|
|
if order.get('to_invoice', False) and self.env['pos.session'].browse(order['session_id']).company_id.country_id.code == 'MX':
|
|
order['l10n_mx_edi_cfdi_to_public'] = order.get('l10n_mx_edi_cfdi_to_public', False)
|
|
order['l10n_mx_edi_usage'] = order.get('l10n_mx_edi_usage', False)
|
|
else:
|
|
partner_id = order.get('partner_id', False)
|
|
# Default to 'G03' when no partner, otherwise take partner's configured usage
|
|
order['l10n_mx_edi_usage'] = self.env['res.partner'].browse(partner_id).l10n_mx_edi_usage or 'G03'
|
|
|
|
data = super().sync_from_ui(orders)
|
|
if len(orders) > 0:
|
|
orders = self.browse([o['id'] for o in data["pos.order"]])
|
|
orders._l10n_mx_edi_check_autogenerate_cfdi_refund()
|
|
|
|
return data
|
|
|
|
def _refund(self):
|
|
# EXTENDS 'point_of_sale'
|
|
orders = super()._refund()
|
|
orders._l10n_mx_edi_check_autogenerate_cfdi_refund()
|
|
return orders
|
|
|
|
def _prepare_invoice_vals(self):
|
|
# EXTENDS 'point_of_sale'
|
|
vals = super()._prepare_invoice_vals()
|
|
if self.company_id.country_id.code == 'MX':
|
|
is_public = any(self.mapped('l10n_mx_edi_cfdi_to_public'))
|
|
usages = self.mapped('l10n_mx_edi_usage')
|
|
payment_methods = self.mapped('l10n_mx_edi_payment_method_id')
|
|
|
|
if len(set(usages)) > 1 or len(set(payment_methods)) > 1:
|
|
raise ValidationError(_("The selected PoS orders do not have the same CFDI public flag, usage, and payment method."))
|
|
|
|
vals.update({
|
|
'l10n_mx_edi_cfdi_to_public': is_public,
|
|
# If the invoice was created through the QRCode on the ticket we take the usage from the filled form
|
|
'l10n_mx_edi_usage': self.env.context.get('default_l10n_mx_edi_usage') or usages[0],
|
|
'l10n_mx_edi_payment_method_id': self.l10n_mx_edi_payment_method_id.id,
|
|
})
|
|
|
|
account_fiscal_folio = self.refunded_order_id.account_move.l10n_mx_edi_cfdi_uuid
|
|
if account_fiscal_folio:
|
|
vals['l10n_mx_edi_cfdi_origin'] = self.env['account.move']._l10n_mx_edi_write_cfdi_origin('03', [account_fiscal_folio])
|
|
return vals
|
|
|
|
# -------------------------------------------------------------------------
|
|
# HELPERS
|
|
# -------------------------------------------------------------------------
|
|
|
|
def _l10n_mx_edi_collect_orders_in_chain(self):
|
|
""" Collect all involved orders by resolving all refund links between orders.
|
|
|
|
:return: A recordset of orders.
|
|
"""
|
|
orders = self
|
|
while True:
|
|
new_orders = orders.refunded_order_id
|
|
new_orders |= self.env['pos.order.line']\
|
|
.search([('refunded_orderline_id.order_id', 'in', (orders + new_orders).ids)])\
|
|
._l10n_mx_edi_cfdi_lines()\
|
|
.order_id
|
|
new_orders -= orders
|
|
if new_orders:
|
|
orders += new_orders
|
|
else:
|
|
break
|
|
return orders
|
|
|
|
def _l10n_mx_edi_check_orders_for_global_invoice(self, origin=None):
|
|
""" Ensure the current records are eligible for the creation of a global invoice.
|
|
|
|
:param origin: The origin of the GI when cancelling an existing one.
|
|
"""
|
|
orders = self._l10n_mx_edi_collect_orders_in_chain()
|
|
|
|
if len(orders.company_id) != 1:
|
|
raise UserError(_("You can only process orders sharing the same company."))
|
|
if len(self.currency_id) != 1:
|
|
raise UserError(_("You can only process orders sharing the same currency."))
|
|
|
|
if not origin:
|
|
failed_orders = orders.filtered(lambda x: (
|
|
not x.l10n_mx_edi_is_cfdi_needed
|
|
or x.l10n_mx_edi_cfdi_state in ('sent', 'global_sent')
|
|
or x.account_move
|
|
))
|
|
if failed_orders:
|
|
orders_str = ", ".join(failed_orders.mapped('name'))
|
|
raise UserError(_("Orders %s are already sent or not eligible for CFDI.", orders_str))
|
|
return orders
|
|
|
|
# -------------------------------------------------------------------------
|
|
# COMPUTE METHODS
|
|
# -------------------------------------------------------------------------
|
|
|
|
@api.depends('company_id')
|
|
def _compute_l10n_mx_edi_is_cfdi_needed(self):
|
|
""" Check whatever or not the CFDI is needed on this invoice.
|
|
"""
|
|
for order in self:
|
|
order.l10n_mx_edi_is_cfdi_needed = \
|
|
order.country_code == 'MX' \
|
|
and order.company_id.currency_id.name == 'MXN'
|
|
|
|
@api.depends('l10n_mx_edi_document_ids.state', 'l10n_mx_edi_document_ids.sat_state')
|
|
def _compute_l10n_mx_edi_cfdi_state_and_attachment(self):
|
|
for order in self:
|
|
order.l10n_mx_edi_cfdi_sat_state = order.l10n_mx_edi_cfdi_sat_state
|
|
order.l10n_mx_edi_cfdi_state = None
|
|
order.l10n_mx_edi_cfdi_attachment_id = None
|
|
for doc in order.l10n_mx_edi_document_ids.sorted():
|
|
if doc.state == 'invoice_sent' and order.refunded_order_id:
|
|
if doc.sat_state != 'skip':
|
|
order.l10n_mx_edi_cfdi_sat_state = doc.sat_state
|
|
order.l10n_mx_edi_cfdi_state = 'sent'
|
|
order.l10n_mx_edi_cfdi_attachment_id = doc.attachment_id
|
|
break
|
|
elif doc.state == 'invoice_cancel' and order.refunded_order_id:
|
|
# Leave cfdi_state unset so the refund is re-eligible for a new global invoice.
|
|
order.l10n_mx_edi_cfdi_sat_state = doc.sat_state
|
|
order.l10n_mx_edi_cfdi_attachment_id = doc.attachment_id
|
|
break
|
|
elif doc.state == 'ginvoice_sent':
|
|
if doc.sat_state != 'skip':
|
|
order.l10n_mx_edi_cfdi_sat_state = doc.sat_state
|
|
order.l10n_mx_edi_cfdi_state = 'global_sent'
|
|
order.l10n_mx_edi_cfdi_attachment_id = doc.attachment_id
|
|
break
|
|
elif doc.state == 'ginvoice_cancel' and doc.cancellation_reason != '01':
|
|
order.l10n_mx_edi_cfdi_sat_state = doc.sat_state
|
|
order.l10n_mx_edi_cfdi_state = 'global_cancel'
|
|
order.l10n_mx_edi_cfdi_attachment_id = doc.attachment_id
|
|
break
|
|
|
|
@api.depends('l10n_mx_edi_is_cfdi_needed', 'partner_id', 'company_id')
|
|
def _compute_l10n_mx_edi_cfdi_to_public(self):
|
|
for order in self:
|
|
if order.l10n_mx_edi_is_cfdi_needed and order.partner_id and order.company_id:
|
|
cfdi_values = self.env['l10n_mx_edi.document']._get_company_cfdi_values(order.company_id)
|
|
self.env['l10n_mx_edi.document']._add_customer_cfdi_values(
|
|
cfdi_values,
|
|
customer=order.partner_id,
|
|
)
|
|
order.l10n_mx_edi_cfdi_to_public = cfdi_values['receptor']['rfc'] == 'XAXX010101000'
|
|
else:
|
|
order.l10n_mx_edi_cfdi_to_public = order.l10n_mx_edi_is_cfdi_needed
|
|
|
|
@api.depends('l10n_mx_edi_document_ids.state')
|
|
def _compute_l10n_mx_edi_update_sat_needed(self):
|
|
for order in self:
|
|
order.l10n_mx_edi_update_sat_needed = bool(order.l10n_mx_edi_document_ids.filtered_domain(
|
|
self.env['l10n_mx_edi.document']._get_update_sat_status_domain(from_cron=False)
|
|
))
|
|
|
|
@api.depends('l10n_mx_edi_cfdi_attachment_id')
|
|
def _compute_l10n_mx_edi_cfdi_uuid(self):
|
|
for order in self:
|
|
if order.l10n_mx_edi_cfdi_attachment_id:
|
|
cfdi_infos = self.env['l10n_mx_edi.document']._decode_cfdi_attachment(order.l10n_mx_edi_cfdi_attachment_id.raw)
|
|
order.l10n_mx_edi_cfdi_uuid = cfdi_infos.get('uuid')
|
|
else:
|
|
order.l10n_mx_edi_cfdi_uuid = None
|
|
|
|
@api.depends('payment_ids', 'refunded_order_id')
|
|
def _compute_l10n_mx_edi_payment_method_id(self):
|
|
for order in self:
|
|
order.l10n_mx_edi_payment_method_id = order.payment_ids\
|
|
.sorted(lambda p: -p.amount).payment_method_id.l10n_mx_edi_payment_method_id[:1]
|
|
if not order.l10n_mx_edi_payment_method_id and order.refunded_order_id:
|
|
order.l10n_mx_edi_payment_method_id = order.refunded_order_id.l10n_mx_edi_payment_method_id[:1]
|
|
|
|
# -------------------------------------------------------------------------
|
|
# CONSTRAINTS METHODS
|
|
# -------------------------------------------------------------------------
|
|
|
|
@api.constrains('amount_total')
|
|
def _l10n_mx_edi_constrains_amount_total(self):
|
|
for order in self:
|
|
order_lines = order.lines._l10n_mx_edi_cfdi_lines()
|
|
if (
|
|
order_lines
|
|
and order.l10n_mx_edi_is_cfdi_needed
|
|
and (
|
|
(
|
|
order.refunded_order_id
|
|
and any(line.price_subtotal > 0.0 for line in order_lines.filtered(
|
|
lambda l: not l.refunded_orderline_id and (
|
|
'coupon_id' not in l._fields or not l.coupon_id
|
|
)
|
|
))
|
|
)
|
|
or (not order.refunded_order_id and order.currency_id.round(order.amount_total) < 0.0)
|
|
)
|
|
):
|
|
raise ValidationError(_("The amount of the order must be positive for a sale and negative for a refund."))
|
|
|
|
# -------------------------------------------------------------------------
|
|
# CFDI Generation
|
|
# -------------------------------------------------------------------------
|
|
|
|
def _l10n_mx_edi_cfdi_check_order_config(self):
|
|
""" Prepare the CFDI xml for the current pos order. """
|
|
self.ensure_one()
|
|
errors = []
|
|
|
|
# == Check the 'l10n_mx_edi_decimal_places' field set on the currency ==
|
|
currency_precision = self.currency_id.l10n_mx_edi_decimal_places
|
|
if currency_precision is False:
|
|
errors.append(_(
|
|
"The SAT does not provide information for the currency %s.\n"
|
|
"You must get manually a key from the PAC to confirm the "
|
|
"currency rate is accurate enough.",
|
|
self.currency_id,
|
|
))
|
|
|
|
# == Check the order ==
|
|
base_lines = self.lines._l10n_mx_edi_cfdi_lines()._prepare_tax_base_line_values()
|
|
negative_lines = [
|
|
x
|
|
for x in base_lines
|
|
if (x['record'].price_subtotal > 0.0 and x['is_refund']) or (x['record'].price_subtotal < 0.0 and not x['is_refund'])
|
|
]
|
|
if negative_lines:
|
|
# Discount line without taxes is not allowed.
|
|
if [x for x in negative_lines if not x['tax_ids']]:
|
|
errors.append(_(
|
|
"Order lines having a negative amount without a tax set is not allowed to "
|
|
"generate the CFDI.",
|
|
))
|
|
return errors
|
|
|
|
# -------------------------------------------------------------------------
|
|
# CFDI: DOCUMENTS
|
|
# -------------------------------------------------------------------------
|
|
|
|
def _l10n_mx_edi_cfdi_invoice_document_sent_failed(self, error, cfdi_filename=None, cfdi_str=None):
|
|
""" Create/update the invoice document for 'sent_failed'.
|
|
The parameters are provided by '_l10n_mx_edi_prepare_invoice_cfdi'.
|
|
|
|
:param error: The error.
|
|
:param cfdi_filename: The optional filename of the cfdi.
|
|
:param cfdi_str: The optional content of the cfdi.
|
|
:return: The created/updated document.
|
|
"""
|
|
self.ensure_one()
|
|
|
|
document_values = {
|
|
'pos_order_ids': [Command.set(self.ids)],
|
|
'state': 'invoice_sent_failed',
|
|
'sat_state': None,
|
|
'message': error,
|
|
}
|
|
if cfdi_filename and cfdi_str:
|
|
document_values['attachment_id'] = {
|
|
'name': cfdi_filename,
|
|
'raw': cfdi_str,
|
|
}
|
|
return self.env['l10n_mx_edi.document']._create_update_invoice_document_from_pos_order(self, document_values)
|
|
|
|
def _l10n_mx_edi_cfdi_invoice_document_sent(self, cfdi_filename, cfdi_str):
|
|
""" Create/update the invoice document for 'sent'.
|
|
The parameters are provided by '_l10n_mx_edi_prepare_invoice_cfdi'.
|
|
|
|
:param cfdi_filename: The filename of the cfdi.
|
|
:param cfdi_str: The content of the cfdi.
|
|
:return: The created/updated document.
|
|
"""
|
|
self.ensure_one()
|
|
|
|
document_values = {
|
|
'pos_order_ids': [Command.set(self.ids)],
|
|
'state': 'invoice_sent',
|
|
'sat_state': 'not_defined',
|
|
'message': None,
|
|
'attachment_id': {
|
|
'name': cfdi_filename,
|
|
'raw': cfdi_str,
|
|
'description': "CFDI",
|
|
},
|
|
}
|
|
return self.env['l10n_mx_edi.document']._create_update_invoice_document_from_pos_order(self, document_values)
|
|
|
|
def _l10n_mx_edi_cfdi_invoice_document_empty(self):
|
|
""" Create/update the invoice document for 'sent'.
|
|
The parameters are provided by '_l10n_mx_edi_prepare_invoice_cfdi'.
|
|
|
|
:return: The created/updated document.
|
|
"""
|
|
self.ensure_one()
|
|
|
|
document_values = {
|
|
'pos_order_ids': [Command.set(self.ids)],
|
|
'state': 'invoice_sent',
|
|
'sat_state': 'skip',
|
|
'message': None,
|
|
}
|
|
return self.env['l10n_mx_edi.document']._create_update_invoice_document_from_pos_order(self, document_values)
|
|
|
|
def _l10n_mx_edi_cfdi_invoice_document_cancel_failed(self, error, cfdi, cancel_reason):
|
|
""" Create/update the invoice document for 'cancel_failed'.
|
|
|
|
:param error: The error.
|
|
:param cfdi: The source cfdi attachment to cancel.
|
|
:param cancel_reason: The reason for this cancel.
|
|
:return: The created/updated document.
|
|
"""
|
|
self.ensure_one()
|
|
|
|
document_values = {
|
|
'pos_order_ids': [Command.set(self.ids)],
|
|
'state': 'invoice_cancel_failed',
|
|
'sat_state': None,
|
|
'message': error,
|
|
'attachment_id': cfdi.attachment_id.id,
|
|
'cancellation_reason': cancel_reason,
|
|
}
|
|
return self.env['l10n_mx_edi.document']._create_update_invoice_document_from_pos_order(self, document_values)
|
|
|
|
def _l10n_mx_edi_cfdi_invoice_document_cancel(self, cfdi, cancel_reason):
|
|
""" Create/update the invoice document for 'cancel'.
|
|
|
|
:param cfdi: The source cfdi attachment to cancel.
|
|
:param cancel_reason: The reason for this cancel.
|
|
:return: The created/updated document.
|
|
"""
|
|
self.ensure_one()
|
|
|
|
document_values = {
|
|
'pos_order_ids': [Command.set(self.ids)],
|
|
'state': 'invoice_cancel',
|
|
'sat_state': 'not_defined',
|
|
'message': None,
|
|
'attachment_id': cfdi.attachment_id.id,
|
|
'cancellation_reason': cancel_reason,
|
|
}
|
|
return self.env['l10n_mx_edi.document']._create_update_invoice_document_from_pos_order(self, document_values)
|
|
|
|
def _l10n_mx_edi_cfdi_global_invoice_document_sent_failed(self, error, cfdi_filename=None, cfdi_str=None):
|
|
""" Create/update the global invoice document for 'sent_failed'.
|
|
|
|
:param error: The error.
|
|
:param cfdi_filename: The optional filename of the cfdi.
|
|
:param cfdi_str: The optional content of the cfdi.
|
|
:return: The created/updated document.
|
|
"""
|
|
document_values = {
|
|
'pos_order_ids': [Command.set(self.ids)],
|
|
'state': 'ginvoice_sent_failed',
|
|
'sat_state': None,
|
|
'message': error,
|
|
}
|
|
if cfdi_filename and cfdi_str:
|
|
document_values['attachment_id'] = {
|
|
'name': cfdi_filename,
|
|
'raw': cfdi_str,
|
|
}
|
|
return self.env['l10n_mx_edi.document']._create_update_global_invoice_document_from_pos_orders(self, document_values)
|
|
|
|
def _l10n_mx_edi_cfdi_global_invoice_document_sent(self, cfdi_filename, cfdi_str):
|
|
""" Create/update the global invoice document for 'sent'.
|
|
|
|
:param cfdi_filename: The filename of the cfdi.
|
|
:param cfdi_str: The content of the cfdi.
|
|
:return: The created/updated document.
|
|
"""
|
|
document_values = {
|
|
'pos_order_ids': [Command.set(self.ids)],
|
|
'state': 'ginvoice_sent',
|
|
'sat_state': 'not_defined',
|
|
'message': None,
|
|
'attachment_id': {
|
|
'name': cfdi_filename,
|
|
'raw': cfdi_str,
|
|
'description': "CFDI",
|
|
},
|
|
}
|
|
return self.env['l10n_mx_edi.document']._create_update_global_invoice_document_from_pos_orders(self, document_values)
|
|
|
|
def _l10n_mx_edi_cfdi_global_invoice_document_empty(self):
|
|
""" Create/update the global invoice document for 'sent'.
|
|
|
|
:return: The created/updated document.
|
|
"""
|
|
document_values = {
|
|
'pos_order_ids': [Command.set(self.ids)],
|
|
'state': 'ginvoice_sent',
|
|
'sat_state': 'skip',
|
|
'message': None,
|
|
}
|
|
return self.env['l10n_mx_edi.document']._create_update_global_invoice_document_from_pos_orders(self, document_values)
|
|
|
|
def _l10n_mx_edi_cfdi_global_invoice_document_cancel_failed(self, error, cfdi, cancel_reason):
|
|
""" Create/update the invoice document for 'cancel_failed'.
|
|
|
|
:param error: The error.
|
|
:param cfdi: The source cfdi attachment to cancel.
|
|
:param cancel_reason: The reason for this cancel.
|
|
:return: The created/updated document.
|
|
"""
|
|
document_values = {
|
|
'pos_order_ids': [Command.set(self.ids)],
|
|
'state': 'ginvoice_cancel_failed',
|
|
'sat_state': None,
|
|
'message': error,
|
|
'attachment_id': cfdi.attachment_id.id,
|
|
'cancellation_reason': cancel_reason,
|
|
}
|
|
return self.env['l10n_mx_edi.document']._create_update_global_invoice_document_from_pos_orders(self, document_values)
|
|
|
|
def _l10n_mx_edi_cfdi_global_invoice_document_cancel(self, cfdi, cancel_reason):
|
|
""" Create/update the invoice document for 'cancel'.
|
|
|
|
:param cfdi: The source cfdi attachment to cancel.
|
|
:param cancel_reason: The reason for this cancel.
|
|
:return: The created/updated document.
|
|
"""
|
|
self.l10n_mx_edi_cfdi_attachment_id.ensure_one()
|
|
|
|
document_values = {
|
|
'pos_order_ids': [Command.set(self.ids)],
|
|
'state': 'ginvoice_cancel',
|
|
'sat_state': 'not_defined',
|
|
'message': None,
|
|
'attachment_id': cfdi.attachment_id.id,
|
|
'cancellation_reason': cancel_reason,
|
|
}
|
|
return self.env['l10n_mx_edi.document']._create_update_global_invoice_document_from_pos_orders(self, document_values)
|
|
|
|
# -------------------------------------------------------------------------
|
|
# CFDI: FLOWS
|
|
# -------------------------------------------------------------------------
|
|
|
|
def _l10n_mx_edi_cfdi_invoice_try_send(self):
|
|
""" Try to generate and send the CFDI for the current pos order refund. """
|
|
self.ensure_one()
|
|
|
|
# == Check the config ==
|
|
errors = self._l10n_mx_edi_cfdi_check_order_config()
|
|
if errors:
|
|
self._l10n_mx_edi_cfdi_invoice_document_sent_failed("\n".join(errors))
|
|
return
|
|
|
|
# == Lock ==
|
|
self.env['res.company']._with_locked_records(self)
|
|
|
|
# == Send ==
|
|
def on_populate(cfdi_values):
|
|
self.ensure_one()
|
|
Document = self.env['l10n_mx_edi.document']
|
|
order_lines = self.lines._l10n_mx_edi_cfdi_lines()
|
|
base_lines = order_lines._prepare_tax_base_line_values()
|
|
base_lines = Document._add_and_round_tax_details(base_lines, self.company_id)
|
|
dispatched_lines = Document._dispatch_negative_base_lines(base_lines, self.company_id)
|
|
if dispatched_lines['remaining_negative_base_lines']:
|
|
cfdi_values['errors'] = [_("Failed to distribute some negative lines")]
|
|
return
|
|
base_lines = dispatched_lines['base_lines']
|
|
if not base_lines:
|
|
cfdi_values['errors'] = ['empty_cfdi']
|
|
return
|
|
|
|
cfdi_values['tipo_de_comprobante'] = 'E'
|
|
if self.amount_total < 0:
|
|
# The order is a refund.
|
|
Document._add_document_origin_cfdi_values(cfdi_values, f"01|{self.refunded_order_id.l10n_mx_edi_cfdi_uuid}")
|
|
else:
|
|
# Refund of the pos order itself.
|
|
Document._add_document_origin_cfdi_values(cfdi_values, f'01|{self.l10n_mx_edi_cfdi_uuid}')
|
|
|
|
Document._add_base_cfdi_values(cfdi_values)
|
|
Document._add_currency_cfdi_values(cfdi_values, self.currency_id)
|
|
Document._add_document_name_cfdi_values(cfdi_values, self.name)
|
|
Document._add_customer_cfdi_values(
|
|
cfdi_values,
|
|
self.partner_id,
|
|
usage=self.l10n_mx_edi_usage,
|
|
to_public=self.l10n_mx_edi_cfdi_to_public,
|
|
)
|
|
Document._add_tax_objected_cfdi_values(cfdi_values, base_lines)
|
|
Document._add_base_lines_cfdi_values(cfdi_values, base_lines)
|
|
# Force description as this is required for refunds of global invoices
|
|
for base_line in cfdi_values['base_lines']:
|
|
base_line['l10n_mx_cfdi_values']['description'] = "Devoluciones, descuentos o bonificaciones"
|
|
|
|
Document._add_payment_policy_cfdi_values(cfdi_values, payment_method=self.l10n_mx_edi_payment_method_id)
|
|
cfdi_values['condiciones_de_pago'] = None
|
|
|
|
# Dates.
|
|
timezoned_now = Document._get_datetime_now_with_mx_timezone(cfdi_values)
|
|
cfdi_values['fecha'] = timezoned_now.strftime(CFDI_DATE_FORMAT)
|
|
|
|
# Currency.
|
|
if self.currency_id.name == 'MXN':
|
|
cfdi_values['tipo_cambio'] = None
|
|
else:
|
|
company_currency = self.company_id.currency_id
|
|
rate = self.currency_id._get_conversion_rate(self.currency_id, company_currency, self.company_id, self.date_order)
|
|
cfdi_values['tipo_cambio'] = rate
|
|
|
|
def on_failure(error, cfdi_filename=None, cfdi_str=None):
|
|
if error == 'empty_cfdi':
|
|
self._l10n_mx_edi_cfdi_invoice_document_empty()
|
|
else:
|
|
self._l10n_mx_edi_cfdi_invoice_document_sent_failed(error, cfdi_filename=cfdi_filename, cfdi_str=cfdi_str)
|
|
|
|
def on_success(_cfdi_values, cfdi_filename, cfdi_str, populate_return=None):
|
|
self._l10n_mx_edi_cfdi_invoice_document_sent(cfdi_filename, cfdi_str)
|
|
|
|
qweb_template = self.env['l10n_mx_edi.document']._get_invoice_cfdi_template()
|
|
cfdi_filename = "MX-Refund-4.0.xml".replace('/', '')
|
|
self.env['l10n_mx_edi.document']._send_api(
|
|
self.company_id,
|
|
qweb_template,
|
|
cfdi_filename,
|
|
on_populate,
|
|
on_failure,
|
|
on_success,
|
|
)
|
|
|
|
def _l10n_mx_edi_cfdi_invoice_try_cancel(self, document, cancel_reason):
|
|
""" Try to cancel the CFDI for the current refund.
|
|
|
|
:param document: The source invoice document to cancel.
|
|
:param cancel_reason: The reason for the cancellation.
|
|
"""
|
|
self.ensure_one()
|
|
|
|
# == Lock ==
|
|
self.env['res.company']._with_locked_records(self)
|
|
|
|
# == Cancel ==
|
|
def on_failure(error):
|
|
self._l10n_mx_edi_cfdi_invoice_document_cancel_failed(error, document, cancel_reason)
|
|
|
|
def on_success():
|
|
self._l10n_mx_edi_cfdi_invoice_document_cancel(document, cancel_reason)
|
|
|
|
document._cancel_api(self.company_id, cancel_reason, on_failure, on_success)
|
|
|
|
def _l10n_mx_edi_cfdi_refund_update_sat_state(self, document, sat_state, error=None):
|
|
""" Update the SAT state of the document for the current pos order refund.
|
|
|
|
:param document: The CFDI document to be updated.
|
|
:param sat_state: The newly fetched state from the SAT
|
|
:param error: In case of error, the message returned by the SAT.
|
|
"""
|
|
self.ensure_one()
|
|
|
|
# The user manually cancelled the document in the SAT portal.
|
|
if document.state == 'invoice_sent' and sat_state == 'cancelled':
|
|
if document.sat_state not in ('valid', 'cancelled', 'skip'):
|
|
document.sat_state = 'skip'
|
|
|
|
document = self._l10n_mx_edi_cfdi_invoice_document_cancel(
|
|
document,
|
|
CANCELLATION_REASON_SELECTION[1][0], # Force '02'.
|
|
)
|
|
|
|
document.sat_state = sat_state
|
|
document.message = None
|
|
if sat_state == 'error' and error:
|
|
document.message = error
|
|
|
|
def l10n_mx_edi_cfdi_try_sat(self):
|
|
self.ensure_one()
|
|
documents = self.l10n_mx_edi_document_ids
|
|
for document in documents.filtered_domain(documents._get_update_sat_status_domain(from_cron=False)):
|
|
document._update_sat_state()
|
|
|
|
def _l10n_mx_edi_cfdi_global_invoice_try_send(self, periodicity='04', origin=None):
|
|
""" Create a CFDI global invoice.
|
|
|
|
:param periodicity: The value to fill the 'Periodicidad' value.
|
|
:param origin: The origin of the GI when cancelling an existing one.
|
|
"""
|
|
AccountTax = self.env['account.tax']
|
|
Document = self.env['l10n_mx_edi.document']
|
|
orders = self._l10n_mx_edi_check_orders_for_global_invoice(origin=origin)
|
|
|
|
# == Check the config ==
|
|
orders = self.filtered(lambda order: not order.refunded_order_id)
|
|
orders |= self.env['pos.order.line'].search([
|
|
('refunded_orderline_id.order_id', 'in', orders.ids),
|
|
('order_id.state', '!=', 'cancel'),
|
|
]).order_id
|
|
pos_journal = self.config_id.invoice_journal_id
|
|
errors = []
|
|
for order in orders:
|
|
errors += order._l10n_mx_edi_cfdi_check_order_config()
|
|
if errors:
|
|
orders._l10n_mx_edi_cfdi_global_invoice_document_sent_failed("\n".join(set(errors)))
|
|
return
|
|
|
|
# == Lock ==
|
|
self.env['res.company']._with_locked_records(orders)
|
|
|
|
def _set_issued_address(cfdi_values):
|
|
"""
|
|
Sets the `l10n_mx_address_issued_id` if it exists.
|
|
The `l10n_mx_address_issued_id` field is defined in the `l10n_mx_edi_extended` module.
|
|
"""
|
|
if 'l10n_mx_address_issued_id' not in pos_journal._fields:
|
|
return
|
|
if (
|
|
(issued_addresses := pos_journal.l10n_mx_address_issued_id)
|
|
and not all(j.l10n_mx_address_issued_id == issued_addresses for j in pos_journal)
|
|
):
|
|
raise UserError(_("You cannot create a global invoice for POS orders that use different journals with different issuing addresses."))
|
|
if issued_address := pos_journal.l10n_mx_address_issued_id:
|
|
cfdi_values.update({'issued_address': issued_address})
|
|
|
|
# == Send ==
|
|
def on_populate(cfdi_values):
|
|
_set_issued_address(cfdi_values)
|
|
|
|
all_base_lines = []
|
|
for order in orders:
|
|
# The refund are managed by the refunded order.
|
|
if order.refunded_order_id:
|
|
continue
|
|
|
|
# Dispatch the negative lines on the order itself.
|
|
order_lines = order.lines._l10n_mx_edi_cfdi_lines()
|
|
base_lines = order_lines._prepare_tax_base_line_values()
|
|
base_lines = Document._add_and_round_tax_details(base_lines, self.company_id)
|
|
dispatched_lines = Document._dispatch_negative_base_lines(base_lines, self.company_id)
|
|
if dispatched_lines['remaining_negative_base_lines']:
|
|
cfdi_values['errors'] = [_("Failed to distribute some negative lines")]
|
|
return
|
|
|
|
base_lines = dispatched_lines['base_lines']
|
|
for base_line in base_lines:
|
|
base_line['document_name'] = order.name
|
|
|
|
# Find the refund lines targeting this order.
|
|
all_refund_order_lines = self.env['pos.order.line']\
|
|
.search([
|
|
('refunded_orderline_id', 'in', order_lines.ids),
|
|
('order_id.state', '!=', 'cancel'),
|
|
])\
|
|
._l10n_mx_edi_cfdi_lines()
|
|
|
|
# Manage the refunds.
|
|
for refund_order_lines in all_refund_order_lines.grouped('order_id').values():
|
|
|
|
# Dispatch the positive lines on the refund itself.
|
|
refund_base_lines = refund_order_lines._prepare_tax_base_line_values()
|
|
refund_base_lines = Document._add_and_round_tax_details(refund_base_lines, self.company_id)
|
|
refund_dispatched_lines = Document._dispatch_negative_base_lines(refund_base_lines, self.company_id)
|
|
if refund_dispatched_lines['remaining_negative_base_lines']:
|
|
cfdi_values['errors'] = [_("Failed to distribute some negative lines")]
|
|
return
|
|
|
|
# Dispatch the remaining negative lines from the refund on the invoice.
|
|
refund_base_lines = AccountTax._turn_base_lines_is_refund_flag_off(refund_dispatched_lines['base_lines'])
|
|
dispatched_lines = Document._dispatch_negative_base_lines(base_lines + refund_base_lines, self.company_id)
|
|
if dispatched_lines['remaining_negative_base_lines']:
|
|
cfdi_values['errors'] = [_("Failed to distribute some negative lines")]
|
|
return
|
|
|
|
base_lines = dispatched_lines['base_lines']
|
|
all_base_lines += base_lines
|
|
|
|
# Nothing left. Everything is refunded or empty.
|
|
base_lines = [x for x in all_base_lines if not x['currency_id'].is_zero(x['tax_details']['raw_total_excluded_currency'])]
|
|
if not base_lines:
|
|
cfdi_values['errors'] = ['empty_cfdi']
|
|
return
|
|
|
|
AccountTax._round_base_lines_tax_details(base_lines, self.company_id)
|
|
_biggest_amount_total, biggest_used_payment_method = max(
|
|
[
|
|
(sum(sub_orders.mapped('amount_total')), payment_method)
|
|
for payment_method, sub_orders in orders.grouped('l10n_mx_edi_payment_method_id').items()
|
|
],
|
|
key=lambda x: x[0],
|
|
)
|
|
Document._add_payment_policy_cfdi_values(cfdi_values, payment_method=biggest_used_payment_method)
|
|
|
|
# Periodicity.
|
|
document_dates = []
|
|
for order in orders:
|
|
order_cfdi_values = dict(cfdi_values)
|
|
tz = order_cfdi_values['issued_address']._l10n_mx_edi_get_cfdi_timezone()
|
|
# convert the date order to the right mx timezone (it's stored in utc)
|
|
timezoned_date_order = order.date_order.astimezone(tz).replace(tzinfo=None)
|
|
Document._add_date_cfdi_values(order_cfdi_values, timezoned_date_order, journal=order.sale_journal)
|
|
document_dates.append(datetime.strptime(order_cfdi_values['fecha'], CFDI_DATE_FORMAT).date())
|
|
|
|
Document._add_global_invoice_cfdi_values(
|
|
cfdi_values,
|
|
base_lines,
|
|
document_date=max(document_dates),
|
|
periodicity=periodicity,
|
|
origin=origin,
|
|
)
|
|
|
|
self.env['res.company']._with_locked_records(cfdi_values['sequence'])
|
|
return cfdi_values['sequence']
|
|
|
|
def on_failure(error, cfdi_filename=None, cfdi_str=None):
|
|
if error == 'empty_cfdi':
|
|
orders._l10n_mx_edi_cfdi_global_invoice_document_empty()
|
|
else:
|
|
orders._l10n_mx_edi_cfdi_global_invoice_document_sent_failed(error, cfdi_filename=cfdi_filename, cfdi_str=cfdi_str)
|
|
|
|
def on_success(cfdi_values, cfdi_filename, cfdi_str, populate_return=None):
|
|
self.env['l10n_mx_edi.document']._consume_global_invoice_cfdi_sequence(populate_return, int(cfdi_values['folio']))
|
|
orders._l10n_mx_edi_cfdi_global_invoice_document_sent(cfdi_filename, cfdi_str)
|
|
|
|
qweb_template = self.env['l10n_mx_edi.document']._get_invoice_cfdi_template()
|
|
cfdi_filename = "MX-Global-Invoice-4.0.xml".replace('/', '')
|
|
self.env['l10n_mx_edi.document']._send_api(
|
|
self.company_id,
|
|
qweb_template,
|
|
cfdi_filename,
|
|
on_populate,
|
|
on_failure,
|
|
on_success,
|
|
)
|
|
if (
|
|
origin
|
|
and (new_doc := orders[0].l10n_mx_edi_invoice_document_ids.sorted()[0])
|
|
and new_doc.state == 'ginvoice_sent'
|
|
and (original_doc := new_doc._get_original_document())
|
|
and original_doc.state == 'ginvoice_sent'
|
|
):
|
|
original_doc.invoice_ids._l10n_mx_edi_cfdi_global_invoice_try_cancel(original_doc, '01')
|
|
|
|
def _l10n_mx_edi_cfdi_global_invoice_try_cancel(self, document, cancel_reason):
|
|
""" Create a CFDI global invoice for multiple pos orders.
|
|
|
|
:param document: The Global invoice document to cancel.
|
|
:param cancel_reason: The reason for the cancellation.
|
|
"""
|
|
# == Lock ==
|
|
self.env['res.company']._with_locked_records(self)
|
|
|
|
# == Cancel ==
|
|
def on_failure(error):
|
|
self._l10n_mx_edi_cfdi_global_invoice_document_cancel_failed(error, document, cancel_reason)
|
|
|
|
def on_success():
|
|
self._l10n_mx_edi_cfdi_global_invoice_document_cancel(document, cancel_reason)
|
|
self._l10n_mx_edi_cfdi_cancel_related_refund_documents(document.attachment_uuid, cancel_reason)
|
|
|
|
document._cancel_api(self.company_id, cancel_reason, on_failure, on_success)
|
|
|
|
def _l10n_mx_edi_cfdi_cancel_related_refund_documents(self, gi_uuid, cancel_reason):
|
|
""" Cancel the auto-generated refund CFDIs that referenced the cancelled global invoice.
|
|
|
|
Without this cascade the refund's pos.order keeps cfdi_state='sent' and blocks
|
|
the creation of a new global invoice for the same chain.
|
|
"""
|
|
if not gi_uuid:
|
|
return
|
|
for refund in self._l10n_mx_edi_collect_orders_in_chain() - self:
|
|
refund_doc = refund.l10n_mx_edi_document_ids.filtered(
|
|
lambda d: d.state == 'invoice_sent'
|
|
and d.attachment_origin == f'01|{gi_uuid}'
|
|
)[:1]
|
|
if refund_doc:
|
|
refund._l10n_mx_edi_cfdi_invoice_try_cancel(refund_doc, cancel_reason)
|
|
|
|
def _l10n_mx_edi_cfdi_global_invoice_update_document_sat_state(self, document, sat_state, error=None):
|
|
""" Update the SAT state of the document for the current global invoice.
|
|
|
|
:param document: The CFDI document to be updated.
|
|
:param sat_state: The newly fetched state from the SAT
|
|
:param error: In case of error, the message returned by the SAT.
|
|
"""
|
|
# The user manually cancelled the document in the SAT portal.
|
|
if document.state == 'ginvoice_sent' and sat_state == 'cancelled':
|
|
if document.sat_state not in ('valid', 'cancelled', 'skip'):
|
|
document.sat_state = 'skip'
|
|
|
|
gi_uuid = document.attachment_uuid
|
|
document = self._l10n_mx_edi_cfdi_global_invoice_document_cancel(
|
|
document,
|
|
CANCELLATION_REASON_SELECTION[1][0], # Force '02'.
|
|
)
|
|
self._l10n_mx_edi_cfdi_cancel_related_refund_documents(gi_uuid, CANCELLATION_REASON_SELECTION[1][0])
|
|
|
|
document.sat_state = sat_state
|
|
document.message = None
|
|
if sat_state == 'error' and error:
|
|
document.message = error
|
|
|
|
def l10n_mx_edi_action_create_global_invoice(self):
|
|
""" Action to open the wizard allowing to create a global invoice CFDI document for the
|
|
selected pos orders.
|
|
|
|
:return: An action to open the wizard.
|
|
"""
|
|
if self.env.company.country_code != "MX":
|
|
raise UserError(_("You cannot create a global invoice as your company is not Mexican."))
|
|
|
|
return {
|
|
'name': _("Create Global Invoice"),
|
|
'type': 'ir.actions.act_window',
|
|
'view_type': 'form',
|
|
'view_mode': 'form',
|
|
'res_model': 'l10n_mx_edi.global_invoice.create',
|
|
'target': 'new',
|
|
'context': {'default_pos_order_ids': [Command.set(self.ids)]},
|
|
}
|
|
|
|
@api.model
|
|
def _get_invoice_lines_values(self, line_values, pos_line, move_type):
|
|
# EXTENDS point_of_sale
|
|
invoice_lines_values = super()._get_invoice_lines_values(line_values, pos_line, move_type)
|
|
if pos_line.company_id.country_code == 'MX':
|
|
invoice_lines_values['account_id'] = line_values['account_id'].id
|
|
return invoice_lines_values
|