This commit is contained in:
+17
@@ -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
|
Electronic fiscal numbers are only reserved by this module. Activating an
|
||||||
E-series authorization never signs or transmits an e-CF to DGII.
|
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.
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
from . import invoice
|
from . import invoice
|
||||||
|
from . import importer
|
||||||
from . import ncf_manager
|
from . import ncf_manager
|
||||||
from . import sequence
|
from . import sequence
|
||||||
from . import configuration
|
from . import configuration
|
||||||
@@ -8,6 +9,7 @@ from . import user
|
|||||||
__all__ = [
|
__all__ = [
|
||||||
'configuration',
|
'configuration',
|
||||||
'invoice',
|
'invoice',
|
||||||
|
'importer',
|
||||||
'ncf_manager',
|
'ncf_manager',
|
||||||
'party',
|
'party',
|
||||||
'sequence',
|
'sequence',
|
||||||
|
|||||||
+342
@@ -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']
|
||||||
@@ -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>
|
||||||
@@ -605,3 +605,75 @@ msgstr "Finalizar secuencia"
|
|||||||
msgctxt "view:ncf.manager:"
|
msgctxt "view:ncf.manager:"
|
||||||
msgid "Authorized Fiscal Sequences"
|
msgid "Authorized Fiscal Sequences"
|
||||||
msgstr "Secuencias fiscales autorizadas"
|
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"
|
||||||
|
|||||||
@@ -1120,3 +1120,75 @@ msgstr "Finalizar secuencia"
|
|||||||
msgctxt "view:ncf.manager:"
|
msgctxt "view:ncf.manager:"
|
||||||
msgid "Authorized Fiscal Sequences"
|
msgid "Authorized Fiscal Sequences"
|
||||||
msgstr "Secuencias fiscales autorizadas"
|
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
@@ -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">
|
<record model="ir.message" id="msg_ncf_authorization_delete_not_draft">
|
||||||
<field name="text">Only draft fiscal authorizations can be deleted.</field>
|
<field name="text">Only draft fiscal authorizations can be deleted.</field>
|
||||||
</record>
|
</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>
|
</data>
|
||||||
</tryton>
|
</tryton>
|
||||||
|
|||||||
+101
-4
@@ -99,9 +99,16 @@ class NCFManager(Workflow, ModelSQL, ModelView):
|
|||||||
app_number = fields.Char('Application Number', required=True, size=10,
|
app_number = fields.Char('Application Number', required=True, size=10,
|
||||||
states=_states, depends=_depends)
|
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)
|
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 = fields.One2Many('ncf.manager.sequence', 'pos',
|
||||||
'NCF Sequences', context={'company': Eval('company', -1)},
|
'NCF Sequences', context={'company': Eval('company', -1)},
|
||||||
states=_states, depends=_depends)
|
states=_states, depends=_depends)
|
||||||
@@ -188,9 +195,16 @@ class NCFManager(Workflow, ModelSQL, ModelView):
|
|||||||
})
|
})
|
||||||
|
|
||||||
def _validate_authorization(self):
|
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(
|
raise UserError(gettext(
|
||||||
'ncf_manager.msg_ncf_missing_authorization'))
|
'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
|
@classmethod
|
||||||
@ModelView.button
|
@ModelView.button
|
||||||
@@ -229,6 +243,11 @@ class NCFManager(Workflow, ModelSQL, ModelView):
|
|||||||
'activated_at': None,
|
'activated_at': None,
|
||||||
'finished_by': None,
|
'finished_by': None,
|
||||||
'finished_at': 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)
|
return super().copy(managers, default=default)
|
||||||
|
|
||||||
@@ -254,6 +273,23 @@ class NCFManagerSequences(Workflow, ModelSQL, ModelView):
|
|||||||
|
|
||||||
ncf_type_string = ncf_type.translated('ncf_type')
|
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(
|
company = fields.Function(
|
||||||
fields.Many2One('company.company', 'Company'),
|
fields.Many2One('company.company', 'Company'),
|
||||||
'on_change_with_company')
|
'on_change_with_company')
|
||||||
@@ -309,6 +345,24 @@ class NCFManagerSequences(Workflow, ModelSQL, ModelView):
|
|||||||
'invisible': Eval('sequence_mode') != 'create',
|
'invisible': Eval('sequence_mode') != 'create',
|
||||||
}, depends=_depends + ['sequence_mode'])
|
}, 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_type = fields.Selection([
|
||||||
('invoice_customer', 'Customer Invoice'),
|
('invoice_customer', 'Customer Invoice'),
|
||||||
('invoice_supplier', 'Supplier Invoice')
|
('invoice_supplier', 'Supplier Invoice')
|
||||||
@@ -364,6 +418,35 @@ class NCFManagerSequences(Workflow, ModelSQL, ModelView):
|
|||||||
and self.ncf_sequences.prefix != ncf_prefix(self.ncf_type)):
|
and self.ncf_sequences.prefix != ncf_prefix(self.ncf_type)):
|
||||||
self.ncf_sequences = None
|
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):
|
def get_rec_name(self, name):
|
||||||
if not self.ncf_type_string:
|
if not self.ncf_type_string:
|
||||||
return ''
|
return ''
|
||||||
@@ -372,9 +455,23 @@ class NCFManagerSequences(Workflow, ModelSQL, ModelView):
|
|||||||
@classmethod
|
@classmethod
|
||||||
def validate_fields(cls, records, field_names):
|
def validate_fields(cls, records, field_names):
|
||||||
super().validate_fields(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:
|
for record in records:
|
||||||
record._validate_fiscal_sequence()
|
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):
|
def _validate_fiscal_sequence(self):
|
||||||
sequence = self.ncf_sequences
|
sequence = self.ncf_sequences
|
||||||
@@ -424,7 +521,7 @@ class NCFManagerSequences(Workflow, ModelSQL, ModelView):
|
|||||||
sequence_type = ModelData.get_id(
|
sequence_type = ModelData.get_id(
|
||||||
'ncf_manager', NCF_SEQUENCE_TYPE_IDS[self.ncf_type])
|
'ncf_manager', NCF_SEQUENCE_TYPE_IDS[self.ncf_type])
|
||||||
sequence, = Sequence.create([{
|
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,
|
'sequence_type': sequence_type,
|
||||||
'company': self.pos.company.id,
|
'company': self.pos.company.id,
|
||||||
'prefix': ncf_prefix(self.ncf_type),
|
'prefix': ncf_prefix(self.ncf_type),
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import unittest
|
|||||||
from trytond.exceptions import UserError
|
from trytond.exceptions import UserError
|
||||||
from trytond.modules.ncf_manager.sequence import Sequence
|
from trytond.modules.ncf_manager.sequence import Sequence
|
||||||
from trytond.modules.ncf_manager.invoice import Invoice
|
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.pool import Pool
|
||||||
from trytond.tests.test_tryton import ModuleTestCase, with_transaction
|
from trytond.tests.test_tryton import ModuleTestCase, with_transaction
|
||||||
from trytond.transaction import Transaction
|
from trytond.transaction import Transaction
|
||||||
@@ -164,6 +165,29 @@ class NcfManagerTestCase(ModuleTestCase):
|
|||||||
}])
|
}])
|
||||||
return sequence
|
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()
|
@with_transaction()
|
||||||
def test_customer_invoice_post_assigns_b01_ncf(self):
|
def test_customer_invoice_post_assigns_b01_ncf(self):
|
||||||
'Test full NCF assignment workflow on posted customer invoice'
|
'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.prefix, 'B01')
|
||||||
self.assertEqual(line.ncf_sequences.padding, 8)
|
self.assertEqual(line.ncf_sequences.padding, 8)
|
||||||
self.assertEqual(line.ncf_sequences.number_next, 100)
|
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')
|
self.assertEqual(line.availability, 'low')
|
||||||
|
|
||||||
Manager.finish([manager])
|
Manager.finish([manager])
|
||||||
@@ -256,6 +286,33 @@ class NcfManagerTestCase(ModuleTestCase):
|
|||||||
self.assertEqual(manager.ncf_sequences[0].state, 'finished')
|
self.assertEqual(manager.ncf_sequences[0].state, 'finished')
|
||||||
self.assertIsNotNone(manager.finished_at)
|
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
|
del ModuleTestCase
|
||||||
|
|
||||||
|
|||||||
@@ -4,10 +4,24 @@ import unittest
|
|||||||
from trytond.modules.ncf_manager.ncf_manager import (
|
from trytond.modules.ncf_manager.ncf_manager import (
|
||||||
NCFManager, NCFManagerSequences)
|
NCFManager, NCFManagerSequences)
|
||||||
from trytond.modules.ncf_manager.sequence import Sequence
|
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):
|
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):
|
def test_manager_defaults_and_rec_name(self):
|
||||||
self.assertEqual(NCFManager.default_state(), 'draft')
|
self.assertEqual(NCFManager.default_state(), 'draft')
|
||||||
manager = SimpleNamespace()
|
manager = SimpleNamespace()
|
||||||
@@ -44,3 +58,23 @@ class NcfManagerUnitTestCase(unittest.TestCase):
|
|||||||
sequence.sequence_type = SimpleNamespace(name='Unknown')
|
sequence.sequence_type = SimpleNamespace(name='Unknown')
|
||||||
self.assertIsNone(Sequence.on_change_with_prefix(sequence))
|
self.assertIsNone(Sequence.on_change_with_prefix(sequence))
|
||||||
self.assertEqual(Sequence.on_change_with_padding(sequence), 0)
|
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')
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ xml:
|
|||||||
sequence.xml
|
sequence.xml
|
||||||
configuration.xml
|
configuration.xml
|
||||||
ncf_manager.xml
|
ncf_manager.xml
|
||||||
|
importer.xml
|
||||||
invoice.xml
|
invoice.xml
|
||||||
party.xml
|
party.xml
|
||||||
user.xml
|
user.xml
|
||||||
@@ -26,5 +27,8 @@ model:
|
|||||||
party.PartyNcfType
|
party.PartyNcfType
|
||||||
party.Party
|
party.Party
|
||||||
user.User
|
user.User
|
||||||
|
importer.ImportNCFStart
|
||||||
|
wizard:
|
||||||
|
importer.ImportNCF
|
||||||
report:
|
report:
|
||||||
invoice.InvoiceReport
|
invoice.InvoiceReport
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -13,12 +13,21 @@ copyright notices and license types. -->
|
|||||||
<newline/>
|
<newline/>
|
||||||
<label name="app_number"/>
|
<label name="app_number"/>
|
||||||
<field name="app_number"/>
|
<field name="app_number"/>
|
||||||
<label name="auth_number"/>
|
|
||||||
<field name="auth_number"/>
|
|
||||||
</group>
|
</group>
|
||||||
<separator string="Authorized Fiscal Sequences" id="sequences" colspan="4"/>
|
<separator string="Authorized Fiscal Sequences" id="sequences" colspan="4"/>
|
||||||
<field name="ncf_sequences" colspan="4"/>
|
<field name="ncf_sequences" colspan="4"/>
|
||||||
<group id="audit" col="4" 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"/>
|
<label name="activated_by"/>
|
||||||
<field name="activated_by"/>
|
<field name="activated_by"/>
|
||||||
<label name="activated_at"/>
|
<label name="activated_at"/>
|
||||||
|
|||||||
@@ -4,6 +4,10 @@ this repository contains the full copyright notices and license terms. -->
|
|||||||
<form>
|
<form>
|
||||||
<label name="invoice_type"/>
|
<label name="invoice_type"/>
|
||||||
<field 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"/>
|
<label name="ncf_type"/>
|
||||||
<field name="ncf_type"/>
|
<field name="ncf_type"/>
|
||||||
<label name="expected_prefix"/>
|
<label name="expected_prefix"/>
|
||||||
@@ -21,6 +25,34 @@ this repository contains the full copyright notices and license terms. -->
|
|||||||
<field name="number_next"/>
|
<field name="number_next"/>
|
||||||
<label name="expiration_date"/>
|
<label name="expiration_date"/>
|
||||||
<field 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"/>
|
<label name="state"/>
|
||||||
<field name="state"/>
|
<field name="state"/>
|
||||||
<label name="availability"/>
|
<label name="availability"/>
|
||||||
|
|||||||
@@ -3,13 +3,14 @@
|
|||||||
this repository contains the full copyright notices and license terms. -->
|
this repository contains the full copyright notices and license terms. -->
|
||||||
<tree>
|
<tree>
|
||||||
<field name="invoice_type"/>
|
<field name="invoice_type"/>
|
||||||
|
<field name="auth_number"/>
|
||||||
<field name="ncf_type"/>
|
<field name="ncf_type"/>
|
||||||
<field name="expected_prefix"/>
|
<field name="expected_prefix"/>
|
||||||
<field name="ncf_sequences"/>
|
<field name="ncf_sequences"/>
|
||||||
<field name="from_number" optional="1"/>
|
<field name="effective_from_number"/>
|
||||||
<field name="until_number" optional="1"/>
|
<field name="effective_until_number"/>
|
||||||
<field name="number_next" optional="1"/>
|
<field name="effective_number_next"/>
|
||||||
<field name="expiration_date" optional="1"/>
|
<field name="effective_expiration_date"/>
|
||||||
<field name="availability"/>
|
<field name="availability"/>
|
||||||
<field name="state"/>
|
<field name="state"/>
|
||||||
<button name="finish"/>
|
<button name="finish"/>
|
||||||
|
|||||||
Reference in New Issue
Block a user