Compare commits
52 Commits
quantity-f
...
model
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2e130d39e6 | ||
|
|
6716efd121 | ||
|
|
302812328e | ||
|
|
3a89c6d3e5 | ||
|
|
3a349a746e | ||
|
|
efe93ecc3c | ||
|
|
64b312a432 | ||
|
|
088fa9e6e0 | ||
|
|
ddee0e45c1 | ||
|
|
69a74c0714 | ||
|
|
a1a9746353 | ||
|
|
b3e4a088b7 | ||
|
|
507ddbe558 | ||
|
|
2e8aa35b29 | ||
|
|
ba908b938c | ||
|
|
4f15926656 | ||
|
|
1d6d1e2601 | ||
|
|
47a0dd33e2 | ||
|
|
53b5207e35 | ||
|
|
bd25bef21f | ||
|
|
5f5a6182c9 | ||
|
|
ab462a6ca5 | ||
|
|
c694603505 | ||
|
|
b6219bd171 | ||
|
|
a9dde83e81 | ||
|
|
3eacb29afa | ||
|
|
f630a544c2 | ||
|
|
ba4e3d546f | ||
|
|
92bae58e51 | ||
|
|
58e7387292 | ||
|
|
a015a9361b | ||
|
|
0216d0141a | ||
|
|
6cc4610b45 | ||
|
|
49feee8809 | ||
|
|
d78a429711 | ||
|
|
84996066fa | ||
|
|
7d060e1786 | ||
|
|
4e68025e48 | ||
|
|
ead21bd4f2 | ||
|
|
5e79850686 | ||
|
|
988d01daf7 | ||
|
|
28a963b76a | ||
|
|
af99a7593a | ||
|
|
d02c0b13b8 | ||
|
|
79209964e0 | ||
|
|
38f4c5ae45 | ||
|
|
1143b26988 | ||
|
|
e571009945 | ||
|
|
48619106c5 | ||
|
|
bcf5120d82 | ||
|
|
f648188834 | ||
|
|
67156ec9a6 |
@@ -3,4 +3,4 @@ History
|
|||||||
=======
|
=======
|
||||||
|
|
||||||
|
|
||||||
* First release on PyPI.
|
* 0.2.1 version usada en produccion.
|
||||||
|
|||||||
20
README.rst
20
README.rst
@@ -2,8 +2,6 @@
|
|||||||
facho
|
facho
|
||||||
=====
|
=====
|
||||||
|
|
||||||
!!INESTABLE NO RECOMENDAMOS USO PARA PRODUCION!!
|
|
||||||
|
|
||||||
Libreria para facturacion electronica colombia.
|
Libreria para facturacion electronica colombia.
|
||||||
|
|
||||||
- facho/facho.py: abstracion para manipulacion del XML
|
- facho/facho.py: abstracion para manipulacion del XML
|
||||||
@@ -24,7 +22,7 @@ usando pip::
|
|||||||
CLI
|
CLI
|
||||||
===
|
===
|
||||||
|
|
||||||
tambien se provee linea de comandos **facho** para firmado y envio de documentos::
|
tambien se provee linea de comandos **facho** para generacion, firmado y envio de documentos::
|
||||||
facho --help
|
facho --help
|
||||||
|
|
||||||
CONTRIBUIR
|
CONTRIBUIR
|
||||||
@@ -32,17 +30,13 @@ CONTRIBUIR
|
|||||||
|
|
||||||
ver **CONTRIBUTING.rst**
|
ver **CONTRIBUTING.rst**
|
||||||
|
|
||||||
|
USO
|
||||||
|
===
|
||||||
|
|
||||||
|
ver **USAGE.rst**
|
||||||
|
|
||||||
|
|
||||||
DIAN HABILITACION
|
DIAN HABILITACION
|
||||||
=================
|
=================
|
||||||
|
|
||||||
guia oficial actualizada al 2020-04-20: https://www.dian.gov.co/fizcalizacioncontrol/herramienconsulta/FacturaElectronica/Facturaci%C3%B3n_Gratuita_DIAN/Documents/Guia_usuario_08052019.pdf#search=numeracion
|
guia oficial actualizada al 2020-04-20: https://www.dian.gov.co/fizcalizacioncontrol/herramienconsulta/FacturaElectronica/Facturaci%C3%B3n_Gratuita_DIAN/Documents/Guia_usuario_08052019.pdf#search=numeracion
|
||||||
|
|
||||||
|
|
||||||
ERROR X509SerialNumber
|
|
||||||
======================
|
|
||||||
|
|
||||||
|
|
||||||
lxml.etree.DocumentInvalid: Element '{http://www.w3.org/2000/09/xmldsig#}X509SerialNumber': '632837201711293159666920255411738137494572618415' is not a valid value of the atomic type 'xs:integer'
|
|
||||||
|
|
||||||
Actualmente el xmlschema usado por xmlsig para el campo X509SerialNumber es tipo
|
|
||||||
integer ahi que parchar manualmente a tipo string, en el archivo site-packages/xmlsig/data/xmldsig-core-schema.xsd.
|
|
||||||
|
|||||||
24
USAGE.rst
Normal file
24
USAGE.rst
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
uso de la libreria
|
||||||
|
==================
|
||||||
|
|
||||||
|
**facho** es tanto una libreria para modelar y generar los documentos xml requeridos para la facturacion,
|
||||||
|
asi como una herramienta de **consola** para facilitar algunas actividades como: generaciones de xml
|
||||||
|
apartir de una especificacion en python, comprimir y enviar archivos según el SOAP vigente.
|
||||||
|
|
||||||
|
**facho** es diseñado para ser usado en conjunto con el documento **docs/DIAN/Anexo_Tecnico_Factura_Electronica_Vr1_7_2020.pdf**, ya que en gran medida sigue la terminologia presente en este.
|
||||||
|
|
||||||
|
|
||||||
|
Para ejemplos ver **examples/** .
|
||||||
|
|
||||||
|
En terminos generales seria modelar la factura usando **facho/fe/form.py**, instanciar las extensiones requeridas ver **facho/fe/fe.py** y
|
||||||
|
una vez generado el objeto invoice y las extensiones requeridas se procede a crear el XML, ejemplo:
|
||||||
|
|
||||||
|
~~~python
|
||||||
|
....
|
||||||
|
xml = form_xml.DIANInvoiceXML(invoice)
|
||||||
|
extensions = module.extensions(invoice)
|
||||||
|
for extension in extensions:
|
||||||
|
xml.add_extension(extension)
|
||||||
|
|
||||||
|
form_xml.DIANWriteSigned(xml, "factura.xml", "llave privada", "frase")
|
||||||
|
~~~
|
||||||
117
examples/generate-invoice-from-cli.py
Normal file
117
examples/generate-invoice-from-cli.py
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
# este archivo es un ejemplo para le generacion
|
||||||
|
# una factura de venta nacional usando el comando **facho**.
|
||||||
|
#
|
||||||
|
# ejemplo: facho generate-invoice generate-invoice-from-cli.py
|
||||||
|
#
|
||||||
|
# importar libreria de modelos
|
||||||
|
from facho.fe import form
|
||||||
|
from facho.fe import form_xml
|
||||||
|
|
||||||
|
# importar libreria extensiones xml para cumplir decreto
|
||||||
|
from facho.fe import fe
|
||||||
|
|
||||||
|
# importar otras necesarias
|
||||||
|
from datetime import datetime, date
|
||||||
|
|
||||||
|
# Datos del fomulario del SET de pruebas
|
||||||
|
INVOICE_AUTHORIZATION = '181360000001' #Número suministrado por la Dian en el momento de la creación del SET de Pruebas
|
||||||
|
ID_SOFTWARE = '57bcb6d1-c591-5a90-b80a-cb030ec91440' #Id suministrado por la Dian en el momento de la creación del SET de Pruebas
|
||||||
|
PIN = '19642' #Número creado por la empresa para poder crear el SET de pruebas
|
||||||
|
CLAVE_TECNICA = 'fc9eac422eba16e21ffd8c5f94b3f30a6e38162d' ##Id suministrado por la Dian en el momento de la creación del SET de Pruebas
|
||||||
|
|
||||||
|
|
||||||
|
# callback que retonar las extensiones XML necesarias
|
||||||
|
# para que el documento final XML cumpla el decreto.
|
||||||
|
#
|
||||||
|
# muchos de los valores usados son obtenidos
|
||||||
|
# del servicio web de la DIAN.
|
||||||
|
def extensions(inv):
|
||||||
|
security_code = fe.DianXMLExtensionSoftwareSecurityCode(ID_SOFTWARE, PIN, inv.invoice_ident)
|
||||||
|
authorization_provider = fe.DianXMLExtensionAuthorizationProvider()
|
||||||
|
cufe = fe.DianXMLExtensionCUFE(inv, CLAVE_TECNICA, fe.AMBIENTE_PRUEBAS)
|
||||||
|
software_provider = fe.DianXMLExtensionSoftwareProvider('nit_empresa', 'dígito_verificación', ID_SOFTWARE)
|
||||||
|
inv_authorization = fe.DianXMLExtensionInvoiceAuthorization(INVOICE_AUTHORIZATION,
|
||||||
|
datetime(2019, 1, 19),#Datos toamdos de
|
||||||
|
datetime(2030, 1, 19),#la configuración
|
||||||
|
'SETP', 990000000, 995000000)#del SET de pruebas
|
||||||
|
return [security_code, authorization_provider, cufe, software_provider, inv_authorization]
|
||||||
|
|
||||||
|
def invoice():
|
||||||
|
# factura de venta nacional
|
||||||
|
inv = form.Invoice('01')
|
||||||
|
# asignar periodo de facturacion
|
||||||
|
inv.set_period(datetime.now(), datetime.now())
|
||||||
|
# asignar fecha de emision de la factura
|
||||||
|
inv.set_issue(datetime.now())
|
||||||
|
# asignar prefijo y numero del documento
|
||||||
|
inv.set_ident('SETP990000008')
|
||||||
|
# asignar tipo de operacion ver DIAN:6.1.5
|
||||||
|
inv.set_operation_type('10')
|
||||||
|
inv.set_supplier(form.Party(
|
||||||
|
legal_name = 'Nombre registrado de la empresa',
|
||||||
|
name = 'Nombre comercial o él mismo nombre registrado',
|
||||||
|
ident = form.PartyIdentification('nit_empresa', 'digito_verificación', '31'),
|
||||||
|
# obligaciones del contribuyente ver DIAN:FAK26
|
||||||
|
responsability_code = form.Responsability(['O-07', 'O-14', 'O-48']),
|
||||||
|
# ver DIAN:FAJ28
|
||||||
|
responsability_regime_code = '48',
|
||||||
|
# tipo de organizacion juridica ver DIAN:6.2.3
|
||||||
|
organization_code = '1',
|
||||||
|
email = "correoempresa@correoempresa.correo",
|
||||||
|
address = form.Address(
|
||||||
|
'', '', form.City('05001', 'Medellín'),
|
||||||
|
form.Country('CO', 'Colombia'),
|
||||||
|
form.CountrySubentity('05', 'Antioquia')),
|
||||||
|
))
|
||||||
|
#Tercero a quien se le factura
|
||||||
|
inv.set_customer(form.Party(
|
||||||
|
legal_name = 'consumidor final',
|
||||||
|
name = 'consumidor final',
|
||||||
|
ident = form.PartyIdentification('222222222222', '', '13'),
|
||||||
|
responsability_code = form.Responsability(['R-99-PN']),
|
||||||
|
responsability_regime_code = '49',
|
||||||
|
organization_code = '2',
|
||||||
|
email = "consumidor_final0final.final",
|
||||||
|
address = form.Address(
|
||||||
|
'', '', form.City('05001', 'Medellín'),
|
||||||
|
form.Country('CO', 'Colombia'),
|
||||||
|
form.CountrySubentity('05', 'Antioquia')),
|
||||||
|
#tax_scheme = form.TaxScheme('01', 'IVA')
|
||||||
|
))
|
||||||
|
# asignar metodo de pago
|
||||||
|
inv.set_payment_mean(form.PaymentMean(
|
||||||
|
# metodo de pago ver DIAN:3.4.1
|
||||||
|
id = '1',
|
||||||
|
# codigo correspondiente al medio de pago ver DIAN:3.4.2
|
||||||
|
code = '20',
|
||||||
|
# fecha de vencimiento de la factura
|
||||||
|
due_at = datetime.now(),
|
||||||
|
# identificador numerico
|
||||||
|
payment_id = '2'
|
||||||
|
))
|
||||||
|
# adicionar una linea al documento
|
||||||
|
inv.add_invoice_line(form.InvoiceLine(
|
||||||
|
quantity = form.Quantity(int(20.5), '94'),
|
||||||
|
# item general de codigo 999
|
||||||
|
description = 'productO3',
|
||||||
|
item = form.StandardItem('test', 9999),
|
||||||
|
price = form.Price(
|
||||||
|
# precio base del item (sin iva)
|
||||||
|
amount = form.Amount(200.00),
|
||||||
|
# ver DIAN:6.3.5.1
|
||||||
|
type_code = '01',
|
||||||
|
type = 'x'
|
||||||
|
),
|
||||||
|
tax = form.TaxTotal(
|
||||||
|
subtotals = [
|
||||||
|
form.TaxSubTotal(
|
||||||
|
percent = 19.00,
|
||||||
|
scheme=form.TaxScheme('01')
|
||||||
|
)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
))
|
||||||
|
return inv
|
||||||
|
|
||||||
|
def document_xml():
|
||||||
|
return form_xml.DIANInvoiceXML
|
||||||
@@ -1,74 +0,0 @@
|
|||||||
import facho.fe.form as form
|
|
||||||
from facho.fe import fe
|
|
||||||
from datetime import datetime
|
|
||||||
|
|
||||||
def extensions(inv):
|
|
||||||
nit = form.PartyIdentification('nit', '5', '31')
|
|
||||||
security_code = fe.DianXMLExtensionSoftwareSecurityCode('id software', 'pin', inv.invoice_ident)
|
|
||||||
authorization_provider = fe.DianXMLExtensionAuthorizationProvider()
|
|
||||||
cufe = fe.DianXMLExtensionCUFE(inv, fe.DianXMLExtensionCUFE.AMBIENTE_PRUEBAS,
|
|
||||||
'clave tecnica')
|
|
||||||
software_provider = fe.DianXMLExtensionSoftwareProvider(nit, nit.dv, 'id software')
|
|
||||||
inv_authorization = fe.DianXMLExtensionInvoiceAuthorization('invoice autorization',
|
|
||||||
datetime(2019, 1, 19),
|
|
||||||
datetime(2030, 1, 19),
|
|
||||||
'SETP', 990000001, 995000000)
|
|
||||||
return [security_code, authorization_provider, cufe, software_provider, inv_authorization]
|
|
||||||
|
|
||||||
|
|
||||||
def invoice():
|
|
||||||
inv = form.Invoice()
|
|
||||||
inv.set_period(datetime.now(), datetime.now())
|
|
||||||
inv.set_issue(datetime.now())
|
|
||||||
inv.set_ident('SETP990003033')
|
|
||||||
inv.set_operation_type('10')
|
|
||||||
inv.set_supplier(form.Party(
|
|
||||||
legal_name = 'FACHO SOS',
|
|
||||||
name = 'FACHO SOS',
|
|
||||||
ident = form.PartyIdentification('900579212', '5', '31'),
|
|
||||||
responsability_code = form.Responsability(['O-07', 'O-09', 'O-14', 'O-48']),
|
|
||||||
responsability_regime_code = '48',
|
|
||||||
organization_code = '1',
|
|
||||||
email = "sdds@sd.com",
|
|
||||||
address = form.Address(
|
|
||||||
'', '', form.City('05001', 'Medellín'),
|
|
||||||
form.Country('CO', 'Colombia'),
|
|
||||||
form.CountrySubentity('05', 'Antioquia'))
|
|
||||||
))
|
|
||||||
inv.set_customer(form.Party(
|
|
||||||
legal_name = 'facho-customer',
|
|
||||||
name = 'facho-customer',
|
|
||||||
ident = form.PartyIdentification('999999999', '', '13'),
|
|
||||||
responsability_code = form.Responsability(['R-99-PN']),
|
|
||||||
responsability_regime_code = '49',
|
|
||||||
organization_code = '2',
|
|
||||||
email = "sdds@sd.com",
|
|
||||||
address = form.Address(
|
|
||||||
'', '', form.City('05001', 'Medellín'),
|
|
||||||
form.Country('CO', 'Colombia'),
|
|
||||||
form.CountrySubentity('05', 'Antioquia'))
|
|
||||||
))
|
|
||||||
inv.set_payment_mean(form.PaymentMean(
|
|
||||||
id = '1',
|
|
||||||
code = '10',
|
|
||||||
due_at = datetime.now(),
|
|
||||||
payment_id = '1'
|
|
||||||
))
|
|
||||||
inv.add_invoice_line(form.InvoiceLine(
|
|
||||||
quantity = form.Quantity(1, '94'),
|
|
||||||
description = 'producto facho',
|
|
||||||
item = form.StandardItem('test', 9999),
|
|
||||||
price = form.Price(
|
|
||||||
amount = form.Amount(100.00),
|
|
||||||
type_code = '01',
|
|
||||||
type = 'x'
|
|
||||||
),
|
|
||||||
tax = form.TaxTotal(
|
|
||||||
subtotals = [
|
|
||||||
form.TaxSubTotal(
|
|
||||||
percent = 19.00,
|
|
||||||
)
|
|
||||||
]
|
|
||||||
)
|
|
||||||
))
|
|
||||||
return inv
|
|
||||||
114
examples/use-as-lib.py
Normal file
114
examples/use-as-lib.py
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
# importar libreria de modelos
|
||||||
|
import facho.fe.form as form
|
||||||
|
import facho.fe.form_xml
|
||||||
|
|
||||||
|
PRIVATE_KEY_PATH='ruta a mi llave privada'
|
||||||
|
PRIVATE_PASSPHRASE='clave de la llave privada'
|
||||||
|
|
||||||
|
# consultar las extensiones necesarias
|
||||||
|
def extensions(inv):
|
||||||
|
security_code = fe.DianXMLExtensionSoftwareSecurityCode('id software', 'pin', inv.invoice_ident)
|
||||||
|
authorization_provider = fe.DianXMLExtensionAuthorizationProvider()
|
||||||
|
cufe = fe.DianXMLExtensionCUFE(inv, fe.DianXMLExtensionCUFE.AMBIENTE_PRUEBAS,
|
||||||
|
'clave tecnica')
|
||||||
|
nit = form.PartyIdentification('nit', '5', '31')
|
||||||
|
software_provider = fe.DianXMLExtensionSoftwareProvider(nit, nit.dv, 'id software')
|
||||||
|
inv_authorization = fe.DianXMLExtensionInvoiceAuthorization('invoice autorization',
|
||||||
|
datetime(2019, 1, 19),
|
||||||
|
datetime(2030, 1, 19),
|
||||||
|
'SETP', 990000001, 995000000)
|
||||||
|
return [security_code, authorization_provider, cufe, software_provider, inv_authorization]
|
||||||
|
|
||||||
|
# generar documento desde modelo a ruta indicada
|
||||||
|
def generate_document(invoice, filepath):
|
||||||
|
xml = form_xml.DIANInvoiceXML(invoice)
|
||||||
|
for extension in extensions(invoice):
|
||||||
|
xml.add_extension(extension)
|
||||||
|
form_xml.utils.DIANWriteSigned(xml, filepath, PRIVATE_KEY_PATH, PRIVATE_PASSPHRASE, True)
|
||||||
|
|
||||||
|
# Modelars las facturas
|
||||||
|
# ...
|
||||||
|
|
||||||
|
# factura de venta nacional
|
||||||
|
inv = form.NationalSalesInvoice()
|
||||||
|
# asignar periodo de facturacion
|
||||||
|
inv.set_period(datetime.now(), datetime.now())
|
||||||
|
# asignar fecha de emision de la factura
|
||||||
|
inv.set_issue(datetime.now())
|
||||||
|
# asignar prefijo y numero del documento
|
||||||
|
inv.set_ident('SETP990003033')
|
||||||
|
# asignar tipo de operacion ver DIAN:6.1.5
|
||||||
|
inv.set_operation_type('10')
|
||||||
|
# asignar proveedor
|
||||||
|
inv.set_supplier(form.Party(
|
||||||
|
legal_name = 'FACHO SOS',
|
||||||
|
name = 'FACHO SOS',
|
||||||
|
ident = form.PartyIdentification('900579212', '5', '31'),
|
||||||
|
# obligaciones del contribuyente ver DIAN:FAK26
|
||||||
|
responsability_code = form.Responsability(['O-07', 'O-09', 'O-14', 'O-48']),
|
||||||
|
# ver DIAN:FAJ28
|
||||||
|
responsability_regime_code = '48',
|
||||||
|
# tipo de organizacion juridica ver DIAN:6.2.3
|
||||||
|
organization_code = '1',
|
||||||
|
email = "sdds@sd.com",
|
||||||
|
address = form.Address(
|
||||||
|
name = '',
|
||||||
|
street = '',
|
||||||
|
city = form.City('05001', 'Medellín'),
|
||||||
|
country = form.Country('CO', 'Colombia'),
|
||||||
|
countrysubentity = form.CountrySubentity('05', 'Antioquia'))
|
||||||
|
))
|
||||||
|
inv.set_customer(form.Party(
|
||||||
|
legal_name = 'facho-customer',
|
||||||
|
name = 'facho-customer',
|
||||||
|
ident = form.PartyIdentification('999999999', '', '13'),
|
||||||
|
responsability_code = form.Responsability(['R-99-PN']),
|
||||||
|
responsability_regime_code = '49',
|
||||||
|
organization_code = '2',
|
||||||
|
email = "sdds@sd.com",
|
||||||
|
address = form.Address(
|
||||||
|
name = '',
|
||||||
|
street = '',
|
||||||
|
city = form.City('05001', 'Medellín'),
|
||||||
|
country = form.Country('CO', 'Colombia'),
|
||||||
|
countrysubentity = form.CountrySubentity('05', 'Antioquia'))
|
||||||
|
))
|
||||||
|
# asignar metodo de pago
|
||||||
|
inv.set_payment_mean(form.PaymentMean(
|
||||||
|
# metodo de pago ver DIAN:3.4.1
|
||||||
|
id = '1',
|
||||||
|
# codigo correspondiente al medio de pago ver DIAN:3.4.2
|
||||||
|
code = '10',
|
||||||
|
# fecha de vencimiento de la factura
|
||||||
|
due_at = datetime.now(),
|
||||||
|
|
||||||
|
# identificador numerico
|
||||||
|
payment_id = '1'
|
||||||
|
))
|
||||||
|
# adicionar una linea al documento
|
||||||
|
inv.add_invoice_line(form.InvoiceLine(
|
||||||
|
quantity = form.Quantity(1, '94'),
|
||||||
|
description = 'producto facho',
|
||||||
|
# item general de codigo 999
|
||||||
|
item = form.StandardItem('test', 9999),
|
||||||
|
price = form.Price(
|
||||||
|
# precio base del tiem
|
||||||
|
amount = form.Amount(100.00),
|
||||||
|
# ver DIAN:6.3.5.1
|
||||||
|
type_code = '01',
|
||||||
|
type = 'x'
|
||||||
|
),
|
||||||
|
tax = form.TaxTotal(
|
||||||
|
subtotals = [
|
||||||
|
form.TaxSubTotal(
|
||||||
|
percent = 19.00,
|
||||||
|
)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
))
|
||||||
|
|
||||||
|
# refrescar valores de la factura
|
||||||
|
inv.calculate()
|
||||||
|
|
||||||
|
# generar el documento xml en la ruta indicada
|
||||||
|
generate_document(inv, 'ruta a donde guardar el .xml')
|
||||||
@@ -129,6 +129,11 @@ class FachoXML:
|
|||||||
self.xpath_for = {}
|
self.xpath_for = {}
|
||||||
self.extensions = []
|
self.extensions = []
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_string(cls, document: str, namespaces: dict() = []) -> 'FachoXML':
|
||||||
|
xml = LXMLBuilder.from_string(document)
|
||||||
|
return FachoXML(xml, nsmap=namespaces)
|
||||||
|
|
||||||
def append_element(self, elem, new_elem):
|
def append_element(self, elem, new_elem):
|
||||||
#elem = self.find_or_create_element(xpath, append=append)
|
#elem = self.find_or_create_element(xpath, append=append)
|
||||||
#self.builder.append(elem, new_elem)
|
#self.builder.append(elem, new_elem)
|
||||||
@@ -252,6 +257,9 @@ class FachoXML:
|
|||||||
def get_element_text(self, xpath, format_=str):
|
def get_element_text(self, xpath, format_=str):
|
||||||
xpath = self.fragment_prefix + self._path_xpath_for(xpath)
|
xpath = self.fragment_prefix + self._path_xpath_for(xpath)
|
||||||
elem = self.builder.xpath(self.root, xpath)
|
elem = self.builder.xpath(self.root, xpath)
|
||||||
|
if elem is None:
|
||||||
|
raise AttributeError('xpath %s invalid' % (xpath))
|
||||||
|
|
||||||
text = self.builder.get_text(elem)
|
text = self.builder.get_text(elem)
|
||||||
return format_(text)
|
return format_(text)
|
||||||
|
|
||||||
|
|||||||
@@ -138,10 +138,16 @@ class GetStatusResponse:
|
|||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def fromdict(cls, data):
|
def fromdict(cls, data):
|
||||||
|
if data['ErrorMessage']:
|
||||||
|
error_message = data['ErrorMessage']['string']
|
||||||
|
else:
|
||||||
|
error_message = None
|
||||||
|
|
||||||
return cls(data['IsValid'],
|
return cls(data['IsValid'],
|
||||||
data['StatusDescription'],
|
data['StatusDescription'],
|
||||||
data['StatusCode'],
|
data['StatusCode'],
|
||||||
data['ErrorMessage']['string'])
|
error_message)
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class GetStatus(SOAPService):
|
class GetStatus(SOAPService):
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ from contextlib import contextmanager
|
|||||||
from .data.dian import codelist
|
from .data.dian import codelist
|
||||||
from . import form
|
from . import form
|
||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
AMBIENTE_PRUEBAS = codelist.TipoAmbiente.by_name('Pruebas')['code']
|
AMBIENTE_PRUEBAS = codelist.TipoAmbiente.by_name('Pruebas')['code']
|
||||||
AMBIENTE_PRODUCCION = codelist.TipoAmbiente.by_name('Producción')['code']
|
AMBIENTE_PRODUCCION = codelist.TipoAmbiente.by_name('Producción')['code']
|
||||||
@@ -25,8 +26,9 @@ SCHEME_AGENCY_ATTRS = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
pwd = Path(__file__).parent
|
||||||
# RESOLUCION 0001: pagina 516
|
# RESOLUCION 0001: pagina 516
|
||||||
POLICY_ID = 'https://facturaelectronica.dian.gov.co/politicadefirma/v2/politicadefirmav2.pdf'
|
POLICY_ID = 'file://'+str(pwd)+'/data/dian/politicadefirmav2.pdf'
|
||||||
POLICY_NAME = u'Política de firma para facturas electrónicas de la República de Colombia.'
|
POLICY_NAME = u'Política de firma para facturas electrónicas de la República de Colombia.'
|
||||||
|
|
||||||
|
|
||||||
@@ -49,8 +51,7 @@ NAMESPACES = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
def fe_from_string(document: str) -> FachoXML:
|
def fe_from_string(document: str) -> FachoXML:
|
||||||
xml = LXMLBuilder.from_string(document)
|
return FeXML.from_string(document)
|
||||||
return FachoXML(xml, nsmap=NAMESPACES)
|
|
||||||
|
|
||||||
from contextlib import contextmanager
|
from contextlib import contextmanager
|
||||||
@contextmanager
|
@contextmanager
|
||||||
@@ -69,6 +70,7 @@ def mock_xades_policy():
|
|||||||
mock.return_value = UrllibPolicyMock()
|
mock.return_value = UrllibPolicyMock()
|
||||||
yield
|
yield
|
||||||
|
|
||||||
|
|
||||||
class FeXML(FachoXML):
|
class FeXML(FachoXML):
|
||||||
|
|
||||||
def __init__(self, root, namespace):
|
def __init__(self, root, namespace):
|
||||||
@@ -79,6 +81,10 @@ class FeXML(FachoXML):
|
|||||||
self._cn = root.rstrip('/')
|
self._cn = root.rstrip('/')
|
||||||
#self.find_or_create_element(self._cn)
|
#self.find_or_create_element(self._cn)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_string(cls, document: str) -> 'FeXML':
|
||||||
|
return super().from_string(document, namespaces=NAMESPACES)
|
||||||
|
|
||||||
def tostring(self, **kw):
|
def tostring(self, **kw):
|
||||||
return super().tostring(**kw)\
|
return super().tostring(**kw)\
|
||||||
.replace("fe:", "")\
|
.replace("fe:", "")\
|
||||||
@@ -185,14 +191,14 @@ class DianXMLExtensionCUFE(DianXMLExtensionCUDFE):
|
|||||||
'%s' % build_vars['NumFac'],
|
'%s' % build_vars['NumFac'],
|
||||||
'%s' % build_vars['FecFac'],
|
'%s' % build_vars['FecFac'],
|
||||||
'%s' % build_vars['HoraFac'],
|
'%s' % build_vars['HoraFac'],
|
||||||
form.Amount(build_vars['ValorBruto']).format('%.02f'),
|
form.Amount(build_vars['ValorBruto']).truncate_as_string(2),
|
||||||
CodImpuesto1,
|
CodImpuesto1,
|
||||||
form.Amount(build_vars['ValorImpuestoPara'].get(CodImpuesto1, 0.0)).format('%.02f'),
|
build_vars['ValorImpuestoPara'].get(CodImpuesto1, form.Amount(0.0)).truncate_as_string(2),
|
||||||
CodImpuesto2,
|
CodImpuesto2,
|
||||||
form.Amount(build_vars['ValorImpuestoPara'].get(CodImpuesto2, 0.0)).format('%.02f'),
|
build_vars['ValorImpuestoPara'].get(CodImpuesto2, form.Amount(0.0)).truncate_as_string(2),
|
||||||
CodImpuesto3,
|
CodImpuesto3,
|
||||||
form.Amount(build_vars['ValorImpuestoPara'].get(CodImpuesto3, 0.0)).format('%.02f'),
|
build_vars['ValorImpuestoPara'].get(CodImpuesto3, form.Amount(0.0)).truncate_as_string(2),
|
||||||
form.Amount(build_vars['ValorTotalPagar']).format('%.02f'),
|
build_vars['ValorTotalPagar'].truncate_as_string(2),
|
||||||
'%s' % build_vars['NitOFE'],
|
'%s' % build_vars['NitOFE'],
|
||||||
'%s' % build_vars['NumAdq'],
|
'%s' % build_vars['NumAdq'],
|
||||||
'%s' % build_vars['ClTec'],
|
'%s' % build_vars['ClTec'],
|
||||||
@@ -222,14 +228,14 @@ class DianXMLExtensionCUDE(DianXMLExtensionCUDFE):
|
|||||||
'%s' % build_vars['NumFac'],
|
'%s' % build_vars['NumFac'],
|
||||||
'%s' % build_vars['FecFac'],
|
'%s' % build_vars['FecFac'],
|
||||||
'%s' % build_vars['HoraFac'],
|
'%s' % build_vars['HoraFac'],
|
||||||
form.Amount(build_vars['ValorBruto']).format('%.02f'),
|
form.Amount(build_vars['ValorBruto']).truncate_as_string(2),
|
||||||
CodImpuesto1,
|
CodImpuesto1,
|
||||||
form.Amount(build_vars['ValorImpuestoPara'].get(CodImpuesto1, 0.0)).format('%.02f'),
|
form.Amount(build_vars['ValorImpuestoPara'].get(CodImpuesto1, 0.0)).truncate_as_string(2),
|
||||||
CodImpuesto2,
|
CodImpuesto2,
|
||||||
form.Amount(build_vars['ValorImpuestoPara'].get(CodImpuesto2, 0.0)).format('%.02f'),
|
form.Amount(build_vars['ValorImpuestoPara'].get(CodImpuesto2, 0.0)).truncate_as_string(2),
|
||||||
CodImpuesto3,
|
CodImpuesto3,
|
||||||
form.Amount(build_vars['ValorImpuestoPara'].get(CodImpuesto3, 0.0)).format('%.02f'),
|
form.Amount(build_vars['ValorImpuestoPara'].get(CodImpuesto3, 0.0)).truncate_as_string(2),
|
||||||
form.Amount(build_vars['ValorTotalPagar']).format('%.02f'),
|
form.Amount(build_vars['ValorTotalPagar']).truncate_as_string(2),
|
||||||
'%s' % build_vars['NitOFE'],
|
'%s' % build_vars['NitOFE'],
|
||||||
'%s' % build_vars['NumAdq'],
|
'%s' % build_vars['NumAdq'],
|
||||||
'%s' % build_vars['Software-PIN'],
|
'%s' % build_vars['Software-PIN'],
|
||||||
@@ -277,16 +283,24 @@ class DianXMLExtensionSoftwareSecurityCode(FachoXMLExtension):
|
|||||||
class DianXMLExtensionSigner:
|
class DianXMLExtensionSigner:
|
||||||
|
|
||||||
def __init__(self, pkcs12_path, passphrase=None, mockpolicy=False):
|
def __init__(self, pkcs12_path, passphrase=None, mockpolicy=False):
|
||||||
self._pkcs12_path = pkcs12_path
|
self._pkcs12_data = open(pkcs12_path, 'rb').read()
|
||||||
self._passphrase = None
|
self._passphrase = None
|
||||||
self._mockpolicy = mockpolicy
|
self._mockpolicy = mockpolicy
|
||||||
if passphrase:
|
if passphrase:
|
||||||
self._passphrase = passphrase.encode('utf-8')
|
self._passphrase = passphrase.encode('utf-8')
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_pkcs12(self, filepath, password=None):
|
def from_bytes(cls, data, passphrase=None, mockpolicy=False):
|
||||||
p12 = OpenSSL.crypto.load_pkcs12(open(filepath, 'rb').read(), password)
|
self = cls.__new__(cls)
|
||||||
|
|
||||||
|
self._pkcs12_data = data
|
||||||
|
self._passphrase = None
|
||||||
|
self._mockpolicy = mockpolicy
|
||||||
|
if passphrase:
|
||||||
|
self._passphrase = passphrase.encode('utf-8')
|
||||||
|
|
||||||
|
return self
|
||||||
|
|
||||||
def sign_xml_string(self, document):
|
def sign_xml_string(self, document):
|
||||||
xml = LXMLBuilder.from_string(document)
|
xml = LXMLBuilder.from_string(document)
|
||||||
signature = self.sign_xml_element(xml)
|
signature = self.sign_xml_element(xml)
|
||||||
@@ -341,7 +355,7 @@ class DianXMLExtensionSigner:
|
|||||||
POLICY_NAME,
|
POLICY_NAME,
|
||||||
xmlsig.constants.TransformSha256)
|
xmlsig.constants.TransformSha256)
|
||||||
ctx = xades.XAdESContext(policy)
|
ctx = xades.XAdESContext(policy)
|
||||||
ctx.load_pkcs12(OpenSSL.crypto.load_pkcs12(open(self._pkcs12_path, 'rb').read(),
|
ctx.load_pkcs12(OpenSSL.crypto.load_pkcs12(self._pkcs12_data,
|
||||||
self._passphrase))
|
self._passphrase))
|
||||||
|
|
||||||
if self._mockpolicy:
|
if self._mockpolicy:
|
||||||
@@ -360,6 +374,7 @@ class DianXMLExtensionSigner:
|
|||||||
extcontent = fachoxml.builder.xpath(fachoxml.root, './ext:UBLExtensions/ext:UBLExtension[2]/ext:ExtensionContent')
|
extcontent = fachoxml.builder.xpath(fachoxml.root, './ext:UBLExtensions/ext:UBLExtension[2]/ext:ExtensionContent')
|
||||||
fachoxml.append_element(extcontent, signature)
|
fachoxml.append_element(extcontent, signature)
|
||||||
|
|
||||||
|
|
||||||
class DianXMLExtensionAuthorizationProvider(FachoXMLExtension):
|
class DianXMLExtensionAuthorizationProvider(FachoXMLExtension):
|
||||||
# RESOLUCION 0004: pagina 176
|
# RESOLUCION 0004: pagina 176
|
||||||
|
|
||||||
@@ -429,7 +444,7 @@ class DianZIP:
|
|||||||
MAX_FILES = 50
|
MAX_FILES = 50
|
||||||
|
|
||||||
def __init__(self, file_like):
|
def __init__(self, file_like):
|
||||||
self.zipfile = zipfile.ZipFile(file_like, mode='w')
|
self.zipfile = zipfile.ZipFile(file_like, mode='w', compression=zipfile.ZIP_DEFLATED)
|
||||||
self.num_files = 0
|
self.num_files = 0
|
||||||
|
|
||||||
def add_invoice_xml(self, name, xml_data):
|
def add_invoice_xml(self, name, xml_data):
|
||||||
@@ -452,8 +467,8 @@ class DianZIP:
|
|||||||
|
|
||||||
class DianXMLExtensionSignerVerifier:
|
class DianXMLExtensionSignerVerifier:
|
||||||
|
|
||||||
def __init__(self, pkcs12_path, passphrase=None, mockpolicy=False):
|
def __init__(self, pkcs12_path_or_bytes, passphrase=None, mockpolicy=False):
|
||||||
self._pkcs12_path = pkcs12_path
|
self._pkcs12_path_or_bytes = pkcs12_path_or_bytes
|
||||||
self._passphrase = None
|
self._passphrase = None
|
||||||
self._mockpolicy = mockpolicy
|
self._mockpolicy = mockpolicy
|
||||||
if passphrase:
|
if passphrase:
|
||||||
@@ -470,7 +485,12 @@ class DianXMLExtensionSignerVerifier:
|
|||||||
fachoxml.root.append(signature)
|
fachoxml.root.append(signature)
|
||||||
|
|
||||||
ctx = xades.XAdESContext()
|
ctx = xades.XAdESContext()
|
||||||
ctx.load_pkcs12(OpenSSL.crypto.load_pkcs12(open(self._pkcs12_path, 'rb').read(),
|
|
||||||
|
pkcs12_data = self._pkcs12_path_or_bytes
|
||||||
|
if isinstance(self._pkcs12_path_or_bytes, str):
|
||||||
|
pkcs12_data = open(self._pkcs12_path_or_bytes, 'rb').read()
|
||||||
|
|
||||||
|
ctx.load_pkcs12(OpenSSL.crypto.load_pkcs12(pkcs12_data,
|
||||||
self._passphrase))
|
self._passphrase))
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
import hashlib
|
import hashlib
|
||||||
from functools import reduce
|
from functools import reduce
|
||||||
import copy
|
import copy
|
||||||
|
import dataclasses
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from datetime import datetime, date
|
from datetime import datetime, date
|
||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
@@ -53,8 +54,8 @@ class AmountCollection(Collection):
|
|||||||
return total
|
return total
|
||||||
|
|
||||||
class Amount:
|
class Amount:
|
||||||
def __init__(self, amount: int or float 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
|
#DIAN 1.7.-2020: 1.2.3.1
|
||||||
if isinstance(amount, Amount):
|
if isinstance(amount, Amount):
|
||||||
if amount < Amount(0.0):
|
if amount < Amount(0.0):
|
||||||
@@ -63,42 +64,60 @@ class Amount:
|
|||||||
self.amount = amount.amount
|
self.amount = amount.amount
|
||||||
self.currency = amount.currency
|
self.currency = amount.currency
|
||||||
else:
|
else:
|
||||||
if amount < 0:
|
if float(amount) < 0:
|
||||||
raise ValueError('amount must be positive >= 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
|
#DIAN 1.7.-2020: 1.2.1.1
|
||||||
rounding=decimal.ROUND_HALF_EVEN ))
|
rounding=decimal.ROUND_HALF_EVEN ))
|
||||||
self.currency = currency
|
self.currency = currency
|
||||||
|
|
||||||
|
def fromNumber(self, val):
|
||||||
|
return Amount(val, currency=self.currency)
|
||||||
|
|
||||||
|
def round(self, prec):
|
||||||
|
return Amount(round(self.amount, prec), currency=self.currency)
|
||||||
|
|
||||||
def __round__(self, prec):
|
def __round__(self, prec):
|
||||||
return round(self.amount, prec)
|
return round(self.amount, prec)
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return '%.06f' % self.amount
|
return str(self.float())
|
||||||
|
|
||||||
def __lt__(self, other):
|
def __lt__(self, other):
|
||||||
if not self.is_same_currency(other):
|
if not self.is_same_currency(other):
|
||||||
raise AmountCurrencyError()
|
raise AmountCurrencyError()
|
||||||
return round(self.amount, DECIMAL_PRECISION) < round(other, 2)
|
return round(self.amount, self.precision) < round(other, 2)
|
||||||
|
|
||||||
def __eq__(self, other):
|
def __eq__(self, other):
|
||||||
if not self.is_same_currency(other):
|
if not self.is_same_currency(other):
|
||||||
raise AmountCurrencyError()
|
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 __add__(self, other):
|
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):
|
||||||
|
other = self._cast(rother)
|
||||||
if not self.is_same_currency(other):
|
if not self.is_same_currency(other):
|
||||||
raise AmountCurrencyError()
|
raise AmountCurrencyError()
|
||||||
return Amount(self.amount + other.amount, self.currency)
|
return Amount(self.amount + other.amount, self.currency)
|
||||||
|
|
||||||
def __sub__(self, other):
|
def __sub__(self, rother):
|
||||||
|
other = self._cast(rother)
|
||||||
if not self.is_same_currency(other):
|
if not self.is_same_currency(other):
|
||||||
raise AmountCurrencyError()
|
raise AmountCurrencyError()
|
||||||
return Amount(self.amount - other.amount, self.currency)
|
return Amount(self.amount - other.amount, self.currency)
|
||||||
|
|
||||||
def __mul__(self, other):
|
def __mul__(self, rother):
|
||||||
|
other = self._cast(rother)
|
||||||
if not self.is_same_currency(other):
|
if not self.is_same_currency(other):
|
||||||
raise AmountCurrencyError()
|
raise AmountCurrencyError()
|
||||||
return Amount(self.amount * other.amount, self.currency)
|
return Amount(self.amount * other.amount, self.currency)
|
||||||
@@ -106,8 +125,9 @@ class Amount:
|
|||||||
def is_same_currency(self, other):
|
def is_same_currency(self, other):
|
||||||
return self.currency == other.currency
|
return self.currency == other.currency
|
||||||
|
|
||||||
def format(self, formatter):
|
def truncate_as_string(self, prec):
|
||||||
return formatter % self.float()
|
parts = str(self.float()).split('.', 1)
|
||||||
|
return '%s.%s' % (parts[0], parts[1][0:prec].ljust(prec,'0'))
|
||||||
|
|
||||||
def float(self):
|
def float(self):
|
||||||
return float(round(self.amount, DECIMAL_PRECISION))
|
return float(round(self.amount, DECIMAL_PRECISION))
|
||||||
@@ -116,27 +136,25 @@ class Amount:
|
|||||||
class Quantity:
|
class Quantity:
|
||||||
|
|
||||||
def __init__(self, val, code):
|
def __init__(self, val, code):
|
||||||
if not isinstance(val, int):
|
if type(val) not in [float, int]:
|
||||||
raise ValueError('val expected int')
|
raise ValueError('val expected int or float')
|
||||||
if code not in codelist.UnidadesMedida:
|
if code not in codelist.UnidadesMedida:
|
||||||
raise ValueError("code [%s] not found" % (code))
|
raise ValueError("code [%s] not found" % (code))
|
||||||
|
|
||||||
self.value = val
|
self.value = Amount(val)
|
||||||
self.code = code
|
self.code = code
|
||||||
|
|
||||||
def __mul__(self, other):
|
def __mul__(self, other):
|
||||||
if isinstance(other, Amount):
|
|
||||||
return Amount(self.value) * other
|
|
||||||
return self.value * other
|
return self.value * other
|
||||||
|
|
||||||
def __lt__(self, other):
|
def __lt__(self, other):
|
||||||
if isinstance(other, Amount):
|
|
||||||
return Amount(self.value) < other
|
|
||||||
return self.value < other
|
return self.value < other
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return str(self.value)
|
return str(self.value)
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return str(self)
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class Item:
|
class Item:
|
||||||
@@ -323,11 +341,15 @@ class Price:
|
|||||||
amount: Amount
|
amount: Amount
|
||||||
type_code: str
|
type_code: str
|
||||||
type: str
|
type: str
|
||||||
|
quantity: int = 1
|
||||||
|
|
||||||
def __post_init__(self):
|
def __post_init__(self):
|
||||||
if self.type_code not in codelist.CodigoPrecioReferencia:
|
if self.type_code not in codelist.CodigoPrecioReferencia:
|
||||||
raise ValueError("type_code [%s] not found" % (self.type_code))
|
raise ValueError("type_code [%s] not found" % (self.type_code))
|
||||||
|
if not isinstance(self.quantity, int):
|
||||||
|
raise ValueError("quantity must be int")
|
||||||
|
|
||||||
|
self.amount *= self.quantity
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class PaymentMean:
|
class PaymentMean:
|
||||||
@@ -378,6 +400,52 @@ class InvoiceDocumentReference(BillingReference):
|
|||||||
date: fecha de emision de la nota credito relacionada
|
date: fecha de emision de la nota credito relacionada
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class AllowanceChargeReason:
|
||||||
|
code: str
|
||||||
|
reason: str
|
||||||
|
|
||||||
|
def __post_init__(self):
|
||||||
|
if self.code not in codelist.CodigoDescuento:
|
||||||
|
raise ValueError("code [%s] not found" % (self.code))
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class AllowanceCharge:
|
||||||
|
#DIAN 1.7.-2020: FAQ03
|
||||||
|
charge_indicator: bool = True
|
||||||
|
amount: Amount = Amount(0.0)
|
||||||
|
reason: AllowanceChargeReason = None
|
||||||
|
|
||||||
|
#Valor Base para calcular el descuento o el cargo
|
||||||
|
base_amount: typing.Optional[Amount] = Amount(0.0)
|
||||||
|
|
||||||
|
# Porcentaje: Porcentaje que aplicar.
|
||||||
|
multiplier_factor_numeric: Amount = Amount(1.0)
|
||||||
|
|
||||||
|
def isCharge(self):
|
||||||
|
return self.charge_indicator == True
|
||||||
|
|
||||||
|
def isDiscount(self):
|
||||||
|
return self.charge_indicator == False
|
||||||
|
|
||||||
|
def asCharge(self):
|
||||||
|
self.charge_indicator = True
|
||||||
|
|
||||||
|
def asDiscount(self):
|
||||||
|
self.charge_indicator = False
|
||||||
|
|
||||||
|
def hasReason(self):
|
||||||
|
return self.reason is not None
|
||||||
|
|
||||||
|
def set_base_amount(self, amount):
|
||||||
|
self.base_amount = amount
|
||||||
|
|
||||||
|
class AllowanceChargeAsDiscount(AllowanceCharge):
|
||||||
|
def __init__(self, amount: Amount = Amount(0.0)):
|
||||||
|
self.charge_indicator = False
|
||||||
|
self.amount = amount
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class InvoiceLine:
|
class InvoiceLine:
|
||||||
# RESOLUCION 0004: pagina 155
|
# RESOLUCION 0004: pagina 155
|
||||||
@@ -391,9 +459,31 @@ class InvoiceLine:
|
|||||||
# de subtotal
|
# de subtotal
|
||||||
tax: typing.Optional[TaxTotal]
|
tax: typing.Optional[TaxTotal]
|
||||||
|
|
||||||
|
allowance_charge: typing.List[AllowanceCharge] = dataclasses.field(default_factory=list)
|
||||||
|
|
||||||
|
def add_allowance_charge(self, charge):
|
||||||
|
if not isinstance(charge, AllowanceCharge):
|
||||||
|
raise TypeError('charge invalid type expected AllowanceCharge')
|
||||||
|
charge.set_base_amount(self.total_amount_without_charge)
|
||||||
|
self.allowance_charge.add(charge)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def total_amount_without_charge(self):
|
||||||
|
return (self.quantity * self.price.amount)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def total_amount(self):
|
def total_amount(self):
|
||||||
return self.quantity * self.price.amount
|
charge = AmountCollection(self.allowance_charge)\
|
||||||
|
.filter(lambda charge: charge.isCharge())\
|
||||||
|
.map(lambda charge: charge.amount)\
|
||||||
|
.sum()
|
||||||
|
|
||||||
|
discount = AmountCollection(self.allowance_charge)\
|
||||||
|
.filter(lambda charge: charge.isDiscount())\
|
||||||
|
.map(lambda charge: charge.amount)\
|
||||||
|
.sum()
|
||||||
|
|
||||||
|
return self.total_amount_without_charge + charge - discount
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def total_tax_inclusive_amount(self):
|
def total_tax_inclusive_amount(self):
|
||||||
@@ -441,37 +531,6 @@ class LegalMonetaryTotal:
|
|||||||
- self.prepaid_amount
|
- self.prepaid_amount
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class AllowanceChargeReason:
|
|
||||||
code: str
|
|
||||||
reason: str
|
|
||||||
|
|
||||||
def __post_init__(self):
|
|
||||||
if self.code not in codelist.CodigoDescuento:
|
|
||||||
raise ValueError("code [%s] not found" % (self.code))
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class AllowanceCharge:
|
|
||||||
#DIAN 1.7.-2020: FAQ03
|
|
||||||
charge_indicator: bool = True
|
|
||||||
amount: Amount = Amount(0.0)
|
|
||||||
reason: AllowanceChargeReason = None
|
|
||||||
|
|
||||||
def isCharge(self):
|
|
||||||
return self.charge_indicator == True
|
|
||||||
|
|
||||||
def isDiscount(self):
|
|
||||||
return self.charge_indicator == False
|
|
||||||
|
|
||||||
def asCharge(self):
|
|
||||||
self.charge_indicator = True
|
|
||||||
|
|
||||||
def asDiscount(self):
|
|
||||||
self.charge_indicator = False
|
|
||||||
|
|
||||||
def hasReason(self):
|
|
||||||
return self.reason is not None
|
|
||||||
|
|
||||||
class NationalSalesInvoiceDocumentType(str):
|
class NationalSalesInvoiceDocumentType(str):
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
@@ -570,7 +629,7 @@ class Invoice:
|
|||||||
|
|
||||||
self.invoice_operation_type = operation
|
self.invoice_operation_type = operation
|
||||||
|
|
||||||
def add_allownace_charge(self, charge: AllowanceCharge):
|
def add_allowance_charge(self, charge: AllowanceCharge):
|
||||||
self.invoice_allowance_charge.append(charge)
|
self.invoice_allowance_charge.append(charge)
|
||||||
|
|
||||||
def add_invoice_line(self, line: InvoiceLine):
|
def add_invoice_line(self, line: InvoiceLine):
|
||||||
@@ -618,11 +677,22 @@ class Invoice:
|
|||||||
#DIAN 1.7.-2020: FAU14
|
#DIAN 1.7.-2020: FAU14
|
||||||
self.invoice_legal_monetary_total.calculate()
|
self.invoice_legal_monetary_total.calculate()
|
||||||
|
|
||||||
|
def _refresh_charges_base_amount(self):
|
||||||
|
if self.invoice_allowance_charge:
|
||||||
|
for invline in self.invoice_lines:
|
||||||
|
if invline.allowance_charge:
|
||||||
|
# TODO actualmente solo uno de los cargos es permitido
|
||||||
|
raise ValueError('allowance charge in invoice exclude invoice line')
|
||||||
|
|
||||||
|
# cargos a nivel de factura
|
||||||
|
for charge in self.invoice_allowance_charge:
|
||||||
|
charge.set_base_amount(self.invoice_legal_monetary_total.line_extension_amount)
|
||||||
|
|
||||||
def calculate(self):
|
def calculate(self):
|
||||||
for invline in self.invoice_lines:
|
for invline in self.invoice_lines:
|
||||||
invline.calculate()
|
invline.calculate()
|
||||||
self._calculate_legal_monetary_total()
|
self._calculate_legal_monetary_total()
|
||||||
|
self._refresh_charges_base_amount()
|
||||||
|
|
||||||
class NationalSalesInvoice(Invoice):
|
class NationalSalesInvoice(Invoice):
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
|
|||||||
@@ -540,10 +540,31 @@ class DIANInvoiceXML(fe.FeXML):
|
|||||||
line.set_element('./cac:Price/cbc:PriceAmount', invoice_line.price.amount, currencyID=invoice_line.price.amount.currency.code)
|
line.set_element('./cac:Price/cbc:PriceAmount', invoice_line.price.amount, currencyID=invoice_line.price.amount.currency.code)
|
||||||
#DIAN 1.7.-2020: FBB04
|
#DIAN 1.7.-2020: FBB04
|
||||||
line.set_element('./cac:Price/cbc:BaseQuantity',
|
line.set_element('./cac:Price/cbc:BaseQuantity',
|
||||||
invoice_line.quantity,
|
invoice_line.price.quantity,
|
||||||
unitCode=invoice_line.quantity.code)
|
unitCode=invoice_line.quantity.code)
|
||||||
|
|
||||||
|
for idx, charge in enumerate(invoice_line.allowance_charge):
|
||||||
|
next_append_charge = idx > 0
|
||||||
|
fexml.append_allowance_charge(line, index + 1, charge, append=next_append_charge)
|
||||||
|
|
||||||
|
def set_allowance_charge(fexml, invoice):
|
||||||
|
for idx, charge in enumerate(invoice.invoice_allowance_charge):
|
||||||
|
next_append = idx > 0
|
||||||
|
fexml.append_allowance_charge(fexml, idx + 1, charge, append=next_append)
|
||||||
|
|
||||||
|
def append_allowance_charge(fexml, parent, idx, charge, append=False):
|
||||||
|
line = parent.fragment('./cac:AllowanceCharge', append=append)
|
||||||
|
#DIAN 1.7.-2020: FAQ02
|
||||||
|
line.set_element('./cbc:ID', idx)
|
||||||
|
#DIAN 1.7.-2020: FAQ03
|
||||||
|
line.set_element('./cbc:ChargeIndicator', str(charge.charge_indicator).lower())
|
||||||
|
if charge.reason:
|
||||||
|
line.set_element('./cbc:AllowanceChargeReasonCode', charge.reason.code)
|
||||||
|
line.set_element('./cbc:allowanceChargeReason', charge.reason.reason)
|
||||||
|
line.set_element('./cbc:MultiplierFactorNumeric', str(round(charge.multiplier_factor_numeric, 2)))
|
||||||
|
fexml.set_element_amount_for(line, './cbc:Amount', charge.amount)
|
||||||
|
fexml.set_element_amount_for(line, './cbc:BaseAmount', charge.base_amount)
|
||||||
|
|
||||||
def attach_invoice(fexml, invoice):
|
def attach_invoice(fexml, invoice):
|
||||||
"""adiciona etiquetas a FEXML y retorna FEXML
|
"""adiciona etiquetas a FEXML y retorna FEXML
|
||||||
en caso de fallar validacion retorna None"""
|
en caso de fallar validacion retorna None"""
|
||||||
@@ -579,6 +600,7 @@ class DIANInvoiceXML(fe.FeXML):
|
|||||||
fexml.set_invoice_totals(invoice)
|
fexml.set_invoice_totals(invoice)
|
||||||
fexml.set_invoice_lines(invoice)
|
fexml.set_invoice_lines(invoice)
|
||||||
fexml.set_payment_mean(invoice)
|
fexml.set_payment_mean(invoice)
|
||||||
|
fexml.set_allowance_charge(invoice)
|
||||||
fexml.set_billing_reference(invoice)
|
fexml.set_billing_reference(invoice)
|
||||||
|
|
||||||
return fexml
|
return fexml
|
||||||
|
|||||||
367
facho/fe/model/__init__.py
Normal file
367
facho/fe/model/__init__.py
Normal file
@@ -0,0 +1,367 @@
|
|||||||
|
import facho.model as model
|
||||||
|
import facho.model.fields as fields
|
||||||
|
import facho.fe.form as form
|
||||||
|
from facho import fe
|
||||||
|
from .common import *
|
||||||
|
from . import dian
|
||||||
|
|
||||||
|
from datetime import date, datetime
|
||||||
|
from collections import defaultdict
|
||||||
|
from copy import copy
|
||||||
|
import hashlib
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
class PhysicalLocation(model.Model):
|
||||||
|
__name__ = 'PhysicalLocation'
|
||||||
|
|
||||||
|
address = fields.Many2One(Address, namespace='cac')
|
||||||
|
|
||||||
|
class PartyTaxScheme(model.Model):
|
||||||
|
__name__ = 'PartyTaxScheme'
|
||||||
|
|
||||||
|
registration_name = fields.Many2One(Name, name='RegistrationName', namespace='cbc')
|
||||||
|
company_id = fields.Many2One(ID, name='CompanyID', namespace='cbc')
|
||||||
|
tax_level_code = fields.Many2One(ID, name='TaxLevelCode', namespace='cbc', default='ZZ')
|
||||||
|
|
||||||
|
|
||||||
|
class Party(model.Model):
|
||||||
|
__name__ = 'Party'
|
||||||
|
|
||||||
|
id = fields.Virtual(setter='_on_set_id')
|
||||||
|
name = fields.Many2One(PartyName, namespace='cac')
|
||||||
|
|
||||||
|
tax_scheme = fields.Many2One(PartyTaxScheme, namespace='cac')
|
||||||
|
location = fields.Many2One(PhysicalLocation, namespace='cac')
|
||||||
|
contact = fields.Many2One(Contact, namespace='cac')
|
||||||
|
|
||||||
|
def _on_set_id(self, name, value):
|
||||||
|
self.tax_scheme.company_id = value
|
||||||
|
return value
|
||||||
|
|
||||||
|
class AccountingCustomerParty(model.Model):
|
||||||
|
__name__ = 'AccountingCustomerParty'
|
||||||
|
|
||||||
|
party = fields.Many2One(Party, namespace='cac')
|
||||||
|
|
||||||
|
class AccountingSupplierParty(model.Model):
|
||||||
|
__name__ = 'AccountingSupplierParty'
|
||||||
|
|
||||||
|
party = fields.Many2One(Party, namespace='cac')
|
||||||
|
|
||||||
|
class Quantity(model.Model):
|
||||||
|
__name__ = 'Quantity'
|
||||||
|
|
||||||
|
code = fields.Attribute('unitCode', default='NAR')
|
||||||
|
|
||||||
|
def __setup__(self):
|
||||||
|
self.value = 0
|
||||||
|
|
||||||
|
def __default_set__(self, value):
|
||||||
|
self.value = value
|
||||||
|
return value
|
||||||
|
|
||||||
|
def __default_get__(self, name, value):
|
||||||
|
return self.value
|
||||||
|
|
||||||
|
class Amount(model.Model):
|
||||||
|
__name__ = 'Amount'
|
||||||
|
|
||||||
|
currency = fields.Attribute('currencyID', default='COP')
|
||||||
|
value = fields.Amount(name='amount', default=0.00, precision=2)
|
||||||
|
|
||||||
|
def __default_set__(self, value):
|
||||||
|
self.value = value
|
||||||
|
return value
|
||||||
|
|
||||||
|
def __default_get__(self, name, value):
|
||||||
|
return self.value
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return str(self.value)
|
||||||
|
|
||||||
|
class Price(model.Model):
|
||||||
|
__name__ = 'Price'
|
||||||
|
|
||||||
|
amount = fields.Many2One(Amount, name='PriceAmount', namespace='cbc')
|
||||||
|
|
||||||
|
def __default_set__(self, value):
|
||||||
|
self.amount = value
|
||||||
|
return value
|
||||||
|
|
||||||
|
def __default_get__(self, name, value):
|
||||||
|
return self.amount
|
||||||
|
|
||||||
|
class Percent(model.Model):
|
||||||
|
__name__ = 'Percent'
|
||||||
|
|
||||||
|
class TaxScheme(model.Model):
|
||||||
|
__name__ = 'TaxScheme'
|
||||||
|
|
||||||
|
id = fields.Many2One(ID, namespace='cbc')
|
||||||
|
name= fields.Many2One(Name, namespace='cbc')
|
||||||
|
|
||||||
|
class TaxCategory(model.Model):
|
||||||
|
__name__ = 'TaxCategory'
|
||||||
|
|
||||||
|
percent = fields.Many2One(Percent, namespace='cbc')
|
||||||
|
tax_scheme = fields.Many2One(TaxScheme, namespace='cac')
|
||||||
|
|
||||||
|
class TaxSubTotal(model.Model):
|
||||||
|
__name__ = 'TaxSubTotal'
|
||||||
|
|
||||||
|
taxable_amount = fields.Many2One(Amount, name='TaxableAmount', namespace='cbc', default=0.00)
|
||||||
|
tax_amount = fields.Many2One(Amount, name='TaxAmount', namespace='cbc', default=0.00)
|
||||||
|
tax_percent = fields.Many2One(Percent, namespace='cbc')
|
||||||
|
tax_category = fields.Many2One(TaxCategory, namespace='cac')
|
||||||
|
|
||||||
|
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) 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', namespace='cbc', default=0.00)
|
||||||
|
subtotals = fields.One2Many(TaxSubTotal, namespace='cac')
|
||||||
|
|
||||||
|
|
||||||
|
class AllowanceCharge(model.Model):
|
||||||
|
__name__ = 'AllowanceCharge'
|
||||||
|
|
||||||
|
amount = fields.Many2One(Amount, namespace='cbc')
|
||||||
|
is_discount = fields.Virtual(default=False)
|
||||||
|
|
||||||
|
def isCharge(self):
|
||||||
|
return self.is_discount == False
|
||||||
|
|
||||||
|
def isDiscount(self):
|
||||||
|
return self.is_discount == True
|
||||||
|
|
||||||
|
class Taxes:
|
||||||
|
class Scheme:
|
||||||
|
def __init__(self, scheme):
|
||||||
|
self.scheme = scheme
|
||||||
|
|
||||||
|
class Iva(Scheme):
|
||||||
|
def __init__(self, percent):
|
||||||
|
super().__init__('01')
|
||||||
|
self.percent = percent
|
||||||
|
|
||||||
|
def calculate(self, amount):
|
||||||
|
return form.Amount(amount) * form.Amount(self.percent / 100)
|
||||||
|
|
||||||
|
class InvoiceLine(model.Model):
|
||||||
|
__name__ = 'InvoiceLine'
|
||||||
|
|
||||||
|
id = fields.Many2One(ID, namespace='cbc')
|
||||||
|
quantity = fields.Many2One(Quantity, name='InvoicedQuantity', namespace='cbc')
|
||||||
|
taxtotal = fields.Many2One(TaxTotal, namespace='cac')
|
||||||
|
price = fields.Many2One(Price, namespace='cac')
|
||||||
|
amount = fields.Many2One(Amount, name='LineExtensionAmount', namespace='cbc')
|
||||||
|
allowance_charge = fields.One2Many(AllowanceCharge, 'cac')
|
||||||
|
tax_amount = fields.Virtual(getter='get_tax_amount')
|
||||||
|
|
||||||
|
def __setup__(self):
|
||||||
|
self._taxs = defaultdict(list)
|
||||||
|
self._subtotals = {}
|
||||||
|
|
||||||
|
def add_tax(self, tax):
|
||||||
|
if not isinstance(tax, Taxes.Scheme):
|
||||||
|
raise ValueError('tax expected TaxIva')
|
||||||
|
|
||||||
|
# inicialiamos subtotal para impuesto
|
||||||
|
if not tax.scheme in self._subtotals:
|
||||||
|
subtotal = self.taxtotal.subtotals.create()
|
||||||
|
subtotal.scheme = tax.scheme
|
||||||
|
|
||||||
|
self._subtotals[tax.scheme] = subtotal
|
||||||
|
|
||||||
|
self._taxs[tax.scheme].append(tax)
|
||||||
|
|
||||||
|
def get_tax_amount(self, name, value):
|
||||||
|
total = form.Amount(0)
|
||||||
|
for (scheme, subtotal) in self._subtotals.items():
|
||||||
|
total += subtotal.tax_amount
|
||||||
|
|
||||||
|
return total
|
||||||
|
|
||||||
|
@fields.on_change(['price', 'quantity'])
|
||||||
|
def update_amount(self, name, value):
|
||||||
|
charge = form.AmountCollection(self.allowance_charge)\
|
||||||
|
.filter(lambda charge: charge.isCharge())\
|
||||||
|
.map(lambda charge: charge.amount)\
|
||||||
|
.sum()
|
||||||
|
|
||||||
|
discount = form.AmountCollection(self.allowance_charge)\
|
||||||
|
.filter(lambda charge: charge.isDiscount())\
|
||||||
|
.map(lambda charge: charge.amount)\
|
||||||
|
.sum()
|
||||||
|
|
||||||
|
total = form.Amount(self.quantity) * form.Amount(self.price)
|
||||||
|
self.amount = total + charge - discount
|
||||||
|
|
||||||
|
for (scheme, subtotal) in self._subtotals.items():
|
||||||
|
subtotal.tax_amount = 0
|
||||||
|
|
||||||
|
for (scheme, taxes) in self._taxs.items():
|
||||||
|
for tax in taxes:
|
||||||
|
self._subtotals[scheme].tax_amount += tax.calculate(self.amount)
|
||||||
|
|
||||||
|
class LegalMonetaryTotal(model.Model):
|
||||||
|
__name__ = 'LegalMonetaryTotal'
|
||||||
|
|
||||||
|
line_extension_amount = fields.Many2One(Amount, name='LineExtensionAmount', namespace='cbc', default=0)
|
||||||
|
|
||||||
|
tax_exclusive_amount = fields.Many2One(Amount, name='TaxExclusiveAmount', namespace='cbc', default=form.Amount(0))
|
||||||
|
tax_inclusive_amount = fields.Many2One(Amount, name='TaxInclusiveAmount', namespace='cbc', default=form.Amount(0))
|
||||||
|
charge_total_amount = fields.Many2One(Amount, name='ChargeTotalAmount', namespace='cbc', default=form.Amount(0))
|
||||||
|
payable_amount = fields.Many2One(Amount, name='PayableAmount', namespace='cbc', 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 + self.charge_total_amount
|
||||||
|
|
||||||
|
|
||||||
|
class DIANExtensionContent(model.Model):
|
||||||
|
__name__ = 'ExtensionContent'
|
||||||
|
|
||||||
|
dian = fields.Many2One(dian.DianExtensions, name='DianExtensions', namespace='sts')
|
||||||
|
|
||||||
|
class DIANExtension(model.Model):
|
||||||
|
__name__ = 'UBLExtension'
|
||||||
|
|
||||||
|
content = fields.Many2One(DIANExtensionContent, namespace='ext')
|
||||||
|
|
||||||
|
def __default_get__(self, name, value):
|
||||||
|
return self.content.dian
|
||||||
|
|
||||||
|
class UBLExtension(model.Model):
|
||||||
|
__name__ = 'UBLExtension'
|
||||||
|
|
||||||
|
content = fields.Many2One(Element, name='ExtensionContent', namespace='ext', default='')
|
||||||
|
|
||||||
|
class UBLExtensions(model.Model):
|
||||||
|
__name__ = 'UBLExtensions'
|
||||||
|
|
||||||
|
dian = fields.Many2One(DIANExtension, namespace='ext', create=True)
|
||||||
|
extension = fields.Many2One(UBLExtension, namespace='ext', create=True)
|
||||||
|
|
||||||
|
class Invoice(model.Model):
|
||||||
|
__name__ = 'Invoice'
|
||||||
|
__namespace__ = fe.NAMESPACES
|
||||||
|
|
||||||
|
_ubl_extensions = fields.Many2One(UBLExtensions, namespace='ext')
|
||||||
|
# nos interesa el acceso solo los atributos de la DIAN
|
||||||
|
dian = fields.Virtual(getter='get_dian_extension')
|
||||||
|
|
||||||
|
profile_id = fields.Many2One(Element, name='ProfileID', namespace='cbc', default='DIAN 2.1')
|
||||||
|
profile_execute_id = fields.Many2One(Element, name='ProfileExecuteID', namespace='cbc', default='2')
|
||||||
|
|
||||||
|
id = fields.Many2One(ID, namespace='cbc')
|
||||||
|
issue = fields.Virtual(setter='set_issue')
|
||||||
|
issue_date = fields.Many2One(Date, name='IssueDate', namespace='cbc')
|
||||||
|
issue_time = fields.Many2One(Time, name='IssueTime', namespace='cbc')
|
||||||
|
|
||||||
|
period = fields.Many2One(Period, name='InvoicePeriod', namespace='cac')
|
||||||
|
|
||||||
|
supplier = fields.Many2One(AccountingSupplierParty, namespace='cac')
|
||||||
|
customer = fields.Many2One(AccountingCustomerParty, namespace='cac')
|
||||||
|
legal_monetary_total = fields.Many2One(LegalMonetaryTotal, namespace='cac')
|
||||||
|
lines = fields.One2Many(InvoiceLine, namespace='cac')
|
||||||
|
|
||||||
|
taxtotal_01 = fields.Many2One(TaxTotal)
|
||||||
|
taxtotal_04 = fields.Many2One(TaxTotal)
|
||||||
|
taxtotal_03 = fields.Many2One(TaxTotal)
|
||||||
|
|
||||||
|
def __setup__(self):
|
||||||
|
self._namespace_prefix = 'fe'
|
||||||
|
# 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 cufe(self, token, environment):
|
||||||
|
|
||||||
|
valor_bruto = self.legal_monetary_total.line_extension_amount
|
||||||
|
valor_total_pagar = self.legal_monetary_total.payable_amount
|
||||||
|
|
||||||
|
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:
|
||||||
|
if subtotal.scheme.id == '01':
|
||||||
|
valor_impuesto_01 += subtotal.tax_amount
|
||||||
|
elif subtotal.scheme.id == '04':
|
||||||
|
valor_impuesto_04 += subtotal.tax_amount
|
||||||
|
elif subtotal.scheme.id == '03':
|
||||||
|
valor_impuesto_03 += subtotal.tax_amount
|
||||||
|
|
||||||
|
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(token),
|
||||||
|
str(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 = 0
|
||||||
|
self.legal_monetary_total.tax_inclusive_amount = 0
|
||||||
|
|
||||||
|
for line in self.lines:
|
||||||
|
self.legal_monetary_total.line_extension_amount += line.amount
|
||||||
|
self.legal_monetary_total.tax_inclusive_amount += line.amount + line.tax_amount
|
||||||
|
|
||||||
|
def set_issue(self, name, value):
|
||||||
|
if not isinstance(value, datetime):
|
||||||
|
raise ValueError('expected type datetime')
|
||||||
|
self.issue_date = value.date()
|
||||||
|
self.issue_time = value
|
||||||
|
|
||||||
|
def get_dian_extension(self, name, _value):
|
||||||
|
return self._ubl_extensions.dian
|
||||||
|
|
||||||
|
def to_xml(self, **kw):
|
||||||
|
# al generar documento el namespace
|
||||||
|
# se hace respecto a la raiz
|
||||||
|
return super().to_xml(**kw)\
|
||||||
|
.replace("fe:", "")\
|
||||||
|
.replace("xmlns:fe", "xmlns")
|
||||||
90
facho/fe/model/common.py
Normal file
90
facho/fe/model/common.py
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
import facho.model as model
|
||||||
|
import facho.model.fields as fields
|
||||||
|
|
||||||
|
from datetime import date, datetime
|
||||||
|
|
||||||
|
__all__ = ['Element', 'PartyName', 'Name', 'Date', 'Time', 'Period', 'ID', 'Address', 'Country', 'Contact']
|
||||||
|
|
||||||
|
class Element(model.Model):
|
||||||
|
"""
|
||||||
|
Lo usuamos para elementos que solo manejan contenido
|
||||||
|
"""
|
||||||
|
__name__ = 'Element'
|
||||||
|
|
||||||
|
class Name(model.Model):
|
||||||
|
__name__ = 'Name'
|
||||||
|
|
||||||
|
class Date(model.Model):
|
||||||
|
__name__ = 'Date'
|
||||||
|
|
||||||
|
def __default_set__(self, value):
|
||||||
|
if isinstance(value, str):
|
||||||
|
return value
|
||||||
|
if isinstance(value, date):
|
||||||
|
return value.isoformat()
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return str(self._value)
|
||||||
|
|
||||||
|
class Time(model.Model):
|
||||||
|
__name__ = 'Time'
|
||||||
|
|
||||||
|
def __default_set__(self, value):
|
||||||
|
if isinstance(value, str):
|
||||||
|
return value
|
||||||
|
if isinstance(value, date):
|
||||||
|
return value.strftime('%H:%M:%S-05:00')
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return str(self._value)
|
||||||
|
|
||||||
|
class Period(model.Model):
|
||||||
|
__name__ = 'Period'
|
||||||
|
|
||||||
|
start_date = fields.Many2One(Date, name='StartDate', namespace='cbc')
|
||||||
|
|
||||||
|
end_date = fields.Many2One(Date, name='EndDate', namespace='cbc')
|
||||||
|
|
||||||
|
class ID(model.Model):
|
||||||
|
__name__ = 'ID'
|
||||||
|
|
||||||
|
def __default_get__(self, name, value):
|
||||||
|
return self._value
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return str(self._value)
|
||||||
|
|
||||||
|
|
||||||
|
class Country(model.Model):
|
||||||
|
__name__ = 'Country'
|
||||||
|
|
||||||
|
name = fields.Many2One(Element, name='Name', namespace='cbc')
|
||||||
|
|
||||||
|
class Address(model.Model):
|
||||||
|
__name__ = 'Address'
|
||||||
|
|
||||||
|
#DIAN 1.7.-2020: FAJ08
|
||||||
|
#DIAN 1.7.-2020: CAJ09
|
||||||
|
id = fields.Many2One(Element, name='ID', namespace='cbc')
|
||||||
|
|
||||||
|
#DIAN 1.7.-2020: FAJ09
|
||||||
|
#DIAN 1.7.-2020: CAJ10
|
||||||
|
city = fields.Many2One(Element, name='CityName', namespace='cbc')
|
||||||
|
|
||||||
|
|
||||||
|
class PartyName(model.Model):
|
||||||
|
__name__ = 'PartyName'
|
||||||
|
|
||||||
|
name = fields.Many2One(Name, namespace='cbc')
|
||||||
|
|
||||||
|
def __default_set__(self, value):
|
||||||
|
self.name = value
|
||||||
|
return value
|
||||||
|
|
||||||
|
def __default_get__(self, name, value):
|
||||||
|
return self.name
|
||||||
|
|
||||||
|
class Contact(model.Model):
|
||||||
|
__name__ = 'Contact'
|
||||||
|
|
||||||
|
email = fields.Many2One(Name, name='ElectronicEmail', namespace='cbc')
|
||||||
58
facho/fe/model/dian.py
Normal file
58
facho/fe/model/dian.py
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
import facho.model as model
|
||||||
|
import facho.model.fields as fields
|
||||||
|
from .common import *
|
||||||
|
|
||||||
|
class DIANElement(Element):
|
||||||
|
"""
|
||||||
|
Elemento que contiene atributos por defecto.
|
||||||
|
|
||||||
|
Puede extender esta clase y modificar los atributos nuevamente
|
||||||
|
"""
|
||||||
|
__name__ = 'DIANElement'
|
||||||
|
|
||||||
|
scheme_id = fields.Attribute('schemeID', default='4')
|
||||||
|
scheme_name = fields.Attribute('schemeName', default='31')
|
||||||
|
scheme_agency_name = fields.Attribute('schemeAgencyName', default='CO, DIAN (Dirección de Impuestos y Aduanas Nacionales)')
|
||||||
|
scheme_agency_id = fields.Attribute('schemeAgencyID', default='195')
|
||||||
|
|
||||||
|
class SoftwareProvider(model.Model):
|
||||||
|
__name__ = 'SoftwareProvider'
|
||||||
|
|
||||||
|
provider_id = fields.Many2One(Element, name='ProviderID', namespace='sts')
|
||||||
|
software_id = fields.Many2One(Element, name='SoftwareID', namespace='sts')
|
||||||
|
|
||||||
|
class InvoiceSource(model.Model):
|
||||||
|
__name__ = 'InvoiceSource'
|
||||||
|
|
||||||
|
identification_code = fields.Many2One(Element, name='IdentificationCode', namespace='sts', default='CO')
|
||||||
|
|
||||||
|
class AuthorizedInvoices(model.Model):
|
||||||
|
__name__ = 'AuthorizedInvoices'
|
||||||
|
|
||||||
|
prefix = fields.Many2One(Element, name='Prefix', namespace='sts')
|
||||||
|
from_range = fields.Many2One(Element, name='From', namespace='sts')
|
||||||
|
to_range = fields.Many2One(Element, name='To', namespace='sts')
|
||||||
|
|
||||||
|
class InvoiceControl(model.Model):
|
||||||
|
__name__ = 'InvoiceControl'
|
||||||
|
|
||||||
|
authorization = fields.Many2One(Element, name='InvoiceAuthorization', namespace='sts')
|
||||||
|
period = fields.Many2One(Period, name='AuthorizationPeriod', namespace='sts')
|
||||||
|
invoices = fields.Many2One(AuthorizedInvoices, namespace='sts')
|
||||||
|
|
||||||
|
class AuthorizationProvider(model.Model):
|
||||||
|
__name__ = 'AuthorizationProvider'
|
||||||
|
|
||||||
|
|
||||||
|
id = fields.Many2One(DIANElement, name='AuthorizationProviderID', namespace='sts', default='800197268')
|
||||||
|
|
||||||
|
class DianExtensions(model.Model):
|
||||||
|
__name__ = 'DianExtensions'
|
||||||
|
|
||||||
|
authorization_provider = fields.Many2One(AuthorizationProvider, namespace='sts', create=True)
|
||||||
|
|
||||||
|
software_security_code = fields.Many2One(Element, name='SoftwareSecurityCode', namespace='sts')
|
||||||
|
software_provider = fields.Many2One(SoftwareProvider, namespace='sts')
|
||||||
|
source = fields.Many2One(InvoiceSource, namespace='sts')
|
||||||
|
control = fields.Many2One(InvoiceControl, namespace='sts')
|
||||||
|
|
||||||
175
facho/model/__init__.py
Normal file
175
facho/model/__init__.py
Normal file
@@ -0,0 +1,175 @@
|
|||||||
|
from .fields import Field
|
||||||
|
from collections import defaultdict
|
||||||
|
|
||||||
|
class ModelMeta(type):
|
||||||
|
def __new__(cls, name, bases, ns):
|
||||||
|
new = type.__new__(cls, name, bases, ns)
|
||||||
|
|
||||||
|
# mapeamos asignacion en declaracion de clase
|
||||||
|
# a attributo de objeto
|
||||||
|
if '__name__' in ns:
|
||||||
|
new.__name__ = ns['__name__']
|
||||||
|
if '__namespace__' in ns:
|
||||||
|
new.__namespace__ = ns['__namespace__']
|
||||||
|
else:
|
||||||
|
new.__namespace__ = {}
|
||||||
|
|
||||||
|
return new
|
||||||
|
|
||||||
|
class ModelBase(object, metaclass=ModelMeta):
|
||||||
|
|
||||||
|
def __new__(cls, *args, **kwargs):
|
||||||
|
obj = super().__new__(cls, *args, **kwargs)
|
||||||
|
obj._xml_attributes = {}
|
||||||
|
obj._fields = {}
|
||||||
|
obj._value = None
|
||||||
|
obj._namespace_prefix = None
|
||||||
|
obj._on_change_fields = defaultdict(list)
|
||||||
|
obj._order_fields = []
|
||||||
|
|
||||||
|
def on_change_fields_for_function():
|
||||||
|
# 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)
|
||||||
|
if not callable(parent_meth):
|
||||||
|
continue
|
||||||
|
on_changes = getattr(parent_meth, 'on_changes', None)
|
||||||
|
if on_changes:
|
||||||
|
return (parent_meth, on_changes)
|
||||||
|
return (None, [])
|
||||||
|
|
||||||
|
# forzamos registros de campos al modelo
|
||||||
|
# al instanciar
|
||||||
|
for (key, v) in type(obj).__dict__.items():
|
||||||
|
if isinstance(v, fields.Field):
|
||||||
|
obj._order_fields.append(key)
|
||||||
|
|
||||||
|
if isinstance(v, fields.Attribute) or isinstance(v, fields.Many2One) or isinstance(v, fields.Function) or isinstance(v, fields.Amount):
|
||||||
|
if hasattr(v, 'default') and v.default is not None:
|
||||||
|
setattr(obj, key, v.default)
|
||||||
|
if hasattr(v, 'create') and v.create == True:
|
||||||
|
setattr(obj, key, '')
|
||||||
|
|
||||||
|
# register callbacks for changes
|
||||||
|
(fun, on_change_fields) = on_change_fields_for_function()
|
||||||
|
for field in on_change_fields:
|
||||||
|
obj._on_change_fields[field].append(fun)
|
||||||
|
|
||||||
|
|
||||||
|
# post inicializacion del objeto
|
||||||
|
obj.__setup__()
|
||||||
|
return obj
|
||||||
|
|
||||||
|
def _set_attribute(self, field, name, value):
|
||||||
|
self._xml_attributes[field] = (name, value)
|
||||||
|
|
||||||
|
def __setitem__(self, key, val):
|
||||||
|
self._xml_attributes[key] = val
|
||||||
|
|
||||||
|
def __getitem__(self, key):
|
||||||
|
return self._xml_attributes[key]
|
||||||
|
|
||||||
|
def _get_field(self, name):
|
||||||
|
return self._fields[name]
|
||||||
|
|
||||||
|
def _set_field(self, name, field):
|
||||||
|
field.name = name
|
||||||
|
self._fields[name] = field
|
||||||
|
|
||||||
|
def _set_content(self, value):
|
||||||
|
default = self.__default_set__(value)
|
||||||
|
if default is not None:
|
||||||
|
self._value = default
|
||||||
|
|
||||||
|
def to_xml(self):
|
||||||
|
"""
|
||||||
|
Genera xml del modelo y sus relaciones
|
||||||
|
"""
|
||||||
|
def _hook_before_xml():
|
||||||
|
self.__before_xml__()
|
||||||
|
for field in self._fields.values():
|
||||||
|
if hasattr(field, '__before_xml__'):
|
||||||
|
field.__before_xml__()
|
||||||
|
|
||||||
|
_hook_before_xml()
|
||||||
|
|
||||||
|
tag = self.__name__
|
||||||
|
ns = ''
|
||||||
|
if self._namespace_prefix is not None:
|
||||||
|
ns = "%s:" % (self._namespace_prefix)
|
||||||
|
|
||||||
|
pair_attributes = ["%s=\"%s\"" % (k, v) for (k, v) in self._xml_attributes.values()]
|
||||||
|
|
||||||
|
for (prefix, url) in self.__namespace__.items():
|
||||||
|
pair_attributes.append("xmlns:%s=\"%s\"" % (prefix, url))
|
||||||
|
attributes = ""
|
||||||
|
if pair_attributes:
|
||||||
|
attributes = " " + " ".join(pair_attributes)
|
||||||
|
|
||||||
|
content = ""
|
||||||
|
|
||||||
|
ordered_fields = {}
|
||||||
|
for name in self._order_fields:
|
||||||
|
if name in self._fields:
|
||||||
|
ordered_fields[name] = True
|
||||||
|
else:
|
||||||
|
for key in self._fields.keys():
|
||||||
|
if key.startswith(name):
|
||||||
|
ordered_fields[key] = True
|
||||||
|
|
||||||
|
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):
|
||||||
|
content += value
|
||||||
|
|
||||||
|
if self._value is not None:
|
||||||
|
content += str(self._value)
|
||||||
|
|
||||||
|
if content == "":
|
||||||
|
return "<%s%s%s/>" % (ns, tag, attributes)
|
||||||
|
else:
|
||||||
|
return "<%s%s%s>%s</%s%s>" % (ns, tag, attributes, content, ns, tag)
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return self.to_xml()
|
||||||
|
|
||||||
|
|
||||||
|
class Model(ModelBase):
|
||||||
|
"""
|
||||||
|
Model clase que representa el modelo
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __before_xml__(self):
|
||||||
|
"""
|
||||||
|
Ejecuta antes de generar el xml, este
|
||||||
|
metodo sirve para realizar actualizaciones
|
||||||
|
en los campos en el ultimo momento
|
||||||
|
"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
def __default_set__(self, value):
|
||||||
|
"""
|
||||||
|
Al asignar un valor al modelo atraves de una relacion (person.relation = '33')
|
||||||
|
se puede personalizar como hacer esta asignacion.
|
||||||
|
"""
|
||||||
|
return value
|
||||||
|
|
||||||
|
def __default_get__(self, name, value):
|
||||||
|
"""
|
||||||
|
Al obtener el valor atraves de una relacion (age = person.age)
|
||||||
|
Retorno de valor por defecto
|
||||||
|
"""
|
||||||
|
return value
|
||||||
|
|
||||||
|
def __setup__(self):
|
||||||
|
"""
|
||||||
|
Inicializar modelo
|
||||||
|
"""
|
||||||
|
|
||||||
21
facho/model/fields/__init__.py
Normal file
21
facho/model/fields/__init__.py
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
from .attribute import Attribute
|
||||||
|
from .many2one import Many2One
|
||||||
|
from .one2many import One2Many
|
||||||
|
from .function import Function
|
||||||
|
from .virtual import Virtual
|
||||||
|
from .field import Field
|
||||||
|
from .amount import Amount
|
||||||
|
|
||||||
|
__all__ = [Attribute, One2Many, Many2One, Virtual, Field, Amount]
|
||||||
|
|
||||||
|
def on_change(fields):
|
||||||
|
from functools import wraps
|
||||||
|
|
||||||
|
def decorator(func):
|
||||||
|
setattr(func, 'on_changes', fields)
|
||||||
|
|
||||||
|
@wraps(func)
|
||||||
|
def wrapper(self, *arg, **kwargs):
|
||||||
|
return func(self, *arg, **kwargs)
|
||||||
|
return wrapper
|
||||||
|
return decorator
|
||||||
35
facho/model/fields/amount.py
Normal file
35
facho/model/fields/amount.py
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
from .field import Field
|
||||||
|
from collections import defaultdict
|
||||||
|
import facho.fe.form as form
|
||||||
|
|
||||||
|
class Amount(Field):
|
||||||
|
"""
|
||||||
|
Amount representa un campo moneda usando form.Amount
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, name=None, default=None, precision=6):
|
||||||
|
self.field_name = name
|
||||||
|
self.values = {}
|
||||||
|
self.default = default
|
||||||
|
self.precision = precision
|
||||||
|
|
||||||
|
def __get__(self, model, cls):
|
||||||
|
if model is None:
|
||||||
|
return self
|
||||||
|
assert self.name is not None
|
||||||
|
|
||||||
|
self.__init_value(model)
|
||||||
|
model._set_field(self.name, self)
|
||||||
|
return self.values[model]
|
||||||
|
|
||||||
|
def __set__(self, model, value):
|
||||||
|
assert self.name is not None
|
||||||
|
self.__init_value(model)
|
||||||
|
model._set_field(self.name, self)
|
||||||
|
self.values[model] = form.Amount(value, precision=self.precision)
|
||||||
|
|
||||||
|
self._changed_field(model, self.name, value)
|
||||||
|
|
||||||
|
def __init_value(self, model):
|
||||||
|
if model not in self.values:
|
||||||
|
self.values[model] = form.Amount(self.default or 0)
|
||||||
29
facho/model/fields/attribute.py
Normal file
29
facho/model/fields/attribute.py
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
from .field import Field
|
||||||
|
|
||||||
|
class Attribute(Field):
|
||||||
|
"""
|
||||||
|
Attribute es un atributo del elemento actual.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, name, default=None):
|
||||||
|
"""
|
||||||
|
:param name: nombre del atribute
|
||||||
|
:param default: valor por defecto del attributo
|
||||||
|
"""
|
||||||
|
self.attribute = name
|
||||||
|
self.value = default
|
||||||
|
self.default = default
|
||||||
|
|
||||||
|
def __get__(self, inst, cls):
|
||||||
|
if inst is None:
|
||||||
|
return self
|
||||||
|
|
||||||
|
assert self.name is not None
|
||||||
|
return self.value
|
||||||
|
|
||||||
|
def __set__(self, inst, value):
|
||||||
|
assert self.name is not None
|
||||||
|
self.value = value
|
||||||
|
|
||||||
|
self._changed_field(inst, self.name, value)
|
||||||
|
inst._set_attribute(self.name, self.attribute, value)
|
||||||
60
facho/model/fields/field.py
Normal file
60
facho/model/fields/field.py
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
import warnings
|
||||||
|
|
||||||
|
class Field:
|
||||||
|
def __set_name__(self, owner, name, virtual=False):
|
||||||
|
self.name = name
|
||||||
|
self.virtual = virtual
|
||||||
|
|
||||||
|
def __get__(self, inst, cls):
|
||||||
|
if inst is None:
|
||||||
|
return self
|
||||||
|
assert self.name is not None
|
||||||
|
return inst._fields[self.name]
|
||||||
|
|
||||||
|
def __set__(self, inst, value):
|
||||||
|
assert self.name is not None
|
||||||
|
inst._fields[self.name] = value
|
||||||
|
|
||||||
|
def _set_namespace(self, inst, name, namespaces):
|
||||||
|
if name is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
#TODO(bit4bit) aunque las pruebas confirmar
|
||||||
|
#que si se escribe el namespace que es
|
||||||
|
#no ahi confirmacion de declaracion previa del namespace
|
||||||
|
|
||||||
|
inst._namespace_prefix = name
|
||||||
|
|
||||||
|
def _call(self, inst, method, *args):
|
||||||
|
call = getattr(inst, method or '', None)
|
||||||
|
|
||||||
|
if callable(call):
|
||||||
|
return call(*args)
|
||||||
|
|
||||||
|
def _create_model(self, inst, name=None, model=None, attribute=None, namespace=None):
|
||||||
|
try:
|
||||||
|
return inst._fields[self.name]
|
||||||
|
except KeyError:
|
||||||
|
if model is not None:
|
||||||
|
obj = model()
|
||||||
|
else:
|
||||||
|
obj = self.model()
|
||||||
|
if name is not None:
|
||||||
|
obj.__name__ = name
|
||||||
|
|
||||||
|
if namespace:
|
||||||
|
self._set_namespace(obj, namespace, inst.__namespace__)
|
||||||
|
else:
|
||||||
|
self._set_namespace(obj, self.namespace, inst.__namespace__)
|
||||||
|
|
||||||
|
if attribute:
|
||||||
|
inst._fields[attribute] = obj
|
||||||
|
else:
|
||||||
|
inst._fields[self.name] = obj
|
||||||
|
|
||||||
|
return obj
|
||||||
|
|
||||||
|
def _changed_field(self, inst, name, value):
|
||||||
|
for fun in inst._on_change_fields[name]:
|
||||||
|
fun(inst, name, value)
|
||||||
|
|
||||||
36
facho/model/fields/function.py
Normal file
36
facho/model/fields/function.py
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
from .field import Field
|
||||||
|
|
||||||
|
class Function(Field):
|
||||||
|
"""
|
||||||
|
Permite modificar el modelo cuando se intenta,
|
||||||
|
obtener el valor de este campo.
|
||||||
|
|
||||||
|
DEPRECATED usar Virtual
|
||||||
|
"""
|
||||||
|
def __init__(self, field, getter=None, default=None):
|
||||||
|
self.field = field
|
||||||
|
self.getter = getter
|
||||||
|
self.default = default
|
||||||
|
|
||||||
|
def __get__(self, inst, cls):
|
||||||
|
if inst is None:
|
||||||
|
return self
|
||||||
|
assert self.name is not None
|
||||||
|
|
||||||
|
# si se indica `field` se adiciona
|
||||||
|
# como campo del modelo, esto es
|
||||||
|
# que se serializa a xml
|
||||||
|
inst._set_field(self.name, self.field)
|
||||||
|
|
||||||
|
if self.getter is not None:
|
||||||
|
value = self._call(inst, self.getter, self.name, self.field)
|
||||||
|
|
||||||
|
if value is not None:
|
||||||
|
self.field.__set__(inst, value)
|
||||||
|
|
||||||
|
return self.field
|
||||||
|
|
||||||
|
def __set__(self, inst, value):
|
||||||
|
inst._set_field(self.name, self.field)
|
||||||
|
self._changed_field(inst, self.name, value)
|
||||||
|
self.field.__set__(inst, value)
|
||||||
62
facho/model/fields/many2one.py
Normal file
62
facho/model/fields/many2one.py
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
from .field import Field
|
||||||
|
from collections import defaultdict
|
||||||
|
|
||||||
|
class Many2One(Field):
|
||||||
|
"""
|
||||||
|
Many2One describe una relacion pertenece a.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, model, name=None, setter=None, namespace=None, default=None, virtual=False, create=False):
|
||||||
|
"""
|
||||||
|
:param model: nombre del modelo destino
|
||||||
|
:param name: nombre del elemento xml
|
||||||
|
:param setter: nombre de methodo usado cuando se asigna usa como asignacion ejemplo model.relation = 3
|
||||||
|
:param namespace: sufijo del namespace al que pertenece el elemento
|
||||||
|
:param default: el valor o contenido por defecto
|
||||||
|
:param virtual: se crea la relacion por no se ve reflejada en el xml final
|
||||||
|
:param create: fuerza la creacion del elemento en el xml, ya que los elementos no son creados sino tienen contenido
|
||||||
|
"""
|
||||||
|
self.model = model
|
||||||
|
self.setter = setter
|
||||||
|
self.namespace = namespace
|
||||||
|
self.field_name = name
|
||||||
|
self.default = default
|
||||||
|
self.virtual = virtual
|
||||||
|
self.relations = defaultdict(dict)
|
||||||
|
self.create = create
|
||||||
|
|
||||||
|
def __get__(self, inst, cls):
|
||||||
|
if inst is None:
|
||||||
|
return self
|
||||||
|
assert self.name is not None
|
||||||
|
|
||||||
|
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)
|
||||||
|
elif hasattr(inst, '__default_get__'):
|
||||||
|
return inst.__default_get__(self.name, value)
|
||||||
|
else:
|
||||||
|
return 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
|
||||||
|
setter = getattr(inst, self.setter or '', None)
|
||||||
|
if callable(setter):
|
||||||
|
setter(inst_model, value)
|
||||||
|
else:
|
||||||
|
inst_model._set_content(value)
|
||||||
|
|
||||||
|
self._changed_field(inst, self.name, value)
|
||||||
|
|
||||||
|
|
||||||
86
facho/model/fields/one2many.py
Normal file
86
facho/model/fields/one2many.py
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
from .field import Field
|
||||||
|
from collections import defaultdict
|
||||||
|
|
||||||
|
# TODO(bit4bit) lograr que isinstance se aplique
|
||||||
|
# al objeto envuelto
|
||||||
|
class _RelationProxy():
|
||||||
|
def __init__(self, obj, inst, attribute):
|
||||||
|
self.__dict__['_obj'] = obj
|
||||||
|
self.__dict__['_inst'] = inst
|
||||||
|
self.__dict__['_attribute'] = attribute
|
||||||
|
|
||||||
|
def __getattr__(self, name):
|
||||||
|
if (name in self.__dict__):
|
||||||
|
return self.__dict__[name]
|
||||||
|
|
||||||
|
rel = getattr(self.__dict__['_obj'], name)
|
||||||
|
if hasattr(rel, '__default_get__'):
|
||||||
|
return rel.__default_get__(name, rel)
|
||||||
|
|
||||||
|
return rel
|
||||||
|
|
||||||
|
def __setattr__(self, attr, value):
|
||||||
|
# TODO(bit4bit) hacemos proxy al sistema de notificacion de cambios
|
||||||
|
# algo burdo, se usa __dict__ para saltarnos el __getattr__ y evitar un fallo por recursion
|
||||||
|
rel = getattr(self.__dict__['_obj'], attr)
|
||||||
|
if hasattr(rel, '__default_set__'):
|
||||||
|
response = setattr(self._obj, attr, rel.__default_set__(value))
|
||||||
|
else:
|
||||||
|
response = setattr(self._obj, attr, value)
|
||||||
|
|
||||||
|
for fun in self.__dict__['_inst']._on_change_fields[self.__dict__['_attribute']]:
|
||||||
|
fun(self.__dict__['_inst'], self.__dict__['_attribute'], value)
|
||||||
|
return response
|
||||||
|
|
||||||
|
class _Relation():
|
||||||
|
def __init__(self, creator, inst, attribute):
|
||||||
|
self.creator = creator
|
||||||
|
self.inst = inst
|
||||||
|
self.attribute = attribute
|
||||||
|
self.relations = []
|
||||||
|
|
||||||
|
def create(self):
|
||||||
|
n_relations = len(self.relations)
|
||||||
|
attribute = '%s_%d' % (self.attribute, n_relations)
|
||||||
|
relation = self.creator(attribute)
|
||||||
|
proxy = _RelationProxy(relation, self.inst, self.attribute)
|
||||||
|
|
||||||
|
self.relations.append(relation)
|
||||||
|
return proxy
|
||||||
|
|
||||||
|
def __len__(self):
|
||||||
|
return len(self.relations)
|
||||||
|
|
||||||
|
def __iter__(self):
|
||||||
|
for relation in self.relations:
|
||||||
|
yield relation
|
||||||
|
|
||||||
|
class One2Many(Field):
|
||||||
|
"""
|
||||||
|
One2Many describe una relacion tiene muchos.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, model, name=None, namespace=None, default=None):
|
||||||
|
"""
|
||||||
|
:param model: nombre del modelo destino
|
||||||
|
:param name: nombre del elemento xml cuando se crea hijo
|
||||||
|
:param namespace: sufijo del namespace al que pertenece el elemento
|
||||||
|
:param default: el valor o contenido por defecto
|
||||||
|
"""
|
||||||
|
self.model = model
|
||||||
|
self.field_name = name
|
||||||
|
self.namespace = namespace
|
||||||
|
self.default = default
|
||||||
|
self.relation = {}
|
||||||
|
|
||||||
|
def __get__(self, inst, cls):
|
||||||
|
assert self.name is not None
|
||||||
|
|
||||||
|
def creator(attribute):
|
||||||
|
return self._create_model(inst, name=self.field_name, model=self.model, attribute=attribute, namespace=self.namespace)
|
||||||
|
|
||||||
|
if inst in self.relation:
|
||||||
|
return self.relation[inst]
|
||||||
|
else:
|
||||||
|
self.relation[inst] = _Relation(creator, inst, self.name)
|
||||||
|
return self.relation[inst]
|
||||||
54
facho/model/fields/virtual.py
Normal file
54
facho/model/fields/virtual.py
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
from .field import Field
|
||||||
|
|
||||||
|
# Un campo virtual
|
||||||
|
# no participa del renderizado
|
||||||
|
# pero puede interactura con este
|
||||||
|
class Virtual(Field):
|
||||||
|
"""
|
||||||
|
Virtual es un campo que no es renderizado en el xml final
|
||||||
|
"""
|
||||||
|
def __init__(self,
|
||||||
|
setter=None,
|
||||||
|
getter='',
|
||||||
|
default=None,
|
||||||
|
update_internal=False):
|
||||||
|
"""
|
||||||
|
:param setter: nombre de methodo usado cuando se asigna usa como asignacion ejemplo model.relation = 3
|
||||||
|
:param getter: nombre del metodo usando cuando se obtiene, ejemplo: valor = mode.relation
|
||||||
|
:param default: valor por defecto
|
||||||
|
:param update_internal: indica que cuando se asigne algun valor este se almacena localmente
|
||||||
|
"""
|
||||||
|
self.default = default
|
||||||
|
self.setter = setter
|
||||||
|
self.getter = getter
|
||||||
|
self.values = {}
|
||||||
|
self.update_internal = update_internal
|
||||||
|
self.virtual = True
|
||||||
|
|
||||||
|
def __get__(self, inst, cls):
|
||||||
|
if inst is None:
|
||||||
|
return self
|
||||||
|
assert self.name is not None
|
||||||
|
|
||||||
|
value = self.default
|
||||||
|
try:
|
||||||
|
value = self.values[inst]
|
||||||
|
except KeyError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
try:
|
||||||
|
self.values[inst] = getattr(inst, self.getter)(self.name, value)
|
||||||
|
except AttributeError:
|
||||||
|
self.values[inst] = value
|
||||||
|
|
||||||
|
return self.values[inst]
|
||||||
|
|
||||||
|
def __set__(self, inst, value):
|
||||||
|
if self.update_internal:
|
||||||
|
inst._value = value
|
||||||
|
|
||||||
|
if self.setter is None:
|
||||||
|
self.values[inst] = value
|
||||||
|
else:
|
||||||
|
self.values[inst] = self._call(inst, self.setter, self.name, value)
|
||||||
|
self._changed_field(inst, self.name, value)
|
||||||
2
setup.py
2
setup.py
@@ -61,6 +61,6 @@ setup(
|
|||||||
test_suite='tests',
|
test_suite='tests',
|
||||||
tests_require=test_requirements,
|
tests_require=test_requirements,
|
||||||
url='https://github.com/bit4bit/facho',
|
url='https://github.com/bit4bit/facho',
|
||||||
version='0.1.2',
|
version='0.2.1',
|
||||||
zip_safe=False,
|
zip_safe=False,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -20,3 +20,34 @@ def test_amount_equals():
|
|||||||
assert price1 == price2
|
assert price1 == price2
|
||||||
assert price1 == form.Amount(100) + form.Amount(10)
|
assert price1 == form.Amount(100) + form.Amount(10)
|
||||||
assert price1 == form.Amount(10) * form.Amount(10) + form.Amount(10)
|
assert price1 == form.Amount(10) * form.Amount(10) + form.Amount(10)
|
||||||
|
assert form.Amount(110) == (form.Amount(1.10) * form.Amount(100))
|
||||||
|
|
||||||
|
def test_round_half_even():
|
||||||
|
# https://www.w3.org/TR/xpath-functions-31/#func-round-half-to-even
|
||||||
|
assert form.Amount(0.5).round(0).float() == 0.0
|
||||||
|
assert form.Amount(1.5).round(0).float() == 2.0
|
||||||
|
assert form.Amount(2.5).round(0).float() == 2.0
|
||||||
|
assert form.Amount(3.567812e+3).round(2).float() == 3567.81e0
|
||||||
|
assert form.Amount(4.7564e-3).round(2).float() == 0.0e0
|
||||||
|
|
||||||
|
def test_round():
|
||||||
|
# Entre 0 y 5 Mantener el dígito menos significativo
|
||||||
|
assert form.Amount(1.133).round(2) == form.Amount(1.13)
|
||||||
|
# Entre 6 y 9 Incrementar el dígito menos significativo
|
||||||
|
assert form.Amount(1.166).round(2) == form.Amount(1.17)
|
||||||
|
# 5, y el segundo dígito siguiente al dígito menos significativo es cero o par Mantener el dígito menos significativo
|
||||||
|
assert str(form.Amount(1.1542).round(2)) == str(form.Amount(1.15))
|
||||||
|
# 5, y el segundo dígito siguiente al dígito menos significativo es impar Incrementar el dígito menos significativo
|
||||||
|
assert str(form.Amount(1.1563).round(2)) == str(form.Amount(1.16))
|
||||||
|
|
||||||
|
def test_amount_truncate():
|
||||||
|
assert form.Amount(1.1569).truncate_as_string(2) == '1.15'
|
||||||
|
assert form.Amount(587.0700).truncate_as_string(2) == '587.07'
|
||||||
|
assert form.Amount(14705.8800).truncate_as_string(2) == '14705.88'
|
||||||
|
assert form.Amount(9423.7000).truncate_as_string(2) == '9423.70'
|
||||||
|
assert form.Amount(10084.03).truncate_as_string(2) == '10084.03'
|
||||||
|
assert form.Amount(10000.02245).truncate_as_string(2) == '10000.02'
|
||||||
|
assert form.Amount(10000.02357).truncate_as_string(2) == '10000.02'
|
||||||
|
|
||||||
|
def test_amount_format():
|
||||||
|
assert str(round(form.Amount(1.1569),2)) == '1.16'
|
||||||
|
|||||||
@@ -110,3 +110,19 @@ def test_xml_sign_dian(monkeypatch):
|
|||||||
helpers.mock_urlopen(m)
|
helpers.mock_urlopen(m)
|
||||||
xmlsigned = signer.sign_xml_string(xmlstring)
|
xmlsigned = signer.sign_xml_string(xmlstring)
|
||||||
assert "Signature" in xmlsigned
|
assert "Signature" in xmlsigned
|
||||||
|
|
||||||
|
def test_xml_sign_dian_using_bytes(monkeypatch):
|
||||||
|
xml = fe.FeXML('Invoice',
|
||||||
|
'http://www.dian.gov.co/contratos/facturaelectronica/v1')
|
||||||
|
xml.find_or_create_element('/fe:Invoice/ext:UBLExtensions/ext:UBLExtension/ext:ExtensionContent')
|
||||||
|
ublextension = xml.fragment('/fe:Invoice/ext:UBLExtensions/ext:UBLExtension', append=True)
|
||||||
|
extcontent = ublextension.find_or_create_element('/ext:UBLExtension/ext:ExtensionContent')
|
||||||
|
|
||||||
|
xmlstring = xml.tostring()
|
||||||
|
p12_data = open('./tests/example.p12', 'rb').read()
|
||||||
|
signer = fe.DianXMLExtensionSigner.from_bytes(p12_data)
|
||||||
|
|
||||||
|
with monkeypatch.context() as m:
|
||||||
|
helpers.mock_urlopen(m)
|
||||||
|
xmlsigned = signer.sign_xml_string(xmlstring)
|
||||||
|
assert "Signature" in xmlsigned
|
||||||
|
|||||||
@@ -55,6 +55,13 @@ def test_invoicesimple_zip(simple_invoice):
|
|||||||
with fe.DianZIP(zipdata) as dianzip:
|
with fe.DianZIP(zipdata) as dianzip:
|
||||||
name_invoice = dianzip.add_invoice_xml(simple_invoice.invoice_ident, str(xml_invoice))
|
name_invoice = dianzip.add_invoice_xml(simple_invoice.invoice_ident, str(xml_invoice))
|
||||||
|
|
||||||
|
# el zip ademas de archivar debe comprimir los archivos
|
||||||
|
# de lo contrario la DIAN lo rechaza
|
||||||
|
with zipfile.ZipFile(zipdata) as dianzip:
|
||||||
|
dianzip.testzip()
|
||||||
|
for zipinfo in dianzip.infolist():
|
||||||
|
assert zipinfo.compress_type == zipfile.ZIP_DEFLATED, "se espera el zip comprimido"
|
||||||
|
|
||||||
with zipfile.ZipFile(zipdata) as dianzip:
|
with zipfile.ZipFile(zipdata) as dianzip:
|
||||||
xml_data = dianzip.open(name_invoice).read().decode('utf-8')
|
xml_data = dianzip.open(name_invoice).read().decode('utf-8')
|
||||||
assert xml_data == str(xml_invoice)
|
assert xml_data == str(xml_invoice)
|
||||||
@@ -112,7 +119,7 @@ def test_invoice_cufe(simple_invoice_without_lines):
|
|||||||
simple_invoice.invoice_supplier.ident = form.PartyIdentification('700085371', '5', '31')
|
simple_invoice.invoice_supplier.ident = form.PartyIdentification('700085371', '5', '31')
|
||||||
simple_invoice.invoice_customer.ident = form.PartyIdentification('800199436', '5', '31')
|
simple_invoice.invoice_customer.ident = form.PartyIdentification('800199436', '5', '31')
|
||||||
simple_invoice.add_invoice_line(form.InvoiceLine(
|
simple_invoice.add_invoice_line(form.InvoiceLine(
|
||||||
quantity = form.Quantity(1, '94'),
|
quantity = form.Quantity(1.00, '94'),
|
||||||
description = 'producto',
|
description = 'producto',
|
||||||
item = form.StandardItem(111),
|
item = form.StandardItem(111),
|
||||||
price = form.Price(form.Amount(1_500_000), '01', ''),
|
price = form.Price(form.Amount(1_500_000), '01', ''),
|
||||||
@@ -170,6 +177,7 @@ def test_invoice_cufe(simple_invoice_without_lines):
|
|||||||
assert cufe == '8bb918b19ba22a694f1da11c643b5e9de39adf60311cf179179e9b33381030bcd4c3c3f156c506ed5908f9276f5bd9b4'
|
assert cufe == '8bb918b19ba22a694f1da11c643b5e9de39adf60311cf179179e9b33381030bcd4c3c3f156c506ed5908f9276f5bd9b4'
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def test_credit_note_cude(simple_credit_note_without_lines):
|
def test_credit_note_cude(simple_credit_note_without_lines):
|
||||||
simple_invoice = simple_credit_note_without_lines
|
simple_invoice = simple_credit_note_without_lines
|
||||||
simple_invoice.invoice_ident = '8110007871'
|
simple_invoice.invoice_ident = '8110007871'
|
||||||
|
|||||||
@@ -38,7 +38,10 @@ def test_invoice_legalmonetary():
|
|||||||
assert inv.invoice_legal_monetary_total.tax_inclusive_amount == form.Amount(119.0)
|
assert inv.invoice_legal_monetary_total.tax_inclusive_amount == form.Amount(119.0)
|
||||||
assert inv.invoice_legal_monetary_total.charge_total_amount == form.Amount(0.0)
|
assert inv.invoice_legal_monetary_total.charge_total_amount == form.Amount(0.0)
|
||||||
|
|
||||||
|
def test_allowancecharge_as_discount():
|
||||||
|
discount = form.AllowanceChargeAsDiscount(amount=form.Amount(1000.0))
|
||||||
|
assert discount.isDiscount() == True
|
||||||
|
|
||||||
def test_FAU10():
|
def test_FAU10():
|
||||||
inv = form.NationalSalesInvoice()
|
inv = form.NationalSalesInvoice()
|
||||||
inv.add_invoice_line(form.InvoiceLine(
|
inv.add_invoice_line(form.InvoiceLine(
|
||||||
@@ -58,7 +61,7 @@ def test_FAU10():
|
|||||||
]
|
]
|
||||||
)
|
)
|
||||||
))
|
))
|
||||||
inv.add_allownace_charge(form.AllowanceCharge(amount=form.Amount(19.0)))
|
inv.add_allowance_charge(form.AllowanceCharge(amount=form.Amount(19.0)))
|
||||||
|
|
||||||
inv.calculate()
|
inv.calculate()
|
||||||
assert inv.invoice_legal_monetary_total.line_extension_amount == form.Amount(100.0)
|
assert inv.invoice_legal_monetary_total.line_extension_amount == form.Amount(100.0)
|
||||||
@@ -86,7 +89,7 @@ def test_FAU14():
|
|||||||
]
|
]
|
||||||
)
|
)
|
||||||
))
|
))
|
||||||
inv.add_allownace_charge(form.AllowanceCharge(amount=form.Amount(19.0)))
|
inv.add_allowance_charge(form.AllowanceCharge(amount=form.Amount(19.0)))
|
||||||
inv.add_prepaid_payment(form.PrePaidPayment(paid_amount = form.Amount(50.0)))
|
inv.add_prepaid_payment(form.PrePaidPayment(paid_amount = form.Amount(50.0)))
|
||||||
inv.calculate()
|
inv.calculate()
|
||||||
|
|
||||||
|
|||||||
@@ -6,9 +6,14 @@
|
|||||||
"""Tests for `facho` package."""
|
"""Tests for `facho` package."""
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
from datetime import datetime
|
||||||
|
import copy
|
||||||
|
|
||||||
|
from facho.fe import form
|
||||||
from facho.fe import form_xml
|
from facho.fe import form_xml
|
||||||
|
|
||||||
|
from fixtures import *
|
||||||
|
|
||||||
def test_import_DIANInvoiceXML():
|
def test_import_DIANInvoiceXML():
|
||||||
try:
|
try:
|
||||||
form_xml.DIANInvoiceXML
|
form_xml.DIANInvoiceXML
|
||||||
@@ -27,3 +32,65 @@ def test_import_DIANCreditNoteXML():
|
|||||||
form_xml.DIANCreditNoteXML
|
form_xml.DIANCreditNoteXML
|
||||||
except AttributeError:
|
except AttributeError:
|
||||||
pytest.fail("unexpected not found")
|
pytest.fail("unexpected not found")
|
||||||
|
|
||||||
|
def test_allowance_charge_in_invoice(simple_invoice_without_lines):
|
||||||
|
inv = copy.copy(simple_invoice_without_lines)
|
||||||
|
inv.add_invoice_line(form.InvoiceLine(
|
||||||
|
quantity = form.Quantity(1, '94'),
|
||||||
|
description = 'producto facho',
|
||||||
|
item = form.StandardItem(9999),
|
||||||
|
price = form.Price(
|
||||||
|
amount = form.Amount(100.0),
|
||||||
|
type_code = '01',
|
||||||
|
type = 'x'
|
||||||
|
),
|
||||||
|
tax = form.TaxTotal(
|
||||||
|
subtotals = [
|
||||||
|
form.TaxSubTotal(
|
||||||
|
percent = 19.0,
|
||||||
|
)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
))
|
||||||
|
inv.add_allowance_charge(form.AllowanceCharge(amount=form.Amount(19.0)))
|
||||||
|
inv.calculate()
|
||||||
|
|
||||||
|
xml = form_xml.DIANInvoiceXML(inv)
|
||||||
|
assert xml.get_element_text('./cac:AllowanceCharge/cbc:ID') == '1'
|
||||||
|
assert xml.get_element_text('./cac:AllowanceCharge/cbc:ChargeIndicator') == 'true'
|
||||||
|
assert xml.get_element_text('./cac:AllowanceCharge/cbc:Amount') == '19.0'
|
||||||
|
assert xml.get_element_text('./cac:AllowanceCharge/cbc:BaseAmount') == '100.0'
|
||||||
|
|
||||||
|
def test_allowance_charge_in_invoice_line(simple_invoice_without_lines):
|
||||||
|
inv = copy.copy(simple_invoice_without_lines)
|
||||||
|
inv.add_invoice_line(form.InvoiceLine(
|
||||||
|
quantity = form.Quantity(1, '94'),
|
||||||
|
description = 'producto facho',
|
||||||
|
item = form.StandardItem(9999),
|
||||||
|
price = form.Price(
|
||||||
|
amount = form.Amount(100.0),
|
||||||
|
type_code = '01',
|
||||||
|
type = 'x'
|
||||||
|
),
|
||||||
|
tax = form.TaxTotal(
|
||||||
|
subtotals = [
|
||||||
|
form.TaxSubTotal(
|
||||||
|
percent = 19.0,
|
||||||
|
)
|
||||||
|
]
|
||||||
|
),
|
||||||
|
allowance_charge = [
|
||||||
|
form.AllowanceChargeAsDiscount(amount=form.Amount(10.0))
|
||||||
|
]
|
||||||
|
))
|
||||||
|
inv.calculate()
|
||||||
|
|
||||||
|
# se aplico descuento
|
||||||
|
assert inv.invoice_legal_monetary_total.line_extension_amount == form.Amount(90.0)
|
||||||
|
|
||||||
|
xml = form_xml.DIANInvoiceXML(inv)
|
||||||
|
|
||||||
|
with pytest.raises(AttributeError):
|
||||||
|
assert xml.get_element_text('/fe:Invoice/cac:AllowanceCharge/cbc:ID') == '1'
|
||||||
|
xml.get_element_text('/fe:Invoice/cac:InvoiceLine/cac:AllowanceCharge/cbc:ID') == '1'
|
||||||
|
xml.get_element_text('/fe:Invoice/cac:InvoiceLine/cac:AllowanceCharge/cbc:BaseAmount') == '100.0'
|
||||||
|
|||||||
601
tests/test_model.py
Normal file
601
tests/test_model.py
Normal file
@@ -0,0 +1,601 @@
|
|||||||
|
#!/usr/bin/env python
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# This file is part of facho. The COPYRIGHT file at the top level of
|
||||||
|
# this repository contains the full copyright notices and license terms.
|
||||||
|
|
||||||
|
"""Tests for `facho` package."""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
import facho.model
|
||||||
|
import facho.model.fields as fields
|
||||||
|
|
||||||
|
def test_model_to_element():
|
||||||
|
class Person(facho.model.Model):
|
||||||
|
__name__ = 'Person'
|
||||||
|
|
||||||
|
person = Person()
|
||||||
|
|
||||||
|
assert "<Person/>" == person.to_xml()
|
||||||
|
|
||||||
|
def test_model_to_element_with_attribute():
|
||||||
|
class Person(facho.model.Model):
|
||||||
|
__name__ = 'Person'
|
||||||
|
id = fields.Attribute('id')
|
||||||
|
|
||||||
|
person = Person()
|
||||||
|
person.id = 33
|
||||||
|
|
||||||
|
personb = Person()
|
||||||
|
personb.id = 44
|
||||||
|
|
||||||
|
assert "<Person id=\"33\"/>" == person.to_xml()
|
||||||
|
assert "<Person id=\"44\"/>" == personb.to_xml()
|
||||||
|
|
||||||
|
def test_model_to_element_with_attribute_as_element():
|
||||||
|
class ID(facho.model.Model):
|
||||||
|
__name__ = 'ID'
|
||||||
|
|
||||||
|
class Person(facho.model.Model):
|
||||||
|
__name__ = 'Person'
|
||||||
|
|
||||||
|
id = fields.Many2One(ID)
|
||||||
|
|
||||||
|
person = Person()
|
||||||
|
person.id = 33
|
||||||
|
assert "<Person><ID>33</ID></Person>" == person.to_xml()
|
||||||
|
|
||||||
|
def test_many2one_with_custom_attributes():
|
||||||
|
class TaxAmount(facho.model.Model):
|
||||||
|
__name__ = 'TaxAmount'
|
||||||
|
|
||||||
|
currencyID = fields.Attribute('currencyID')
|
||||||
|
|
||||||
|
class TaxTotal(facho.model.Model):
|
||||||
|
__name__ = 'TaxTotal'
|
||||||
|
|
||||||
|
amount = fields.Many2One(TaxAmount)
|
||||||
|
|
||||||
|
tax_total = TaxTotal()
|
||||||
|
tax_total.amount = 3333
|
||||||
|
tax_total.amount.currencyID = 'COP'
|
||||||
|
assert '<TaxTotal><TaxAmount currencyID="COP">3333</TaxAmount></TaxTotal>' == tax_total.to_xml()
|
||||||
|
|
||||||
|
def test_many2one_with_custom_setter():
|
||||||
|
|
||||||
|
class PhysicalLocation(facho.model.Model):
|
||||||
|
__name__ = 'PhysicalLocation'
|
||||||
|
|
||||||
|
id = fields.Attribute('ID')
|
||||||
|
|
||||||
|
class Party(facho.model.Model):
|
||||||
|
__name__ = 'Party'
|
||||||
|
|
||||||
|
location = fields.Many2One(PhysicalLocation, setter='location_setter')
|
||||||
|
|
||||||
|
def location_setter(self, field, value):
|
||||||
|
field.id = value
|
||||||
|
|
||||||
|
party = Party()
|
||||||
|
party.location = 99
|
||||||
|
assert '<Party><PhysicalLocation ID="99"/></Party>' == party.to_xml()
|
||||||
|
|
||||||
|
def test_many2one_always_create():
|
||||||
|
class Name(facho.model.Model):
|
||||||
|
__name__ = 'Name'
|
||||||
|
|
||||||
|
class Person(facho.model.Model):
|
||||||
|
__name__ = 'Person'
|
||||||
|
|
||||||
|
name = fields.Many2One(Name, default='facho')
|
||||||
|
|
||||||
|
person = Person()
|
||||||
|
assert '<Person><Name>facho</Name></Person>' == person.to_xml()
|
||||||
|
|
||||||
|
def test_many2one_nested_always_create():
|
||||||
|
class Name(facho.model.Model):
|
||||||
|
__name__ = 'Name'
|
||||||
|
|
||||||
|
class Contact(facho.model.Model):
|
||||||
|
__name__ = 'Contact'
|
||||||
|
|
||||||
|
name = fields.Many2One(Name, default='facho')
|
||||||
|
|
||||||
|
class Person(facho.model.Model):
|
||||||
|
__name__ = 'Person'
|
||||||
|
|
||||||
|
contact = fields.Many2One(Contact, create=True)
|
||||||
|
|
||||||
|
person = Person()
|
||||||
|
assert '<Person><Contact><Name>facho</Name></Contact></Person>' == person.to_xml()
|
||||||
|
|
||||||
|
def test_many2one_auto_create():
|
||||||
|
class TaxAmount(facho.model.Model):
|
||||||
|
__name__ = 'TaxAmount'
|
||||||
|
|
||||||
|
currencyID = fields.Attribute('currencyID')
|
||||||
|
|
||||||
|
class TaxTotal(facho.model.Model):
|
||||||
|
__name__ = 'TaxTotal'
|
||||||
|
|
||||||
|
amount = fields.Many2One(TaxAmount)
|
||||||
|
|
||||||
|
tax_total = TaxTotal()
|
||||||
|
tax_total.amount.currencyID = 'COP'
|
||||||
|
tax_total.amount = 3333
|
||||||
|
assert '<TaxTotal><TaxAmount currencyID="COP">3333</TaxAmount></TaxTotal>' == tax_total.to_xml()
|
||||||
|
|
||||||
|
def test_field_model():
|
||||||
|
class ID(facho.model.Model):
|
||||||
|
__name__ = 'ID'
|
||||||
|
|
||||||
|
class Person(facho.model.Model):
|
||||||
|
__name__ = 'Person'
|
||||||
|
|
||||||
|
id = fields.Many2One(ID)
|
||||||
|
|
||||||
|
person = Person()
|
||||||
|
person.id = ID()
|
||||||
|
person.id = 33
|
||||||
|
assert "<Person><ID>33</ID></Person>" == person.to_xml()
|
||||||
|
|
||||||
|
def test_field_multiple_model():
|
||||||
|
class ID(facho.model.Model):
|
||||||
|
__name__ = 'ID'
|
||||||
|
|
||||||
|
class Person(facho.model.Model):
|
||||||
|
__name__ = 'Person'
|
||||||
|
|
||||||
|
id = fields.Many2One(ID)
|
||||||
|
id2 = fields.Many2One(ID)
|
||||||
|
|
||||||
|
person = Person()
|
||||||
|
person.id = 33
|
||||||
|
person.id2 = 44
|
||||||
|
assert "<Person><ID>33</ID><ID>44</ID></Person>" == person.to_xml()
|
||||||
|
|
||||||
|
def test_field_model_failed_initialization():
|
||||||
|
class ID(facho.model.Model):
|
||||||
|
__name__ = 'ID'
|
||||||
|
|
||||||
|
class Person(facho.model.Model):
|
||||||
|
__name__ = 'Person'
|
||||||
|
|
||||||
|
id = fields.Many2One(ID)
|
||||||
|
|
||||||
|
|
||||||
|
person = Person()
|
||||||
|
person.id = 33
|
||||||
|
assert "<Person><ID>33</ID></Person>" == person.to_xml()
|
||||||
|
|
||||||
|
def test_field_model_with_custom_name():
|
||||||
|
class ID(facho.model.Model):
|
||||||
|
__name__ = 'ID'
|
||||||
|
|
||||||
|
class Person(facho.model.Model):
|
||||||
|
__name__ = 'Person'
|
||||||
|
|
||||||
|
id = fields.Many2One(ID, name='DID')
|
||||||
|
|
||||||
|
|
||||||
|
person = Person()
|
||||||
|
person.id = 33
|
||||||
|
assert "<Person><DID>33</DID></Person>" == person.to_xml()
|
||||||
|
|
||||||
|
def test_field_model_default_initialization_with_attributes():
|
||||||
|
class ID(facho.model.Model):
|
||||||
|
__name__ = 'ID'
|
||||||
|
|
||||||
|
reference = fields.Attribute('REFERENCE')
|
||||||
|
|
||||||
|
class Person(facho.model.Model):
|
||||||
|
__name__ = 'Person'
|
||||||
|
|
||||||
|
id = fields.Many2One(ID)
|
||||||
|
|
||||||
|
person = Person()
|
||||||
|
person.id = 33
|
||||||
|
person.id.reference = 'haber'
|
||||||
|
assert '<Person><ID REFERENCE="haber">33</ID></Person>' == person.to_xml()
|
||||||
|
|
||||||
|
def test_model_with_xml_namespace():
|
||||||
|
class Person(facho.model.Model):
|
||||||
|
__name__ = 'Person'
|
||||||
|
__namespace__ = {
|
||||||
|
'facho': 'http://lib.facho.cyou'
|
||||||
|
}
|
||||||
|
|
||||||
|
person = Person()
|
||||||
|
assert '<Person xmlns:facho="http://lib.facho.cyou"/>'
|
||||||
|
|
||||||
|
def test_model_with_xml_namespace_nested():
|
||||||
|
class ID(facho.model.Model):
|
||||||
|
__name__ = 'ID'
|
||||||
|
|
||||||
|
class Person(facho.model.Model):
|
||||||
|
__name__ = 'Person'
|
||||||
|
__namespace__ = {
|
||||||
|
'facho': 'http://lib.facho.cyou'
|
||||||
|
}
|
||||||
|
|
||||||
|
id = fields.Many2One(ID, namespace='facho')
|
||||||
|
|
||||||
|
person = Person()
|
||||||
|
person.id = 33
|
||||||
|
assert '<Person xmlns:facho="http://lib.facho.cyou"><facho:ID>33</facho:ID></Person>' == person.to_xml()
|
||||||
|
|
||||||
|
def test_model_with_xml_namespace_nested_nested():
|
||||||
|
class ID(facho.model.Model):
|
||||||
|
__name__ = 'ID'
|
||||||
|
|
||||||
|
class Party(facho.model.Model):
|
||||||
|
__name__ = 'Party'
|
||||||
|
|
||||||
|
id = fields.Many2One(ID, namespace='party')
|
||||||
|
|
||||||
|
def __default_set__(self, value):
|
||||||
|
self.id = value
|
||||||
|
|
||||||
|
class Person(facho.model.Model):
|
||||||
|
__name__ = 'Person'
|
||||||
|
__namespace__ = {
|
||||||
|
'person': 'http://lib.facho.cyou',
|
||||||
|
'party': 'http://lib.facho.cyou'
|
||||||
|
}
|
||||||
|
|
||||||
|
id = fields.Many2One(Party, namespace='person')
|
||||||
|
|
||||||
|
person = Person()
|
||||||
|
person.id = 33
|
||||||
|
assert '<Person xmlns:person="http://lib.facho.cyou" xmlns:party="http://lib.facho.cyou"><person:Party><party:ID>33</party:ID></person:Party></Person>' == person.to_xml()
|
||||||
|
|
||||||
|
def test_model_with_xml_namespace_nested_one_many():
|
||||||
|
class Name(facho.model.Model):
|
||||||
|
__name__ = 'Name'
|
||||||
|
|
||||||
|
class Contact(facho.model.Model):
|
||||||
|
__name__ = 'Contact'
|
||||||
|
|
||||||
|
name = fields.Many2One(Name, namespace='contact')
|
||||||
|
|
||||||
|
class Person(facho.model.Model):
|
||||||
|
__name__ = 'Person'
|
||||||
|
__namespace__ = {
|
||||||
|
'facho': 'http://lib.facho.cyou',
|
||||||
|
'contact': 'http://lib.facho.cyou'
|
||||||
|
}
|
||||||
|
|
||||||
|
contacts = fields.One2Many(Contact, namespace='facho')
|
||||||
|
|
||||||
|
person = Person()
|
||||||
|
contact = person.contacts.create()
|
||||||
|
contact.name = 'contact1'
|
||||||
|
|
||||||
|
contact = person.contacts.create()
|
||||||
|
contact.name = 'contact2'
|
||||||
|
|
||||||
|
assert '<Person xmlns:facho="http://lib.facho.cyou" xmlns:contact="http://lib.facho.cyou"><facho:Contact><contact:Name>contact1</contact:Name></facho:Contact><facho:Contact><contact:Name>contact2</contact:Name></facho:Contact></Person>' == person.to_xml()
|
||||||
|
|
||||||
|
def test_field_model_with_namespace():
|
||||||
|
class ID(facho.model.Model):
|
||||||
|
__name__ = 'ID'
|
||||||
|
|
||||||
|
class Person(facho.model.Model):
|
||||||
|
__name__ = 'Person'
|
||||||
|
__namespace__ = {
|
||||||
|
"facho": "http://lib.facho.cyou"
|
||||||
|
}
|
||||||
|
id = fields.Many2One(ID, namespace="facho")
|
||||||
|
|
||||||
|
|
||||||
|
person = Person()
|
||||||
|
person.id = 33
|
||||||
|
assert '<Person xmlns:facho="http://lib.facho.cyou"><facho:ID>33</facho:ID></Person>' == person.to_xml()
|
||||||
|
|
||||||
|
def test_field_hook_before_xml():
|
||||||
|
class Hash(facho.model.Model):
|
||||||
|
__name__ = 'Hash'
|
||||||
|
|
||||||
|
class Person(facho.model.Model):
|
||||||
|
__name__ = 'Person'
|
||||||
|
|
||||||
|
hash = fields.Many2One(Hash)
|
||||||
|
|
||||||
|
def __before_xml__(self):
|
||||||
|
self.hash = "calculate"
|
||||||
|
|
||||||
|
person = Person()
|
||||||
|
assert "<Person><Hash>calculate</Hash></Person>" == person.to_xml()
|
||||||
|
|
||||||
|
|
||||||
|
def test_field_function_with_attribute():
|
||||||
|
class Person(facho.model.Model):
|
||||||
|
__name__ = 'Person'
|
||||||
|
|
||||||
|
hash = fields.Function(fields.Attribute('hash'), getter='get_hash')
|
||||||
|
|
||||||
|
def get_hash(self, name, field):
|
||||||
|
return 'calculate'
|
||||||
|
|
||||||
|
person = Person()
|
||||||
|
assert '<Person hash="calculate"/>'
|
||||||
|
|
||||||
|
def test_field_function_with_model():
|
||||||
|
class Hash(facho.model.Model):
|
||||||
|
__name__ = 'Hash'
|
||||||
|
|
||||||
|
id = fields.Attribute('id')
|
||||||
|
|
||||||
|
class Person(facho.model.Model):
|
||||||
|
__name__ = 'Person'
|
||||||
|
|
||||||
|
hash = fields.Function(fields.Many2One(Hash), getter='get_hash')
|
||||||
|
|
||||||
|
def get_hash(self, name, field):
|
||||||
|
field.id = 'calculate'
|
||||||
|
|
||||||
|
|
||||||
|
person = Person()
|
||||||
|
assert person.hash.id == 'calculate'
|
||||||
|
assert '<Person/>'
|
||||||
|
|
||||||
|
|
||||||
|
def test_field_function_setter():
|
||||||
|
class Person(facho.model.Model):
|
||||||
|
__name__ = 'Person'
|
||||||
|
|
||||||
|
hash = fields.Attribute('hash')
|
||||||
|
password = fields.Virtual(setter='set_hash')
|
||||||
|
|
||||||
|
def set_hash(self, name, value):
|
||||||
|
self.hash = "%s+2" % (value)
|
||||||
|
|
||||||
|
person = Person()
|
||||||
|
person.password = 'calculate'
|
||||||
|
assert '<Person hash="calculate+2"/>' == person.to_xml()
|
||||||
|
|
||||||
|
def test_field_function_only_setter():
|
||||||
|
class Person(facho.model.Model):
|
||||||
|
__name__ = 'Person'
|
||||||
|
|
||||||
|
hash = fields.Attribute('hash')
|
||||||
|
password = fields.Virtual(setter='set_hash')
|
||||||
|
|
||||||
|
def set_hash(self, name, value):
|
||||||
|
self.hash = "%s+2" % (value)
|
||||||
|
|
||||||
|
person = Person()
|
||||||
|
person.password = 'calculate'
|
||||||
|
assert '<Person hash="calculate+2"/>' == person.to_xml()
|
||||||
|
|
||||||
|
def test_model_set_default_setter():
|
||||||
|
class Hash(facho.model.Model):
|
||||||
|
__name__ = 'Hash'
|
||||||
|
|
||||||
|
def __default_set__(self, value):
|
||||||
|
return "%s+3" % (value)
|
||||||
|
|
||||||
|
class Person(facho.model.Model):
|
||||||
|
__name__ = 'Person'
|
||||||
|
|
||||||
|
hash = fields.Many2One(Hash)
|
||||||
|
|
||||||
|
person = Person()
|
||||||
|
person.hash = 'hola'
|
||||||
|
assert '<Person><Hash>hola+3</Hash></Person>' == person.to_xml()
|
||||||
|
|
||||||
|
|
||||||
|
def test_field_virtual():
|
||||||
|
class Person(facho.model.Model):
|
||||||
|
__name__ = 'Person'
|
||||||
|
|
||||||
|
age = fields.Virtual()
|
||||||
|
|
||||||
|
person = Person()
|
||||||
|
person.age = 55
|
||||||
|
assert person.age == 55
|
||||||
|
assert "<Person/>" == person.to_xml()
|
||||||
|
|
||||||
|
|
||||||
|
def test_field_inserted_default_attribute():
|
||||||
|
class Person(facho.model.Model):
|
||||||
|
__name__ = 'Person'
|
||||||
|
|
||||||
|
hash = fields.Attribute('hash', default='calculate')
|
||||||
|
|
||||||
|
|
||||||
|
person = Person()
|
||||||
|
assert '<Person hash="calculate"/>' == person.to_xml()
|
||||||
|
|
||||||
|
def test_field_function_inserted_default_attribute():
|
||||||
|
class Person(facho.model.Model):
|
||||||
|
__name__ = 'Person'
|
||||||
|
|
||||||
|
hash = fields.Function(fields.Attribute('hash'), default='calculate')
|
||||||
|
|
||||||
|
person = Person()
|
||||||
|
assert '<Person hash="calculate"/>' == person.to_xml()
|
||||||
|
|
||||||
|
def test_field_inserted_default_many2one():
|
||||||
|
class ID(facho.model.Model):
|
||||||
|
__name__ = 'ID'
|
||||||
|
|
||||||
|
key = fields.Attribute('key')
|
||||||
|
|
||||||
|
def __default_set__(self, value):
|
||||||
|
self.key = value
|
||||||
|
|
||||||
|
class Person(facho.model.Model):
|
||||||
|
__name__ = 'Person'
|
||||||
|
|
||||||
|
id = fields.Many2One(ID, default="oe")
|
||||||
|
|
||||||
|
person = Person()
|
||||||
|
assert '<Person><ID key="oe"/></Person>' == person.to_xml()
|
||||||
|
|
||||||
|
def test_field_inserted_default_nested_many2one():
|
||||||
|
class ID(facho.model.Model):
|
||||||
|
__name__ = 'ID'
|
||||||
|
|
||||||
|
class Person(facho.model.Model):
|
||||||
|
__name__ = 'Person'
|
||||||
|
|
||||||
|
id = fields.Many2One(ID, default="ole")
|
||||||
|
|
||||||
|
person = Person()
|
||||||
|
assert '<Person><ID>ole</ID></Person>' == person.to_xml()
|
||||||
|
|
||||||
|
def test_model_on_change_field():
|
||||||
|
class Hash(facho.model.Model):
|
||||||
|
__name__ = 'Hash'
|
||||||
|
|
||||||
|
class Person(facho.model.Model):
|
||||||
|
__name__ = 'Person'
|
||||||
|
|
||||||
|
react = fields.Attribute('react')
|
||||||
|
hash = fields.Many2One(Hash)
|
||||||
|
|
||||||
|
@fields.on_change(['hash'])
|
||||||
|
def on_change_react(self, name, value):
|
||||||
|
assert name == 'hash'
|
||||||
|
self.react = "%s+4" % (value)
|
||||||
|
|
||||||
|
person = Person()
|
||||||
|
person.hash = 'hola'
|
||||||
|
assert '<Person react="hola+4"><Hash>hola</Hash></Person>' == person.to_xml()
|
||||||
|
|
||||||
|
def test_model_on_change_field_attribute():
|
||||||
|
class Person(facho.model.Model):
|
||||||
|
__name__ = 'Person'
|
||||||
|
|
||||||
|
react = fields.Attribute('react')
|
||||||
|
hash = fields.Attribute('Hash')
|
||||||
|
|
||||||
|
@fields.on_change(['hash'])
|
||||||
|
def on_react(self, name, value):
|
||||||
|
assert name == 'hash'
|
||||||
|
self.react = "%s+4" % (value)
|
||||||
|
|
||||||
|
person = Person()
|
||||||
|
person.hash = 'hola'
|
||||||
|
assert '<Person react="hola+4" Hash="hola"/>' == person.to_xml()
|
||||||
|
|
||||||
|
def test_model_one2many():
|
||||||
|
class Line(facho.model.Model):
|
||||||
|
__name__ = 'Line'
|
||||||
|
|
||||||
|
quantity = fields.Attribute('quantity')
|
||||||
|
|
||||||
|
class Invoice(facho.model.Model):
|
||||||
|
__name__ = 'Invoice'
|
||||||
|
|
||||||
|
lines = fields.One2Many(Line)
|
||||||
|
|
||||||
|
invoice = Invoice()
|
||||||
|
line = invoice.lines.create()
|
||||||
|
line.quantity = 3
|
||||||
|
line = invoice.lines.create()
|
||||||
|
line.quantity = 5
|
||||||
|
assert '<Invoice><Line quantity="3"/><Line quantity="5"/></Invoice>' == invoice.to_xml()
|
||||||
|
|
||||||
|
|
||||||
|
def test_model_one2many_with_on_changes():
|
||||||
|
class Line(facho.model.Model):
|
||||||
|
__name__ = 'Line'
|
||||||
|
|
||||||
|
quantity = fields.Attribute('quantity')
|
||||||
|
|
||||||
|
class Invoice(facho.model.Model):
|
||||||
|
__name__ = 'Invoice'
|
||||||
|
|
||||||
|
lines = fields.One2Many(Line)
|
||||||
|
count = fields.Attribute('count', default=0)
|
||||||
|
|
||||||
|
@fields.on_change(['lines'])
|
||||||
|
def refresh_count(self, name, value):
|
||||||
|
self.count = len(self.lines)
|
||||||
|
|
||||||
|
invoice = Invoice()
|
||||||
|
line = invoice.lines.create()
|
||||||
|
line.quantity = 3
|
||||||
|
line = invoice.lines.create()
|
||||||
|
line.quantity = 5
|
||||||
|
|
||||||
|
assert len(invoice.lines) == 2
|
||||||
|
assert '<Invoice count="2"><Line quantity="3"/><Line quantity="5"/></Invoice>' == invoice.to_xml()
|
||||||
|
|
||||||
|
def test_model_one2many_as_list():
|
||||||
|
class Line(facho.model.Model):
|
||||||
|
__name__ = 'Line'
|
||||||
|
|
||||||
|
quantity = fields.Attribute('quantity')
|
||||||
|
|
||||||
|
class Invoice(facho.model.Model):
|
||||||
|
__name__ = 'Invoice'
|
||||||
|
|
||||||
|
lines = fields.One2Many(Line)
|
||||||
|
|
||||||
|
invoice = Invoice()
|
||||||
|
line = invoice.lines.create()
|
||||||
|
line.quantity = 3
|
||||||
|
line = invoice.lines.create()
|
||||||
|
line.quantity = 5
|
||||||
|
|
||||||
|
lines = list(invoice.lines)
|
||||||
|
assert len(list(invoice.lines)) == 2
|
||||||
|
|
||||||
|
for line in lines:
|
||||||
|
assert isinstance(line, Line)
|
||||||
|
assert '<Invoice><Line quantity="3"/><Line quantity="5"/></Invoice>' == invoice.to_xml()
|
||||||
|
|
||||||
|
|
||||||
|
def test_model_attributes_order():
|
||||||
|
class Line(facho.model.Model):
|
||||||
|
__name__ = 'Line'
|
||||||
|
|
||||||
|
quantity = fields.Attribute('quantity')
|
||||||
|
|
||||||
|
class Invoice(facho.model.Model):
|
||||||
|
__name__ = 'Invoice'
|
||||||
|
|
||||||
|
line1 = fields.Many2One(Line, name='Line1')
|
||||||
|
line2 = fields.Many2One(Line, name='Line2')
|
||||||
|
line3 = fields.Many2One(Line, name='Line3')
|
||||||
|
|
||||||
|
|
||||||
|
invoice = Invoice()
|
||||||
|
invoice.line2.quantity = 2
|
||||||
|
invoice.line3.quantity = 3
|
||||||
|
invoice.line1.quantity = 1
|
||||||
|
|
||||||
|
assert '<Invoice><Line1 quantity="1"/><Line2 quantity="2"/><Line3 quantity="3"/></Invoice>' == invoice.to_xml()
|
||||||
|
|
||||||
|
|
||||||
|
def test_field_amount():
|
||||||
|
class Line(facho.model.Model):
|
||||||
|
__name__ = 'Line'
|
||||||
|
|
||||||
|
amount = fields.Amount(name='Amount', precision=1)
|
||||||
|
amount_as_attribute = fields.Attribute('amount')
|
||||||
|
|
||||||
|
@fields.on_change(['amount'])
|
||||||
|
def on_amount(self, name, value):
|
||||||
|
self.amount_as_attribute = self.amount
|
||||||
|
|
||||||
|
line = Line()
|
||||||
|
line.amount = 33
|
||||||
|
|
||||||
|
assert '<Line amount="33.0"/>' == line.to_xml()
|
||||||
|
|
||||||
|
|
||||||
|
def test_model_setup():
|
||||||
|
class Line(facho.model.Model):
|
||||||
|
__name__ = 'Line'
|
||||||
|
|
||||||
|
amount = fields.Attribute(name='amount')
|
||||||
|
|
||||||
|
def __setup__(self):
|
||||||
|
self.amount = 23
|
||||||
|
|
||||||
|
line = Line()
|
||||||
|
assert '<Line amount="23"/>' == line.to_xml()
|
||||||
119
tests/test_model_invoice.py
Normal file
119
tests/test_model_invoice.py
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
#!/usr/bin/env python
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# This file is part of facho. The COPYRIGHT file at the top level of
|
||||||
|
# this repository contains the full copyright notices and license terms.
|
||||||
|
|
||||||
|
"""Nuevo esquema para modelar segun decreto"""
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from lxml import etree
|
||||||
|
import facho.fe.model as model
|
||||||
|
import facho.fe.form as form
|
||||||
|
from facho import fe
|
||||||
|
import helpers
|
||||||
|
|
||||||
|
def simple_invoice():
|
||||||
|
invoice = model.Invoice()
|
||||||
|
invoice.dian.software_security_code = '12345'
|
||||||
|
invoice.dian.software_provider.provider_id = 'provider-id'
|
||||||
|
invoice.dian.software_provider.software_id = 'facho'
|
||||||
|
invoice.dian.control.prefix = 'SETP'
|
||||||
|
invoice.dian.control.from_range = '1000'
|
||||||
|
invoice.dian.control.to_range = '1000'
|
||||||
|
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.Taxes.Iva(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
|
||||||
|
|
||||||
|
return invoice
|
||||||
|
|
||||||
|
def test_simple_invoice_cufe():
|
||||||
|
token = '693ff6f2a553c3646a063436fd4dd9ded0311471'
|
||||||
|
environment = fe.AMBIENTE_PRODUCCION
|
||||||
|
invoice = simple_invoice()
|
||||||
|
assert invoice.cufe(token, environment) == '8bb918b19ba22a694f1da11c643b5e9de39adf60311cf179179e9b33381030bcd4c3c3f156c506ed5908f9276f5bd9b4'
|
||||||
|
|
||||||
|
def test_simple_invoice_sign_dian(monkeypatch):
|
||||||
|
invoice = simple_invoice()
|
||||||
|
|
||||||
|
xmlstring = invoice.to_xml()
|
||||||
|
p12_data = open('./tests/example.p12', 'rb').read()
|
||||||
|
signer = fe.DianXMLExtensionSigner.from_bytes(p12_data)
|
||||||
|
|
||||||
|
with monkeypatch.context() as m:
|
||||||
|
helpers.mock_urlopen(m)
|
||||||
|
xmlsigned = signer.sign_xml_string(xmlstring)
|
||||||
|
assert "Signature" in xmlsigned
|
||||||
|
|
||||||
|
|
||||||
|
def test_dian_extension_authorization_provider():
|
||||||
|
invoice = simple_invoice()
|
||||||
|
xml = fe.FeXML.from_string(invoice.to_xml())
|
||||||
|
provider_id = xml.get_element('/fe:Invoice/ext:UBLExtensions/ext:UBLExtension/ext:ExtensionContent/sts:DianExtensions/sts:AuthorizationProvider/sts:AuthorizationProviderID')
|
||||||
|
|
||||||
|
assert provider_id.attrib['schemeID'] == '4'
|
||||||
|
assert provider_id.attrib['schemeName'] == '31'
|
||||||
|
assert provider_id.attrib['schemeAgencyName'] == 'CO, DIAN (Dirección de Impuestos y Aduanas Nacionales)'
|
||||||
|
assert provider_id.attrib['schemeAgencyID'] == '195'
|
||||||
|
assert provider_id.text == '800197268'
|
||||||
|
|
||||||
|
def test_invoicesimple_xml_signed_using_fexml(monkeypatch):
|
||||||
|
invoice = simple_invoice()
|
||||||
|
|
||||||
|
xml = fe.FeXML.from_string(invoice.to_xml())
|
||||||
|
|
||||||
|
signer = fe.DianXMLExtensionSigner('./tests/example.p12')
|
||||||
|
|
||||||
|
print(xml.tostring())
|
||||||
|
with monkeypatch.context() as m:
|
||||||
|
import helpers
|
||||||
|
helpers.mock_urlopen(m)
|
||||||
|
xml.add_extension(signer)
|
||||||
|
|
||||||
|
elem = xml.get_element('/fe:Invoice/ext:UBLExtensions/ext:UBLExtension[2]/ext:ExtensionContent/ds:Signature')
|
||||||
|
assert elem.text is not None
|
||||||
|
|
||||||
|
def test_invoice_supplier_party():
|
||||||
|
invoice = simple_invoice()
|
||||||
|
invoice.supplier.party.name = 'superfacho'
|
||||||
|
invoice.supplier.party.tax_scheme.registration_name = 'legal-superfacho'
|
||||||
|
invoice.supplier.party.contact.email = 'superfacho@etrivial.net'
|
||||||
|
|
||||||
|
xml = fe.FeXML.from_string(invoice.to_xml())
|
||||||
|
|
||||||
|
name = xml.get_element('/fe:Invoice/cac:AccountingSupplierParty/cac:Party/cac:PartyName/cbc:Name')
|
||||||
|
assert name.text == 'superfacho'
|
||||||
|
|
||||||
|
name = xml.get_element('/fe:Invoice/cac:AccountingSupplierParty/cac:Party/cac:PartyTaxScheme/cbc:RegistrationName')
|
||||||
|
assert name.text == 'legal-superfacho'
|
||||||
|
|
||||||
|
name = xml.get_element('/fe:Invoice/cac:AccountingSupplierParty/cac:Party/cac:Contact/cbc:ElectronicEmail')
|
||||||
|
assert name.text == 'superfacho@etrivial.net'
|
||||||
|
|
||||||
|
def test_invoice_customer_party():
|
||||||
|
invoice = simple_invoice()
|
||||||
|
invoice.customer.party.name = 'superfacho-customer'
|
||||||
|
invoice.customer.party.tax_scheme.registration_name = 'legal-superfacho-customer'
|
||||||
|
invoice.customer.party.contact.email = 'superfacho@etrivial.net'
|
||||||
|
|
||||||
|
xml = fe.FeXML.from_string(invoice.to_xml())
|
||||||
|
|
||||||
|
name = xml.get_element('/fe:Invoice/cac:AccountingCustomerParty/cac:Party/cac:PartyName/cbc:Name')
|
||||||
|
assert name.text == 'superfacho-customer'
|
||||||
|
|
||||||
|
name = xml.get_element('/fe:Invoice/cac:AccountingCustomerParty/cac:Party/cac:PartyTaxScheme/cbc:RegistrationName')
|
||||||
|
assert name.text == 'legal-superfacho-customer'
|
||||||
|
|
||||||
|
name = xml.get_element('/fe:Invoice/cac:AccountingCustomerParty/cac:Party/cac:Contact/cbc:ElectronicEmail')
|
||||||
|
assert name.text == 'superfacho@etrivial.net'
|
||||||
Reference in New Issue
Block a user