From 69a74c0714c86fa474ba8353475c866a98cf0d35 Mon Sep 17 00:00:00 2001 From: bit4bit Date: Sat, 31 Jul 2021 17:09:42 +0000 Subject: [PATCH] generacion de cufe desde invoice FossilOrigin-Name: d3494f20063452571b1e86d505f211e61fdf435aa43b870408136e3e9302bc17 --- facho/fe/form/__init__.py | 13 ++- facho/fe/model/__init__.py | 196 +++++++++++++++++++++++++++++---- facho/model/__init__.py | 12 +- facho/model/fields/field.py | 3 +- facho/model/fields/many2one.py | 21 +++- facho/model/fields/one2many.py | 11 +- tests/test_model.py | 4 +- tests/test_model_invoice.py | 18 ++- 8 files changed, 233 insertions(+), 45 deletions(-) diff --git a/facho/fe/form/__init__.py b/facho/fe/form/__init__.py index b12a86a..7f0c5e7 100644 --- a/facho/fe/form/__init__.py +++ b/facho/fe/form/__init__.py @@ -54,8 +54,8 @@ class AmountCollection(Collection): return total class Amount: - def __init__(self, amount: int or float or str or Amount, currency: Currency = Currency('COP')): - + def __init__(self, amount: int or float or str or Amount, currency: Currency = Currency('COP'), precision = DECIMAL_PRECISION): + self.precision = precision #DIAN 1.7.-2020: 1.2.3.1 if isinstance(amount, Amount): if amount < Amount(0.0): @@ -67,7 +67,7 @@ class Amount: if float(amount) < 0: raise ValueError('amount must be positive >= 0') - self.amount = Decimal(amount, decimal.Context(prec=DECIMAL_PRECISION, + self.amount = Decimal(amount, decimal.Context(prec=self.precision, #DIAN 1.7.-2020: 1.2.1.1 rounding=decimal.ROUND_HALF_EVEN )) self.currency = currency @@ -87,18 +87,21 @@ class Amount: def __lt__(self, other): if not self.is_same_currency(other): raise AmountCurrencyError() - return round(self.amount, DECIMAL_PRECISION) < round(other, 2) + return round(self.amount, self.precision) < round(other, 2) def __eq__(self, other): if not self.is_same_currency(other): raise AmountCurrencyError() - return round(self.amount, DECIMAL_PRECISION) == round(other.amount, DECIMAL_PRECISION) + return round(self.amount, self.precision) == round(other.amount, self.precision) def _cast(self, val): if type(val) in [int, float]: return self.fromNumber(val) if isinstance(val, Amount): return val + if isinstance(val, Decimal): + return self.fromNumber(float(val)) + raise TypeError("cant cast %s to amount" % (type(val))) def __add__(self, rother): diff --git a/facho/fe/model/__init__.py b/facho/fe/model/__init__.py index 5f6d6a8..7b714e1 100644 --- a/facho/fe/model/__init__.py +++ b/facho/fe/model/__init__.py @@ -1,8 +1,12 @@ import facho.model as model import facho.model.fields as fields import facho.fe.form as form +from facho import fe + from datetime import date, datetime +from collections import defaultdict from copy import copy +import hashlib class Name(model.Model): __name__ = 'Name' @@ -16,6 +20,9 @@ class Date(model.Model): if isinstance(value, date): return value.isoformat() + def __str__(self): + return str(self._value) + class Time(model.Model): __name__ = 'Time' @@ -23,7 +30,10 @@ class Time(model.Model): if isinstance(value, str): return value if isinstance(value, date): - return value.strftime('%H:%M%S-05:00') + return value.strftime('%H:%M:%S-05:00') + + def __str__(self): + return str(self._value) class InvoicePeriod(model.Model): __name__ = 'InvoicePeriod' @@ -35,6 +45,9 @@ class InvoicePeriod(model.Model): class ID(model.Model): __name__ = 'ID' + def __str__(self): + return str(self._value) + class Party(model.Model): __name__ = 'Party' @@ -63,22 +76,36 @@ class Quantity(model.Model): def __mul__(self, other): return form.Amount(self.value) * other.value + def __add__(self, other): + return form.Amount(self.value) + other.value class Amount(model.Model): __name__ = 'Amount' currency = fields.Attribute('currencyID', default='COP') - value = fields.Virtual(default=form.Amount(0), update_internal=True) + value = fields.Amount(name='amount', default=0.00, precision=2) def __default_set__(self, value): self.value = value return value + def __default__get__(self, value): + return value + + def __str__(self): + return str(self.value) + + def __add__(self, other): + if isinstance(other, form.Amount): + return self.value + other + + return self.value + other.value + class Price(model.Model): __name__ = 'Price' amount = fields.Many2One(Amount, name='PriceAmount') - value = fields.Virtual(default=form.Amount(0)) + value = fields.Amount(0.0) def __default_set__(self, value): self.amount = value @@ -101,32 +128,40 @@ class TaxScheme(model.Model): class TaxCategory(model.Model): __name__ = 'TaxCategory' - percent = fields.Many2One(Percent, default='19.0') + percent = fields.Many2One(Percent) tax_scheme = fields.Many2One(TaxScheme) class TaxSubTotal(model.Model): __name__ = 'TaxSubTotal' - taxable_amount = fields.Many2One(Amount, name='TaxableAmount') - tax_amount = fields.Many2One(Amount, name='TaxAmount') + taxable_amount = fields.Many2One(Amount, name='TaxableAmount', default=0.00) + tax_amount = fields.Many2One(Amount, name='TaxAmount', default=0.00) + tax_percent = fields.Many2One(Percent) tax_category = fields.Many2One(TaxCategory) - percent = fields.Virtual(setter='set_category') - scheme = fields.Virtual(setter='set_category') + percent = fields.Virtual(setter='set_category', getter='get_category') + scheme = fields.Virtual(setter='set_category', getter='get_category') + def set_category(self, name, value): if name == 'percent': self.tax_category.percent = value - # TODO(bit4bit) hacer variable - self.tax_category.tax_scheme.id = '01' - self.tax_category.tax_scheme.name = 'IVA' + # TODO(bit4bit) debe variar en conjunto? + self.tax_percent = value elif name == 'scheme': self.tax_category.tax_scheme.id = value - - + + return value + + def get_category(self, name, value): + if name == 'percent': + return value + elif name == 'scheme': + return self.tax_category.tax_scheme + class TaxTotal(model.Model): __name__ = 'TaxTotal' - tax_amount = fields.Many2One(Amount, name='TaxAmount') + tax_amount = fields.Many2One(Amount, name='TaxAmount', default=0.00) subtotals = fields.One2Many(TaxSubTotal) @@ -142,6 +177,17 @@ class AllowanceCharge(model.Model): def isDiscount(self): return self.is_discount == True +class TaxScheme: + pass + +class TaxIva(TaxScheme): + def __init__(self, percent): + self.scheme = '01' + self.percent = percent + + def calculate(self, amount): + return form.Amount(amount) * form.Amount(self.percent / 100) + class InvoiceLine(model.Model): __name__ = 'InvoiceLine' @@ -150,6 +196,26 @@ class InvoiceLine(model.Model): price = fields.Many2One(Price) amount = fields.Many2One(Amount, name='LineExtensionAmount') allowance_charge = fields.One2Many(AllowanceCharge) + tax_amount = fields.Virtual(getter='get_tax_amount') + + def __setup__(self): + self._taxs = defaultdict(list) + self._subtotals = { + '01': self.taxtotal.subtotals.create() + } + self._subtotals['01'].scheme = '01' + + def get_tax_amount(self, name, value): + total = form.Amount(0) + for (scheme, subtotal) in self._subtotals.items(): + total += subtotal.tax_amount.value + return total + + def add_tax(self, tax): + if not isinstance(tax, TaxScheme): + raise ValueError('tax expected TaxScheme') + + self._taxs[tax.scheme].append(tax) @fields.on_change(['price', 'quantity']) def update_amount(self, name, value): @@ -165,19 +231,38 @@ class InvoiceLine(model.Model): total = self.quantity * self.price self.amount = total + charge - discount + for (scheme, subtotal) in self._subtotals.items(): + subtotal.tax_amount.value = 0 + + for (scheme, taxes) in self._taxs.items(): + for tax in taxes: + self._subtotals[scheme].tax_amount += tax.calculate(self.amount.value) class LegalMonetaryTotal(model.Model): __name__ = 'LegalMonetaryTotal' - line_extension_amount = fields.Many2One(Amount, name='LineExtensionAmount', default=form.Amount(0)) - tax_exclusive_amount = fields.Many2One(Amount, name='TaxExclusiveAmount') - tax_inclusive_amount = fields.Many2One(Amount, name='TaxInclusiveAmount') - charge_total_amount = fields.Many2One(Amount, name='ChargeTotalAmount') - payable_amount = fields.Many2One(Amount, name='PayableAmount') + line_extension_amount = fields.Many2One(Amount, name='LineExtensionAmount', default=0) + + tax_exclusive_amount = fields.Many2One(Amount, name='TaxExclusiveAmount', default=form.Amount(0)) + tax_inclusive_amount = fields.Many2One(Amount, name='TaxInclusiveAmount', default=form.Amount(0)) + charge_total_amount = fields.Many2One(Amount, name='ChargeTotalAmount', default=form.Amount(0)) + payable_amount = fields.Many2One(Amount, name='PayableAmount', default=form.Amount(0)) + + @fields.on_change(['tax_inclusive_amount', 'charge_total']) + def update_payable_amount(self, name, value): + self.payable_amount = self.tax_inclusive_amount.value + self.charge_total_amount.value +class Technical(model.Model): + __name__ = 'Technical' + + token = fields.Virtual(default='') + environment = fields.Virtual(default=fe.AMBIENTE_PRODUCCION) + class Invoice(model.Model): __name__ = 'Invoice' + technical = fields.Many2One(Technical, virtual=True) + id = fields.Many2One(ID) issue = fields.Virtual(setter='set_issue') issue_date = fields.Many2One(Date, name='IssueDate') @@ -189,16 +274,83 @@ class Invoice(model.Model): customer = fields.Many2One(AccountingCustomerParty) lines = fields.One2Many(InvoiceLine) legal_monetary_total = fields.Many2One(LegalMonetaryTotal) + + taxtotal_01 = fields.Many2One(TaxTotal) + taxtotal_04 = fields.Many2One(TaxTotal) + taxtotal_03 = fields.Many2One(TaxTotal) - cufe = fields.Virtual() + cufe = fields.Virtual(getter='calculate_cufe') + + _subtotal_01 = fields.Virtual() + _subtotal_04 = fields.Virtual() + _subtotal_03 = fields.Virtual() + + def __setup__(self): + # Se requieren minimo estos impuestos para + # validar el cufe + self._subtotal_01 = self.taxtotal_01.subtotals.create() + self._subtotal_01.scheme = '01' + self._subtotal_01.percent = 19.0 + + self._subtotal_04 = self.taxtotal_04.subtotals.create() + self._subtotal_04.scheme = '04' + + self._subtotal_03 = self.taxtotal_03.subtotals.create() + self._subtotal_03.scheme = '03' + + def calculate_cufe(self, name, value): + + valor_bruto = self.legal_monetary_total.line_extension_amount.value + valor_total_pagar = self.legal_monetary_total.payable_amount.value + + valor_impuesto_01 = form.Amount(0.0) + valor_impuesto_04 = form.Amount(0.0) + valor_impuesto_03 = form.Amount(0.0) + + for line in self.lines: + for subtotal in line.taxtotal.subtotals: + scheme_id = subtotal.scheme + if str(subtotal.scheme.id) == '01': + valor_impuesto_01 += subtotal.tax_amount.value + elif subtotal.scheme.id == '04': + valor_impuesto_04 += subtotal.tax_amount.value + elif subtotal.scheme.id == '03': + valor_impuesto_03 += subtotal.tax_amount.value + + + + pattern = [ + '%s' % str(self.id), + '%s' % str(self.issue_date), + '%s' % str(self.issue_time), + valor_bruto.truncate_as_string(2), + '01', valor_impuesto_01.truncate_as_string(2), + '04', valor_impuesto_04.truncate_as_string(2), + '03', valor_impuesto_03.truncate_as_string(2), + valor_total_pagar.truncate_as_string(2), + str(self.supplier.party.id), + str(self.customer.party.id), + str(self.technical.token), + str(self.technical.environment) + ] + + cufe = "".join(pattern) + h = hashlib.sha384() + h.update(cufe.encode('utf-8')) + return h.hexdigest() @fields.on_change(['lines']) def update_legal_monetary_total(self, name, value): + self.legal_monetary_total.line_extension_amount.value = 0 + self.legal_monetary_total.tax_inclusive_amount.value = 0 + for line in self.lines: self.legal_monetary_total.line_extension_amount.value += line.amount.value - + self.legal_monetary_total.tax_inclusive_amount += line.amount.value + line.tax_amount + print("update legal monetary %s" % (str(line.amount.value))) + def set_issue(self, name, value): if not isinstance(value, datetime): raise ValueError('expected type datetime') - self.issue_date = value + self.issue_date = value.date() self.issue_time = value diff --git a/facho/model/__init__.py b/facho/model/__init__.py index a6689c2..d9b9ca7 100644 --- a/facho/model/__init__.py +++ b/facho/model/__init__.py @@ -25,7 +25,7 @@ class ModelBase(object, metaclass=ModelMeta): obj._order_fields = [] def on_change_fields_for_function(): - # se recorre arbol buscando el primero + # se recorre arbol de herencia buscando attributo on_changes for parent_cls in type(obj).__mro__: for parent_attr in dir(parent_cls): parent_meth = getattr(parent_cls, parent_attr, None) @@ -114,6 +114,10 @@ class ModelBase(object, metaclass=ModelMeta): for name in ordered_fields.keys(): value = self._fields[name] + # al ser virtual no adicinamos al arbol xml + if hasattr(value, 'virtual') and value.virtual: + continue + if hasattr(value, 'to_xml'): content += value.to_xml() elif isinstance(value, str): @@ -143,6 +147,12 @@ class Model(ModelBase): """ return value + def __default_get__(self, name, value): + """ + Retorno de valor por defecto + """ + return value + def __setup__(self): """ Inicializar modelo diff --git a/facho/model/fields/field.py b/facho/model/fields/field.py index c447020..d8a6fe4 100644 --- a/facho/model/fields/field.py +++ b/facho/model/fields/field.py @@ -1,6 +1,7 @@ class Field: - def __set_name__(self, owner, name): + def __set_name__(self, owner, name, virtual=False): self.name = name + self.virtual = virtual def __get__(self, inst, cls): if inst is None: diff --git a/facho/model/fields/many2one.py b/facho/model/fields/many2one.py index 5134e3d..c22bd3d 100644 --- a/facho/model/fields/many2one.py +++ b/facho/model/fields/many2one.py @@ -1,22 +1,37 @@ from .field import Field +from collections import defaultdict class Many2One(Field): - def __init__(self, model, name=None, setter=None, namespace=None, default=None): + def __init__(self, model, name=None, setter=None, namespace=None, default=None, virtual=False): self.model = model self.setter = setter self.namespace = namespace self.field_name = name self.default = default - + self.virtual = virtual + self.relations = defaultdict(dict) + def __get__(self, inst, cls): if inst is None: return self assert self.name is not None - return self._create_model(inst, name=self.field_name) + + if self.name in self.relations: + value = self.relations[inst][self.name] + else: + value = self._create_model(inst, name=self.field_name) + self.relations[inst][self.name] = value + + # se puede obtener directamente un valor indicado por el modelo + if hasattr(value, '__default_get__'): + return value.__default_get__(self.name, value) + else: + return inst.__default_get__(self.name, value) def __set__(self, inst, value): assert self.name is not None inst_model = self._create_model(inst, name=self.field_name, model=self.model) + self.relations[inst][self.name] = inst_model # si hay setter manual se ejecuta # de lo contrario se asigna como texto del elemento diff --git a/facho/model/fields/one2many.py b/facho/model/fields/one2many.py index aeb6547..13b2e51 100644 --- a/facho/model/fields/one2many.py +++ b/facho/model/fields/one2many.py @@ -1,4 +1,5 @@ from .field import Field +from collections import defaultdict # TODO(bit4bit) lograr que isinstance se aplique # al objeto envuelto @@ -51,7 +52,7 @@ class One2Many(Field): self.field_name = name self.namespace = namespace self.default = default - self.relation = None + self.relation = {} def __get__(self, inst, cls): assert self.name is not None @@ -59,8 +60,8 @@ class One2Many(Field): def creator(attribute): return self._create_model(inst, name=self.field_name, model=self.model, attribute=attribute) - if self.relation: - return self.relation + if inst in self.relation: + return self.relation[inst] else: - self.relation = _Relation(creator, inst, self.name) - return self.relation + self.relation[inst] = _Relation(creator, inst, self.name) + return self.relation[inst] diff --git a/tests/test_model.py b/tests/test_model.py index 7dc5ef8..10cc99e 100644 --- a/tests/test_model.py +++ b/tests/test_model.py @@ -494,7 +494,7 @@ def test_field_amount(): class Line(facho.model.Model): __name__ = 'Line' - amount = fields.Amount(name='Amount', precision=0) + amount = fields.Amount(name='Amount', precision=1) amount_as_attribute = fields.Attribute('amount') @fields.on_change(['amount']) @@ -504,7 +504,7 @@ def test_field_amount(): line = Line() line.amount = 33 - assert '' == line.to_xml() + assert '' == line.to_xml() def test_model_setup(): diff --git a/tests/test_model_invoice.py b/tests/test_model_invoice.py index c7355d9..c99cdb8 100644 --- a/tests/test_model_invoice.py +++ b/tests/test_model_invoice.py @@ -11,8 +11,9 @@ import pytest import facho.fe.model as model import facho.fe.form as form +from facho import fe -def test_simple_invoice(): +def _test_simple_invoice(): invoice = model.Invoice() invoice.id = '323200000129' invoice.issue = datetime.strptime('2019-01-16 10:53:10-05:00', '%Y-%m-%d %H:%M:%S%z') @@ -24,20 +25,25 @@ def test_simple_invoice(): line.price = form.Amount(5_000) subtotal = line.taxtotal.subtotals.create() subtotal.percent = 19.0 - assert '3232000001292019-01-16T10:53:10-05:0010:5310-05:00700085371800199436119.001IVA5000.05000.05000.035000.0' == invoice.to_xml() + assert '3232000001292019-01-16T10:53:10-05:0010:5310-05:0070008537180019943610.00.00.019.019.05000.05000.05000.00.00.00.00.019.019.0010.00.00.0040.00.00.003' == invoice.to_xml() -def _test_simple_invoice_cufe(): + +def test_simple_invoice_cufe(): invoice = model.Invoice() + invoice.technical.token = '693ff6f2a553c3646a063436fd4dd9ded0311471' + invoice.technical.environment = fe.AMBIENTE_PRODUCCION invoice.id = '323200000129' invoice.issue = datetime.strptime('2019-01-16 10:53:10-05:00', '%Y-%m-%d %H:%M:%S%z') invoice.supplier.party.id = '700085371' invoice.customer.party.id = '800199436' line = invoice.lines.create() + line.add_tax(model.TaxIva(19.0)) + + # TODO(bit4bit) acoplamiento temporal + # se debe crear primero el subotatl + # para poder calcularse al cambiar el precio line.quantity = 1 line.price = 1_500_000 - line_subtotal = line.taxtotal.subtotals.create() - line_subtotal.percent = 19.0 - line.subtotal.scheme = '01' assert invoice.cufe == '8bb918b19ba22a694f1da11c643b5e9de39adf60311cf179179e9b33381030bcd4c3c3f156c506ed5908f9276f5bd9b4'