Import DGII fiscal authorizations safely
Publicar paquete PyPI / publish (push) Failing after 15s

This commit is contained in:
José Arturo García
2026-08-16 14:09:42 -04:00
parent 60bbed6de0
commit 3d038bd9fe
15 changed files with 830 additions and 10 deletions
+17
View File
@@ -42,3 +42,20 @@ an authorization closes every remaining active line without reusing numbers.
Electronic fiscal numbers are only reserved by this module. Activating an
E-series authorization never signs or transmits an e-CF to DGII.
Importing DGII authorizations
*****************************
Use **NCF Manager → Import DGII Authorization** to load an authorization
downloaded from the DGII Virtual Office. XML, CSV and the HTML table exported
with an ``.xls`` extension are supported and detected from their content.
The importer groups lines by ``NUM_SOL`` and identifies each authorization by
``NUM_AUTOR``; file names are not used as fiscal identifiers. It validates
approval status, company name, NCF type, prefix, range and approved quantity,
and stores the original file, SHA-256, user and import date for audit.
Imports are idempotent. Reimporting an equivalent CSV, XML or XLS record does
not create another line, while different fiscal data for an existing
authorization is rejected. The safe default leaves the result in draft so
the first number can be reviewed. Immediate activation is explicit and still
does not transmit electronic documents.
+2
View File
@@ -1,4 +1,5 @@
from . import invoice
from . import importer
from . import ncf_manager
from . import sequence
from . import configuration
@@ -8,6 +9,7 @@ from . import user
__all__ = [
'configuration',
'invoice',
'importer',
'ncf_manager',
'party',
'sequence',
+342
View File
@@ -0,0 +1,342 @@
import csv
import datetime
import hashlib
from html.parser import HTMLParser
import json
import re
import unicodedata
import xml.etree.ElementTree as ET
from trytond.exceptions import UserError
from trytond.i18n import gettext
from trytond.model import ModelView, fields
from trytond.pool import Pool
from trytond.transaction import Transaction
from trytond.wizard import Button, StateTransition, StateView, Wizard
from .ncf_manager import NCF_SEQUENCE_TYPE_IDS, ncf_padding, ncf_prefix
REQUIRED_COLUMNS = {
'NUM_SOL', 'NUM_AUTOR', 'FECHA_AUTOR', 'OBSERVACIONES',
'NUMERO_DESDE', 'NUMERO_HASTA', 'CODTIPO', 'FECHA_SOLICITUD',
'ESTATUS', 'CANTIDAD_APROBADA',
}
SUPPLIER_NCF_TYPES = frozenset({'11', '13', '17', '41', '43', '47'})
MAX_IMPORT_SIZE = 5 * 1024 * 1024
def _text(value):
return (value or '').replace('\xa0', ' ').strip()
def _normalized_name(value):
value = unicodedata.normalize('NFKD', _text(value).upper())
return ''.join(character for character in value
if character.isalnum())
def _parse_datetime(value):
value = _text(value)
if not value:
return None
try:
return datetime.datetime.fromisoformat(value).replace(tzinfo=None)
except ValueError:
pass
for pattern in ('%d/%m/%Y %H:%M:%S', '%d/%m/%Y %H:%M'):
try:
return datetime.datetime.strptime(value, pattern)
except ValueError:
continue
raise ValueError(f'Invalid date: {value}')
class _HTMLTableParser(HTMLParser):
def __init__(self):
super().__init__()
self.rows = []
self._row = None
self._cell = None
def handle_starttag(self, tag, attrs):
if tag == 'tr':
self._row = []
elif tag in {'td', 'th'} and self._row is not None:
self._cell = []
def handle_data(self, data):
if self._cell is not None:
self._cell.append(data)
def handle_endtag(self, tag):
if tag in {'td', 'th'} and self._cell is not None:
self._row.append(_text(''.join(self._cell)))
self._cell = None
elif tag == 'tr' and self._row:
self.rows.append(self._row)
self._row = None
def _parse_xml(data):
root = ET.fromstring(data)
rows = []
for element in list(root):
row = {
child.tag.rsplit('}', 1)[-1]: _text(child.text)
for child in list(element)
}
if row:
rows.append(row)
return rows
def _parse_html(data):
parser = _HTMLTableParser()
parser.feed(data.decode('utf-8-sig', errors='replace'))
if len(parser.rows) < 2:
return []
header = parser.rows[0]
return [dict(zip(header, row)) for row in parser.rows[1:]]
def _parse_csv(data):
# DGII does not always quote DETALLE_LOG even when it contains commas.
# Splitting at most len(header)-1 times preserves that final field.
text = data.decode('utf-8-sig', errors='strict')
lines = [line for line in text.splitlines() if line.strip()]
if len(lines) < 2:
return []
header = next(csv.reader([lines[0]]))
rows = []
for line in lines[1:]:
values = line.split(',', len(header) - 1)
rows.append(dict(zip(header, values)))
return rows
def parse_dgii_authorizations(data):
data = bytes(data)
if not data or len(data) > MAX_IMPORT_SIZE:
raise UserError(gettext('ncf_manager.msg_dgii_import_invalid_size'))
stripped = data.lstrip()
try:
if stripped.startswith(b'<'):
if re.search(br'<\s*(?:\w+:)?SolicitudNCF\b', stripped[:500]):
rows = _parse_xml(data)
source_format = 'xml'
elif re.search(br'<\s*(?:html|div|table)\b', stripped[:500], re.I):
rows = _parse_html(data)
source_format = 'xls_html'
else:
raise ValueError('Unsupported XML/HTML document')
else:
rows = _parse_csv(data)
source_format = 'csv'
except (UnicodeDecodeError, ET.ParseError, ValueError) as exception:
raise UserError(gettext(
'ncf_manager.msg_dgii_import_read_error', error=str(exception)))
if not rows or not REQUIRED_COLUMNS.issubset(rows[0]):
raise UserError(gettext('ncf_manager.msg_dgii_import_invalid_file'))
return source_format, [{key: _text(value) for key, value in row.items()}
for row in rows]
def normalize_dgii_row(row):
if row['ESTATUS'].upper() != 'APROBADA':
raise UserError(gettext(
'ncf_manager.msg_dgii_import_not_approved',
authorization=row['NUM_AUTOR']))
ncf_type = row['CODTIPO'].lstrip('0')
if ncf_type not in NCF_SEQUENCE_TYPE_IDS:
raise UserError(gettext(
'ncf_manager.msg_dgii_import_unknown_type', type=ncf_type))
start = row['NUMERO_DESDE'].upper()
end = row['NUMERO_HASTA'].upper()
prefix = ncf_prefix(ncf_type)
padding = ncf_padding(ncf_type)
pattern = rf'{re.escape(prefix)}(\d{{{padding}}})'
start_match = re.fullmatch(pattern, start)
end_match = re.fullmatch(pattern, end)
if not start_match or not end_match:
raise UserError(gettext(
'ncf_manager.msg_dgii_import_invalid_range',
authorization=row['NUM_AUTOR']))
from_number = int(start_match.group(1))
until_number = int(end_match.group(1))
approved = int(row['CANTIDAD_APROBADA'])
if from_number < 1 or until_number < from_number:
raise UserError(gettext('ncf_manager.msg_ncf_invalid_range'))
if approved != until_number - from_number + 1:
raise UserError(gettext(
'ncf_manager.msg_dgii_import_quantity_mismatch',
authorization=row['NUM_AUTOR']))
try:
requested = int(row.get('CANTIDAD_SOLICITADA') or 0) or None
authorization_date = _parse_datetime(row['FECHA_AUTOR'])
application_date = _parse_datetime(row['FECHA_SOLICITUD'])
expiration = _parse_datetime(row.get('FECHA_VIGENCIA'))
except ValueError as exception:
raise UserError(gettext(
'ncf_manager.msg_dgii_import_read_error', error=str(exception)))
if not application_date or not authorization_date:
raise UserError(gettext(
'ncf_manager.msg_dgii_import_missing_dates',
authorization=row['NUM_AUTOR']))
normalized = {
'application_number': row['NUM_SOL'],
'authorization_number': row['NUM_AUTOR'],
'application_date': application_date,
'authorization_date': authorization_date,
'company_name': row.get('OBSERVACIONES', ''),
'ncf_type': ncf_type,
'invoice_type': ('invoice_supplier'
if ncf_type in SUPPLIER_NCF_TYPES else 'invoice_customer'),
'from_number': from_number,
'until_number': until_number,
'approved_quantity': approved,
'requested_quantity': requested,
'expiration_date': expiration.date() if expiration else None,
'usage_type': row.get('TIPO_USO', ''),
'status': row['ESTATUS'].upper(),
'detail': row.get('DETALLE_LOG', ''),
}
fingerprint_values = {key: value.isoformat()
if isinstance(value, (datetime.date, datetime.datetime)) else value
for key, value in normalized.items()
if key not in {'detail', 'company_name'}}
normalized['fingerprint'] = hashlib.sha256(json.dumps(
fingerprint_values, ensure_ascii=False, sort_keys=True,
separators=(',', ':')).encode()).hexdigest()
return normalized
def import_dgii_authorizations(company, data, filename, activate=False):
pool = Pool()
Manager = pool.get('ncf.manager')
Line = pool.get('ncf.manager.sequence')
data = bytes(data)
_source_format, rows = parse_dgii_authorizations(data)
normalized_rows = [normalize_dgii_row(row) for row in rows]
company_name = _normalized_name(company.party.name)
for row in normalized_rows:
supplied_name = _normalized_name(row['company_name'])
if supplied_name and supplied_name != company_name:
raise UserError(gettext(
'ncf_manager.msg_dgii_import_company_mismatch',
company=row['company_name']))
file_hash = hashlib.sha256(data).hexdigest()
managers_to_activate = []
imported_lines = []
for row in normalized_rows:
managers = Manager.search([
('company', '=', company.id),
('app_number', '=', row['application_number']),
], limit=1)
if managers:
manager = managers[0]
else:
manager, = Manager.create([{
'company': company.id,
'app_number': row['application_number'],
'app_date': row['application_date'].date(),
'source_filename': filename,
'source_hash': file_hash,
'source_file': data,
'imported_by': Transaction().user,
'imported_at': datetime.datetime.now(),
}])
duplicates = Line.search([
('pos.company', '=', company.id),
('auth_number', '=', row['authorization_number']),
], limit=1)
if duplicates:
if duplicates[0].import_fingerprint != row['fingerprint']:
raise UserError(gettext(
'ncf_manager.msg_dgii_import_conflict',
authorization=row['authorization_number']))
continue
if manager.state != 'draft':
raise UserError(gettext(
'ncf_manager.msg_dgii_import_closed_request',
request=manager.app_number))
line, = Line.create([{
'pos': manager.id,
'auth_number': row['authorization_number'],
'authorization_date': row['authorization_date'],
'ncf_type': row['ncf_type'],
'invoice_type': row['invoice_type'],
'sequence_mode': 'create',
'from_number': row['from_number'],
'number_next': row['from_number'],
'until_number': row['until_number'],
'expiration_date': row['expiration_date'],
'dgii_status': row['status'],
'requested_quantity': row['requested_quantity'],
'approved_quantity': row['approved_quantity'],
'usage_type': row['usage_type'],
'dgii_detail': row['detail'],
'import_fingerprint': row['fingerprint'],
'source_filename': filename,
'source_hash': file_hash,
'source_file': data,
'imported_by': Transaction().user,
'imported_at': datetime.datetime.now(),
}])
imported_lines.append(line)
if manager not in managers_to_activate:
managers_to_activate.append(manager)
if activate and managers_to_activate:
Manager.activate(managers_to_activate)
return managers_to_activate, imported_lines
class ImportNCFStart(ModelView):
'Import DGII NCF Authorization'
__name__ = 'ncf.import.start'
company = fields.Many2One(
'company.company', 'Company', required=True)
file_ = fields.Binary('DGII File', required=True, filename='filename')
filename = fields.Char('Filename', required=True)
activate = fields.Boolean(
'Activate Imported Authorization',
help='Creates the strict sequence and activates it immediately. '
'Leave disabled when numbers may already have been issued outside '
'Tryton so the first number can be reviewed.')
@staticmethod
def default_company():
return Transaction().context.get('company')
@staticmethod
def default_activate():
return False
class ImportNCF(Wizard):
'Import DGII NCF Authorization'
__name__ = 'ncf.import'
start = StateView('ncf.import.start',
'ncf_manager.ncf_import_start_view_form', [
Button('Cancel', 'end', 'tryton-cancel'),
Button('Import', 'import_', 'tryton-ok', default=True),
])
import_ = StateTransition()
def transition_import_(self):
import_dgii_authorizations(
self.start.company, self.start.file_, self.start.filename,
activate=self.start.activate)
return 'end'
__all__ = [
'ImportNCF', 'ImportNCFStart', 'import_dgii_authorizations',
'normalize_dgii_row', 'parse_dgii_authorizations']
+26
View File
@@ -0,0 +1,26 @@
<?xml version="1.0"?>
<!-- This file is part of Tryton. The COPYRIGHT file at the top level of
this repository contains the full copyright notices and license terms. -->
<tryton>
<data>
<record model="ir.ui.view" id="ncf_import_start_view_form">
<field name="model">ncf.import.start</field>
<field name="type">form</field>
<field name="name">ncf_import_start_form</field>
</record>
<record model="ir.action.wizard" id="act_ncf_import">
<field name="name">Import DGII Authorization</field>
<field name="wiz_name">ncf.import</field>
</record>
<menuitem name="Import DGII Authorization"
parent="main_menu_ncf_manager" action="act_ncf_import"
sequence="20" id="menu_ncf_import"/>
<record model="ir.action-res.group" id="act_ncf_import_group">
<field name="action" ref="act_ncf_import"/>
<field name="group" ref="account.group_account_admin"/>
</record>
</data>
</tryton>
+72
View File
@@ -605,3 +605,75 @@ msgstr "Finalizar secuencia"
msgctxt "view:ncf.manager:"
msgid "Authorized Fiscal Sequences"
msgstr "Secuencias fiscales autorizadas"
msgctxt "field:ncf.import.start,company:"
msgid "Company"
msgstr "Empresa"
msgctxt "field:ncf.import.start,file_:"
msgid "DGII File"
msgstr "Archivo de la DGII"
msgctxt "field:ncf.import.start,filename:"
msgid "Filename"
msgstr "Nombre del archivo"
msgctxt "field:ncf.import.start,activate:"
msgid "Activate Imported Authorization"
msgstr "Activar autorización importada"
msgctxt "field:ncf.manager,source_filename:"
msgid "Source Filename"
msgstr "Nombre del archivo fuente"
msgctxt "field:ncf.manager,source_hash:"
msgid "Source SHA-256"
msgstr "SHA-256 del archivo fuente"
msgctxt "field:ncf.manager,source_file:"
msgid "DGII Source File"
msgstr "Archivo fuente de la DGII"
msgctxt "field:ncf.manager,imported_by:"
msgid "Imported By"
msgstr "Importado por"
msgctxt "field:ncf.manager,imported_at:"
msgid "Imported At"
msgstr "Fecha de importación"
msgctxt "field:ncf.manager.sequence,auth_number:"
msgid "Authorization Number"
msgstr "Número de autorización"
msgctxt "field:ncf.manager.sequence,authorization_date:"
msgid "Authorization Date"
msgstr "Fecha de autorización"
msgctxt "field:ncf.manager.sequence,dgii_status:"
msgid "DGII Status"
msgstr "Estado en DGII"
msgctxt "field:ncf.manager.sequence,requested_quantity:"
msgid "Requested Quantity"
msgstr "Cantidad solicitada"
msgctxt "field:ncf.manager.sequence,approved_quantity:"
msgid "Approved Quantity"
msgstr "Cantidad aprobada"
msgctxt "field:ncf.manager.sequence,usage_type:"
msgid "Usage Type"
msgstr "Tipo de uso"
msgctxt "field:ncf.manager.sequence,dgii_detail:"
msgid "DGII Detail"
msgstr "Detalle de la DGII"
msgctxt "model:ir.action,name:act_ncf_import"
msgid "Import DGII Authorization"
msgstr "Importar autorización de la DGII"
msgctxt "model:ir.ui.menu,name:menu_ncf_import"
msgid "Import DGII Authorization"
msgstr "Importar autorización de la DGII"
+72
View File
@@ -1120,3 +1120,75 @@ msgstr "Finalizar secuencia"
msgctxt "view:ncf.manager:"
msgid "Authorized Fiscal Sequences"
msgstr "Secuencias fiscales autorizadas"
msgctxt "field:ncf.import.start,company:"
msgid "Company"
msgstr "Empresa"
msgctxt "field:ncf.import.start,file_:"
msgid "DGII File"
msgstr "Archivo de la DGII"
msgctxt "field:ncf.import.start,filename:"
msgid "Filename"
msgstr "Nombre del archivo"
msgctxt "field:ncf.import.start,activate:"
msgid "Activate Imported Authorization"
msgstr "Activar autorización importada"
msgctxt "field:ncf.manager,source_filename:"
msgid "Source Filename"
msgstr "Nombre del archivo fuente"
msgctxt "field:ncf.manager,source_hash:"
msgid "Source SHA-256"
msgstr "SHA-256 del archivo fuente"
msgctxt "field:ncf.manager,source_file:"
msgid "DGII Source File"
msgstr "Archivo fuente de la DGII"
msgctxt "field:ncf.manager,imported_by:"
msgid "Imported By"
msgstr "Importado por"
msgctxt "field:ncf.manager,imported_at:"
msgid "Imported At"
msgstr "Fecha de importación"
msgctxt "field:ncf.manager.sequence,auth_number:"
msgid "Authorization Number"
msgstr "Número de autorización"
msgctxt "field:ncf.manager.sequence,authorization_date:"
msgid "Authorization Date"
msgstr "Fecha de autorización"
msgctxt "field:ncf.manager.sequence,dgii_status:"
msgid "DGII Status"
msgstr "Estado en DGII"
msgctxt "field:ncf.manager.sequence,requested_quantity:"
msgid "Requested Quantity"
msgstr "Cantidad solicitada"
msgctxt "field:ncf.manager.sequence,approved_quantity:"
msgid "Approved Quantity"
msgstr "Cantidad aprobada"
msgctxt "field:ncf.manager.sequence,usage_type:"
msgid "Usage Type"
msgstr "Tipo de uso"
msgctxt "field:ncf.manager.sequence,dgii_detail:"
msgid "DGII Detail"
msgstr "Detalle de la DGII"
msgctxt "model:ir.action,name:act_ncf_import"
msgid "Import DGII Authorization"
msgstr "Importar autorización de la DGII"
msgctxt "model:ir.ui.menu,name:menu_ncf_import"
msgid "Import DGII Authorization"
msgstr "Importar autorización de la DGII"
+39
View File
@@ -159,5 +159,44 @@ this repository contains the full copyright notices and license terms. -->
<record model="ir.message" id="msg_ncf_authorization_delete_not_draft">
<field name="text">Only draft fiscal authorizations can be deleted.</field>
</record>
<record model="ir.message" id="msg_ncf_line_missing_authorization">
<field name="text">Every fiscal sequence line requires its DGII authorization number.</field>
</record>
<record model="ir.message" id="msg_dgii_import_read_error">
<field name="text">The DGII file could not be read: %(error)s.</field>
</record>
<record model="ir.message" id="msg_dgii_import_invalid_file">
<field name="text">The file does not contain the required DGII authorization columns.</field>
</record>
<record model="ir.message" id="msg_dgii_import_not_approved">
<field name="text">DGII authorization "%(authorization)s" is not approved and cannot be imported.</field>
</record>
<record model="ir.message" id="msg_dgii_import_unknown_type">
<field name="text">DGII NCF type "%(type)s" is not supported.</field>
</record>
<record model="ir.message" id="msg_dgii_import_invalid_range">
<field name="text">DGII authorization "%(authorization)s" has an invalid fiscal range or prefix.</field>
</record>
<record model="ir.message" id="msg_dgii_import_quantity_mismatch">
<field name="text">The approved quantity for DGII authorization "%(authorization)s" does not match its range.</field>
</record>
<record model="ir.message" id="msg_dgii_import_company_mismatch">
<field name="text">The DGII file belongs to "%(company)s", which does not match the selected company.</field>
</record>
<record model="ir.message" id="msg_dgii_import_conflict">
<field name="text">DGII authorization "%(authorization)s" already exists with different fiscal data.</field>
</record>
<record model="ir.message" id="msg_dgii_import_closed_request">
<field name="text">DGII request "%(request)s" is no longer in draft and cannot receive another imported authorization.</field>
</record>
<record model="ir.message" id="msg_ncf_authorization_number_duplicate">
<field name="text">DGII authorization number "%(authorization)s" is already registered for this company.</field>
</record>
<record model="ir.message" id="msg_dgii_import_invalid_size">
<field name="text">The DGII import file is empty or exceeds the 5 MB safety limit.</field>
</record>
<record model="ir.message" id="msg_dgii_import_missing_dates">
<field name="text">DGII authorization "%(authorization)s" does not contain the required request and authorization dates.</field>
</record>
</data>
</tryton>
+101 -4
View File
@@ -99,9 +99,16 @@ class NCFManager(Workflow, ModelSQL, ModelView):
app_number = fields.Char('Application Number', required=True, size=10,
states=_states, depends=_depends)
auth_number = fields.Char('Authorization Number', required=True, size=10,
auth_number = fields.Char('Legacy Authorization Number', size=10,
states=_states, depends=_depends)
source_filename = fields.Char('Source Filename', readonly=True)
source_hash = fields.Char('Source SHA-256', readonly=True)
source_file = fields.Binary(
'DGII Source File', filename='source_filename', readonly=True)
imported_by = fields.Many2One('res.user', 'Imported By', readonly=True)
imported_at = fields.DateTime('Imported At', readonly=True)
ncf_sequences = fields.One2Many('ncf.manager.sequence', 'pos',
'NCF Sequences', context={'company': Eval('company', -1)},
states=_states, depends=_depends)
@@ -188,9 +195,16 @@ class NCFManager(Workflow, ModelSQL, ModelView):
})
def _validate_authorization(self):
if not self.app_number.strip() or not self.auth_number.strip():
if not self.app_number.strip():
raise UserError(gettext(
'ncf_manager.msg_ncf_missing_authorization'))
for line in self.ncf_sequences:
if not line.auth_number:
if self.auth_number:
line.auth_number = self.auth_number
else:
raise UserError(gettext(
'ncf_manager.msg_ncf_line_missing_authorization'))
@classmethod
@ModelView.button
@@ -229,6 +243,11 @@ class NCFManager(Workflow, ModelSQL, ModelView):
'activated_at': None,
'finished_by': None,
'finished_at': None,
'source_filename': None,
'source_hash': None,
'source_file': None,
'imported_by': None,
'imported_at': None,
})
return super().copy(managers, default=default)
@@ -254,6 +273,23 @@ class NCFManagerSequences(Workflow, ModelSQL, ModelView):
ncf_type_string = ncf_type.translated('ncf_type')
auth_number = fields.Char(
'Authorization Number', size=10, states=_states, depends=_depends)
authorization_date = fields.DateTime(
'Authorization Date', states=_states, depends=_depends)
dgii_status = fields.Char('DGII Status', readonly=True)
requested_quantity = fields.Integer('Requested Quantity', readonly=True)
approved_quantity = fields.Integer('Approved Quantity', readonly=True)
usage_type = fields.Char('Usage Type', readonly=True)
dgii_detail = fields.Text('DGII Detail', readonly=True)
import_fingerprint = fields.Char('Import Fingerprint', readonly=True)
source_filename = fields.Char('Source Filename', readonly=True)
source_hash = fields.Char('Source SHA-256', readonly=True)
source_file = fields.Binary(
'DGII Source File', filename='source_filename', readonly=True)
imported_by = fields.Many2One('res.user', 'Imported By', readonly=True)
imported_at = fields.DateTime('Imported At', readonly=True)
company = fields.Function(
fields.Many2One('company.company', 'Company'),
'on_change_with_company')
@@ -309,6 +345,24 @@ class NCFManagerSequences(Workflow, ModelSQL, ModelView):
'invisible': Eval('sequence_mode') != 'create',
}, depends=_depends + ['sequence_mode'])
effective_from_number = fields.Function(
fields.Integer('Authorized From', states={
'invisible': Eval('sequence_mode') == 'create',
}), 'get_effective_sequence_values')
effective_until_number = fields.Function(
fields.Integer('Authorized Until', states={
'invisible': Eval('sequence_mode') == 'create',
}), 'get_effective_sequence_values')
effective_number_next = fields.Function(
fields.Integer('First Number to Issue', states={
'invisible': Eval('sequence_mode') == 'create',
}),
'get_effective_sequence_values')
effective_expiration_date = fields.Function(
fields.Date('Expiration Date', states={
'invisible': Eval('sequence_mode') == 'create',
}), 'get_effective_sequence_values')
invoice_type = fields.Selection([
('invoice_customer', 'Customer Invoice'),
('invoice_supplier', 'Supplier Invoice')
@@ -364,6 +418,35 @@ class NCFManagerSequences(Workflow, ModelSQL, ModelView):
and self.ncf_sequences.prefix != ncf_prefix(self.ncf_type)):
self.ncf_sequences = None
@fields.depends(
'ncf_sequences', 'from_number', 'until_number', 'number_next',
'expiration_date')
def on_change_ncf_sequences(self):
if not self.ncf_sequences:
return
self.from_number = self.ncf_sequences.from_number
self.until_number = self.ncf_sequences.until_number
self.number_next = self.ncf_sequences.number_next
self.expiration_date = self.ncf_sequences.expiration_date
@classmethod
def get_effective_sequence_values(cls, records, names):
values = {name: {} for name in names}
mapping = {
'effective_from_number': 'from_number',
'effective_until_number': 'until_number',
'effective_number_next': 'number_next',
'effective_expiration_date': 'expiration_date',
}
for record in records:
sequence = record.ncf_sequences
for name in names:
source = mapping[name]
value = (getattr(sequence, source, None) if sequence
else getattr(record, source, None))
values[name][record.id] = value
return values
def get_rec_name(self, name):
if not self.ncf_type_string:
return ''
@@ -372,9 +455,23 @@ class NCFManagerSequences(Workflow, ModelSQL, ModelView):
@classmethod
def validate_fields(cls, records, field_names):
super().validate_fields(records, field_names)
if field_names & {'pos', 'ncf_sequences', 'ncf_type'}:
if field_names & {
'pos', 'ncf_sequences', 'ncf_type', 'auth_number'}:
for record in records:
record._validate_fiscal_sequence()
record._validate_unique_authorization()
def _validate_unique_authorization(self):
if not self.auth_number or not self.pos or not self.pos.company:
return
if self.__class__.search([
('id', '!=', self.id),
('pos.company', '=', self.pos.company.id),
('auth_number', '=', self.auth_number),
], limit=1):
raise UserError(gettext(
'ncf_manager.msg_ncf_authorization_number_duplicate',
authorization=self.auth_number))
def _validate_fiscal_sequence(self):
sequence = self.ncf_sequences
@@ -424,7 +521,7 @@ class NCFManagerSequences(Workflow, ModelSQL, ModelView):
sequence_type = ModelData.get_id(
'ncf_manager', NCF_SEQUENCE_TYPE_IDS[self.ncf_type])
sequence, = Sequence.create([{
'name': f'{ncf_prefix(self.ncf_type)} - {self.pos.auth_number}',
'name': f'{ncf_prefix(self.ncf_type)} - {self.auth_number}',
'sequence_type': sequence_type,
'company': self.pos.company.id,
'prefix': ncf_prefix(self.ncf_type),
+57
View File
@@ -7,6 +7,7 @@ import unittest
from trytond.exceptions import UserError
from trytond.modules.ncf_manager.sequence import Sequence
from trytond.modules.ncf_manager.invoice import Invoice
from trytond.modules.ncf_manager.importer import import_dgii_authorizations
from trytond.pool import Pool
from trytond.tests.test_tryton import ModuleTestCase, with_transaction
from trytond.transaction import Transaction
@@ -164,6 +165,29 @@ class NcfManagerTestCase(ModuleTestCase):
}])
return sequence
@staticmethod
def _dgii_xml(ncf_type='31', authorization='6005423851',
start='E310000000001', end='E310000000100', quantity='100'):
return f'''<SolicitudNCF>
<Solicitud_6009912073 xmlns="http://tempuri.org/dsCFiscales.xsd">
<NUM_SOL>6009912073</NUM_SOL>
<NUM_AUTOR>{authorization}</NUM_AUTOR>
<FECHA_AUTOR>2026-08-07T13:45:02-04:00</FECHA_AUTOR>
<COMP>FACTURA ELECTRÓNICA</COMP>
<OBSERVACIONES>Empresa NCF</OBSERVACIONES>
<NUMERO_DESDE>{start}</NUMERO_DESDE>
<NUMERO_HASTA>{end}</NUMERO_HASTA>
<CODTIPO>{ncf_type}</CODTIPO>
<FECHA_SOLICITUD>2026-08-07T13:36:23-04:00</FECHA_SOLICITUD>
<ESTATUS>APROBADA</ESTATUS>
<CANTIDAD_SOLICITADA>{quantity}</CANTIDAD_SOLICITADA>
<TIPO_USO>ELECTRÓNICA</TIPO_USO>
<CANTIDAD_APROBADA>{quantity}</CANTIDAD_APROBADA>
<FECHA_VIGENCIA>2027-12-31T00:00:00-04:00</FECHA_VIGENCIA>
<DETALLE_LOG>Autorizada por DGII</DETALLE_LOG>
</Solicitud_6009912073>
</SolicitudNCF>'''.encode()
@with_transaction()
def test_customer_invoice_post_assigns_b01_ncf(self):
'Test full NCF assignment workflow on posted customer invoice'
@@ -248,6 +272,12 @@ class NcfManagerTestCase(ModuleTestCase):
self.assertEqual(line.ncf_sequences.prefix, 'B01')
self.assertEqual(line.ncf_sequences.padding, 8)
self.assertEqual(line.ncf_sequences.number_next, 100)
self.assertEqual(line.effective_from_number, 100)
self.assertEqual(line.effective_until_number, 109)
self.assertEqual(line.effective_number_next, 100)
self.assertEqual(
line.effective_expiration_date,
datetime.date(today.year, 12, 31))
self.assertEqual(line.availability, 'low')
Manager.finish([manager])
@@ -256,6 +286,33 @@ class NcfManagerTestCase(ModuleTestCase):
self.assertEqual(manager.ncf_sequences[0].state, 'finished')
self.assertIsNotNone(manager.finished_at)
@with_transaction()
def test_import_dgii_authorization_is_idempotent(self):
pool = Pool()
Manager = pool.get('ncf.manager')
company = self._create_company()
data = self._dgii_xml()
managers, lines = import_dgii_authorizations(
company, data, 'authorization.xml')
self.assertEqual(len(managers), 1)
self.assertEqual(len(lines), 1)
manager = managers[0]
line = lines[0]
self.assertEqual(manager.app_number, '6009912073')
self.assertEqual(line.auth_number, '6005423851')
self.assertEqual(line.ncf_type, '31')
self.assertEqual(line.from_number, 1)
self.assertEqual(line.until_number, 100)
self.assertEqual(line.expiration_date, datetime.date(2027, 12, 31))
managers, lines = import_dgii_authorizations(
company, data, 'authorization-again.xml')
self.assertEqual(managers, [])
self.assertEqual(lines, [])
self.assertEqual(Manager.search_count([
('app_number', '=', '6009912073')]), 1)
del ModuleTestCase
+34
View File
@@ -4,10 +4,24 @@ import unittest
from trytond.modules.ncf_manager.ncf_manager import (
NCFManager, NCFManagerSequences)
from trytond.modules.ncf_manager.sequence import Sequence
from trytond.modules.ncf_manager.importer import (
normalize_dgii_row, parse_dgii_authorizations)
class NcfManagerUnitTestCase(unittest.TestCase):
HEADERS = (
'NUM_SOL,NUM_AUTOR,FECHA_AUTOR,COMP,OBSERVACIONES,NUMERO_DESDE,'
'NUMERO_HASTA,CANTIDAD,CODTIPO,FECHA_SOLICITUD,ESTATUS,'
'CANTIDAD_SOLICITADA,TIPO_USO,RNC_IMPRENTA,IMPRENTA,'
'CANTIDAD_APROBADA,FECHA_VIGENCIA,DETALLE_LOG')
ROW = (
'6009912073,6005423851,07/08/2026 13:45:02,CRÉDITO ELECTRÓNICO,'
'SOLUTEMA SRL,E310000000001,E310000000100,,31,'
'07/08/2026 13:36:23,APROBADA,1000,ELECTRÓNICA,,,100,'
'31/12/2027 0:00:00,Aviso, con comas')
def test_manager_defaults_and_rec_name(self):
self.assertEqual(NCFManager.default_state(), 'draft')
manager = SimpleNamespace()
@@ -44,3 +58,23 @@ class NcfManagerUnitTestCase(unittest.TestCase):
sequence.sequence_type = SimpleNamespace(name='Unknown')
self.assertIsNone(Sequence.on_change_with_prefix(sequence))
self.assertEqual(Sequence.on_change_with_padding(sequence), 0)
def test_csv_parser_preserves_unquoted_detail(self):
source_format, rows = parse_dgii_authorizations(
f'{self.HEADERS}\r\n{self.ROW}\r\n'.encode())
self.assertEqual(source_format, 'csv')
self.assertEqual(rows[0]['NUM_AUTOR'], '6005423851')
self.assertEqual(rows[0]['DETALLE_LOG'], 'Aviso, con comas')
normalized = normalize_dgii_row(rows[0])
self.assertEqual(normalized['from_number'], 1)
self.assertEqual(normalized['until_number'], 100)
def test_html_xls_parser(self):
cells = ''.join(f'<th>{header}</th>'
for header in self.HEADERS.split(','))
values = self.ROW.split(',', len(self.HEADERS.split(',')) - 1)
row = ''.join(f'<td>{value}</td>' for value in values)
source_format, rows = parse_dgii_authorizations(
f'<div><table><tr>{cells}</tr><tr>{row}</tr></table></div>'.encode())
self.assertEqual(source_format, 'xls_html')
self.assertEqual(rows[0]['CODTIPO'], '31')
+4
View File
@@ -9,6 +9,7 @@ xml:
sequence.xml
configuration.xml
ncf_manager.xml
importer.xml
invoice.xml
party.xml
user.xml
@@ -26,5 +27,8 @@ model:
party.PartyNcfType
party.Party
user.User
importer.ImportNCFStart
wizard:
importer.ImportNCF
report:
invoice.InvoiceReport
+16
View File
@@ -0,0 +1,16 @@
<?xml version="1.0"?>
<!-- This file is part of Tryton. The COPYRIGHT file at the top level of
this repository contains the full copyright notices and license terms. -->
<form>
<label name="company"/>
<field name="company"/>
<label name="filename"/>
<field name="filename"/>
<label name="file_"/>
<field name="file_"/>
<newline/>
<label name="activate"/>
<field name="activate"/>
<label string="Keep activation disabled if any number may already have been issued outside Tryton."
id="activation_warning" colspan="4"/>
</form>
+11 -2
View File
@@ -13,12 +13,21 @@ copyright notices and license types. -->
<newline/>
<label name="app_number"/>
<field name="app_number"/>
<label name="auth_number"/>
<field name="auth_number"/>
</group>
<separator string="Authorized Fiscal Sequences" id="sequences" colspan="4"/>
<field name="ncf_sequences" colspan="4"/>
<group id="audit" col="4" colspan="4">
<label name="source_filename"/>
<field name="source_filename"/>
<label name="source_hash"/>
<field name="source_hash"/>
<label name="source_file"/>
<field name="source_file"/>
<newline/>
<label name="imported_by"/>
<field name="imported_by"/>
<label name="imported_at"/>
<field name="imported_at"/>
<label name="activated_by"/>
<field name="activated_by"/>
<label name="activated_at"/>
+32
View File
@@ -4,6 +4,10 @@ this repository contains the full copyright notices and license terms. -->
<form>
<label name="invoice_type"/>
<field name="invoice_type"/>
<label name="auth_number"/>
<field name="auth_number"/>
<label name="authorization_date"/>
<field name="authorization_date"/>
<label name="ncf_type"/>
<field name="ncf_type"/>
<label name="expected_prefix"/>
@@ -21,6 +25,34 @@ this repository contains the full copyright notices and license terms. -->
<field name="number_next"/>
<label name="expiration_date"/>
<field name="expiration_date"/>
<label name="effective_from_number"/>
<field name="effective_from_number"/>
<label name="effective_until_number"/>
<field name="effective_until_number"/>
<label name="effective_number_next"/>
<field name="effective_number_next"/>
<label name="effective_expiration_date"/>
<field name="effective_expiration_date"/>
<label name="dgii_status"/>
<field name="dgii_status"/>
<label name="requested_quantity"/>
<field name="requested_quantity"/>
<label name="approved_quantity"/>
<field name="approved_quantity"/>
<label name="usage_type"/>
<field name="usage_type"/>
<separator name="dgii_detail" colspan="4"/>
<field name="dgii_detail" colspan="4"/>
<label name="source_filename"/>
<field name="source_filename"/>
<label name="source_hash"/>
<field name="source_hash"/>
<label name="source_file"/>
<field name="source_file"/>
<label name="imported_by"/>
<field name="imported_by"/>
<label name="imported_at"/>
<field name="imported_at"/>
<label name="state"/>
<field name="state"/>
<label name="availability"/>
+5 -4
View File
@@ -3,13 +3,14 @@
this repository contains the full copyright notices and license terms. -->
<tree>
<field name="invoice_type"/>
<field name="auth_number"/>
<field name="ncf_type"/>
<field name="expected_prefix"/>
<field name="ncf_sequences"/>
<field name="from_number" optional="1"/>
<field name="until_number" optional="1"/>
<field name="number_next" optional="1"/>
<field name="expiration_date" optional="1"/>
<field name="effective_from_number"/>
<field name="effective_until_number"/>
<field name="effective_number_next"/>
<field name="effective_expiration_date"/>
<field name="availability"/>
<field name="state"/>
<button name="finish"/>