# -*- coding: utf-8 -*- from odoo import fields, models, api, _ from odoo.exceptions import ValidationError from odoo.tools.sql import column_exists, create_column from odoo.tools import float_round import re from collections import defaultdict CUSTOM_NUMBERS_PATTERN = re.compile(r'[0-9]{2} [0-9]{2} [0-9]{4} [0-9]{7}') class AccountMove(models.Model): _inherit = 'account.move' l10n_mx_edi_external_trade = fields.Boolean( string="Need external trade?", readonly=False, store=True, compute='_compute_l10n_mx_edi_external_trade', help="If this field is active, the CFDI that generates this invoice will include the complement " "'External Trade'.") l10n_mx_edi_external_trade_type = fields.Selection( selection=[ ('02', 'Definitive'), ('03', 'Temporary'), ], string="External Trade", readonly=False, store=True, compute='_compute_l10n_mx_edi_external_trade_type', help="If this field is 02, the CFDI will include the complement.") def _auto_init(self): """ Create compute stored field l10n_mx_edi_external_trade here to avoid MemoryError on large databases. """ if not column_exists(self.env.cr, 'account_move', 'l10n_mx_edi_external_trade'): create_column(self.env.cr, 'account_move', 'l10n_mx_edi_external_trade', 'boolean') # _compute_l10n_mx_edi_external_trade uses res_partner.l10n_mx_edi_external_trade, # which is a new field in this module hence all values set to False. self.env.cr.execute("UPDATE account_move set l10n_mx_edi_external_trade=FALSE;") if not column_exists(self.env.cr, 'account_move', 'l10n_mx_edi_external_trade_type'): create_column(self.env.cr, 'account_move', 'l10n_mx_edi_external_trade_type', 'varchar') return super()._auto_init() # ------------------------------------------------------------------------- # CFDI: HELPERS # ------------------------------------------------------------------------- @api.depends('l10n_mx_edi_external_trade_type') def _compute_l10n_mx_edi_external_trade(self): for move in self: move.l10n_mx_edi_external_trade = move.l10n_mx_edi_external_trade_type == '02' @api.depends('partner_id', 'partner_id.l10n_mx_edi_external_trade_type') def _compute_l10n_mx_edi_external_trade_type(self): for move in self: move.l10n_mx_edi_external_trade_type = move.partner_id.l10n_mx_edi_external_trade_type # ------------------------------------------------------------------------- # CFDI # ------------------------------------------------------------------------- def _l10n_mx_edi_add_invoice_cfdi_values(self, cfdi_values, percentage_paid=None, global_invoice=False): # EXTENDS 'l10n_mx_edi' self.ensure_one() if self.journal_id.l10n_mx_address_issued_id: cfdi_values['issued_address'] = self.journal_id.l10n_mx_address_issued_id super()._l10n_mx_edi_add_invoice_cfdi_values(cfdi_values, percentage_paid=percentage_paid, global_invoice=global_invoice) if cfdi_values.get('errors'): return cfdi_values['exportacion'] = self.l10n_mx_edi_external_trade_type or '01' # External Trade ext_trade_values = cfdi_values['comercio_exterior'] = {} if self.l10n_mx_edi_external_trade_type == '02': # Customer. customer_values = cfdi_values['receptor'] customer = customer_values['customer'] if customer_values['rfc'] == 'XEXX010101000': cfdi_values['receptor']['num_reg_id_trib'] = customer.vat # A value must be registered in the ResidenciaFiscal field when information is registered in the # NumRegIdTrib field. cfdi_values['receptor']['residencia_fiscal'] = customer.country_id.l10n_mx_edi_code ext_trade_values['receptor'] = { **cfdi_values['receptor'], 'curp': customer.l10n_mx_edi_curp, 'calle': customer.street_name, 'numero_exterior': customer.street_number, 'numero_interior': customer.street_number2, 'colonia': customer.l10n_mx_edi_colony_code, 'localidad': customer.l10n_mx_edi_locality_id.code, 'municipio': customer.city_id.l10n_mx_edi_code, 'estado': customer.state_id.code, 'pais': customer.country_id.l10n_mx_edi_code, 'codigo_postal': customer.zip, } # Supplier. supplier_values = cfdi_values['emisor'] supplier = supplier_values['supplier'] ext_trade_values['emisor'] = { 'curp': supplier.l10n_mx_edi_curp, 'calle': supplier.street_name, 'numero_exterior': supplier.street_number, 'numero_interior': supplier.street_number2, 'colonia': supplier.l10n_mx_edi_colony_code, 'localidad': supplier.l10n_mx_edi_locality_id.code, 'municipio': supplier.city_id.l10n_mx_edi_code, 'estado': supplier.state_id.code, 'pais': supplier.country_id.l10n_mx_edi_code, 'codigo_postal': supplier.zip, } # Shipping. shipping = self.partner_shipping_id if shipping != customer: shipping_cfdi_values = dict(cfdi_values) # In case of COMEX we need to fill "NumRegIdTrib" with the real tax id of the customer # but let the generic RFC. self.env['l10n_mx_edi.document']._add_customer_cfdi_values( shipping_cfdi_values, customer=shipping, usage=cfdi_values['receptor']['uso_cfdi'], to_public=self.l10n_mx_edi_cfdi_to_public, ) shipping_values = shipping_cfdi_values['receptor'] if ( shipping.country_id == shipping.commercial_partner_id.country_id and shipping_values['rfc'] == 'XEXX010101000' ): shipping_vat = shipping.vat.strip() if shipping.vat else None else: shipping_vat = None if shipping.country_id.l10n_mx_edi_code == 'MEX': colony = shipping.l10n_mx_edi_colony_code locality = shipping.l10n_mx_edi_locality_id.code city = shipping.city_id.l10n_mx_edi_code else: colony = shipping.l10n_mx_edi_colony locality = shipping.l10n_mx_edi_locality city = shipping.city if shipping.country_id.l10n_mx_edi_code in ('MEX', 'USA', 'CAN') or shipping.state_id.code: state = shipping.state_id.code else: state = 'NA' ext_trade_values['destinario'] = { 'num_reg_id_trib': shipping_vat, 'nombre': shipping.name, 'calle': shipping.street_name, 'numero_exterior': shipping.street_number, 'numero_interior': shipping.street_number2, 'colonia': colony, 'localidad': locality, 'municipio': city, 'estado': state, 'pais': shipping.country_id.l10n_mx_edi_code, 'codigo_postal': shipping.zip, } # Certificate. ext_trade_values['certificado_origen'] = '1' if self.l10n_mx_edi_cer_source else '0' ext_trade_values['num_certificado_origen'] = self.l10n_mx_edi_cer_source # Rate. mxn = self.env["res.currency"].search([('name', '=', 'MXN')], limit=1) usd = self.env["res.currency"].search([('name', '=', 'USD')], limit=1) ext_trade_values['tipo_cambio_usd'] = usd._get_conversion_rate(usd, mxn, self.company_id, self.date) if ext_trade_values['tipo_cambio_usd']: to_usd_rate = (cfdi_values['tipo_cambio'] or 1.0) / ext_trade_values['tipo_cambio_usd'] else: to_usd_rate = 0.0 # Misc. if customer.country_id in self.env.ref('base.europe').country_ids: ext_trade_values['numero_exportador_confiable'] = self.company_id.l10n_mx_edi_num_exporter else: ext_trade_values['numero_exportador_confiable'] = None ext_trade_values['incoterm'] = self.invoice_incoterm_id.code ext_trade_values['observaciones'] = self.narration # Details per product. product_values_map = defaultdict(lambda: { 'quantity': 0.0, 'price_unit': 0.0, 'total': 0.0, }) for line_vals in cfdi_values['conceptos_list']: line = line_vals['line']['record'] product_values_map[line.product_id]['quantity'] += line.l10n_mx_edi_qty_umt product_values_map[line.product_id]['price_unit'] += line.l10n_mx_edi_price_unit_umt product_values_map[line.product_id]['total'] += line_vals['importe'] ext_trade_values['total_usd'] = 0.0 ext_trade_values['mercancia_list'] = [] for product, product_values in product_values_map.items(): total_usd = float_round(product_values['total'] * to_usd_rate, precision_digits=4) ext_trade_values['mercancia_list'].append({ 'no_identificacion': product.default_code, 'fraccion_arancelaria': product.l10n_mx_edi_tariff_fraction_id.code, 'cantidad_aduana': product_values['quantity'], 'unidad_aduana': product.l10n_mx_edi_umt_aduana_id.l10n_mx_edi_code_aduana, 'valor_unitario_udana': float_round(product_values['price_unit'] * to_usd_rate, precision_digits=6), 'valor_dolares': total_usd, }) ext_trade_values['total_usd'] += total_usd else: # Invoice lines. for line_vals in cfdi_values['conceptos_list']: line_vals['informacion_aduanera_list'] = line_vals['line']['record']._l10n_mx_edi_get_custom_numbers() class AccountMoveLine(models.Model): _inherit = "account.move.line" l10n_mx_edi_customs_number = fields.Char( help='Optional field for entering the customs information in the case ' 'of first-hand sales of imported goods or in the case of foreign trade' ' operations with goods or services.\n' 'The format must be:\n' ' - 2 digits of the year of validation followed by two spaces.\n' ' - 2 digits of customs clearance followed by two spaces.\n' ' - 4 digits of the serial number followed by two spaces.\n' ' - 1 digit corresponding to the last digit of the current year, ' 'except in case of a consolidated customs initiated in the previous ' 'year of the original request for a rectification.\n' ' - 6 digits of the progressive numbering of the custom.', string='Customs number', copy=False) l10n_mx_edi_umt_aduana_id = fields.Many2one( comodel_name='uom.uom', string="UMT Aduana", readonly=True, store=True, compute_sudo=True, related='product_id.l10n_mx_edi_umt_aduana_id', help="Used in complement 'Comercio Exterior' to indicate in the products the TIGIE Units of Measurement. " "It is based in the SAT catalog.") l10n_mx_edi_qty_umt = fields.Float( string="Qty UMT", digits=(16, 3), readonly=False, store=True, compute='_compute_l10n_mx_edi_qty_umt', help="Quantity expressed in the UMT from product. It is used in the attribute 'CantidadAduana' in the CFDI") l10n_mx_edi_price_unit_umt = fields.Float( string="Unit Value UMT", readonly=True, store=True, compute='_compute_l10n_mx_edi_price_unit_umt', help="Unit value expressed in the UMT from product. It is used in the attribute 'ValorUnitarioAduana' in the " "CFDI") def _auto_init(self): if not column_exists(self.env.cr, "account_move_line", "l10n_mx_edi_umt_aduana_id"): create_column(self.env.cr, "account_move_line", "l10n_mx_edi_umt_aduana_id", "int4") # Since l10n_mx_edi_umt_aduana_id columns does not exist we can assume the columns # l10n_mx_edi_qty_umt and l10n_mx_edi_price_unit_umt do not exist either create_column(self.env.cr, "account_move_line", "l10n_mx_edi_qty_umt", "numeric") create_column(self.env.cr, "account_move_line", "l10n_mx_edi_price_unit_umt", "float8") return super()._auto_init() # ------------------------------------------------------------------------- # HELPERS # ------------------------------------------------------------------------- def _l10n_mx_edi_get_custom_numbers(self): self.ensure_one() if self.l10n_mx_edi_customs_number: return [num.strip() for num in self.l10n_mx_edi_customs_number.split(',')] else: return [] # ------------------------------------------------------------------------- # COMPUTE METHODS # ------------------------------------------------------------------------- @api.depends('l10n_mx_edi_umt_aduana_id', 'product_uom_id', 'quantity') def _compute_l10n_mx_edi_qty_umt(self): for line in self: product_aduana_code = line.l10n_mx_edi_umt_aduana_id.l10n_mx_edi_code_aduana uom_aduana_code = line.product_uom_id.l10n_mx_edi_code_aduana if product_aduana_code == uom_aduana_code: line.l10n_mx_edi_qty_umt = line.quantity elif '01' in (product_aduana_code or ''): line.l10n_mx_edi_qty_umt = line.product_id.weight * line.quantity else: line.l10n_mx_edi_qty_umt = None @api.depends('quantity', 'price_unit', 'l10n_mx_edi_qty_umt') def _compute_l10n_mx_edi_price_unit_umt(self): for line in self: if line.l10n_mx_edi_qty_umt: line.l10n_mx_edi_price_unit_umt = line.quantity * line.price_unit / line.l10n_mx_edi_qty_umt else: line.l10n_mx_edi_price_unit_umt = line.price_unit # ------------------------------------------------------------------------- # CONSTRAINT METHODS # ------------------------------------------------------------------------- @api.constrains('l10n_mx_edi_customs_number') def _check_l10n_mx_edi_customs_number(self): invalid_lines = self.env['account.move.line'] for line in self: custom_numbers = line._l10n_mx_edi_get_custom_numbers() if any(not CUSTOM_NUMBERS_PATTERN.match(custom_number) for custom_number in custom_numbers): invalid_lines |= line if not invalid_lines: return raise ValidationError(_( "Custom numbers set on invoice lines are invalid and should have a pattern like: 15 48 3009 0001234:\n%(invalid_message)s", invalid_message='\n'.join('%s (id=%s)' % (line.l10n_mx_edi_customs_number, line.id) for line in invalid_lines), ))