diff --git a/README.md b/README.md index c7b3810..802558d 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,10 @@ ## Mini ERP para la legislación mexicana -Este proyecto esta en continuo desarrollo, contratar un esquema de soporte, + +**En cada relación comercial, hay una relación humana** + + +Este proyecto está en continuo desarrollo, contratar un esquema de soporte, nos ayuda a continuar su desarrollo. Ponte en contacto con nosotros para contratar. diff --git a/requirements.txt b/requirements.txt index 759159c..9b10d62 100644 --- a/requirements.txt +++ b/requirements.txt @@ -9,3 +9,6 @@ bcrypt python-dateutil zeep chardet +pyqrcode +pypng +reportlab diff --git a/source/app/controllers/helper.py b/source/app/controllers/helper.py index 159a6b6..0ccfc90 100644 --- a/source/app/controllers/helper.py +++ b/source/app/controllers/helper.py @@ -1,12 +1,726 @@ #!/usr/bin/env python3 -import falcon -from models.main import get_cp +#~ import falcon +import re +import smtplib +import collections + +from collections import OrderedDict +from email.mime.multipart import MIMEMultipart +from email.mime.base import MIMEBase +from email.mime.text import MIMEText +from email import encoders +from email.utils import formatdate + +from reportlab.platypus import BaseDocTemplate, Frame, PageTemplate, Image +from reportlab.lib import colors +from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle +from reportlab.lib.units import cm +from reportlab.lib.pagesizes import letter +from reportlab.lib.enums import TA_CENTER, TA_LEFT, TA_RIGHT +from reportlab.platypus import Paragraph, Table, TableStyle, Spacer +from reportlab.pdfgen import canvas -class AppPostalCode(object): +#~ https://github.com/kennethreitz/requests/blob/v1.2.3/requests/structures.py#L37 +class CaseInsensitiveDict(collections.MutableMapping): + """A case-insensitive ``dict``-like object. + Implements all methods and operations of + ``collections.MutableMapping`` as well as dict's ``copy``. Also + provides ``lower_items``. + All keys are expected to be strings. The structure remembers the + case of the last key to be set, and ``iter(instance)``, + ``keys()``, ``items()``, ``iterkeys()``, and ``iteritems()`` + will contain case-sensitive keys. However, querying and contains + testing is case insensitive:: + cid = CaseInsensitiveDict() + cid['Accept'] = 'application/json' + cid['aCCEPT'] == 'application/json' # True + list(cid) == ['Accept'] # True + For example, ``headers['content-encoding']`` will return the + value of a ``'Content-Encoding'`` response header, regardless + of how the header name was originally stored. + If the constructor, ``.update``, or equality comparison + operations are given keys that have equal ``.lower()``s, the + behavior is undefined. + """ + + def __init__(self, data=None, **kwargs): + self._store = OrderedDict() + if data is None: + data = {} + self.update(data, **kwargs) + + def __setitem__(self, key, value): + # Use the lowercased key for lookups, but store the actual + # key alongside the value. + self._store[key.lower()] = (key, value) + + def __getitem__(self, key): + return self._store[key.lower()][1] + + def __delitem__(self, key): + del self._store[key.lower()] + + def __iter__(self): + return (casedkey for casedkey, mappedvalue in self._store.values()) + + def __len__(self): + return len(self._store) + + def lower_items(self): + """Like iteritems(), but with all lowercase keys.""" + return ( + (lowerkey, keyval[1]) + for (lowerkey, keyval) + in self._store.items() + ) + + def __eq__(self, other): + if isinstance(other, collections.Mapping): + other = CaseInsensitiveDict(other) + else: + return NotImplemented + # Compare insensitively + return dict(self.lower_items()) == dict(other.lower_items()) + + # Copy is required + def copy(self): + return CaseInsensitiveDict(self._store.values()) + + def __repr__(self): + return str(dict(self.items())) + + +class NumLet(object): + + def __init__(self, value, moneda, **args): + self._letras = self._letters(value, moneda) + + @property + def letras(self): + return self._letras.upper() + + #~ def _letters(self, numero, moneda='peso', texto_inicial='-(', + #~ texto_final='/100 m.n.)-', fraccion_letras=False, fraccion=''): + def _letters(self, numero, moneda='peso'): + + texto_inicial = '-(' + texto_final = '/100 m.n.)-' + fraccion_letras = False + fraccion = '' + + enletras = texto_inicial + numero = abs(numero) + numtmp = '%015d' % numero + + if numero < 1: + enletras += 'cero ' + self._plural(moneda) + ' ' + else: + enletras += self._numlet(numero) + if numero == 1 or numero < 2: + enletras += moneda + ' ' + elif int(''.join(numtmp[3:])) == 0 or int(''.join(numtmp[9:])) == 0: + enletras += 'de ' + self._plural(moneda) + ' ' + else: + enletras += self._plural(moneda) + ' ' + + decimal = '%0.2f' % numero + decimal = decimal.split('.')[1] + #~ decimal = int((numero-int(numero))*100) + if fraccion_letras: + if decimal == 0: + enletras += 'con cero ' + self._plural(fraccion) + elif decimal == 1: + enletras += 'con un ' + fraccion + else: + enletras += 'con ' + self._numlet(int(decimal)) + self.plural(fraccion) + else: + enletras += decimal + + enletras += texto_final + return enletras + + def _numlet(self, numero): + numtmp = '%015d' % numero + co1=0 + letras = '' + leyenda = '' + for co1 in range(0,5): + inicio = co1*3 + cen = int(numtmp[inicio:inicio+1][0]) + dec = int(numtmp[inicio+1:inicio+2][0]) + uni = int(numtmp[inicio+2:inicio+3][0]) + letra3 = self.centena(uni, dec, cen) + letra2 = self.decena(uni, dec) + letra1 = self.unidad(uni, dec) + + if co1 == 0: + if (cen+dec+uni) == 1: + leyenda = 'billon ' + elif (cen+dec+uni) > 1: + leyenda = 'billones ' + elif co1 == 1: + if (cen+dec+uni) >= 1 and int(''.join(numtmp[6:9])) == 0: + leyenda = "mil millones " + elif (cen+dec+uni) >= 1: + leyenda = "mil " + elif co1 == 2: + if (cen+dec) == 0 and uni == 1: + leyenda = 'millon ' + elif cen > 0 or dec > 0 or uni > 1: + leyenda = 'millones ' + elif co1 == 3: + if (cen+dec+uni) >= 1: + leyenda = 'mil ' + elif co1 == 4: + if (cen+dec+uni) >= 1: + leyenda = '' + + letras += letra3 + letra2 + letra1 + leyenda + letra1 = '' + letra2 = '' + letra3 = '' + leyenda = '' + return letras + + def centena(self, uni, dec, cen): + letras = '' + numeros = ["","","doscientos ","trescientos ","cuatrocientos ","quinientos ","seiscientos ","setecientos ","ochocientos ","novecientos "] + if cen == 1: + if (dec+uni) == 0: + letras = 'cien ' + else: + letras = 'ciento ' + elif cen >= 2 and cen <= 9: + letras = numeros[cen] + return letras + + def decena(self, uni, dec): + letras = '' + numeros = ["diez ","once ","doce ","trece ","catorce ","quince ","dieci","dieci","dieci","dieci"] + decenas = ["","","","treinta ","cuarenta ","cincuenta ","sesenta ","setenta ","ochenta ","noventa "] + if dec == 1: + letras = numeros[uni] + elif dec == 2: + if uni == 0: + letras = 'veinte ' + elif uni > 0: + letras = 'veinti' + elif dec >= 3 and dec <= 9: + letras = decenas[dec] + if uni > 0 and dec > 2: + letras = letras+'y ' + return letras + + def unidad(self, uni, dec): + letras = '' + numeros = ["","un ","dos ","tres ","cuatro ","cinco ","seis ","siete ","ocho ","nueve "] + if dec != 1: + if uni > 0 and uni <= 5: + letras = numeros[uni] + if uni >= 6 and uni <= 9: + letras = numeros[uni] + return letras + + def _plural(self, palabra): + if re.search('[aeiou]$', palabra): + return re.sub('$', 's', palabra) + else: + return palabra + 'es' + + +class SendMail(object): + + def __init__(self, config): + self._config = config + self._server = None + self._error = '' + self._is_connect = self._login() + + @property + def is_connect(self): + return self._is_connect + + @property + def error(self): + return self._error + + def _login(self): + try: + if self._config['ssl']: + self._server = smtplib.SMTP_SSL( + self._config['servidor'], + self._config['puerto'], timeout=10) + else: + self._server = smtplib.SMTP( + self._config['servidor'], + self._config['puerto'], timeout=10) + self._server.login(self._config['usuario'], self._config['contra']) + return True + except smtplib.SMTPAuthenticationError as e: + if '535' in str(e): + self._error = 'Nombre de usuario o contraseña inválidos' + return False + if '534' in str(e) and 'gmail' in self._config['servidor']: + self._error = 'Necesitas activar el acceso a otras ' \ + 'aplicaciones en tu cuenta de GMail' + return False + except smtplib.SMTPException as e: + self._error = str(e) + return False + except Exception as e: + self._error = str(e) + return False + return + + def send(self, options): + try: + message = MIMEMultipart() + message['From'] = self._config['usuario'] + message['To'] = options['para'] + message['CC'] = options['copia'] + message['Subject'] = options['asunto'] + message['Date'] = formatdate(localtime=True) + message.attach(MIMEText(options['mensaje'], 'html')) + for f in options['files']: + part = MIMEBase('application', 'octet-stream') + part.set_payload(f[0]) + encoders.encode_base64(part) + part.add_header( + 'Content-Disposition', + "attachment; filename={}".format(f[1])) + message.attach(part) + + receivers = options['para'].split(',') + options['copia'].split(',') + self._server.sendmail( + self._config['usuario'], receivers, message.as_string()) + return '' + except Exception as e: + return str(e) + + def close(self): + try: + self._server.quit() + except: + pass + return + + +class NumberedCanvas(canvas.Canvas): + X = 20.59 * cm + Y = 1.5 * cm + + def __init__(self, *args, **kwargs): + canvas.Canvas.__init__(self, *args, **kwargs) + self._saved_page_states = [] + + def showPage(self): + self._saved_page_states.append(dict(self.__dict__)) + self._startPage() + return + + def save(self): + """add page info to each page (page x of y)""" + page_count = len(self._saved_page_states) + for state in self._saved_page_states: + self.__dict__.update(state) + self.draw_page_number(page_count) + canvas.Canvas.showPage(self) + canvas.Canvas.save(self) + return + + def draw_page_number(self, page_count): + self.setFont('Helvetica', 8) + self.setFillColor(colors.darkred) + text = 'Página {} de {}'.format(self._pageNumber, page_count) + self.drawRightString(self.X, self.Y, text) + text = 'Factura elaborada con software libre: www.empresalibre.net' + self.drawString(1.5 * cm, 1.5 * cm, text) + return + + +class TemplateInvoice(BaseDocTemplate): + + def __init__(self, *args, **kwargs): + # letter 21.6 x 27.9 + kwargs['pagesize'] = letter + kwargs['rightMargin'] = 1 * cm + kwargs['leftMargin'] = 1 * cm + kwargs['topMargin'] = 1.5 * cm + kwargs['bottomMargin'] = 1.5 * cm + BaseDocTemplate.__init__(self, *args, **kwargs) + self._data = {} + self._rows = [] + + def _set_rect(self, style): + color = style.pop('color', 'black') + if isinstance(color, str): + self.canv.setFillColor(getattr(colors, color)) + else: + self.canv.setFillColorRGB(*color) + + keys = ('x', 'y', 'width', 'height', 'radius') + for k in keys: + style[k] = style[k] * cm + + self.canv.roundRect(**style) + return + + def _set_text(self, styles, value): + text = styles.pop('valor', '') + if not value: + value = text + + rect = styles['rectangulo'] + if value: + self.canv.setFillColor(colors.white) + self.canv.setStrokeColor(colors.white) + ps = ParagraphStyle(**styles['estilo']) + p = Paragraph(value, ps) + p.wrap(rect['width'] * cm, rect['height'] * cm) + p.drawOn(self.canv, rect['x'] * cm, rect['y'] * cm) + + self._set_rect(rect) + return + + def _emisor(self, styles, data): + logo_path = data.pop('logo', '') + logo_style = styles.pop('logo', {}) + + for k, v in styles.items(): + self._set_text(styles[k], data.get(k, '')) + + if logo_path and logo_style: + rect = logo_style['rectangulo'] + keys = ('x', 'y', 'width', 'height') + for k in keys: + rect[k] = rect[k] * cm + self.canv.drawImage(logo_path, **rect) + return + + def _receptor(self, styles, data): + title = styles.pop('titulo', {}) + + for k, v in styles.items(): + self._set_text(styles[k], data.get(k, '')) + + if title: + rect = title['rectangulo'] + self.canv.saveState() + self.canv.rotate(90) + value = title.pop('valor', '') + title['rectangulo']['x'], title['rectangulo']['y'] = \ + title['rectangulo']['y'], title['rectangulo']['x'] * -1 + self._set_text(title, value) + self.canv.restoreState() + return + + def _comprobante1(self, styles, data): + title = styles.pop('titulo', {}) + + for k, v in styles.items(): + self._set_text(styles[k], data.get(k, '')) + + if title: + rect = title['rectangulo'] + self.canv.saveState() + self.canv.rotate(90) + value = title.pop('valor', '') + title['rectangulo']['x'], title['rectangulo']['y'] = \ + title['rectangulo']['y'], title['rectangulo']['x'] * -1 + self._set_text(title, value) + self.canv.restoreState() + return + + def afterPage(self): + encabezado = self._custom_styles['encabezado'] + self.canv.saveState() + self._emisor(encabezado['emisor'], self._data['emisor']) + self._receptor(encabezado['receptor'], self._data['receptor']) + self._comprobante1(encabezado['comprobante'], self._data['comprobante']) + self.canv.restoreState() + return + + def _currency(self, value, simbol='$'): + return '{} {:,.2f}'.format(simbol, float(value)) + + def _format(self, value, field): + fields = ('valorunitario', 'importe') + if field in fields: + return self._currency(value) + return value + + def _conceptos(self, conceptos): + headers = (('Clave', 'Descripción', 'Unidad', 'Cantidad', + 'Valor Unitario', 'Importe'),) + fields = ('noidentificacion', 'descripcion', 'unidad', 'cantidad', + 'valorunitario', 'importe') + rows = [] + for concepto in conceptos: + row = tuple([self._format(concepto[f], f) for f in fields]) + rows.append(row) + return headers + tuple(rows) + + def _totales(self, values): + #~ print (values) + rows = [('', 'Subtotal', self._currency(values['subtotal']))] + + for tax in values['traslados']: + row = ('', tax[0], self._currency(tax[1])) + rows.append(row) + for tax in values['retenciones']: + row = ('', tax[0], self._currency(tax[1])) + rows.append(row) + for tax in values['taxlocales']: + row = ('', tax[0], self._currency(tax[1])) + rows.append(row) + + row = ('', 'Total', self._currency(values['total'])) + rows.append(row) + + widths = [12.5 * cm, 4 * cm, 3 * cm] + table_styles = [ + ('GRID', (0, 0), (-1, -1), 0.05 * cm, colors.white), + ('ALIGN', (1, 0), (-1, -1), 'RIGHT'), + ('FONTSIZE', (0, 0), (-1, -1), 8), + ('BACKGROUND', (1, 0), (-1, -1), colors.linen), + ('TEXTCOLOR', (1, 0), (-1, -1), colors.darkred), + ('FACE', (1, 0), (-1, -1), 'Helvetica-Bold'), + ] + table = Table(rows, colWidths=widths, spaceBefore=0.25*cm) + table.setStyle(TableStyle(table_styles)) + return table + + def _comprobante2(self, styles, data): + leyenda = styles.pop('leyenda', {}) + + ls = [] + for k, v in styles.items(): + if k in data: + if 'spaceBefore' in v['estilo']: + v['estilo']['spaceBefore'] = v['estilo']['spaceBefore'] * cm + ps = ParagraphStyle(**v['estilo']) + p = Paragraph(data[k], ps) + ls.append(p) + + cbb = Image(data['path_cbb']) + cbb.drawHeight = 4 * cm + cbb.drawWidth = 4 * cm + + style_bt = getSampleStyleSheet()['BodyText'] + style_bt.leading = 8 + html_t = '{}' + html = '{}' + msg = 'Cadena original del complemento de certificación digital del SAT' + rows = [ + (cbb, Paragraph(html_t.format('Sello Digital del CFDI'), style_bt)), + ('', Paragraph(html.format(data['sellocfd']), style_bt)), + ('', Paragraph(html_t.format('Sello Digital del SAT'), style_bt)), + ('', Paragraph(html.format(data['sellosat']), style_bt)), + ('', Paragraph(html_t.format(msg), style_bt)), + ('', Paragraph(html.format(data['cadenaoriginal']), style_bt)), + ] + + widths = [4 * cm, 15.5 * cm] + table_styles = [ + ('FONTSIZE', (0, 0), (-1, -1), 6), + ('SPAN', (0, 0), (0, -1)), + ('FACE', (1, 0), (1, 0), 'Helvetica-Bold'), + ('BACKGROUND', (1, 1), (1, 1), colors.linen), + ('TEXTCOLOR', (1, 1), (1, 1), colors.darkred), + ('FACE', (1, 2), (1, 2), 'Helvetica-Bold'), + ('BACKGROUND', (1, 3), (1, 3), colors.linen), + ('TEXTCOLOR', (1, 3), (1, 3), colors.darkred), + ('FACE', (1, 4), (1, 4), 'Helvetica-Bold'), + ('BACKGROUND', (1, 5), (1, 5), colors.linen), + ('TEXTCOLOR', (1, 5), (1, 5), colors.darkred), + ('ALIGN', (0, 0), (0, 0), 'CENTER'), + ('VALIGN', (0, 0), (0, 0), 'MIDDLE'), + ] + table = Table(rows, colWidths=widths) + table.setStyle(TableStyle(table_styles)) + ls.append(table) + + if leyenda: + if 'spaceBefore' in leyenda['estilo']: + leyenda['estilo']['spaceBefore'] = \ + leyenda['estilo']['spaceBefore'] * cm + msg = 'Este documento es una representación impresa de un CFDI' + ps = ParagraphStyle(**leyenda['estilo']) + p = Paragraph(msg, ps) + ls.append(p) + + return ls + + @property + def custom_styles(self): + return self._custom_styles + @custom_styles.setter + def custom_styles(self, values): + self._custom_styles = values + + @property + def data(self): + return self._data + @data.setter + def data(self, values): + #~ print (values) + self._data = values + + rows = self._conceptos(self._data['conceptos']) + widths = [2 * cm, 9 * cm, 1.5 * cm, 2 * cm, 2 * cm, 3 * cm] + table_styles = [ + ('GRID', (0, 0), (-1, -1), 0.05 * cm, colors.white), + ('FONTSIZE', (0, 0), (-1, 0), 7), + ('ALIGN', (0, 0), (-1, 0), 'CENTER'), + ('TEXTCOLOR', (0, 0), (-1, 0), colors.white), + ('FACE', (0, 0), (-1, 0), 'Helvetica-Bold'), + ('BACKGROUND', (0, 0), (-1, 0), colors.darkred), + ('FONTSIZE', (0, 1), (-1, -1), 7), + ('VALIGN', (0, 1), (-1, -1), 'TOP'), + ('ALIGN', (0, 1), (0, -1), 'CENTER'), + ('ALIGN', (2, 1), (2, -1), 'CENTER'), + ('ALIGN', (3, 1), (5, -1), 'RIGHT'), + ('LINEBELOW', (0, 1), (-1, -1), 0.05 * cm, colors.darkred), + ('LINEBEFORE', (0, 1), (-1, -1), 0.05 * cm, colors.white), + ] + table_conceptos = Table(rows, colWidths=widths, repeatRows=1) + table_conceptos.setStyle(TableStyle(table_styles)) + + totales = self._totales(self.data['totales']) + comprobante = self._comprobante2( + self._custom_styles['comprobante'], self.data['comprobante']) + + self._rows = [Spacer(0, 6*cm), table_conceptos, totales] + comprobante + + def render(self): + frame = Frame(self.leftMargin, self.bottomMargin, + self.width, self.height, id='normal') + template = PageTemplate(id='invoice', frames=frame) + self.addPageTemplates([template]) + self.build(self._rows, canvasmaker=NumberedCanvas) + return + + +class ReportTemplate(BaseDocTemplate): + """Override the BaseDocTemplate class to do custom handle_XXX actions""" + + def __init__(self, *args, **kwargs): + # letter 21.6 x 27.9 + kwargs['pagesize'] = letter + kwargs['rightMargin'] = 1 * cm + kwargs['leftMargin'] = 1 * cm + kwargs['topMargin'] = 1.5 * cm + kwargs['bottomMargin'] = 1.5 * cm + BaseDocTemplate.__init__(self, *args, **kwargs) + self.styles = getSampleStyleSheet() + self.header = {} + self.data = [] + + def afterPage(self): + """Called after each page has been processed""" + self.canv.saveState() + date = datetime.datetime.today().strftime('%A, %d de %B del %Y') + self.canv.setStrokeColorRGB(0.5, 0, 0) + self.canv.setFont("Helvetica", 8) + self.canv.drawRightString(20.59 * cm, 26.9 * cm, date) + self.canv.line(1 * cm, 26.4 * cm, 20.6 * cm, 26.4 * cm) + + path_cur = os.path.dirname(os.path.realpath(__file__)) + path_img = os.path.join(path_cur, 'logo.png') + try: + self.canv.drawImage(path_img, 1 * cm, 24.2 * cm, 4 * cm, 2 * cm) + except: + pass + + self.canv.roundRect( + 5 * cm, 25.4 * cm, 15.5 * cm, 0.6 * cm, 0.15 * cm, + stroke=True, fill=False) + self.canv.setFont('Helvetica-BoldOblique', 10) + self.canv.drawCentredString(12.75 * cm, 25.6 * cm, self.header['emisor']) + + self.canv.roundRect( + 5 * cm, 24.4 * cm, 15.5 * cm, 0.6 * cm, 0.15 * cm, + stroke=True, fill=False) + self.canv.setFont('Helvetica-BoldOblique', 9) + self.canv.drawCentredString(12.75 * cm, 24.6 * cm, self.header['title']) + + self.canv.line(1 * cm, 1.5 * cm, 20.6 * cm, 1.5 * cm) + self.canv.restoreState() + return + + def set_data(self, data): + self.header['emisor'] = data['emisor'] + self.header['title'] = data['title'] + cols = len(data['rows'][0]) + widths = [] + for w in data['widths']: + widths.append(float(w) * cm) + t_styles = [ + ('GRID', (0, 0), (-1, -1), 0.25, colors.darkred), + ('FONTSIZE', (0, 0), (-1, 0), 9), + ('BOX', (0, 0), (-1, 0), 1, colors.darkred), + ('TEXTCOLOR', (0, 0), (-1, 0), colors.black), + ('FONTSIZE', (0, 1), (-1, -1), 8), + ('ALIGN', (0, 0), (-1, 0), 'CENTER'), + ('ALIGN', (0, 0), (0, -1), 'RIGHT'), + ] + if cols == 6: + t_styles += [ + ('ALIGN', (1, 1), (1, -1), 'CENTER'), + ('ALIGN', (3, 1), (3, -1), 'CENTER'), + ('ALIGN', (4, 1), (4, -1), 'RIGHT'), + ] + elif cols == 3: + t_styles += [ + ('ALIGN', (-1, 0), (-1, -1), 'RIGHT'), + ('ALIGN', (-2, 0), (-2, -1), 'RIGHT'), + ('ALIGN', (0, 0), (-1, 0), 'CENTER'), + ] + elif cols == 2: + t_styles += [ + ('ALIGN', (-1, 0), (-1, -1), 'RIGHT'), + ('ALIGN', (0, 0), (-1, 0), 'CENTER'), + ] + rows = [] + for i, r in enumerate(data['rows']): + n = i + 1 + rows.append(('{}.-'.format(n),) + r) + if cols == 6: + if r[4] == 'Cancelado': + t_styles += [ + ('GRID', (0, n), (-1, n), 0.25, colors.red), + ('TEXTCOLOR', (0, n), (-1, n), colors.red), + ] + rows.insert(0, data['titles']) + t = Table(rows, colWidths=widths, repeatRows=1) + t.setStyle(TableStyle(t_styles)) + + text = 'Total este reporte = $ {}'.format(data['total']) + ps = ParagraphStyle( + name='Total', + fontSize=12, + fontName='Helvetica-BoldOblique', + textColor=colors.darkred, + spaceBefore=0.5 * cm, + spaceAfter=0.5 * cm) + p1 = Paragraph(text, ps) + text = 'Nota: esta suma no incluye documentos cancelados' + ps = ParagraphStyle( + name='Note', + fontSize=7, + fontName='Helvetica-BoldOblique') + p2 = Paragraph(text, ps) + self.data = [t, p1, p2] + return + + def render(self): + frame = Frame(self.leftMargin, self.bottomMargin, + self.width, self.height, id='normal') + template = PageTemplate(id='report', frames=frame) + self.addPageTemplates([template]) + self.build(self.data, canvasmaker=NumberedCanvas) + return + - def on_get(self, req, resp): - values = req.params - req.context['result'] = get_cp(values['cp']) - resp.status = falcon.HTTP_200 diff --git a/source/app/controllers/main.py b/source/app/controllers/main.py index ac55355..1939e44 100644 --- a/source/app/controllers/main.py +++ b/source/app/controllers/main.py @@ -76,12 +76,33 @@ class AppValues(object): if file_object is None: session = req.env['beaker.session'] values = req.params - req.context['result'] = self._db.validate_cert(values, session) + if table == 'correo': + req.context['result'] = self._db.validate_email(values) + elif table == 'sendmail': + req.context['result'] = self._db.send_email(values, session) + else: + req.context['result'] = self._db.validate_cert(values, session) else: req.context['result'] = self._db.add_cert(file_object) resp.status = falcon.HTTP_200 +class AppConfig(object): + + def __init__(self, db): + self._db = db + + def on_get(self, req, resp): + values = req.params + req.context['result'] = self._db.get_config(values) + resp.status = falcon.HTTP_200 + + def on_post(self, req, resp): + values = req.params + req.context['result'] = self._db.add_config(values) + resp.status = falcon.HTTP_200 + + class AppPartners(object): @@ -206,8 +227,9 @@ class AppDocumentos(object): #~ self._not_json = True def on_get(self, req, resp, type_doc, id_doc): + session = req.env['beaker.session'] req.context['result'], file_name, content_type = \ - self._db.get_doc(type_doc, id_doc) + self._db.get_doc(type_doc, id_doc, session['rfc']) resp.append_header('Content-Disposition', 'attachment; filename={}'.format(file_name)) resp.content_type = content_type diff --git a/source/app/controllers/pac.py b/source/app/controllers/pac.py index 49e3cc5..25b7fa4 100644 --- a/source/app/controllers/pac.py +++ b/source/app/controllers/pac.py @@ -20,7 +20,10 @@ from zeep.cache import SqliteCache from zeep.transports import Transport from zeep.exceptions import Fault, TransportError -from .configpac import DEBUG, TIMEOUT, AUTH, URL +if __name__ == '__main__': + from configpac import DEBUG, TIMEOUT, AUTH, URL +else: + from .configpac import DEBUG, TIMEOUT, AUTH, URL log = Logger('PAC') @@ -147,7 +150,7 @@ class Ecodex(object): class Finkok(object): - def __init__(self): + def __init__(self, auth={}): self.codes = URL['codes'] self.error = '' self.message = '' @@ -159,6 +162,9 @@ class Finkok(object): if DEBUG: self._history = HistoryPlugin() self._plugins = [self._history] + self._auth = AUTH + else: + self._auth = auth def _debug(self): if not DEBUG: @@ -225,6 +231,11 @@ class Finkok(object): def timbra_xml(self, file_xml): self.error = '' + + if not DEBUG and not self._auth: + self.error = 'Sin datos para timbrar' + return + method = 'timbra' ok, xml = self._validate_xml(file_xml) if not ok: @@ -233,8 +244,8 @@ class Finkok(object): URL[method], transport=self._transport, plugins=self._plugins) args = { - 'username': AUTH['USER'], - 'password': AUTH['PASS'], + 'username': self._auth['USER'], + 'password': self._auth['PASS'], 'xml': xml, } if URL['quick_stamp']: @@ -261,8 +272,8 @@ class Finkok(object): URL[method], transport=self._transport, plugins=self._plugins) args = { - 'username': AUTH['USER'], - 'password': AUTH['PASS'], + 'username': self._auth['USER'], + 'password': self._auth['PASS'], 'uuid': uuid, 'taxpayer_id': self.rfc, 'invoice_type': 'I', @@ -296,7 +307,8 @@ class Finkok(object): client = Client( URL[method], transport=self._transport, plugins=self._plugins) try: - result = client.service.stamped(xml, AUTH['USER'], AUTH['PASS']) + result = client.service.stamped( + xml, self._auth['user'], self._auth['pass']) except Fault as e: self.error = str(e) return '' @@ -310,7 +322,8 @@ class Finkok(object): client = Client( URL[method], transport=self._transport, plugins=self._plugins) try: - result = client.service.query_pending(AUTH['USER'], AUTH['PASS'], uuid) + result = client.service.query_pending( + self._auth['USER'], self._auth['PASS'], uuid) #~ print (result.date) #~ tree = parseString(unescape(result.xml)) #~ response = tree.toprettyxml(encoding='utf-8').decode('utf-8') @@ -334,8 +347,8 @@ class Finkok(object): args = { 'UUIDS': uuid_type(uuids=sa(string=uuids)), - 'username': AUTH['USER'], - 'password': AUTH['PASS'], + 'username': self._auth['USER'], + 'password': self._auth['PASS'], 'taxpayer_id': rfc, 'cer': cer, 'key': key, @@ -366,8 +379,8 @@ class Finkok(object): URL[method], transport=self._transport, plugins=self._plugins) args = { - 'username': AUTH['USER'], - 'password': AUTH['PASS'], + 'username': self._auth['USER'], + 'password': self._auth['PASS'], 'xml': xml, 'store_pending': True, } @@ -385,8 +398,8 @@ class Finkok(object): URL[method], transport=self._transport, plugins=self._plugins) args = { - 'username': AUTH['USER'], - 'password': AUTH['PASS'], + 'username': self._auth['USER'], + 'password': self._auth['PASS'], 'taxpayer_id': rfc, 'uuid': '', 'type': type_acuse, @@ -413,8 +426,8 @@ class Finkok(object): URL[method], transport=self._transport, plugins=self._plugins) args = { - 'username': AUTH['USER'], - 'password': AUTH['PASS'], + 'username': self._auth['USER'], + 'password': self._auth['PASS'], 'uuid': '', } try: diff --git a/source/app/controllers/util.py b/source/app/controllers/util.py index 156de65..2b4fea4 100644 --- a/source/app/controllers/util.py +++ b/source/app/controllers/util.py @@ -8,15 +8,32 @@ import mimetypes import os import re import sqlite3 +import socket import subprocess import tempfile +import time import unicodedata import uuid +import zipfile +from io import BytesIO +from smtplib import SMTPException, SMTPAuthenticationError +from xml.etree import ElementTree as ET + +try: + import uno + from com.sun.star.beans import PropertyValue + from com.sun.star.awt import Size + APP_LIBO = True +except: + APP_LIBO = False + +import pyqrcode from dateutil import parser +from .helper import CaseInsensitiveDict, NumLet, SendMail, TemplateInvoice from settings import DEBUG, log, template_lookup, COMPANIES, DB_SAT, \ - PATH_XSLT, PATH_XSLTPROC, PATH_OPENSSL + PATH_XSLT, PATH_XSLTPROC, PATH_OPENSSL, PATH_TEMPLATES, PATH_MEDIA, PRE #~ def _get_hash(password): @@ -78,7 +95,23 @@ def get_value(arg): return value +def _valid_db_companies(): + con = sqlite3.connect(COMPANIES) + sql = """ + CREATE TABLE IF NOT EXISTS names( + rfc TEXT NOT NULL COLLATE NOCASE UNIQUE, + con TEXT NOT NULL + ); + """ + cursor = con.cursor() + cursor.executescript(sql) + cursor.close() + con.close() + return + + def _get_args(rfc): + _valid_db_companies() con = sqlite3.connect(COMPANIES) cursor = con.cursor() sql = "SELECT con FROM names WHERE rfc=?" @@ -94,6 +127,18 @@ def _get_args(rfc): return values[0] +def get_rfcs(): + _valid_db_companies() + con = sqlite3.connect(COMPANIES) + cursor = con.cursor() + sql = "SELECT * FROM names" + cursor.execute(sql) + values = cursor.fetchall() + cursor.close() + con.close() + return values + + def get_con(rfc=''): if not rfc: rfc = get_value('RFC').upper() @@ -106,17 +151,6 @@ def get_con(rfc=''): return loads(args) -def get_rfcs(): - con = sqlite3.connect(COMPANIES) - cursor = con.cursor() - sql = "SELECT * FROM names" - cursor.execute(sql) - values = cursor.fetchall() - cursor.close() - con.close() - return values - - def get_sat_key(table, key): con = sqlite3.connect(DB_SAT) cursor = con.cursor() @@ -155,6 +189,10 @@ def get_file(path): return open(path, 'rb') +def read_file(path, mode='rb'): + return open(path, mode).read() + + def get_size(path): return os.path.getsize(path) @@ -165,6 +203,32 @@ def get_template(name, data={}): return template.render(**data) +def get_custom_styles(name, default='plantilla_factura.json'): + path = _join(PATH_MEDIA, 'templates', name.lower()) + if is_file(path): + with open(path) as fh: + return loads(fh.read()) + + path = _join(PATH_TEMPLATES, default) + if is_file(path): + with open(path) as fh: + return loads(fh.read()) + + return {} + + +def get_template_ods(name, default='plantilla_factura.ods'): + path = _join(PATH_MEDIA, 'templates', name.lower()) + if is_file(path): + return path + + path = _join(PATH_TEMPLATES, default) + if is_file(path): + return path + + return '' + + def dumps(data): return json.dumps(data, default=str) @@ -207,7 +271,7 @@ def to_slug(string): value = (unicodedata.normalize('NFKD', string) .encode('ascii', 'ignore') .decode('ascii').lower()) - return value + return value.replace(' ', '_') class Certificado(object): @@ -384,11 +448,19 @@ def make_xml(data, certificado): return cfdi.add_sello(sello) -def timbra_xml(xml): +def timbra_xml(xml, auth): from .pac import Finkok as PAC + if DEBUG: + auth = {} + else: + if not auth: + msg = 'Sin datos para timbrar' + result = {'ok': True, 'error': msg} + return result + result = {'ok': True, 'error': ''} - pac = PAC() + pac = PAC(auth) xml = pac.timbra_xml(xml) if not xml: result['ok'] = False @@ -399,3 +471,663 @@ def timbra_xml(xml): result['uuid'] = pac.uuid result['fecha'] = pac.fecha return result + + +class LIBO(object): + HOST = 'localhost' + PORT = '8100' + ARG = 'socket,host={},port={};urp;StarOffice.ComponentContext'.format( + HOST, PORT) + + def __init__(self): + self._app = None + self._start_office() + self._init_values() + + def _init_values(self): + self._ctx = None + self._sm = None + self._desktop = None + if self.is_running: + ctx = uno.getComponentContext() + service = 'com.sun.star.bridge.UnoUrlResolver' + resolver = ctx.ServiceManager.createInstanceWithContext(service, ctx) + self._ctx = resolver.resolve('uno:{}'.format(self.ARG)) + self._sm = self._ctx.ServiceManager + self._desktop = self._create_instance('com.sun.star.frame.Desktop') + return + + def _create_instance(self, name, with_context=True): + if with_context: + instance = self._sm.createInstanceWithContext(name, self._ctx) + else: + instance = self._sm.createInstance(name) + return instance + + @property + def is_running(self): + try: + s = socket.create_connection((self.HOST, self.PORT), 5.0) + s.close() + return True + except ConnectionRefusedError: + return False + + def _start_office(self): + if self.is_running: + return + + c = 1 + while c < 4: + c += 1 + self.app = subprocess.Popen([ + 'soffice', '--headless', '--accept={}'.format(self.ARG)], + stdout=subprocess.PIPE, stderr=subprocess.PIPE) + time.sleep(5) + if self.is_running: + return + return + + def _set_properties(self, properties): + pl = [] + for k, v in properties.items(): + pv = PropertyValue() + pv.Name = k + pv.Value = v + pl.append(pv) + return tuple(pl) + + def _doc_open(self, path, options): + options = self._set_properties(options) + path = self._path_url(path) + try: + doc = self._desktop.loadComponentFromURL(path, '_blank', 0, options) + return doc + except: + return None + + def _path_url(self, path): + if path.startswith('file://'): + return path + return uno.systemPathToFileUrl(path) + + def close(self): + if self.is_running: + if not self._desktop is None: + self._desktop.terminate() + if not self._app is None: + self._app.terminate() + return + + def _read(self, path): + try: + return open(path, 'rb').read() + except: + return b'' + + def _clean(self): + self._sd.SearchRegularExpression = True + self._sd.setSearchString("\{(\w.+)\}") + self._search.replaceAll(self._sd) + return + + def _cancelado(self, cancel): + if not cancel: + pd = self._sheet.getDrawPage() + if pd.getCount(): + pd.remove(pd.getByIndex(0)) + return + + def _set_search(self): + self._sheet = self._template.getSheets().getByIndex(0) + self._search = self._sheet.getPrintAreas()[0] + self._search = self._sheet.getCellRangeByPosition( + self._search.StartColumn, + self._search.StartRow, + self._search.EndColumn, + self._search.EndRow + ) + self._sd = self._sheet.createSearchDescriptor() + self._sd.SearchCaseSensitive = False + return + + def _next_cell(self, cell): + col = cell.getCellAddress().Column + row = cell.getCellAddress().Row + 1 + return self._sheet.getCellByPosition(col, row) + + def _copy_cell(self, cell): + destino = self._next_cell(cell) + self._sheet.copyRange(destino.getCellAddress(), cell.getRangeAddress()) + return destino + + def _set_cell(self, k='', v=None, cell=None, value=False): + if k: + self._sd.setSearchString(k) + ranges = self._search.findAll(self._sd) + if ranges: + ranges = ranges.getRangeAddressesAsString().split(';') + for r in ranges: + for c in r.split(','): + cell = self._sheet.getCellRangeByName(c) + if v is None: + return cell + if cell.getImplementationName() == 'ScCellObj': + pattern = re.compile(k, re.IGNORECASE) + nv = pattern.sub(v, cell.getString()) + if value: + cell.setValue(nv) + else: + cell.setString(nv) + return cell + if cell: + if cell.getImplementationName() == 'ScCellObj': + ca = cell.getCellAddress() + new_cell = self._sheet.getCellByPosition(ca.Column, ca.Row + 1) + if value: + new_cell.setValue(v) + else: + new_cell.setString(v) + return new_cell + + def _comprobante(self, data): + for k, v in data.items(): + if k in ('total', 'descuento', 'subtotal'): + self._set_cell('{cfdi.%s}' % k, v, value=True) + else: + self._set_cell('{cfdi.%s}' % k, v) + return + + def _emisor(self, data): + for k, v in data.items(): + self._set_cell('{emisor.%s}' % k, v) + return + + def _receptor(self, data): + for k, v in data.items(): + self._set_cell('{receptor.%s}' % k, v) + return + + def _conceptos(self, data): + first = True + for concepto in data: + key = concepto.get('noidentificacion', '') + description = concepto['descripcion'] + unidad = concepto['unidad'] + cantidad = concepto['cantidad'] + valor_unitario = concepto['valorunitario'] + importe = concepto['importe'] + if first: + first = False + cell_1 = self._set_cell('{noidentificacion}', key) + cell_2 = self._set_cell('{descripcion}', description) + cell_3 = self._set_cell('{unidad}', unidad) + cell_4 = self._set_cell('{cantidad}', cantidad, value=True) + cell_5 = self._set_cell('{valorunitario}', valor_unitario, value=True) + cell_6 = self._set_cell('{importe}', importe, value=True) + return + + def _totales(self, data): + currency = data['moneda'] + + cell_title = self._set_cell('{subtotal.titulo}', 'SubTotal') + value = data['subtotal'] + cell_value = self._set_cell('{subtotal}', value, value=True) + cell_value.CellStyle = currency + + #~ Si encuentra el campo {total}, se asume que los totales e impuestos + #~ están declarados de forma independiente cada uno + #~ if self._add_totales(xml): + #~ return + + #~ Si no se encuentra, copia las celdas hacia abajo de + #~ {subtotal.titulo} y {subtotal} + if 'descuento' in data: + self._copy_cell(cell_title) + self._copy_cell(cell_value) + cell_title = self._set_cell(v='Descuento', cell=cell_title) + value = data['descuento'] + cell_value = self._set_cell(v=value, cell=cell_value, value=True) + cell_value.CellStyle = currency + + for tax in data['traslados']: + self._copy_cell(cell_title) + self._copy_cell(cell_value) + cell_title = self._set_cell(v=tax[0], cell=cell_title) + cell_value = self._set_cell(v=tax[1], cell=cell_value, value=True) + cell_value.CellStyle = currency + + for tax in data['retenciones']: + self._copy_cell(cell_title) + self._copy_cell(cell_value) + cell_title = self._set_cell(v=tax[0], cell=cell_title) + cell_value = self._set_cell(v=tax[1], cell=cell_value, value=True) + cell_value.CellStyle = currency + + for tax in data['taxlocales']: + self._copy_cell(cell_title) + self._copy_cell(cell_value) + cell_title = self._set_cell(v=tax[0], cell=cell_title) + cell_value = self._set_cell(v=tax[1], cell=cell_value, value=True) + cell_value.CellStyle = currency + + self._copy_cell(cell_title) + self._copy_cell(cell_value) + cell_title = self._set_cell(v='Total', cell=cell_title) + value = data['total'] + cell_value = self._set_cell(v=value, cell=cell_value, value=True) + cell_value.CellStyle = currency + return + + def _timbre(self, data): + for k, v in data.items(): + self._set_cell('{timbre.%s}' % k, v) + pd = self._sheet.getDrawPage() + image = self._template.createInstance('com.sun.star.drawing.GraphicObjectShape') + image.GraphicURL = data['path_cbb'] + pd.add(image) + s = Size() + s.Width = 4250 + s.Height = 4500 + image.setSize(s) + image.Anchor = self._set_cell('{timbre.cbb}') + return + + def _render(self, data): + self._set_search() + self._comprobante(data['comprobante']) + self._emisor(data['emisor']) + self._receptor(data['receptor']) + self._conceptos(data['conceptos']) + self._totales(data['totales']) + self._timbre(data['timbre']) + self._cancelado(data['cancelada']) + self._clean() + return + + def pdf(self, path, data): + options = {'AsTemplate': True, 'Hidden': True} + self._template = self._doc_open(path, options) + if self._template is None: + return b'' + + self._render(data) + + path = '{}.ods'.format(tempfile.mkstemp()[1]) + self._template.storeToURL(self._path_url(path), ()) + doc = self._doc_open(path, {'Hidden': True}) + + options = {'FilterName': 'calc_pdf_Export'} + path = tempfile.mkstemp()[1] + doc.storeToURL(self._path_url(path), self._set_properties(options)) + doc.close(True) + self._template.close(True) + + return self._read(path) + + +def to_pdf(data): + rfc = data['emisor']['rfc'] + version = data['comprobante']['version'] + + if APP_LIBO: + app = LIBO() + if app.is_running: + name = '{}_{}.ods'.format(rfc, version) + path = get_template_ods(name) + if path: + return app.pdf(path, data) + + name = '{}_{}.json'.format(rfc, version) + custom_styles = get_custom_styles(name) + + path = get_path_temp() + pdf = TemplateInvoice(path) + pdf.custom_styles = custom_styles + pdf.data = data + pdf.render() + return read_file(path) + + +def parse_xml(xml): + return ET.fromstring(xml) + + +def get_dict(data): + return CaseInsensitiveDict(data) + + +def to_letters(value, moneda): + monedas = { + 'MXN': 'peso', + 'USD': 'dólar', + 'EUR': 'euro', + } + return NumLet(value, monedas[moneda]).letras + + +def get_qr(data): + path = tempfile.mkstemp()[1] + qr = pyqrcode.create(data, mode='binary') + qr.png(path, scale=7) + return path + + +def _comprobante(values, options): + data = CaseInsensitiveDict(values) + del data['certificado'] + + data['totalenletras'] = to_letters(float(data['total']), data['moneda']) + if data['version'] == '3.3': + tipos = { + 'I': 'ingreso', + 'E': 'egreso', + 'T': 'traslado', + } + data['tipodecomprobante'] = tipos.get(data['tipodecomprobante']) + data['lugarexpedicion'] = 'C.P. de Expedición: {}'.format(data['lugarexpedicion']) + data['metododepago'] = options['metododepago'] + data['formadepago'] = options['formadepago'] + data['moneda'] = options['moneda'] + + data['tipocambio'] = 'Tipo de Cambio: $ {:0.2f}'.format( + float(data['tipocambio'])) + if 'serie' in data: + data['folio'] = '{}-{}'.format(data['serie'], data['folio']) + return data + + +def _emisor(doc, version, values): + node = doc.find('{}Emisor'.format(PRE[version])) + data = CaseInsensitiveDict(node.attrib.copy()) + node = node.find('{}DomicilioFiscal'.format(PRE[version])) + if not node is None: + data.update(CaseInsensitiveDict(node.attrib.copy())) + data['regimenfiscal'] = values['regimenfiscal'] + + path = _join(PATH_MEDIA, 'logos', '{}.png'.format(data['rfc'].lower())) + if is_file(path): + data['logo'] = path + + return data + + +def _receptor(doc, version, values): + node = doc.find('{}Receptor'.format(PRE[version])) + data = CaseInsensitiveDict(node.attrib.copy()) + node = node.find('{}Domicilio'.format(PRE[version])) + if not node is None: + data.update(node.attrib.copy()) + data['usocfdi'] = values['usocfdi'] + return data + + +def _conceptos(doc, version): + data = [] + conceptos = doc.find('{}Conceptos'.format(PRE[version])) + for c in conceptos.getchildren(): + values = CaseInsensitiveDict(c.attrib.copy()) + if version == '3.3': + values['noidentificacion'] = '{}\n(SAT {})'.format( + values['noidentificacion'], values['ClaveProdServ']) + values['unidad'] = '({})\n{}'.format( + values['ClaveUnidad'], values['unidad']) + data.append(values) + return data + + +def _totales(doc, cfdi, version): + data = {} + data['moneda'] = doc.attrib['Moneda'] + data['subtotal'] = cfdi['subtotal'] + if 'descuento' in cfdi: + data['descuento'] = cfdi['descuento'] + data['total'] = cfdi['total'] + + tn = { + '001': 'ISR', + '002': 'IVA', + '003': 'IEPS', + } + traslados = [] + retenciones = [] + taxlocales = [] + + imp = doc.find('{}Impuestos'.format(PRE[version])) + if imp is not None: + tmp = CaseInsensitiveDict(imp.attrib.copy()) + for k, v in tmp.items(): + data[k] = v + + node = imp.find('{}Traslados'.format(PRE[version])) + if node is not None: + for n in node.getchildren(): + tmp = CaseInsensitiveDict(n.attrib.copy()) + if version == '3.3': + title = 'Traslado {} {}'.format( + tn.get(tmp['impuesto']), tmp['tasaocuota']) + else: + title = 'Traslado {} {}'.format(tmp['impuesto'], tmp['tasa']) + traslados.append((title, float(tmp['importe']))) + + node = imp.find('{}Retenciones'.format(PRE[version])) + if node is not None: + for n in node.getchildren(): + tmp = CaseInsensitiveDict(n.attrib.copy()) + if version == '3.3': + title = 'Retención {} {}'.format( + tn.get(tmp['impuesto']), '') + else: + title = 'Retención {} {}'.format(tmp['impuesto'], '') + retenciones.append((title, float(tmp['importe']))) + + #~ com = xml.find('%sComplemento' % PRE) + #~ if com is not None: + #~ otros = com.find('%sImpuestosLocales' % IMP_LOCAL) + #~ if otros is not None: + #~ for otro in list(otros): + #~ if otro.tag == '%sRetencionesLocales' % IMP_LOCAL: + #~ name = 'ImpLocRetenido' + #~ tasa = 'TasadeRetencion' + #~ else: + #~ name = 'ImpLocTrasladado' + #~ tasa = 'TasadeTraslado' + #~ title = '%s %s %%' % (otro.attrib[name], otro.attrib[tasa]) + #~ value = otro.attrib['Importe'] + #~ self._copy_cell(cell_title) + #~ self._copy_cell(cell_value) + #~ cell_title = self._set_cell(v=title, cell=cell_title) + #~ cell_value = self._set_cell(v=value, cell=cell_value, value=True) + #~ cell_value.CellStyle = currency + + data['traslados'] = traslados + data['retenciones'] = retenciones + data['taxlocales'] = taxlocales + return data + + +def _timbre(doc, version, values): + CADENA = '||{version}|{UUID}|{FechaTimbrado}|{selloCFD}|{noCertificadoSAT}||' + if version == '3.3': + CADENA = '||{Version}|{UUID}|{FechaTimbrado}|{SelloCFD}|{NoCertificadoSAT}||' + node = doc.find('{}Complemento/{}TimbreFiscalDigital'.format( + PRE[version], PRE['TIMBRE'])) + data = CaseInsensitiveDict(node.attrib.copy()) + total_s = '%017.06f' % float(values['total']) + qr_data = '?re=%s&rr=%s&tt=%s&id=%s' % ( + values['rfc_emisor'], + values['rfc_receptor'], + total_s, + node.attrib['UUID']) + data['path_cbb'] = get_qr(qr_data) + data['cadenaoriginal'] = CADENA.format(**node.attrib) + return data + + +def get_data_from_xml(invoice, values): + data = {'cancelada': invoice.cancelada} + doc = parse_xml(invoice.xml) + data['comprobante'] = _comprobante(doc.attrib.copy(), values) + version = data['comprobante']['version'] + data['emisor'] = _emisor(doc, version, values) + data['receptor'] = _receptor(doc, version, values) + data['conceptos'] = _conceptos(doc, version) + data['totales'] = _totales(doc, data['comprobante'], version) + + options = { + 'rfc_emisor': data['emisor']['rfc'], + 'rfc_receptor': data['receptor']['rfc'], + 'total': data['comprobante']['total'], + } + data['timbre'] = _timbre(doc, version, options) + del data['timbre']['version'] + data['comprobante'].update(data['timbre']) + + return data + + +def to_zip(*files): + zip_buffer = BytesIO() + + with zipfile.ZipFile(zip_buffer, 'a', zipfile.ZIP_DEFLATED, False) as zip_file: + for data, file_name in files: + zip_file.writestr(file_name, data) + + return zip_buffer.getvalue() + + +def make_fields(xml): + doc = ET.fromstring(xml) + data = CaseInsensitiveDict(doc.attrib.copy()) + data.pop('certificado') + data.pop('sello') + version = data['version'] + receptor = doc.find('{}Receptor'.format(PRE[version])) + receptor = CaseInsensitiveDict(receptor.attrib.copy()) + data['receptor_nombre'] = receptor['nombre'] + data['receptor_rfc'] = receptor['rfc'] + data = {k.lower(): v for k, v in data.items()} + return data + + +def make_info_mail(data, fields): + return data.format(**fields).replace('\n', '
') + + +def send_mail(data): + msg = '' + server = SendMail(data['server']) + is_connect = server.is_connect + if is_connect: + msg = server.send(data['options']) + else: + msg = server.error + server.close() + return {'ok': is_connect, 'msg': msg} + + +def get_path_info(path): + path, filename = os.path.split(path) + name, extension = os.path.splitext(filename) + return (path, filename, name, extension) + + +def get_path_temp(): + return tempfile.mkstemp()[1] + + +class ImportFacturaLibre(object): + + def __init__(self, path): + self._con = None + self._cursor = None + self._is_connect = self._connect(path) + + @property + def is_connect(self): + return self._is_connect + + def _connect(self, path): + try: + self._con = sqlite3.connect(path) + self._con.row_factory = sqlite3.Row + self._cursor = self._con.cursor() + return True + except Exception as e: + log.error(e) + return False + + def close(self): + try: + self._cursor.close() + self._con.close() + except: + pass + return + + def import_data(self): + data = {} + tables = ( + ('receptores', 'Socios'), + ) + for source, target in tables: + data[target] = self._get_table(source) + return data + + def _get_table(self, table): + return getattr(self, '_{}'.format(table))() + + def _receptores(self): + sql = "SELECT * FROM receptores" + self._cursor.execute(sql) + rows = self._cursor.fetchall() + #~ names = [d[0] for d in self._cursor.description] + fields = ( + ('id', 'id'), + ('rfc', 'rfc'), + ('nombre', 'nombre'), + ('calle', 'calle'), + ('noExterior', 'no_exterior'), + ('noInterior', 'no_interior'), + ('colonia', 'colonia'), + ('municipio', 'municipio'), + ('estado', 'estado'), + ('pais', 'pais'), + ('codigoPostal', 'codigo_postal'), + ('extranjero', 'es_extranjero'), + ('activo', 'es_activo'), + ('fechaalta', 'fecha_alta'), + ('notas', 'notas'), + ('cuentaCliente', 'cuenta_cliente'), + ('cuentaProveedor', 'cuenta_proveedor'), + ('saldoCliente', 'saldo_cliente'), + ('saldoProveedor', 'saldo_proveedor'), + ('esCliente', 'es_cliente'), + ('esProveedor', 'es_proveedor'), + ) + data = [] + + sql1 = "SELECT correo FROM correos WHERE id_cliente=?" + sql2 = "SELECT telefono FROM telefonos WHERE id_cliente=?" + for row in rows: + new = {t: row[s] for s, t in fields} + new['slug'] = to_slug(new['nombre']) + if new['es_extranjero']: + new['tipo_persona'] = 4 + elif new['rfc'] == 'XAXX010101000': + new['tipo_persona'] = 3 + elif len(new['rfc']) == 12: + new['tipo_persona'] = 2 + + self._cursor.execute(sql1, (new['id'],)) + tmp = self._cursor.fetchall() + if tmp: + new['correo_facturas'] = ', '.join([r[0] for r in tmp]) + + self._cursor.execute(sql2, (new['id'],)) + tmp = self._cursor.fetchall() + if tmp: + new['telefonos'] = ', '.join([r[0] for r in tmp]) + + data.append(new) + return data diff --git a/source/app/main.py b/source/app/main.py index b423f09..a35833f 100644 --- a/source/app/main.py +++ b/source/app/main.py @@ -13,7 +13,7 @@ from middleware import ( ) from models.db import StorageEngine from controllers.main import ( - AppLogin, AppLogout, AppAdmin, AppEmisor, + AppLogin, AppLogout, AppAdmin, AppEmisor, AppConfig, AppMain, AppValues, AppPartners, AppProducts, AppInvoices, AppFolios, AppDocumentos ) @@ -38,6 +38,7 @@ api.add_route('/emisor', AppEmisor(db)) api.add_route('/folios', AppFolios(db)) api.add_route('/main', AppMain(db)) api.add_route('/values/{table}', AppValues(db)) +api.add_route('/config', AppConfig(db)) api.add_route('/doc/{type_doc}/{id_doc}', AppDocumentos(db)) api.add_route('/partners', AppPartners(db)) api.add_route('/products', AppProducts(db)) diff --git a/source/app/models/db.py b/source/app/models/db.py index fe07363..f5a460d 100644 --- a/source/app/models/db.py +++ b/source/app/models/db.py @@ -14,12 +14,24 @@ class StorageEngine(object): def get_values(self, table, values=None): return getattr(self, '_get_{}'.format(table))(values) + def get_config(self, values): + return main.Configuracion.get_(values) + + def add_config(self, values): + return main.Configuracion.add(values) + def add_cert(self, file_object): return main.Certificado.add(file_object) def validate_cert(self, values, session): return main.Certificado.validate(values, session) + def validate_email(self, values): + return main.test_correo(values) + + def send_email(self, values, session): + return main.Facturas.send(values['id'], session['rfc']) + def _get_cert(self, values): return main.Certificado.get_data() @@ -115,9 +127,15 @@ class StorageEngine(object): def add_folios(self, values): return main.Folios.add(values) - def get_doc(self, type_doc, id): + def get_doc(self, type_doc, id, rfc): if type_doc == 'xml': data, file_name = main.Facturas.get_xml(id) - content_type = 'application.xml' + content_type = 'application/xml' + if type_doc == 'pdf': + data, file_name = main.Facturas.get_pdf(id, rfc) + content_type = 'application/pdf' + if type_doc == 'zip': + data, file_name = main.Facturas.get_zip(id, rfc) + content_type = 'application/octet-stream' return data, file_name, content_type diff --git a/source/app/models/main.py b/source/app/models/main.py index 586b8c4..60ecc57 100644 --- a/source/app/models/main.py +++ b/source/app/models/main.py @@ -14,7 +14,9 @@ if __name__ == '__main__': from controllers import util -from settings import log, VERSION, PATH_CP, COMPANIES +from settings import log, VERSION, PATH_CP, COMPANIES, PRE, CURRENT_CFDI, \ + INIT_VALUES + FORMAT = '{0:.2f}' @@ -59,9 +61,34 @@ def desconectar(): class Configuracion(BaseModel): - clave = TextField() + clave = TextField(unique=True) valor = TextField(default='') + @classmethod + def get_(cls, keys): + if keys['fields'] == 'correo': + fields = ('correo_servidor', 'correo_puerto', 'correo_ssl', + 'correo_usuario', 'correo_contra', 'correo_copia', + 'correo_asunto', 'correo_mensaje', 'correo_directo') + data = (Configuracion + .select() + .where(Configuracion.clave.in_(fields)) + ) + values = {r.clave: r.valor for r in data} + return values + + @classmethod + def add(cls, values): + try: + for k, v in values.items(): + obj, created = Configuracion.get_or_create(clave=k) + obj.valor = v + obj.save() + return {'ok': True} + except Exception as e: + log.error(str(e)) + return {'ok': False, 'msg': str(e)} + class Meta: order_by = ('clave',) indexes = ( @@ -110,6 +137,9 @@ class SATRegimenes(BaseModel): (('key', 'name'), True), ) + def __str__(self): + return '{} ({})'.format(self.name, self.key) + @classmethod def get_(cls, ids): if isinstance(ids, int): @@ -155,6 +185,10 @@ class Emisor(BaseModel): correo = TextField(default='') web = TextField(default='') curp = TextField(default='') + correo_timbrado = TextField(default='') + token_timbrado = TextField(default='') + token_soporte = TextField(default='') + logo = TextField(default='') regimenes = ManyToManyField(SATRegimenes, related_name='emisores') def __str__(self): @@ -184,6 +218,7 @@ class Emisor(BaseModel): 'emisor_municipio': obj.municipio, 'emisor_estado': obj.estado, 'emisor_pais': obj.pais, + 'emisor_logo': obj.logo, 'emisor_nombre_comercial': obj.nombre_comercial, 'emisor_telefono': obj.telefono, 'emisor_correo': obj.correo, @@ -193,6 +228,9 @@ class Emisor(BaseModel): 'ong_autorizacion': obj.autorizacion, 'ong_fecha': obj.fecha_autorizacion, 'ong_fecha_dof': obj.fecha_dof, + 'correo_timbrado': obj.correo_timbrado, + 'token_timbrado': obj.token_timbrado, + 'token_soporte': obj.token_soporte, 'regimenes': [row.id for row in obj.regimenes] } else: @@ -200,6 +238,14 @@ class Emisor(BaseModel): return {'ok': True, 'row': row} + @classmethod + def get_auth(cls): + try: + obj = Emisor.select()[0] + return {'USER': obj.correo_timbrado, 'PASS': obj.token_timbrado} + except: + return {} + @classmethod def get_regimenes(cls): obj = Emisor.select()[0] @@ -216,6 +262,7 @@ class Emisor(BaseModel): fields['municipio'] = fields.pop('emisor_municipio', '') fields['estado'] = fields.pop('emisor_estado', '') fields['pais'] = fields.pop('emisor_pais', 'México') + fields['logo'] = fields.pop('emisor_logo', '') fields['nombre_comercial'] = fields.pop('emisor_nombre_comercial', '') fields['telefono'] = fields.pop('emisor_telefono', '') fields['correo'] = fields.pop('emisor_correo', '') @@ -458,6 +505,9 @@ class SATFormaPago(BaseModel): (('key', 'name'), True), ) + def __str__(self): + return 'Forma de pago: ({}) {}'.format(self.key, self.name) + @classmethod def get_activos(cls, values): field = SATFormaPago.id @@ -496,6 +546,9 @@ class SATMonedas(BaseModel): (('key', 'name'), True), ) + def __str__(self): + return 'Moneda: ({}) {}'.format(self.key, self.name) + @classmethod def get_activos(cls): rows = (SATMonedas @@ -529,6 +582,22 @@ class SATImpuestos(BaseModel): return tuple(rows) +class SATTipoRelacion(BaseModel): + key = TextField(index=True, unique=True) + name = TextField(default='', index=True) + activo = BooleanField(default=False) + default = BooleanField(default=False) + + class Meta: + order_by = ('-default', 'name',) + indexes = ( + (('key', 'name'), True), + ) + + def __str__(self): + return 'Tipo de relación: ({}) {}'.format(self.key, self.name) + + class SATUsoCfdi(BaseModel): key = TextField(index=True, unique=True) name = TextField(default='', index=True) @@ -543,6 +612,9 @@ class SATUsoCfdi(BaseModel): (('key', 'name'), True), ) + def __str__(self): + return 'Uso del CFDI: {} ({})'.format(self.name, self.key) + @classmethod def get_activos(cls, values): field = SATUsoCfdi.id @@ -594,6 +666,8 @@ class Socios(BaseModel): es_proveedor = BooleanField(default=False) cuenta_cliente = TextField(default='') cuenta_proveedor = TextField(default='') + saldo_cliente = DecimalField(default=0.0, decimal_places=6, auto_round=True) + saldo_proveedor = DecimalField(default=0.0, decimal_places=6, auto_round=True) web = TextField(default='') correo_facturas = TextField(default='') forma_pago = ForeignKeyField(SATFormaPago, null=True) @@ -644,12 +718,12 @@ class Socios(BaseModel): Socios.id, Socios.nombre, Socios.rfc, SATFormaPago.key.alias('forma_pago'), SATUsoCfdi.key.alias('uso_cfdi')) - .join(SATFormaPago).switch(Socios) - .join(SATUsoCfdi).switch(Socios) - .where( - (Socios.id==id) & (Socios.es_cliente==True)) + .join(SATFormaPago, JOIN.LEFT_OUTER).switch(Socios) + .join(SATUsoCfdi, JOIN.LEFT_OUTER).switch(Socios) + .where((Socios.id==id) & (Socios.es_cliente==True)) .dicts() ) + print (id, row) if len(row): return {'ok': True, 'row': row[0]} return {'ok': False} @@ -660,8 +734,8 @@ class Socios(BaseModel): .select(Socios.id, Socios.nombre, Socios.rfc, SATFormaPago.key.alias('forma_pago'), SATUsoCfdi.key.alias('uso_cfdi')) - .join(SATFormaPago).switch(Socios) - .join(SATUsoCfdi).switch(Socios) + .join(SATFormaPago, JOIN.LEFT_OUTER).switch(Socios) + .join(SATUsoCfdi, JOIN.LEFT_OUTER).switch(Socios) .where((Socios.es_cliente==True) & (Socios.rfc.contains(name) | Socios.nombre.contains(name))) @@ -724,7 +798,7 @@ class Socios(BaseModel): class Productos(BaseModel): categoria = ForeignKeyField(Categorias, null=True) clave = TextField(unique=True, index=True) - clave_sat = TextField() + clave_sat = TextField(default='') descripcion = TextField(index=True) unidad = ForeignKeyField(SATUnidades) valor_unitario = DecimalField(default=0.0, decimal_places=6, auto_round=True) @@ -901,6 +975,7 @@ class Productos(BaseModel): class Facturas(BaseModel): cliente = ForeignKeyField(Socios) + version = TextField(default=CURRENT_CFDI) serie = TextField(default='') folio = IntegerField(default=0) fecha = DateTimeField(default=util.now, formats=['%Y-%m-%d %H:%M:%S']) @@ -928,6 +1003,9 @@ class Facturas(BaseModel): regimen_fiscal = TextField(default='') notas = TextField(default='') pagada = BooleanField(default=False) + cancelada = BooleanField(default=False) + donativo = BooleanField(default=False) + tipo_relacion = TextField(default='') error = TextField(default='') class Meta: @@ -939,6 +1017,158 @@ class Facturas(BaseModel): name = '{}{}_{}.xml'.format(obj.serie, obj.folio, obj.cliente.rfc) return obj.xml, name + #~ Revisar + def _get_data_cfdi_to_pdf(self, xml, cancel, version): + pre_nomina = PRE['NOMINA'][version] + + data['nomina'] = {} + node = doc.find('{}Complemento/{}Nomina'.format(pre, pre_nomina)) + if not node is None: + data['nomina']['nomina'] = node.attrib.copy() + subnode = node.find('{}Emisor'.format(pre_nomina)) + if not subnode is None: + data['emisor'].update(subnode.attrib.copy()) + subnode = node.find('{}Receptor'.format(pre_nomina)) + data['receptor'].update(subnode.attrib.copy()) + + subnode = node.find('{}Percepciones'.format(pre_nomina)) + data['nomina']['percepciones'] = subnode.attrib.copy() + detalle = [] + for n in subnode.getchildren(): + if 'SeparacionIndemnizacion' in n.tag: + continue + detalle.append(n.attrib.copy()) + data['nomina']['percepciones']['detalle'] = detalle + + data['nomina']['deducciones'] = None + subnode = node.find('{}Deducciones'.format(pre_nomina)) + if not subnode is None: + data['nomina']['deducciones'] = subnode.attrib.copy() + detalle = [] + for n in subnode.getchildren(): + detalle.append(n.attrib.copy()) + data['nomina']['deducciones']['detalle'] = detalle + + data['nomina']['incapacidades'] = None + subnode = node.find('{}Incapacidades'.format(pre_nomina)) + if not subnode is None: + detalle = [] + for n in subnode.getchildren(): + detalle.append(n.attrib.copy()) + data['nomina']['incapacidades'] = detalle + + data['nomina']['otrospagos'] = None + subnode = node.find('{}OtrosPagos'.format(pre_nomina)) + if not subnode is None: + data['nomina']['otrospagos'] = subnode.attrib.copy() + detalle = [] + for n in subnode.getchildren(): + detalle.append(n.attrib.copy()) + ns = n.find('{}SubsidioAlEmpleo'.format(pre_nomina)) + if not ns is None: + data['nomina']['otrospagos']['SubsidioCausado'] = ns.attrib['SubsidioCausado'] + data['nomina']['otrospagos']['detalle'] = detalle + + return data + + def _get_not_in_xml(self, invoice): + values = {} + obj = SATRegimenes.get(SATRegimenes.key==invoice.regimen_fiscal) + values['regimenfiscal'] = str(obj) + + obj = SATUsoCfdi.get(SATUsoCfdi.key==invoice.uso_cfdi) + values['usocfdi'] = str(obj) + + mp = { + 'PUE': 'Pago en una sola exhibición', + 'PPD': 'Pago en parcialidades o diferido', + } + values['metododepago'] = 'Método de Pago: ({}) {}'.format( + invoice.metodo_pago, mp[invoice.metodo_pago]) + + obj = SATFormaPago.get(SATFormaPago.key==invoice.forma_pago) + values['formadepago'] = str(obj) + + obj = SATMonedas.get(SATMonedas.key==invoice.moneda) + values['moneda'] = str(obj) + + return values + + @classmethod + def get_pdf(cls, id, rfc): + obj = Facturas.get(Facturas.id==id) + name = '{}{}_{}.pdf'.format(obj.serie, obj.folio, obj.cliente.rfc) + if obj.uuid is None: + return b'', name + + values = cls._get_not_in_xml(cls, obj) + data = util.get_data_from_xml(obj, values) + doc = util.to_pdf(data) + return doc, name + + @classmethod + def get_zip(cls, id, rfc): + obj = Facturas.get(Facturas.id==id) + name_zip = '{}{}_{}.zip'.format(obj.serie, obj.folio, obj.cliente.rfc) + if obj.uuid is None: + return b'', name_zip + + file_xml = cls.get_xml(id) + if not file_xml[0]: + return b'', name_zip + + file_pdf = cls.get_pdf(id, rfc) + if not file_pdf[0]: + return b'', name_zip + + file_zip = util.to_zip(file_xml, file_pdf) + + return file_zip, name_zip + + @classmethod + def send(cls, id, rfc): + values = Configuracion.get_({'fields': 'correo'}) + if not values: + msg = 'No esta configurado el servidor de correo de salida' + return {'ok': False, 'msg': msg} + + obj = Facturas.get(Facturas.id==id) + if obj.uuid is None: + msg = 'La factura no esta timbrada' + return {'ok': False, 'msg': msg} + + if not obj.cliente.correo_facturas: + msg = 'El cliente no tiene configurado el correo para facturas' + return {'ok': False, 'msg': msg} + + files = (cls.get_zip(id, rfc),) + + fields = util.make_fields(obj.xml) + server = { + 'servidor': values['correo_servidor'], + 'puerto': values['correo_puerto'], + 'ssl': bool(int(values['correo_ssl'])), + 'usuario': values['correo_usuario'], + 'contra': values['correo_contra'], + } + options = { + 'para': obj.cliente.correo_facturas, + 'copia': values['correo_copia'], + 'asunto': util.make_info_mail(values['correo_asunto'], fields), + 'mensaje': util.make_info_mail(values['correo_mensaje'], fields), + 'files': files, + } + data= { + 'server': server, + 'options': options, + } + result = util.send_mail(data) + if not result['ok'] or result['msg']: + return {'ok': False, 'msg': result['msg']} + + msg = 'Factura enviada correctamente' + return {'ok': True, 'msg': msg} + @classmethod def get_(cls, values): rows = tuple(Facturas @@ -1214,14 +1444,17 @@ class Facturas(BaseModel): obj.estatus = 'Generada' obj.save() + auth = Emisor.get_auth() + error = False msg = 'Factura timbrada correctamente' - result = util.timbra_xml(obj.xml) + result = util.timbra_xml(obj.xml, auth) if result['ok']: obj.xml = result['xml'] obj.uuid = result['uuid'] obj.fecha_timbrado = result['fecha'] obj.estatus = 'Timbrada' + obj.error = '' obj.save() row = {'uuid': obj.uuid, 'estatus': 'Timbrada'} else: @@ -1234,6 +1467,17 @@ class Facturas(BaseModel): return {'ok': result['ok'], 'msg': msg, 'row': row} +class FacturasRelacionadas(BaseModel): + factura = ForeignKeyField(Facturas, related_name='original') + factura_origen = ForeignKeyField(Facturas, related_name='relacion') + + class Meta: + order_by = ('factura',) + indexes = ( + (('factura', 'factura_origen'), True), + ) + + class FacturasDetalle(BaseModel): factura = ForeignKeyField(Facturas) producto = ForeignKeyField(Productos, null=True) @@ -1330,26 +1574,51 @@ def get_sat_key(key): return util.get_sat_key('products', key) +def test_correo(values): + server = { + 'servidor': values['correo_servidor'], + 'puerto': values['correo_puerto'], + 'ssl': bool(values['correo_ssl'].replace('0', '')), + 'usuario': values['correo_usuario'], + 'contra': values['correo_contra'], + } + options = { + 'para': values['correo_usuario'], + 'copia': values['correo_copia'], + 'asunto': values['correo_asunto'], + 'mensaje': values['correo_mensaje'].replace('\n', '
'), + 'files': [], + } + data= { + 'server': server, + 'options': options, + } + return util.send_mail(data) + + def _init_values(): data = ( {'key': 'version', 'value': VERSION}, {'key': 'rfc_publico', 'value': 'XAXX010101000'}, {'key': 'rfc_extranjero', 'value': 'XEXX010101000'}, + {'key': 'decimales', 'value': '2'}, ) for row in data: try: - Configuration.create(**row) + Configuracion.create(**row) except IntegrityError: pass log.info('Valores iniciales insertados...') return -def _crear_tablas(): +def _crear_tablas(rfc): tablas = [Addendas, Categorias, Certificado, CondicionesPago, Configuracion, - Emisor, Facturas, FacturasDetalle, FacturasImpuestos, Folios, Productos, + Emisor, Facturas, FacturasDetalle, FacturasImpuestos, Folios, + FacturasRelacionadas, Productos, SATAduanas, SATFormaPago, SATImpuestos, SATMonedas, SATRegimenes, - SATUnidades, SATUsoCfdi, Socios, Tags, Usuarios, + SATTipoRelacion, SATUnidades, SATUsoCfdi, + Socios, Tags, Usuarios, Emisor.regimenes.get_through_model(), Socios.tags.get_through_model(), Productos.impuestos.get_through_model(), @@ -1357,6 +1626,21 @@ def _crear_tablas(): ] database_proxy.create_tables(tablas, True) log.info('Tablas creadas correctamente...') + + try: + usuario = 'admin' + contraseña = 'blades3.3' + obj = Usuarios.create( + usuario=usuario, contraseña=contraseña, es_superusuario=True) + log.info('SuperUsuario creado correctamente...') + except IntegrityError: + msg = 'El usuario ya existe' + log.error(msg) + pass + + _init_values() + _importar_valores('', rfc) + return True @@ -1425,6 +1709,7 @@ def _cambiar_contraseña(): def _add_emisor(rfc, args): + util._valid_db_companies() con = sqlite3.connect(COMPANIES) cursor = con.cursor() sql = """ @@ -1442,6 +1727,25 @@ def _add_emisor(rfc, args): return True +def _delete_emisor(rfc): + util._valid_db_companies() + con = sqlite3.connect(COMPANIES) + cursor = con.cursor() + sql = """ + DELETE FROM names + WHERE rfc = ?""" + try: + cursor.execute(sql, (rfc,)) + except Exception as e: + log.error(e) + return False + + con.commit() + cursor.close() + con.close() + return True + + def _iniciar_bd(): rfc = input('Introduce el RFC: ').strip().upper() if not rfc: @@ -1454,7 +1758,7 @@ def _iniciar_bd(): return conectar(args) - if _crear_tablas(): + if _crear_tablas(rfc): return log.error('No se pudieron crear las tablas') @@ -1481,7 +1785,7 @@ def _agregar_rfc(): args = opt.copy() if conectar(args): - if _add_emisor(rfc, util.dumps(opt)) and _crear_tablas(): + if _add_emisor(rfc, util.dumps(opt)) and _crear_tablas(rfc): log.info('RFC agregado correctamente...') return @@ -1489,6 +1793,23 @@ def _agregar_rfc(): return +def _borrar_rfc(): + rfc = input('Introduce el RFC a borrar: ').strip().upper() + if not rfc: + msg = 'El RFC es requerido' + log.error(msg) + return + + confirm = input('¿Estás seguro de borrar el RFC?') + + if _delete_emisor(rfc): + log.info('RFC borrado correctamente...') + return + + log.error('No se pudo borrar el RFC') + return + + def _listar_rfc(): data = util.get_rfcs() for row in data: @@ -1497,7 +1818,40 @@ def _listar_rfc(): return -def _importar_valores(archivo): +def _importar_valores(archivo='', rfc=''): + if not rfc: + rfc = input('Introduce el RFC: ').strip().upper() + if not rfc: + msg = 'El RFC es requerido' + log.error(msg) + return + + args = util.get_con(rfc) + if not args: + return + + conectar(args) + + if not archivo: + archivo = INIT_VALUES + + log.info('Importando datos...') + regimen = '' + rows = util.loads(open(archivo, 'r').read()) + for row in rows: + log.info('\tImportando tabla: {}'.format(row['tabla'])) + table = globals()[row['tabla']] + for r in row['datos']: + try: + table.create(**r) + except IntegrityError: + pass + + log.info('Importación terminada...') + return + + +def _importar_factura_libre(archivo): rfc = input('Introduce el RFC: ').strip().upper() if not rfc: msg = 'El RFC es requerido' @@ -1511,24 +1865,21 @@ def _importar_valores(archivo): conectar(args) log.info('Importando datos...') - regimen = '' - rows = util.loads(open(archivo, 'r').read()) - for row in rows: - log.info('\tImportando tabla: {}'.format(row['tabla'])) - if row['tabla'] == 'Emisor' and 'regimen' in row: - regimen = row['regimen'] - table = globals()[row['tabla']] - for r in row['datos']: - try: - table.create(**r) - except IntegrityError: - pass + app = util.ImportFacturaLibre(archivo) + if not app.is_connect: + log.error('\tNo se pudo conectar a la base de datos') + return - if regimen: - emisor = Emisor.select()[0] - regimen = SATRegimenes.get(SATRegimenes.key == regimen) - emisor.regimenes.clear() - emisor.regimenes.add(regimen) + data = app.import_data() + for table, rows in data.items(): + log.info('\tImportando: {}'.format(table)) + model = globals()[table] + for row in rows: + try: + model.create(**row) + except IntegrityError: + msg = '\t{}'.format(str(row)) + log.error(msg) log.info('Importación terminada...') return @@ -1555,10 +1906,11 @@ help_lr = 'Listar RFCs' @click.option('-rfc', '--rfc', help=help_rfc, is_flag=True, default=False) @click.option('-br', '--borrar-rfc', help=help_br, is_flag=True, default=False) @click.option('-lr', '--listar-rfc', help=help_lr, is_flag=True, default=False) -@click.option('-i', '--importar_valores', is_flag=True, default=False) +@click.option('-i', '--importar-valores', is_flag=True, default=False) @click.option('-a', '--archivo') +@click.option('-fl', '--factura-libre', is_flag=True, default=False) def main(iniciar_bd, migrar_bd, nuevo_superusuario, cambiar_contraseña, rfc, - borrar_rfc, listar_rfc, importar_valores, archivo): + borrar_rfc, listar_rfc, importar_valores, archivo, factura_libre): opt = locals() if opt['iniciar_bd']: @@ -1600,6 +1952,21 @@ def main(iniciar_bd, migrar_bd, nuevo_superusuario, cambiar_contraseña, rfc, _importar_valores(opt['archivo']) sys.exit(0) + if opt['factura_libre']: + if not opt['archivo']: + msg = 'Falta la ruta de la base de datos' + raise click.ClickException(msg) + if not util.is_file(opt['archivo']): + msg = 'No es un archivo' + raise click.ClickException(msg) + _, _, _, ext = util.get_path_info(opt['archivo']) + if ext != '.sqlite': + msg = 'No es una base de datos' + raise click.ClickException(msg) + + _importar_factura_libre(opt['archivo']) + sys.exit(0) + return diff --git a/source/app/settings.py b/source/app/settings.py index 88b87ff..1776135 100644 --- a/source/app/settings.py +++ b/source/app/settings.py @@ -7,19 +7,25 @@ from mako.lookup import TemplateLookup from logbook import Logger, StreamHandler, RotatingFileHandler logbook.set_datetime_format('local') +from conf import DEBUG -DEBUG = True + +DEBUG = DEBUG VERSION = '0.1.0' EMAIL_SUPPORT = ('soporte@empresalibre.net',) BASE_DIR = os.path.abspath(os.path.dirname(__file__)) PATH_STATIC = os.path.abspath(os.path.join(BASE_DIR, '..')) PATH_TEMPLATES = os.path.abspath(os.path.join(BASE_DIR, '..', 'templates')) +PATH_MEDIA = os.path.abspath(os.path.join(BASE_DIR, '..', 'docs')) PATH_CP = os.path.abspath(os.path.join(BASE_DIR, '..', 'db', 'cp.db')) COMPANIES = os.path.abspath(os.path.join(BASE_DIR, '..', 'db', 'rfc.db')) DB_SAT = os.path.abspath(os.path.join(BASE_DIR, '..', 'db', 'sat.db')) +IV = 'valores_iniciales.json' +INIT_VALUES = os.path.abspath(os.path.join(BASE_DIR, '..', 'db', IV)) + PATH_XSLT = os.path.abspath(os.path.join(BASE_DIR, '..', 'xslt')) PATH_BIN = os.path.abspath(os.path.join(BASE_DIR, '..', 'bin')) @@ -36,6 +42,7 @@ format_string = '[{record.time:%d-%b-%Y %H:%M:%S}] ' \ '{record.channel}: ' \ '{record.message}' + if DEBUG: LOG_LEVEL = 'DEBUG' StreamHandler( @@ -51,6 +58,12 @@ else: level=LOG_LEVEL, format_string=format_string).push_application() + StreamHandler( + sys.stdout, + level=LOG_LEVEL, + format_string=format_string).push_application() + + log = Logger(LOG_NAME) @@ -59,3 +72,19 @@ PATH_OPENSSL = 'openssl' if 'win' in sys.platform: PATH_XSLTPROC = os.path.join(PATH_BIN, 'xsltproc.exe') PATH_OPENSSL = os.path.join(PATH_BIN, 'openssl.exe') + + +PRE = { + '2.0': '{http://www.sat.gob.mx/cfd/2}', + '2.2': '{http://www.sat.gob.mx/cfd/2}', + '3.0': '{http://www.sat.gob.mx/cfd/3}', + '3.2': '{http://www.sat.gob.mx/cfd/3}', + '3.3': '{http://www.sat.gob.mx/cfd/3}', + 'TIMBRE': '{http://www.sat.gob.mx/TimbreFiscalDigital}', + 'NOMINA': { + '1.1': '{http://www.sat.gob.mx/nomina}', + '1.2': '{http://www.sat.gob.mx/nomina12}', + } +} + +CURRENT_CFDI = '3.3' diff --git a/source/db/rfc.db.ini b/source/db/rfc.db.ini deleted file mode 100644 index cd38d7c..0000000 Binary files a/source/db/rfc.db.ini and /dev/null differ diff --git a/source/db/valores_iniciales.json b/source/db/valores_iniciales.json index 60fae31..53adaed 100644 --- a/source/db/valores_iniciales.json +++ b/source/db/valores_iniciales.json @@ -23,7 +23,20 @@ {"key": "HUR", "name": "Hora", "activo": true}, {"key": "H87", "name": "Pieza", "activo": true}, {"key": "E48", "name": "Servicio", "activo": true}, - {"key": "E51", "name": "Trabajo", "activo": false} + {"key": "E51", "name": "Trabajo", "activo": false}, + {"key": "ACT", "name": "Actividad", "activo": false} + ] +}, +{ + "tabla": "SATTipoRelacion", + "datos": [ + {"key": "01", "name": "Nota de crédito de los documentos relacionados", "activo": true}, + {"key": "02", "name": "Nota de débito de los documentos relacionados", "activo": true}, + {"key": "03", "name": "Devolución de mercancía sobre facturas o traslados previos", "activo": true}, + {"key": "04", "name": "Sustitución de los CFDI previos", "activo": true, "default": true}, + {"key": "05", "name": "Traslados de mercancias facturados previamente", "activo": true}, + {"key": "06", "name": "Factura generada por los traslados previos", "activo": true}, + {"key": "07", "name": "Actividad", "CFDI por aplicación de anticipo": true} ] }, { diff --git a/source/static/js/controller/admin.js b/source/static/js/controller/admin.js index 47c90b8..0790a61 100644 --- a/source/static/js/controller/admin.js +++ b/source/static/js/controller/admin.js @@ -16,6 +16,8 @@ var controllers = { $$('up_cert').attachEvent('onUploadComplete', up_cert_upload_complete) $$('cmd_agregar_serie').attachEvent('onItemClick', cmd_agregar_serie_click) $$('grid_folios').attachEvent('onItemClick', grid_folios_click) + $$('cmd_probar_correo').attachEvent('onItemClick', cmd_probar_correo_click) + $$('cmd_guardar_correo').attachEvent('onItemClick', cmd_guardar_correo_click) } } @@ -133,7 +135,9 @@ function get_emisor(){ var emisor = values.row.emisor $$('lst_emisor_regimen').parse(values.row.regimenes) form.setValues(emisor, true) - $$('lst_emisor_regimen').select(emisor.regimenes) + if(emisor.regimenes){ + $$('lst_emisor_regimen').select(emisor.regimenes) + } }else{ msg_error(values.msg) } @@ -175,6 +179,24 @@ function get_table_folios(){ } +function get_config_correo(){ + var form = $$('form_correo') + var fields = form.getValues() + + webix.ajax().get('/config', {'fields': 'correo'}, { + error: function(text, data, xhr) { + msg = 'Error al consultar' + msg_error(msg) + }, + success: function(text, data, xhr) { + var values = data.json() + form.setValues(values) + } + }) + +} + + function multi_admin_change(prevID, nextID){ //~ webix.message(nextID) if(nextID == 'app_emisor'){ @@ -188,6 +210,11 @@ function multi_admin_change(prevID, nextID){ get_table_folios() return } + + if(nextID == 'app_correo'){ + get_config_correo() + return + } } @@ -442,10 +469,120 @@ function grid_folios_click(id, e, node){ msg_error(msg) } }) - - - } } }) } + + +function validar_correo(values){ + + if(!values.correo_servidor.trim()){ + msg = 'El servidor de salida no puede estar vacío' + msg_error(msg) + return false + } + if(!values.correo_puerto){ + msg = 'El puerto no puede ser cero' + msg_error(msg) + return false + } + if(!values.correo_usuario.trim()){ + msg = 'El nombre de usuario no puede estar vacío' + msg_error(msg) + return false + } + if(!values.correo_contra.trim()){ + msg = 'La contraseña no puede estar vacía' + msg_error(msg) + return false + } + if(!values.correo_asunto.trim()){ + msg = 'El asunto del correo no puede estar vacío' + msg_error(msg) + return false + } + if(!values.correo_mensaje.trim()){ + msg = 'El mensaje del correo no puede estar vacío' + msg_error(msg) + return false + } + + return true +} + + +function cmd_probar_correo_click(){ + var form = $$('form_correo') + var values = form.getValues() + + if(!validar_correo(values)){ + return + } + + webix.ajax().sync().post('/values/correo', values, { + error: function(text, data, xhr) { + msg = 'Error al probar el correo' + msg_error(msg) + }, + success: function(text, data, xhr) { + var values = data.json(); + if (values.ok){ + msg = 'Correo de prueba enviado correctamente. Ya puedes \ + guardar esta configuración' + msg_sucess(msg) + }else{ + msg_error(values.msg) + } + } + }) + +} + + +function save_config_mail(values){ + + webix.ajax().sync().post('/config', values, { + error: function(text, data, xhr) { + msg = 'Error al guardar la configuración' + msg_error(msg) + }, + success: function(text, data, xhr) { + var values = data.json(); + if (values.ok){ + msg = 'Configuración guardada correctamente' + msg_sucess(msg) + }else{ + msg_error(values.msg) + } + } + }) + +} + + +function cmd_guardar_correo_click(){ + var form = $$('form_correo') + var values = form.getValues() + + if(!validar_correo(values)){ + return + } + + msg = 'Asegurate de haber probado la configuración

\ + ¿Estás seguro de guardar estos datos?' + webix.confirm({ + title: 'Configuración de correo', + ok: 'Si', + cancel: 'No', + type: 'confirm-error', + text: msg, + callback:function(result){ + if(result){ + save_config_mail(values) + } + } + }) +} + + diff --git a/source/static/js/controller/invoices.js b/source/static/js/controller/invoices.js index eaf553d..a79cdc2 100644 --- a/source/static/js/controller/invoices.js +++ b/source/static/js/controller/invoices.js @@ -11,8 +11,8 @@ function get_series(){ pre = values[0] $$('lst_serie').getList().parse(values) $$('lst_serie').setValue(pre.id) - if(pre.usar_con){ - $$('lst_tipo_comprobante').setValue(pre.usar_con) + if(pre.usarcon){ + $$('lst_tipo_comprobante').setValue(pre.usarcon) $$('lst_tipo_comprobante').config.readonly = true $$('lst_tipo_comprobante').refresh() } @@ -42,7 +42,7 @@ function get_monedas(){ $$('lst_moneda').setValue(pre.id) if(values.length == 1){ $$('fs_moneda').hide() - $$('fs_moneda').refresh() + //~ $$('fs_moneda').refresh() } }) } @@ -63,7 +63,7 @@ function get_regimen_fiscal(){ $$('lst_regimen_fiscal').setValue(pre.id) if(values.length == 1){ $$('fs_regimen_fiscal').hide() - $$('fs_regimen_fiscal').refresh() + //~ $$('fs_regimen_fiscal').refresh() } }) } @@ -121,6 +121,9 @@ function delete_invoice(id){ function cmd_delete_invoice_click(id, e, node){ + if(gi.count() == 0){ + return + } var row = gi.getSelectedItem() if (row == undefined){ @@ -642,10 +645,90 @@ function cmd_invoice_timbrar_click(){ } +function enviar_correo(row){ + if(!row.uuid){ + msg_error('La factura no esta timbrada') + return + } + + msg = '¿Estás seguro de enviar por correo esta factura?' + webix.confirm({ + title: 'Enviar Factura', + ok: 'Si', + cancel: 'No', + type: 'confirm-error', + text: msg, + callback:function(result){ + if(result){ + webix.ajax().post('/values/sendmail', {'id': row.id}, { + error:function(text, data, XmlHttpRequest){ + msg = 'Ocurrio un error, consulta a soporte técnico' + msg_error(msg) + }, + success:function(text, data, XmlHttpRequest){ + values = data.json(); + if(values.ok){ + msg_sucess(values.msg) + }else{ + msg_error(values.msg) + } + } + }) + } + } + }) +} + + function grid_invoices_click(id, e, node){ var row = this.getItem(id) + if(id.column == 'xml'){ location = '/doc/xml/' + row.id + }else if(id.column == 'pdf'){ + location = '/doc/pdf/' + row.id + }else if(id.column == 'zip'){ + location = '/doc/zip/' + row.id + }else if(id.column == 'email'){ + enviar_correo(row) } } + + +function send_cancel(id){ + show(id) +} + +function cmd_invoice_cancelar_click(){ + if(gi.count() == 0){ + return + } + + var row = gi.getSelectedItem() + if (row == undefined){ + msg_error('Selecciona una factura') + return + } + + if(!row.uuid){ + msg_error('La factura no esta timbrada, solo es posible cancelar \ + facturas timbradas') + return + } + + msg = '¿Estás seguro de enviar a cancelar esta factura?

\ + ESTA ACCIÓN NO SE PUEDE DESHACER' + webix.confirm({ + title: 'Cancelar Factura', + ok: 'Si', + cancel: 'No', + type: 'confirm-error', + text: msg, + callback:function(result){ + if(result){ + send_cancel(row.id) + } + } + }) +} diff --git a/source/static/js/controller/main.js b/source/static/js/controller/main.js index 8830ebd..4dd4c24 100644 --- a/source/static/js/controller/main.js +++ b/source/static/js/controller/main.js @@ -44,6 +44,7 @@ var controllers = { $$('grid_details').attachEvent('onBeforeEditStart', grid_details_before_edit_start) $$('grid_details').attachEvent('onBeforeEditStop', grid_details_before_edit_stop) $$('cmd_invoice_timbrar').attachEvent('onItemClick', cmd_invoice_timbrar_click) + $$('cmd_invoice_cancelar').attachEvent('onItemClick', cmd_invoice_cancelar_click) $$('grid_invoices').attachEvent('onItemClick', grid_invoices_click) } } diff --git a/source/static/js/ui/admin.js b/source/static/js/ui/admin.js index 9ca919d..53d6fa8 100644 --- a/source/static/js/ui/admin.js +++ b/source/static/js/ui/admin.js @@ -3,6 +3,7 @@ var menu_data = [ {id: 'app_home', icon: 'dashboard', value: 'Inicio'}, {id: 'app_emisor', icon: 'user-circle', value: 'Emisor'}, {id: 'app_folios', icon: 'sort-numeric-asc', value: 'Folios'}, + {id: 'app_correo', icon: 'envelope-o', value: 'Correo'}, ] @@ -61,6 +62,8 @@ var emisor_datos_fiscales = [ var emisor_otros_datos= [ {template: 'Generales', type: 'section'}, + {view: 'search', id: 'emisor_logo', icon: 'file-image-o', + name: 'emisor_logo', label: 'Logotipo: '}, {view: 'text', id: 'emisor_nombre_comercial', name: 'emisor_nombre_comercial', label: 'Nombre comercial: '}, {view: 'text', id: 'emisor_telefono', name: 'emisor_telefono', @@ -86,6 +89,13 @@ var emisor_otros_datos= [ {cols: [{view: 'datepicker', id: 'ong_fecha_dof', name: 'ong_fecha_dof', label: 'Fecha de DOF: ', disabled: true, format: '%d-%M-%Y', placeholder: 'Fecha de publicación en el DOF'}, {}]}, + {template: 'Timbrado y Soporte', type: 'section'}, + {view: 'text', id: 'correo_timbrado', + name: 'correo_timbrado', label: 'Usuario para Timbrado: '}, + {view: 'text', id: 'token_timbrado', + name: 'token_timbrado', label: 'Token de Timbrado: '}, + {view: 'text', id: 'token_soporte', + name: 'token_soporte', label: 'Token de Soporte: '}, ] @@ -205,6 +215,55 @@ var emisor_folios = [ ] +var emisor_correo = [ + {template: 'Servidor de Salida', type: 'section'}, + {cols: [ + {view: 'text', id: 'correo_servidor', name: 'correo_servidor', + label: 'Servidor SMTP: '}, + {}]}, + {cols: [ + {view: 'counter', id: 'correo_puerto', name: 'correo_puerto', + label: 'Puerto: ', value: 26, step: 1}, + {}]}, + {cols: [ + {view: 'checkbox', id: 'correo_ssl', name: 'correo_ssl', + label: 'Usar TLS/SSL: '}, + {}]}, + {cols: [ + {view: 'text', id: 'correo_usuario', name: 'correo_usuario', + label: 'Usuario: '}, + {}]}, + {cols: [ + {view: 'text', id: 'correo_contra', name: 'correo_contra', + label: 'Contraseña: ', type: 'password'}, + {}]}, + {cols: [ + {view: 'text', id: 'correo_copia', name: 'correo_copia', + label: 'Con copia a: '} + ]}, + {cols: [ + {view: 'text', id: 'correo_asunto', name: 'correo_asunto', + label: 'Asunto del correo: '} + ]}, + {cols: [ + {view: 'textarea', id: 'correo_mensaje', name: 'correo_mensaje', + label: 'Mensaje del correo: ', height: 200} + ]}, + {cols: [ + {view: 'checkbox', id: 'correo_directo', name: 'correo_directo', + label: 'Enviar directamente: '}, + {}]}, + {minHeight: 25}, + {cols: [{}, + {view: 'button', id: 'cmd_probar_correo', label: 'Probar Configuración', + autowidth: true, type: 'form'}, + {maxWidth: 100}, + {view: 'button', id: 'cmd_guardar_correo', label: 'Guardar Configuración', + autowidth: true, type: 'form'}, + {}]} +] + + var controls_folios = [ { view: 'tabview', @@ -219,6 +278,20 @@ var controls_folios = [ ] +var controls_correo = [ + { + view: 'tabview', + id: 'tab_correo', + tabbar: {options: ['Correo Electrónico']}, + animate: true, + cells: [ + {id: 'Correo Electrónico', rows: emisor_correo}, + {}, + ] + } +] + + var form_folios = { type: 'space', cols: [{ @@ -239,6 +312,22 @@ var form_folios = { } +var form_correo = { + type: 'space', + cols: [{ + view: 'form', + id: 'form_correo', + complexData: true, + elements: controls_correo, + elementsConfig: { + labelWidth: 150, + labelAlign: 'right' + }, + autoheight: true + }] +} + + var app_emisor = { id: 'app_emisor', rows:[ @@ -267,6 +356,17 @@ var app_folios = { } +var app_correo = { + id: 'app_correo', + rows:[ + {view: 'template', id: 'th_correo', type: 'header', + template: 'Configuración de correo'}, + form_correo, + {}, + ] +} + + var multi_admin = { id: 'multi_admin', animate: true, @@ -278,6 +378,7 @@ var multi_admin = { }, app_emisor, app_folios, + app_correo, ] } diff --git a/source/static/js/ui/invoices.js b/source/static/js/ui/invoices.js index 950baf1..9e110c6 100644 --- a/source/static/js/ui/invoices.js +++ b/source/static/js/ui/invoices.js @@ -14,6 +14,9 @@ var toolbar_invoices = [ var toolbar_invoices_util = [ {view: 'button', id: 'cmd_invoice_timbrar', label: 'Timbrar', type: 'iconButton', autowidth: true, icon: 'ticket'}, + {}, + {view: 'button', id: 'cmd_invoice_cancelar', label: 'Cancelar', + type: 'iconButton', autowidth: true, icon: 'ban'}, ] @@ -142,10 +145,10 @@ var suggest_products = { header: true, columns: [ {id: 'id', hidden: true}, - {id: 'clave', adjust: 'data'}, - {id: 'descripcion', adjust: 'data'}, - {id: 'unidad', adjust: 'data'}, - {id: 'valor_unitario', adjust: 'data', + {id: 'clave', header: 'Clave', adjust: 'data'}, + {id: 'descripcion', header: 'Descripción', adjust: 'data'}, + {id: 'unidad', header: 'Unidad', adjust: 'data'}, + {id: 'valor_unitario', header: 'Valor Unitario', adjust: 'data', format: webix.i18n.priceFormat} ], dataFeed:function(text){ diff --git a/source/static/js/ui/partners.js b/source/static/js/ui/partners.js index 9c031af..c927068 100644 --- a/source/static/js/ui/partners.js +++ b/source/static/js/ui/partners.js @@ -131,7 +131,7 @@ var toolbar_contacts = [ var grid_contacts_cols = [ - {id: 'index', header:'#', adjust:'data', css:'right', + {id: 'index', header: '#', adjust:'data', css:'right', footer: {content: 'rowCount'}}, {id: 'id', header: '', hidden: true}, {id: 'title', header: 'Título', adjust:'data', sort: 'string', @@ -160,7 +160,7 @@ var grid_contacts = { on:{ 'data->onStoreUpdated':function(){ this.data.each(function(obj, i){ - obj.index = i+1; + obj.index = i + 1 }) } }, diff --git a/source/templates/plantilla_factura.json b/source/templates/plantilla_factura.json new file mode 100644 index 0000000..6aabf3f --- /dev/null +++ b/source/templates/plantilla_factura.json @@ -0,0 +1,217 @@ +{ +"encabezado": { + "emisor": { + "direccion": { + "rectangulo": {"x": 1.0, "y": 26.2, "width": 19.6, "height": 0.4, + "radius": 0.1, "stroke": false, "fill": true, "color": "darkred"} + }, + "nombre": { + "rectangulo": {"x": 10.6, "y": 25.6, "width": 10.0, "height": 0.4, + "radius": 0.0, "stroke": false, "fill": false}, + "estilo": {"name": "nombre", "fontName": "Helvetica-Bold", + "fontSize": 14, "alignment": 2, "textColor": "darkred", + "backColor": "white"} + }, + "rfc": { + "rectangulo": {"x": 10.6, "y": 25.0, "width": 10.0, "height": 0.4, + "radius": 0.0, "stroke": false, "fill": false}, + "estilo": {"name": "rfc", "fontName": "Helvetica-Bold", + "fontSize": 12, "alignment": 2, "textColor": "darkred", + "backColor": "white"} + }, + "regimenfiscal": { + "rectangulo": {"x": 10.6, "y": 24.4, "width": 10.0, "height": 0.4, + "radius": 0.0, "stroke": false, "fill": false}, + "estilo": {"name": "regimenfiscal", "fontName": "Helvetica-Bold", + "fontSize": 7, "alignment": 2, "textColor": "darkred", + "backColor": "white"} + }, + "logo": { + "rectangulo": {"x": 1.0, "y": 24.2, "width": 2.5, "height": 2.5} + } + }, + "receptor": { + "titulo": { + "valor": "Receptor", + "rectangulo": {"x": 1.5, "y": 20.8, "width": 2.8, "height": 0.9, + "radius": 0.0, "stroke": false, "fill": false}, + "estilo": {"name": "nombre", "fontName": "Helvetica-Bold", + "fontSize": 9, "alignment": 1, "textColor": "darkred", + "backColor": "linen"} + }, + "nombre": { + "rectangulo": {"x": 2.0, "y": 23.2, "width": 15.0, "height": 0.4, + "radius": 0.0, "stroke": false, "fill": false}, + "estilo": {"name": "nombre", "fontName": "Helvetica-Bold", + "fontSize": 10, "alignment": 0, "textColor": "black", + "backColor": "white"} + }, + "rfc": { + "rectangulo": {"x": 2.0, "y": 22.5, "width": 10.0, "height": 0.4, + "radius": 0.0, "stroke": false, "fill": false}, + "estilo": {"name": "rfc", "fontName": "Helvetica-Bold", + "fontSize": 9, "alignment": 0, "textColor": "black", + "backColor": "white"} + }, + "usocfdi": { + "rectangulo": {"x": 2.0, "y": 20.7, "width": 15.0, "height": 0.4, + "radius": 0.0, "stroke": false, "fill": false}, + "estilo": {"name": "usocfdi", "fontName": "Helvetica", + "fontSize": 8, "alignment": 0, "textColor": "black", + "backColor": "white"} + } + }, + "comprobante": { + "titulo": { + "valor": "Datos CFDI", + "rectangulo": {"x": 14.0, "y": 20.8, "width": 2.8, "height": 0.9, + "radius": 0.0, "stroke": false, "fill": false}, + "estilo": {"name": "nombre", "fontName": "Helvetica-Bold", + "fontSize": 9, "alignment": 1, "textColor": "darkred", + "backColor": "linen"} + }, + "t_folio": { + "valor": "Folio:", + "rectangulo": {"x": 14.2, "y": 23.2, "width": 1.0, "height": 0.8, + "radius": 0.0, "stroke": false, "fill": false}, + "estilo": {"name": "nombre", "fontName": "Helvetica-Bold", + "fontSize": 8, "alignment": 0, "textColor": "black", + "backColor": "white"} + }, + "folio": { + "rectangulo": {"x": 15.1, "y": 23.2, "width": 3.0, "height": 0.4, + "radius": 0.0, "stroke": false, "fill": false}, + "estilo": {"name": "nombre", "fontName": "Helvetica-Bold", + "fontSize": 8, "alignment": 0, "textColor": "red", + "backColor": "white"} + }, + "t_tipo": { + "valor": "Tipo:", + "rectangulo": {"x": 18.2, "y": 23.2, "width": 1.0, "height": 0.8, + "radius": 0.0, "stroke": false, "fill": false}, + "estilo": {"name": "nombre", "fontName": "Helvetica-Bold", + "fontSize": 8, "alignment": 0, "textColor": "black", + "backColor": "white"} + }, + "tipodecomprobante": { + "rectangulo": {"x": 19.0, "y": 23.2, "width": 1.5, "height": 0.8, + "radius": 0.0, "stroke": false, "fill": false}, + "estilo": {"name": "nombre", "fontName": "Helvetica-Bold", + "fontSize": 8, "alignment": 0, "textColor": "red", + "backColor": "white"} + }, + "t_uuid": { + "valor": "Folio Fiscal:", + "rectangulo": {"x": 14.2, "y": 22.7, "width": 3.0, "height": 0.4, + "radius": 0.0, "stroke": false, "fill": false}, + "estilo": {"name": "nombre", "fontName": "Helvetica-Bold", + "fontSize": 6, "alignment": 0, "textColor": "black", + "backColor": "white"} + }, + "uuid": { + "rectangulo": {"x": 15.6, "y": 22.7, "width": 6.5, "height": 0.4, + "radius": 0.0, "stroke": false, "fill": false}, + "estilo": {"name": "nombre", "fontName": "Helvetica-Bold", + "fontSize": 6, "alignment": 0, "textColor": "red", + "backColor": "white"} + }, + "t_fecha": { + "valor": "Fecha Expedición:", + "rectangulo": {"x": 14.2, "y": 22.1, "width": 2.2, "height": 0.3, + "radius": 0.0, "stroke": false, "fill": false}, + "estilo": {"name": "nombre", "fontName": "Helvetica-Bold", + "fontSize": 6, "alignment": 2, "textColor": "black", + "backColor": "white"} + }, + "fecha": { + "rectangulo": {"x": 16.5, "y": 22.1, "width": 3.0, "height": 0.3, + "radius": 0.0, "stroke": false, "fill": false}, + "estilo": {"name": "nombre", "fontName": "Helvetica-Bold", + "fontSize": 6, "alignment": 0, "textColor": "black", + "backColor": "white"} + }, + "t_fecha_timbrado": { + "valor": "Fecha Timbrado:", + "rectangulo": {"x": 14.2, "y": 21.8, "width": 2.2, "height": 0.3, + "radius": 0.0, "stroke": false, "fill": false}, + "estilo": {"name": "nombre", "fontName": "Helvetica-Bold", + "fontSize": 6, "alignment": 2, "textColor": "black", + "backColor": "white"} + }, + "fechatimbrado": { + "rectangulo": {"x": 16.5, "y": 21.8, "width": 3.0, "height": 0.3, + "radius": 0.0, "stroke": false, "fill": false}, + "estilo": {"name": "nombre", "fontName": "Helvetica-Bold", + "fontSize": 6, "alignment": 0, "textColor": "black", + "backColor": "white"} + }, + "t_serie_emisor": { + "valor": "Serie CSD Emisor:", + "rectangulo": {"x": 14.2, "y": 21.4, "width": 2.2, "height": 0.3, + "radius": 0.0, "stroke": false, "fill": false}, + "estilo": {"name": "nombre", "fontName": "Helvetica-Bold", + "fontSize": 6, "alignment": 2, "textColor": "black", + "backColor": "white"} + }, + "nocertificado": { + "rectangulo": {"x": 16.5, "y": 21.4, "width": 3.0, "height": 0.3, + "radius": 0.0, "stroke": false, "fill": false}, + "estilo": {"name": "nombre", "fontName": "Helvetica-Bold", + "fontSize": 6, "alignment": 0, "textColor": "black", + "backColor": "white"} + }, + "t_serie_sat": { + "valor": "Serie CSD SAT:", + "rectangulo": {"x": 14.2, "y": 21.1, "width": 2.2, "height": 0.3, + "radius": 0.0, "stroke": false, "fill": false}, + "estilo": {"name": "nombre", "fontName": "Helvetica-Bold", + "fontSize": 6, "alignment": 2, "textColor": "black", + "backColor": "white"} + }, + "nocertificadosat": { + "rectangulo": {"x": 16.5, "y": 21.1, "width": 3.0, "height": 0.3, + "radius": 0.0, "stroke": false, "fill": false}, + "estilo": {"name": "nombre", "fontName": "Helvetica-Bold", + "fontSize": 6, "alignment": 0, "textColor": "black", + "backColor": "white"} + }, + "lugarexpedicion": { + "rectangulo": {"x": 14.2, "y": 20.7, "width": 5.5, "height": 0.4, + "radius": 0.0, "stroke": false, "fill": false}, + "estilo": {"name": "nombre", "fontName": "Helvetica-Bold", + "fontSize": 7, "alignment": 1, "textColor": "black", + "backColor": "white"} + } + } +}, +"conceptos": { +}, +"comprobante": { + "totalenletras": { + "estilo": {"name": "enletras", "fontName": "Helvetica-Bold", + "fontSize": 7, "alignment": 1, "textColor": "black", + "spaceBefore": 0.1} + }, + "formadepago": { + "estilo": {"name": "formadepago", "fontName": "Helvetica", + "fontSize": 7, "alignment": 0, "textColor": "black"} + }, + "metododepago": { + "estilo": {"name": "metododepago", "fontName": "Helvetica", + "fontSize": 7, "alignment": 0, "textColor": "black"} + }, + "moneda": { + "estilo": {"name": "moneda", "fontName": "Helvetica", + "fontSize": 7, "alignment": 0, "textColor": "black"} + }, + "tipocambio": { + "estilo": {"name": "tipocambio", "fontName": "Helvetica", + "fontSize": 7, "alignment": 0, "textColor": "black"} + }, + "leyenda": { + "estilo": {"name": "leyenda", "fontName": "Helvetica-Bold", + "fontSize": 6, "alignment": 1, "textColor": "black", + "spaceBefore": 0.2} + } +} +}