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
|
||||
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 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
@@ -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:"
|
||||
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"
|
||||
|
||||
@@ -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
@@ -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
@@ -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),
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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/>
|
||||
<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"/>
|
||||
|
||||
@@ -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"/>
|
||||
|
||||
@@ -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"/>
|
||||
|
||||
Reference in New Issue
Block a user