@devlas/dte-sii 2.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/BoletaService.js +109 -0
- package/CAF.js +173 -0
- package/CafSolicitor.js +380 -0
- package/Certificado.js +123 -0
- package/ConsumoFolio.js +376 -0
- package/DTE.js +399 -0
- package/EnviadorSII.js +1304 -0
- package/Envio.js +196 -0
- package/FolioRegistry.js +553 -0
- package/FolioService.js +703 -0
- package/LICENSE +27 -0
- package/LibroBase.js +134 -0
- package/LibroCompraVenta.js +205 -0
- package/LibroGuia.js +225 -0
- package/README.md +239 -0
- package/Signer.js +94 -0
- package/SiiCertificacion.js +1189 -0
- package/SiiPortalAuth.js +460 -0
- package/SiiSession.js +499 -0
- package/cert/BoletaCert.js +731 -0
- package/cert/CertFolioHelper.js +185 -0
- package/cert/CertRunner.js +2658 -0
- package/cert/ConfigLoader.js +133 -0
- package/cert/IntercambioCert.js +429 -0
- package/cert/LibroCompras.js +359 -0
- package/cert/LibroGuias.js +171 -0
- package/cert/LibroVentas.js +153 -0
- package/cert/MuestrasImpresas.js +676 -0
- package/cert/SetBase.js +321 -0
- package/cert/SetBasico.js +413 -0
- package/cert/SetCompra.js +472 -0
- package/cert/SetExenta.js +490 -0
- package/cert/SetGuia.js +283 -0
- package/cert/SetParser.js +1184 -0
- package/cert/SetsProvider.js +499 -0
- package/cert/Simulacion.js +521 -0
- package/cert/comunaOficina.js +460 -0
- package/cert/index.js +124 -0
- package/cert/types.js +330 -0
- package/dte-sii.d.ts +458 -0
- package/index.js +428 -0
- package/package.json +48 -0
- package/utils/c14n.js +275 -0
- package/utils/calculo.js +396 -0
- package/utils/config.js +276 -0
- package/utils/constants.js +302 -0
- package/utils/emisor.js +174 -0
- package/utils/endpoints.js +225 -0
- package/utils/error.js +235 -0
- package/utils/index.js +339 -0
- package/utils/logger.js +239 -0
- package/utils/pfx.js +203 -0
- package/utils/receptor.js +218 -0
- package/utils/referencia.js +169 -0
- package/utils/resolucion.js +119 -0
- package/utils/rut.js +169 -0
- package/utils/sanitize.js +124 -0
- package/utils/tokenCache.js +214 -0
- package/utils/xml.js +358 -0
- package/utils.js +4 -0
|
@@ -0,0 +1,1189 @@
|
|
|
1
|
+
// Copyright (c) 2026 Devlas SpA — https://devlas.cl
|
|
2
|
+
// Licencia MIT. Ver archivo LICENSE para mas detalles.
|
|
3
|
+
/**
|
|
4
|
+
* SiiCertificacion.js - Automatización del proceso de certificación DTE
|
|
5
|
+
*
|
|
6
|
+
* Este servicio automatiza las operaciones del portal de certificación del SII:
|
|
7
|
+
* - Generar set de pruebas
|
|
8
|
+
* - Declarar avance
|
|
9
|
+
* - Ver estado de avance
|
|
10
|
+
* - Declarar cumplimiento
|
|
11
|
+
*
|
|
12
|
+
* Solo requiere el certificado digital (autenticación TLS mutua).
|
|
13
|
+
* No requiere clave SII del usuario.
|
|
14
|
+
*
|
|
15
|
+
* @module SiiCertificacion
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
const SiiSession = require('./SiiSession.js');
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Etapas de certificación DTE
|
|
22
|
+
*/
|
|
23
|
+
const ETAPAS_CERTIFICACION = {
|
|
24
|
+
SET_BASICO: 'SET_BASICO',
|
|
25
|
+
SET_SIMULACION_FACTURACION: 'SET_SIMULACION_FACTURACION',
|
|
26
|
+
SET_INTERCAMBIO: 'SET_INTERCAMBIO',
|
|
27
|
+
SET_ENVIO_BOLETAS: 'SET_ENVIO_BOLETAS',
|
|
28
|
+
MUESTRAS_IMPRESAS: 'MUESTRAS_IMPRESAS',
|
|
29
|
+
DECLARACION_CUMPLIMIENTO: 'DECLARACION_CUMPLIMIENTO',
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Tipos de documentos para certificación
|
|
34
|
+
*/
|
|
35
|
+
const TIPOS_DTE = {
|
|
36
|
+
FACTURA_ELECTRONICA: 33,
|
|
37
|
+
FACTURA_EXENTA: 34,
|
|
38
|
+
BOLETA_ELECTRONICA: 39,
|
|
39
|
+
BOLETA_EXENTA: 41,
|
|
40
|
+
LIQUIDACION_FACTURA: 43,
|
|
41
|
+
FACTURA_COMPRA: 46,
|
|
42
|
+
GUIA_DESPACHO: 52,
|
|
43
|
+
NOTA_DEBITO: 56,
|
|
44
|
+
NOTA_CREDITO: 61,
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
class SiiCertificacion {
|
|
48
|
+
/**
|
|
49
|
+
* @param {Object} options - Opciones de configuración
|
|
50
|
+
* @param {string} options.pfxPath - Ruta al certificado PFX/P12
|
|
51
|
+
* @param {string} options.pfxPassword - Contraseña del certificado
|
|
52
|
+
* @param {string} options.rutEmpresa - RUT de la empresa (sin DV)
|
|
53
|
+
* @param {string} options.dvEmpresa - Dígito verificador de la empresa
|
|
54
|
+
*/
|
|
55
|
+
constructor(options = {}) {
|
|
56
|
+
if (!options.pfxPath) {
|
|
57
|
+
throw new Error('SiiCertificacion: pfxPath es obligatorio');
|
|
58
|
+
}
|
|
59
|
+
if (!options.pfxPassword && options.pfxPassword !== '') {
|
|
60
|
+
throw new Error('SiiCertificacion: pfxPassword es obligatorio');
|
|
61
|
+
}
|
|
62
|
+
if (!options.rutEmpresa || !options.dvEmpresa) {
|
|
63
|
+
throw new Error('SiiCertificacion: rutEmpresa y dvEmpresa son obligatorios');
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
this.rutEmpresa = options.rutEmpresa.replace(/\./g, '');
|
|
67
|
+
this.dvEmpresa = options.dvEmpresa.toUpperCase();
|
|
68
|
+
|
|
69
|
+
this.session = new SiiSession({
|
|
70
|
+
pfxPath: options.pfxPath,
|
|
71
|
+
pfxPassword: options.pfxPassword,
|
|
72
|
+
ambiente: 'certificacion',
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Guarda la sesión actual en un archivo
|
|
78
|
+
* @param {string} filePath - Ruta donde guardar la sesión
|
|
79
|
+
*/
|
|
80
|
+
saveSession(filePath) {
|
|
81
|
+
this.session.saveSession(filePath);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Carga una sesión desde un archivo
|
|
86
|
+
* @param {string} filePath - Ruta del archivo de sesión
|
|
87
|
+
* @returns {boolean} - true si se cargó exitosamente
|
|
88
|
+
*/
|
|
89
|
+
loadSession(filePath) {
|
|
90
|
+
return this.session.loadSession(filePath);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Verifica si existe una sesión válida en el archivo
|
|
95
|
+
* @param {string} filePath - Ruta del archivo de sesión
|
|
96
|
+
* @returns {boolean}
|
|
97
|
+
*/
|
|
98
|
+
static isSessionValid(filePath) {
|
|
99
|
+
return SiiSession.isSessionValid(filePath);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Extrae el valor de un campo de formulario del HTML
|
|
104
|
+
* @private
|
|
105
|
+
*/
|
|
106
|
+
_extractFormValue(html, fieldName) {
|
|
107
|
+
const regex = new RegExp(`name="${fieldName}"[^>]*value="([^"]*)"`, 'i');
|
|
108
|
+
const match = html.match(regex);
|
|
109
|
+
return match ? match[1] : null;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Extrae opciones de un select
|
|
114
|
+
* @private
|
|
115
|
+
*/
|
|
116
|
+
_extractSelectOptions(html, selectName) {
|
|
117
|
+
const selectRegex = new RegExp(`<select[^>]*name="${selectName}"[^>]*>([\\s\\S]*?)<\\/select>`, 'i');
|
|
118
|
+
const selectMatch = html.match(selectRegex);
|
|
119
|
+
if (!selectMatch) return [];
|
|
120
|
+
|
|
121
|
+
const options = [];
|
|
122
|
+
const optionRegex = /<option[^>]*value="([^"]*)"[^>]*>([^<]*)<\/option>/gi;
|
|
123
|
+
let match;
|
|
124
|
+
while ((match = optionRegex.exec(selectMatch[1])) !== null) {
|
|
125
|
+
if (match[1]) {
|
|
126
|
+
options.push({ value: match[1], text: match[2].trim() });
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
return options;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Maneja la página de selección de representación
|
|
134
|
+
* @private
|
|
135
|
+
*/
|
|
136
|
+
async _handleRepresentacionPage(response) {
|
|
137
|
+
const body = response.body || '';
|
|
138
|
+
|
|
139
|
+
// Si es página de selección de representación, seguir con "Continuar"
|
|
140
|
+
if (body.includes('ESCOJA COMO DESEA INGRESAR')) {
|
|
141
|
+
const continuarMatch = body.match(/href="([^"]+)"[^>]*>\s*Continuar\s*</i);
|
|
142
|
+
if (continuarMatch) {
|
|
143
|
+
const continuarUrl = continuarMatch[1];
|
|
144
|
+
return await this.session.request(continuarUrl, { method: 'GET' });
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
return response;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Genera un set de pruebas para certificación
|
|
153
|
+
* @param {Object} options - Opciones
|
|
154
|
+
* @param {boolean} options.descargar - Si true, descarga el set (envía formulario)
|
|
155
|
+
* @param {Object} options.setsOpcionales - Sets opcionales a incluir {SET03: 'S', SET06: 'S', etc}
|
|
156
|
+
* @returns {Promise<Object>} Información del set generado
|
|
157
|
+
*/
|
|
158
|
+
async generarSetPruebas(options = {}) {
|
|
159
|
+
try {
|
|
160
|
+
// 1. Acceder a la página de generación
|
|
161
|
+
let response = await this.session.ensureSession('/cvc_cgi/dte/pe_generar');
|
|
162
|
+
|
|
163
|
+
// 2. Manejar página de representación si aparece
|
|
164
|
+
response = await this._handleRepresentacionPage(response);
|
|
165
|
+
|
|
166
|
+
let body = response.body || '';
|
|
167
|
+
|
|
168
|
+
// 3. Si la sesión guardada causó "no inscrito", limpiar cookies y re-autenticar
|
|
169
|
+
// desde pe_generar mismo (ensureSession hará el redirect TLS correcto)
|
|
170
|
+
if (body.includes('no est\u00e1 inscrito') || body.includes('no esta inscrito')) {
|
|
171
|
+
this.session.reset();
|
|
172
|
+
response = await this.session.ensureSession('/cvc_cgi/dte/pe_generar');
|
|
173
|
+
response = await this._handleRepresentacionPage(response);
|
|
174
|
+
body = response.body || '';
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// 4. Primer paso: Enviar RUT de empresa (pe_generar → pe_generar1) — solo si el portal lo pide
|
|
178
|
+
if (body.includes('pe_generar1') || body.includes('Confirmar Empresa')) {
|
|
179
|
+
const formResponse = await this.session.submitForm(
|
|
180
|
+
'/cvc_cgi/dte/pe_generar1',
|
|
181
|
+
{
|
|
182
|
+
RUT_EMP: this.rutEmpresa,
|
|
183
|
+
DV_EMP: this.dvEmpresa,
|
|
184
|
+
CODIGO: '8',
|
|
185
|
+
ACEPTAR: 'Confirmar Empresa',
|
|
186
|
+
},
|
|
187
|
+
'https://maullin.sii.cl/cvc_cgi/dte/pe_generar'
|
|
188
|
+
);
|
|
189
|
+
body = formResponse.body || '';
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// 5. Parsear estado de sets desde la tabla HTML
|
|
193
|
+
const estadoSets = this._parseEstadoSets(body);
|
|
194
|
+
|
|
195
|
+
// 6. Extraer checkboxes de sets opcionales disponibles
|
|
196
|
+
const setsOpcionales = this._extractSetsOpcionales(body);
|
|
197
|
+
|
|
198
|
+
// 7. Información de la página
|
|
199
|
+
const info = {
|
|
200
|
+
success: true,
|
|
201
|
+
estadoSets,
|
|
202
|
+
setsOpcionales,
|
|
203
|
+
rawHtml: body,
|
|
204
|
+
// Detectar 'no inscrito' con y sin HTML entities (SII usa ISO-8859-1)
|
|
205
|
+
noInscrito: body.includes('no está inscrito') || body.includes('no esta inscrito') ||
|
|
206
|
+
body.includes('no está') || body.includes('no est\xE1 inscrito'),
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
// 8. Si se solicitó descargar, enviar formulario
|
|
210
|
+
if (options.descargar) {
|
|
211
|
+
// Leer AUTORIZADA y TOTAL dinámicamente desde el HTML
|
|
212
|
+
const autorizadaMatch = body.match(/name=["']?AUTORIZADA["']?[^>]*value=["']([^"']*)["']/i)
|
|
213
|
+
|| body.match(/value=["']([^"']*)["'][^>]*name=["']?AUTORIZADA["']?/i);
|
|
214
|
+
const totalMatch = body.match(/name=["']?TOTAL["']?[^>]*value=["']([^"']*)["']/i)
|
|
215
|
+
|| body.match(/value=["']([^"']*)["'][^>]*name=["']?TOTAL["']?/i);
|
|
216
|
+
|
|
217
|
+
const formData = {
|
|
218
|
+
RUT_EMP: this.rutEmpresa,
|
|
219
|
+
DV_EMP: this.dvEmpresa,
|
|
220
|
+
AUTORIZADA: autorizadaMatch ? autorizadaMatch[1] : 'S',
|
|
221
|
+
TOTAL: totalMatch ? totalMatch[1] : '15',
|
|
222
|
+
};
|
|
223
|
+
|
|
224
|
+
// Marcar TODOS los checkboxes disponibles en la página del SII (dinámico)
|
|
225
|
+
// Esto evita que sets nuevos que agrega el SII queden sin marcar
|
|
226
|
+
for (const set of setsOpcionales) {
|
|
227
|
+
formData[set.id] = 'S';
|
|
228
|
+
}
|
|
229
|
+
// SET01 (básico) siempre incluido aunque no aparezca como checkbox opcional
|
|
230
|
+
formData.SET01 = 'S';
|
|
231
|
+
|
|
232
|
+
const genResponse = await this.session.submitForm(
|
|
233
|
+
'/cvc_cgi/dte/pe_generar2',
|
|
234
|
+
formData,
|
|
235
|
+
'https://maullin.sii.cl/cvc_cgi/dte/pe_generar1'
|
|
236
|
+
);
|
|
237
|
+
|
|
238
|
+
info.setDescargado = {
|
|
239
|
+
success: true,
|
|
240
|
+
rawHtml: genResponse.body,
|
|
241
|
+
};
|
|
242
|
+
|
|
243
|
+
// Parsear el contenido del set descargado
|
|
244
|
+
const setContent = this._parseSetDescargado(genResponse.body);
|
|
245
|
+
if (setContent) {
|
|
246
|
+
info.setDescargado.casos = setContent.casos;
|
|
247
|
+
info.setDescargado.caratula = setContent.caratula;
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
return info;
|
|
252
|
+
|
|
253
|
+
} catch (error) {
|
|
254
|
+
return {
|
|
255
|
+
success: false,
|
|
256
|
+
error: error.message,
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Parsea el estado de los sets desde la tabla HTML
|
|
263
|
+
* @private
|
|
264
|
+
*/
|
|
265
|
+
_parseEstadoSets(html) {
|
|
266
|
+
const sets = [];
|
|
267
|
+
|
|
268
|
+
// Buscar filas de la tabla con sets
|
|
269
|
+
const filaRegex = /<tr[^>]*>[\s\S]*?<td[^>]*>[\s\S]*?<font[^>]*>(.*?)<\/font>[\s\S]*?<\/td>[\s\S]*?<td[^>]*>[\s\S]*?<font[^>]*>(.*?)<\/font>[\s\S]*?<\/td>[\s\S]*?<\/tr>/gi;
|
|
270
|
+
|
|
271
|
+
let match;
|
|
272
|
+
while ((match = filaRegex.exec(html)) !== null) {
|
|
273
|
+
const nombre = match[1].replace(/<[^>]+>/g, '').trim();
|
|
274
|
+
const estado = match[2].replace(/<[^>]+>/g, '').trim();
|
|
275
|
+
|
|
276
|
+
// Ignorar cabeceras
|
|
277
|
+
if (nombre && !nombre.includes('Set Obtenido') && nombre.length > 2) {
|
|
278
|
+
sets.push({ nombre, estado });
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
return sets;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Extrae los checkboxes de sets opcionales disponibles
|
|
287
|
+
* @private
|
|
288
|
+
*/
|
|
289
|
+
_extractSetsOpcionales(html) {
|
|
290
|
+
const opcionales = [];
|
|
291
|
+
|
|
292
|
+
// Buscar checkboxes con nombre SETXX (type="CHECKBOX" o type=CHECKBOX, con o sin comillas)
|
|
293
|
+
const checkboxRegex = /<input[^>]*name=["']?(SET\d+)["']?[^>]*type=["']?checkbox["']?[^>]*>\s*([^<\r\n]+)/gi;
|
|
294
|
+
|
|
295
|
+
let match;
|
|
296
|
+
while ((match = checkboxRegex.exec(html)) !== null) {
|
|
297
|
+
opcionales.push({
|
|
298
|
+
id: match[1],
|
|
299
|
+
nombre: match[2].trim(),
|
|
300
|
+
});
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
return opcionales;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* Parsea el contenido del set descargado
|
|
308
|
+
* @private
|
|
309
|
+
*/
|
|
310
|
+
_parseSetDescargado(html) {
|
|
311
|
+
if (!html) return null;
|
|
312
|
+
|
|
313
|
+
// El set viene en formato texto plano dentro del HTML o como descarga
|
|
314
|
+
// Buscar patrones de casos de prueba
|
|
315
|
+
const resultado = {
|
|
316
|
+
casos: [],
|
|
317
|
+
caratula: null,
|
|
318
|
+
rawText: '',
|
|
319
|
+
};
|
|
320
|
+
|
|
321
|
+
// Extraer texto limpio
|
|
322
|
+
const textoLimpio = html
|
|
323
|
+
.replace(/<script[^>]*>[\s\S]*?<\/script>/gi, '')
|
|
324
|
+
.replace(/<style[^>]*>[\s\S]*?<\/style>/gi, '')
|
|
325
|
+
.replace(/<[^>]+>/g, '\n')
|
|
326
|
+
.replace(/ /g, ' ')
|
|
327
|
+
.replace(/á/g, 'á')
|
|
328
|
+
.replace(/é/g, 'é')
|
|
329
|
+
.replace(/í/g, 'í')
|
|
330
|
+
.replace(/ó/g, 'ó')
|
|
331
|
+
.replace(/ú/g, 'ú')
|
|
332
|
+
.replace(/ñ/g, 'ñ')
|
|
333
|
+
.replace(/\n\s*\n/g, '\n')
|
|
334
|
+
.trim();
|
|
335
|
+
|
|
336
|
+
resultado.rawText = textoLimpio;
|
|
337
|
+
|
|
338
|
+
// Buscar casos numerados (CASO-1, CASO-2, etc.)
|
|
339
|
+
const casoRegex = /CASO[-\s]*(\d+)[\s:]+([^\n]+)/gi;
|
|
340
|
+
let casoMatch;
|
|
341
|
+
while ((casoMatch = casoRegex.exec(textoLimpio)) !== null) {
|
|
342
|
+
resultado.casos.push({
|
|
343
|
+
numero: parseInt(casoMatch[1]),
|
|
344
|
+
descripcion: casoMatch[2].trim(),
|
|
345
|
+
});
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
return resultado;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* Consulta el estado de los sets sin declarar avance
|
|
353
|
+
* Solo accede a pe_avance2 para ver el estado actual
|
|
354
|
+
* @returns {Promise<Object>} Estado de los sets
|
|
355
|
+
*/
|
|
356
|
+
async consultarEstadoSets() {
|
|
357
|
+
try {
|
|
358
|
+
// 1. Acceder a la página de declarar avance
|
|
359
|
+
let response = await this.session.ensureSession('/cvc_cgi/dte/pe_avance1');
|
|
360
|
+
response = await this._handleRepresentacionPage(response);
|
|
361
|
+
|
|
362
|
+
// 2. Enviar formulario con RUT empresa para ir al formulario de sets
|
|
363
|
+
const formResponse = await this.session.submitForm(
|
|
364
|
+
'/cvc_cgi/dte/pe_avance2',
|
|
365
|
+
{
|
|
366
|
+
RUT_EMP: this.rutEmpresa,
|
|
367
|
+
DV_EMP: this.dvEmpresa,
|
|
368
|
+
ACEPTAR: 'Continuar',
|
|
369
|
+
},
|
|
370
|
+
'https://maullin.sii.cl/cvc_cgi/dte/pe_avance1'
|
|
371
|
+
);
|
|
372
|
+
|
|
373
|
+
const html = formResponse.body || '';
|
|
374
|
+
|
|
375
|
+
// Parsear estado de cada set/libro
|
|
376
|
+
const estadoSets = {};
|
|
377
|
+
|
|
378
|
+
// Buscar filas de la tabla con formato:
|
|
379
|
+
// <td>SET/LIBRO nombre</td><td><b>ESTADO</b></td>
|
|
380
|
+
const rowRegex = /<tr[^>]*>[\s\S]*?<td[^>]*>[\s\S]*?(SET[^<]*|LIBRO[^<]*)<\/font><\/td>[\s\S]*?<td[^>]*>[\s\S]*?<b>([^<]+)<\/b>/gi;
|
|
381
|
+
let rowMatch;
|
|
382
|
+
while ((rowMatch = rowRegex.exec(html)) !== null) {
|
|
383
|
+
const nombre = rowMatch[1].trim();
|
|
384
|
+
const estado = rowMatch[2].trim().toUpperCase();
|
|
385
|
+
estadoSets[nombre] = estado;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
// Verificar si todos los sets requeridos están REVISADO CONFORME
|
|
389
|
+
const setsRequeridos = [
|
|
390
|
+
'SET BASICO',
|
|
391
|
+
'SET GUIA DE DESPACHO',
|
|
392
|
+
'SET FACTURA EXENTA',
|
|
393
|
+
'SET CASO GENERAL FACTURA COMPRA'
|
|
394
|
+
];
|
|
395
|
+
|
|
396
|
+
const todosConformes = setsRequeridos.every(setName => {
|
|
397
|
+
const estado = Object.entries(estadoSets).find(([k]) => k.includes(setName))?.[1];
|
|
398
|
+
return estado && estado.includes('REVISADO CONFORME');
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
// Verificar si todos los libros requeridos están REVISADO CONFORME
|
|
402
|
+
const librosRequeridos = [
|
|
403
|
+
'LIBRO DE VENTAS',
|
|
404
|
+
'LIBRO DE COMPRAS',
|
|
405
|
+
'LIBRO DE GUIAS'
|
|
406
|
+
];
|
|
407
|
+
|
|
408
|
+
const librosConformes = librosRequeridos.every(libroName => {
|
|
409
|
+
const estado = Object.entries(estadoSets).find(([k]) => k.includes(libroName))?.[1];
|
|
410
|
+
return estado && estado.includes('REVISADO CONFORME');
|
|
411
|
+
});
|
|
412
|
+
|
|
413
|
+
const todosConformesTotales = todosConformes && librosConformes;
|
|
414
|
+
|
|
415
|
+
return {
|
|
416
|
+
success: true,
|
|
417
|
+
estadoSets,
|
|
418
|
+
todosConformes,
|
|
419
|
+
librosConformes,
|
|
420
|
+
todosConformesTotales,
|
|
421
|
+
rawHtml: html,
|
|
422
|
+
};
|
|
423
|
+
|
|
424
|
+
} catch (error) {
|
|
425
|
+
return {
|
|
426
|
+
success: false,
|
|
427
|
+
error: error.message,
|
|
428
|
+
todosConformes: false,
|
|
429
|
+
librosConformes: false,
|
|
430
|
+
todosConformesTotales: false,
|
|
431
|
+
};
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
/**
|
|
436
|
+
* Declara avance en una etapa de certificación con TrackIds específicos
|
|
437
|
+
* @param {Object} options - Opciones
|
|
438
|
+
* @param {Object} options.sets - Sets a declarar con sus TrackIds
|
|
439
|
+
* @param {Object} options.sets.setBasico - { trackId, fecha } para Set Básico
|
|
440
|
+
* @param {Object} options.sets.setGuiaDespacho - { trackId, fecha } para Set Guía Despacho
|
|
441
|
+
* @param {Object} options.sets.setFacturaExenta - { trackId, fecha } para Set Factura Exenta
|
|
442
|
+
* @param {Object} options.sets.libroVentas - { trackId, fecha } para Libro Ventas
|
|
443
|
+
* @param {Object} options.sets.libroCompras - { trackId, fecha } para Libro Compras
|
|
444
|
+
* @param {Object} options.sets.libroGuias - { trackId, fecha } para Libro Guías
|
|
445
|
+
* @param {Object} options.sets.setExportacion1 - { trackId, fecha } para Set Exportación 1
|
|
446
|
+
* @param {Object} options.sets.setExportacion2 - { trackId, fecha } para Set Exportación 2
|
|
447
|
+
* @param {Object} options.sets.setFacturaCompra - { trackId, fecha } para Set Factura Compra
|
|
448
|
+
* @param {Object} options.sets.setLiquidacion - { trackId, fecha } para Set Liquidación
|
|
449
|
+
* @param {Object} options.sets.setSimulacion - { trackId, fecha } para Set de Simulación
|
|
450
|
+
* @returns {Promise<Object>} Resultado de la declaración
|
|
451
|
+
*/
|
|
452
|
+
async declararAvance(options = {}) {
|
|
453
|
+
const { sets = {} } = options;
|
|
454
|
+
|
|
455
|
+
try {
|
|
456
|
+
// 1. Acceder a la página de declarar avance
|
|
457
|
+
let response = await this.session.ensureSession('/cvc_cgi/dte/pe_avance1');
|
|
458
|
+
response = await this._handleRepresentacionPage(response);
|
|
459
|
+
|
|
460
|
+
// 2. Enviar formulario con RUT empresa para ir al formulario de sets
|
|
461
|
+
const formResponse = await this.session.submitForm(
|
|
462
|
+
'/cvc_cgi/dte/pe_avance2',
|
|
463
|
+
{
|
|
464
|
+
RUT_EMP: this.rutEmpresa,
|
|
465
|
+
DV_EMP: this.dvEmpresa,
|
|
466
|
+
ACEPTAR: 'Continuar',
|
|
467
|
+
},
|
|
468
|
+
'https://maullin.sii.cl/cvc_cgi/dte/pe_avance1'
|
|
469
|
+
);
|
|
470
|
+
|
|
471
|
+
const formHtml = formResponse.body || '';
|
|
472
|
+
|
|
473
|
+
// 3. Si no hay sets para declarar, solo retornar el estado actual
|
|
474
|
+
if (!Object.keys(sets).length) {
|
|
475
|
+
return {
|
|
476
|
+
success: true,
|
|
477
|
+
rawHtml: formHtml,
|
|
478
|
+
message: 'Página de declaración de avance obtenida',
|
|
479
|
+
};
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
// 4. Parsear los campos ocultos del formulario (SET1, EST1, etc.)
|
|
483
|
+
const hiddenFields = {};
|
|
484
|
+
const currentValues = {}; // Para preservar valores existentes de NUM_ENV y FEC_ENV
|
|
485
|
+
|
|
486
|
+
// Regex más flexible para encontrar inputs hidden en cualquier orden de atributos
|
|
487
|
+
const inputRegex = /<input[^>]+>/gi;
|
|
488
|
+
let inputMatch;
|
|
489
|
+
while ((inputMatch = inputRegex.exec(formHtml)) !== null) {
|
|
490
|
+
const tag = inputMatch[0];
|
|
491
|
+
const nameMatch = tag.match(/name\s*=\s*["']([^"']+)["']/i);
|
|
492
|
+
const valueMatch = tag.match(/value\s*=\s*["']([^"']*)["']/i);
|
|
493
|
+
|
|
494
|
+
if (nameMatch) {
|
|
495
|
+
const fieldName = nameMatch[1];
|
|
496
|
+
const fieldValue = valueMatch ? valueMatch[1] : '';
|
|
497
|
+
|
|
498
|
+
// Verificar si es hidden
|
|
499
|
+
if (/type\s*=\s*["']?HIDDEN["']?/i.test(tag)) {
|
|
500
|
+
hiddenFields[fieldName] = fieldValue;
|
|
501
|
+
}
|
|
502
|
+
// También capturar campos text que pueden tener valores (NUM_ENV, FEC_ENV)
|
|
503
|
+
else if (/type\s*=\s*["']?text["']?/i.test(tag) && fieldValue) {
|
|
504
|
+
currentValues[fieldName] = fieldValue;
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
if (process.env.DEBUG_SII) {
|
|
510
|
+
console.log(' [DEBUG] Campos hidden encontrados:', Object.keys(hiddenFields).join(', '));
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
// Si no hay formulario para declarar (página de estado/reparos)
|
|
514
|
+
const hasFormFields = Object.keys(hiddenFields).length > 0;
|
|
515
|
+
if (!hasFormFields && Object.keys(sets).length > 0) {
|
|
516
|
+
// Extraer estado visible en la página
|
|
517
|
+
const estadoSets = [];
|
|
518
|
+
const rowRegex = /<tr[^>]*>\s*<td[^>]*>\s*<font[^>]*>([^<]+)<\/font>\s*<\/td>\s*<td[^>]*>\s*<font[^>]*><b>([^<]+)<\/b><\/font>/gi;
|
|
519
|
+
let rowMatch;
|
|
520
|
+
while ((rowMatch = rowRegex.exec(formHtml)) !== null) {
|
|
521
|
+
const nombre = rowMatch[1].trim();
|
|
522
|
+
const estado = rowMatch[2].trim();
|
|
523
|
+
if (nombre && estado) estadoSets.push({ nombre, estado });
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
const reparos = estadoSets.some((s) => /REPAROS|ERRORES/i.test(s.estado));
|
|
527
|
+
return {
|
|
528
|
+
success: false,
|
|
529
|
+
error: reparos
|
|
530
|
+
? 'ENVIO CON ERRORES O REPAROS'
|
|
531
|
+
: 'No hay formulario para declarar avance (página de estado)',
|
|
532
|
+
rawHtml: formHtml,
|
|
533
|
+
estadoSets,
|
|
534
|
+
};
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
// Parsear el HTML para encontrar el mapeo dinámico de índices
|
|
538
|
+
// El SII cambia el orden según qué sets están aprobados
|
|
539
|
+
const fieldMapping = {};
|
|
540
|
+
|
|
541
|
+
if (process.env.DEBUG_SII) {
|
|
542
|
+
const fs = require('fs');
|
|
543
|
+
const path = require('path');
|
|
544
|
+
const debugDir = process.env.SII_DEBUG_DIR || path.join(__dirname, '../../debug');
|
|
545
|
+
if (!fs.existsSync(debugDir)) {
|
|
546
|
+
fs.mkdirSync(debugDir, { recursive: true });
|
|
547
|
+
}
|
|
548
|
+
const debugPath = path.join(debugDir, 'pe_avance2.html');
|
|
549
|
+
fs.writeFileSync(debugPath, formHtml, 'utf8');
|
|
550
|
+
console.log(' [DEBUG] HTML guardado en:', debugPath);
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
// Extraer todas las filas <tr>...</tr> del formulario
|
|
554
|
+
const rowRegex = /<tr[^>]*>([\s\S]*?)<\/tr>/gi;
|
|
555
|
+
const rows = [];
|
|
556
|
+
let rowMatch;
|
|
557
|
+
while ((rowMatch = rowRegex.exec(formHtml)) !== null) {
|
|
558
|
+
rows.push(rowMatch[1]); // Contenido interno de cada <tr>
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
// Buscar en cada fila individualmente
|
|
562
|
+
const patterns = [
|
|
563
|
+
{ name: 'setSimulacion', label: /SET DE SIMULACION/i },
|
|
564
|
+
{ name: 'libroVentas', label: /LIBRO DE VENTAS/i },
|
|
565
|
+
{ name: 'libroCompras', label: /LIBRO DE COMPRAS(?!\s+PARA EXENTOS)/i },
|
|
566
|
+
{ name: 'libroComprasExentos', label: /LIBRO DE COMPRAS PARA EXENTOS/i },
|
|
567
|
+
{ name: 'libroGuias', label: /LIBRO DE GUIAS/i },
|
|
568
|
+
{ name: 'setBasico', label: /SET BASICO/i },
|
|
569
|
+
{ name: 'setGuiaDespacho', label: /SET GUIA DE DESPACHO/i },
|
|
570
|
+
{ name: 'setFacturaExenta', label: /SET FACTURA EXENTA/i },
|
|
571
|
+
{ name: 'setExportacion1', label: /SET DOCUMENTOS DE EXPORTACION(?!\(2\))/i },
|
|
572
|
+
{ name: 'setExportacion2', label: /SET DOCUMENTOS DE EXPORTACION\(2\)/i },
|
|
573
|
+
{ name: 'setFacturaCompra', label: /SET CASO GENERAL FACTURA COMPRA/i },
|
|
574
|
+
{ name: 'setLiquidacion', label: /SET LIQUIDACION FACTURA/i },
|
|
575
|
+
];
|
|
576
|
+
|
|
577
|
+
for (const pattern of patterns) {
|
|
578
|
+
for (const rowContent of rows) {
|
|
579
|
+
// Verificar si esta fila contiene el label
|
|
580
|
+
if (pattern.label.test(rowContent)) {
|
|
581
|
+
// Buscar NUM_ENV en esta fila específica
|
|
582
|
+
const numEnvMatch = rowContent.match(/NAME="NUM_ENV(\d+)"/i);
|
|
583
|
+
if (numEnvMatch) {
|
|
584
|
+
// Solo agregar si tiene NUM_ENV (no si tiene REVISADO CONFORME o EN REVISION)
|
|
585
|
+
fieldMapping[pattern.name] = parseInt(numEnvMatch[1]);
|
|
586
|
+
if (process.env.DEBUG_SII) {
|
|
587
|
+
console.log(` [DEBUG] ${pattern.name} → NUM_ENV${numEnvMatch[1]}`);
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
break; // Ya encontramos la fila para este pattern
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
if (process.env.DEBUG_SII) {
|
|
596
|
+
console.log(' [DEBUG] Mapeo dinámico de índices:', fieldMapping);
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
if (sets.setSimulacion && !fieldMapping.setSimulacion) {
|
|
600
|
+
return {
|
|
601
|
+
success: false,
|
|
602
|
+
error: 'SET DE SIMULACION no está disponible en pe_avance2. La empresa no está en la etapa SIMULACION.',
|
|
603
|
+
rawHtml: formHtml,
|
|
604
|
+
};
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
// Formato de fecha: dd-mm-aaaa
|
|
608
|
+
const formatDate = (dateStr) => {
|
|
609
|
+
if (!dateStr) return '';
|
|
610
|
+
if (/^\d{2}-\d{2}-\d{4}$/.test(dateStr)) return dateStr;
|
|
611
|
+
const date = new Date(dateStr);
|
|
612
|
+
if (isNaN(date.getTime())) return '';
|
|
613
|
+
const dd = String(date.getDate()).padStart(2, '0');
|
|
614
|
+
const mm = String(date.getMonth() + 1).padStart(2, '0');
|
|
615
|
+
const yyyy = date.getFullYear();
|
|
616
|
+
return `${dd}-${mm}-${yyyy}`;
|
|
617
|
+
};
|
|
618
|
+
|
|
619
|
+
// Construir formData con todos los campos EN EL ORDEN CORRECTO del formulario HTML
|
|
620
|
+
// El orden es: NUM_ENV1, FEC_ENV1, SET1, EST1, NUM_ENV2, FEC_ENV2, SET2, EST2, etc.
|
|
621
|
+
// Luego: RUT_EMP, DV_EMP, TOTREG, ACEPTAR
|
|
622
|
+
|
|
623
|
+
// Primero, preparar los valores de NUM_ENV/FEC_ENV que vamos a enviar
|
|
624
|
+
const numEnvValues = {};
|
|
625
|
+
const fecEnvValues = {};
|
|
626
|
+
|
|
627
|
+
// Agregar/sobrescribir los TrackIds de los sets proporcionados
|
|
628
|
+
for (const [setName, index] of Object.entries(fieldMapping)) {
|
|
629
|
+
if (sets[setName]) {
|
|
630
|
+
const { trackId, fecha } = sets[setName];
|
|
631
|
+
if (trackId) {
|
|
632
|
+
// SII espera el TrackID sin ceros iniciales (ej: 245711048, no 0245711048)
|
|
633
|
+
const cleanTrackId = String(parseInt(String(trackId), 10));
|
|
634
|
+
numEnvValues[index] = cleanTrackId;
|
|
635
|
+
fecEnvValues[index] = formatDate(fecha);
|
|
636
|
+
if (String(trackId) !== cleanTrackId) {
|
|
637
|
+
console.log(` [TrackID] Normalizado: ${trackId} → ${cleanTrackId}`);
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
// Construir formData en orden - TODOS los campos deben enviarse
|
|
644
|
+
const formData = {};
|
|
645
|
+
|
|
646
|
+
// Agregar campos en el orden del formulario HTML: NUM_ENV, FEC_ENV, SET, EST para cada índice
|
|
647
|
+
const maxIndex = parseInt(hiddenFields.TOTREG) || 10;
|
|
648
|
+
for (let i = 1; i <= maxIndex; i++) {
|
|
649
|
+
// NUM_ENV - siempre enviar (vacío si no hay valor)
|
|
650
|
+
formData[`NUM_ENV${i}`] = numEnvValues[i] || currentValues[`NUM_ENV${i}`] || '';
|
|
651
|
+
|
|
652
|
+
// FEC_ENV - siempre enviar (vacío si no hay valor)
|
|
653
|
+
formData[`FEC_ENV${i}`] = fecEnvValues[i] || currentValues[`FEC_ENV${i}`] || '';
|
|
654
|
+
|
|
655
|
+
// SET y EST (hidden) - siempre enviar
|
|
656
|
+
formData[`SET${i}`] = hiddenFields[`SET${i}`] || '';
|
|
657
|
+
formData[`EST${i}`] = hiddenFields[`EST${i}`] || '';
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
// Campos finales
|
|
661
|
+
formData['RUT_EMP'] = this.rutEmpresa;
|
|
662
|
+
formData['DV_EMP'] = this.dvEmpresa;
|
|
663
|
+
formData['TOTREG'] = hiddenFields.TOTREG || '10';
|
|
664
|
+
formData['ACEPTAR'] = 'Confirmar Revisión';
|
|
665
|
+
|
|
666
|
+
// Debug: mostrar datos del formulario
|
|
667
|
+
if (process.env.DEBUG_SII) {
|
|
668
|
+
console.log(' [DEBUG] Datos del formulario a enviar:');
|
|
669
|
+
for (const [k, v] of Object.entries(formData)) {
|
|
670
|
+
console.log(` ${k}: ${v || '(vacío)'}`);
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
// Mostrar el body codificado
|
|
674
|
+
const SiiSession = require('./SiiSession');
|
|
675
|
+
const encodedBody = SiiSession.formEncode(formData);
|
|
676
|
+
console.log(' [DEBUG] Body codificado (primeros 500 chars):');
|
|
677
|
+
console.log(` ${encodedBody.substring(0, 500)}`);
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
// 5. Enviar formulario de declaración
|
|
681
|
+
let declareResponse = await this.session.submitForm(
|
|
682
|
+
'/cvc_cgi/dte/pe_avance3',
|
|
683
|
+
formData,
|
|
684
|
+
'https://maullin.sii.cl/cvc_cgi/dte/pe_avance2'
|
|
685
|
+
);
|
|
686
|
+
|
|
687
|
+
// 6. Seguir redirecciones si las hay
|
|
688
|
+
if ([301, 302, 303, 307, 308].includes(declareResponse.status)) {
|
|
689
|
+
const redirectResult = await this.session.followRedirects(declareResponse);
|
|
690
|
+
declareResponse = redirectResult.response;
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
const body = declareResponse.body || '';
|
|
694
|
+
|
|
695
|
+
// Debug - guardar respuesta
|
|
696
|
+
if (process.env.DEBUG_SII) {
|
|
697
|
+
console.log(` [DEBUG] Respuesta: ${body.length} bytes, status: ${declareResponse.status}`);
|
|
698
|
+
// Guardar respuesta de pe_avance3 para debug
|
|
699
|
+
const fs = require('fs');
|
|
700
|
+
const path = require('path');
|
|
701
|
+
const debugDir = process.env.SII_DEBUG_DIR || path.join(__dirname, '../../debug');
|
|
702
|
+
if (!fs.existsSync(debugDir)) {
|
|
703
|
+
fs.mkdirSync(debugDir, { recursive: true });
|
|
704
|
+
}
|
|
705
|
+
const debugPath = path.join(debugDir, 'pe_avance3_response.html');
|
|
706
|
+
fs.writeFileSync(debugPath, body);
|
|
707
|
+
console.log(` [DEBUG] Respuesta guardada en: ${debugPath}`);
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
const bodyLower = body.toLowerCase();
|
|
711
|
+
|
|
712
|
+
// "ERRORES O REPAROS" es un ESTADO del envío, NO un error de la declaración.
|
|
713
|
+
// La declaración (submit del form) fue exitosa; el estado del envío se resuelve
|
|
714
|
+
// después vía polling (EN REVISION → REVISADO CONFORME).
|
|
715
|
+
// Reemplazar para no confundir la detección de errores reales.
|
|
716
|
+
const bodyForErrorCheck = bodyLower.replace(/errores o reparos/g, 'reparos_estado');
|
|
717
|
+
|
|
718
|
+
const hasError = bodyForErrorCheck.includes('error') && !body.includes('Error de Sesión');
|
|
719
|
+
const hasSuccess = body.includes('exitosamente') || body.includes('Avance declarado') || body.includes('actualizado');
|
|
720
|
+
const contenidoNoCorresponde = bodyLower.includes('contenido no corresponde');
|
|
721
|
+
const sesionExpirada = bodyLower.includes('no se encuentra autenticado') || bodyLower.includes('error de sesi');
|
|
722
|
+
|
|
723
|
+
let errorMsg = '';
|
|
724
|
+
if (sesionExpirada) {
|
|
725
|
+
errorMsg = 'Sesión SII no autenticada al declarar avance';
|
|
726
|
+
} else if (contenidoNoCorresponde) {
|
|
727
|
+
errorMsg = 'Contenido no corresponde a lo esperado';
|
|
728
|
+
} else if (!body) {
|
|
729
|
+
errorMsg = 'Respuesta vacía al declarar avance';
|
|
730
|
+
} else if (hasError && !hasSuccess) {
|
|
731
|
+
errorMsg = 'Respuesta inválida al declarar avance';
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
// Éxito si el form se envió sin errores de sesión/contenido.
|
|
735
|
+
// El estado del envío (ERRORES O REPAROS / EN REVISION / REVISADO CONFORME)
|
|
736
|
+
// NO determina el éxito de la declaración — eso se resuelve vía polling.
|
|
737
|
+
const success = (!hasError || hasSuccess) && !sesionExpirada && !contenidoNoCorresponde;
|
|
738
|
+
|
|
739
|
+
return {
|
|
740
|
+
success,
|
|
741
|
+
error: errorMsg || undefined,
|
|
742
|
+
status: declareResponse.status,
|
|
743
|
+
rawHtml: body,
|
|
744
|
+
setsDeclarados: Object.keys(sets),
|
|
745
|
+
formDataSent: formData,
|
|
746
|
+
};
|
|
747
|
+
|
|
748
|
+
} catch (error) {
|
|
749
|
+
return {
|
|
750
|
+
success: false,
|
|
751
|
+
error: error.message,
|
|
752
|
+
};
|
|
753
|
+
}
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
/**
|
|
757
|
+
* Avanza al siguiente paso cuando todos los ítems están REVISADO CONFORME
|
|
758
|
+
* @returns {Promise<Object>} Resultado del avance
|
|
759
|
+
*/
|
|
760
|
+
async avanzarSiguientePaso() {
|
|
761
|
+
try {
|
|
762
|
+
// 1. Acceder a la página de declarar avance
|
|
763
|
+
let response = await this.session.ensureSession('/cvc_cgi/dte/pe_avance1');
|
|
764
|
+
response = await this._handleRepresentacionPage(response);
|
|
765
|
+
|
|
766
|
+
// 2. Enviar formulario con RUT empresa para ir al formulario de sets
|
|
767
|
+
const formResponse = await this.session.submitForm(
|
|
768
|
+
'/cvc_cgi/dte/pe_avance2',
|
|
769
|
+
{
|
|
770
|
+
RUT_EMP: this.rutEmpresa,
|
|
771
|
+
DV_EMP: this.dvEmpresa,
|
|
772
|
+
ACEPTAR: 'Continuar',
|
|
773
|
+
},
|
|
774
|
+
'https://maullin.sii.cl/cvc_cgi/dte/pe_avance1'
|
|
775
|
+
);
|
|
776
|
+
|
|
777
|
+
const formHtml = formResponse.body || '';
|
|
778
|
+
|
|
779
|
+
// 3. Enviar formulario de avance final
|
|
780
|
+
// El botón "Avanzar Siguiente Paso" cambia la acción a /pe_avance4
|
|
781
|
+
const formData = {
|
|
782
|
+
RUT_EMP: this.rutEmpresa,
|
|
783
|
+
DV_EMP: this.dvEmpresa,
|
|
784
|
+
TOTREG: '0',
|
|
785
|
+
PASO: 'P01',
|
|
786
|
+
ACEPTAR: 'Avanzar Siguiente Paso',
|
|
787
|
+
};
|
|
788
|
+
|
|
789
|
+
if (process.env.DEBUG_SII) {
|
|
790
|
+
const fs = require('fs');
|
|
791
|
+
const path = require('path');
|
|
792
|
+
const debugDir = process.env.SII_DEBUG_DIR || path.join(__dirname, '../../debug');
|
|
793
|
+
if (!fs.existsSync(debugDir)) {
|
|
794
|
+
fs.mkdirSync(debugDir, { recursive: true });
|
|
795
|
+
}
|
|
796
|
+
const debugPath = path.join(debugDir, 'pe_avance2_avanzar.html');
|
|
797
|
+
fs.writeFileSync(debugPath, formHtml, 'utf8');
|
|
798
|
+
console.log(' [DEBUG] HTML avanzar guardado en:', debugPath);
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
let avanceResponse = await this.session.submitForm(
|
|
802
|
+
'/cvc_cgi/dte/pe_avance4',
|
|
803
|
+
formData,
|
|
804
|
+
'https://maullin.sii.cl/cvc_cgi/dte/pe_avance2'
|
|
805
|
+
);
|
|
806
|
+
|
|
807
|
+
// 4. Seguir redirecciones si las hay
|
|
808
|
+
if ([301, 302, 303, 307, 308].includes(avanceResponse.status)) {
|
|
809
|
+
const redirectResult = await this.session.followRedirects(avanceResponse);
|
|
810
|
+
avanceResponse = redirectResult.response;
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
const body = avanceResponse.body || '';
|
|
814
|
+
|
|
815
|
+
if (process.env.DEBUG_SII) {
|
|
816
|
+
const fs = require('fs');
|
|
817
|
+
const path = require('path');
|
|
818
|
+
const debugDir = process.env.SII_DEBUG_DIR || path.join(__dirname, '../../debug');
|
|
819
|
+
if (!fs.existsSync(debugDir)) {
|
|
820
|
+
fs.mkdirSync(debugDir, { recursive: true });
|
|
821
|
+
}
|
|
822
|
+
const debugPath = path.join(debugDir, 'pe_avance3_avanzar_response.html');
|
|
823
|
+
fs.writeFileSync(debugPath, body);
|
|
824
|
+
console.log(' [DEBUG] Respuesta avanzar guardada en:', debugPath);
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
const hasError = body.toLowerCase().includes('error') && !body.includes('Error de Sesión');
|
|
828
|
+
const hasSuccess = body.includes('exitosamente') || body.includes('Avance declarado') || body.includes('actualizado');
|
|
829
|
+
|
|
830
|
+
return {
|
|
831
|
+
success: !hasError || hasSuccess,
|
|
832
|
+
rawHtml: body,
|
|
833
|
+
};
|
|
834
|
+
|
|
835
|
+
} catch (error) {
|
|
836
|
+
return {
|
|
837
|
+
success: false,
|
|
838
|
+
error: error.message,
|
|
839
|
+
};
|
|
840
|
+
}
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
/**
|
|
844
|
+
* Consulta el estado de avance de la certificación
|
|
845
|
+
* @returns {Promise<Object>} Estado de avance
|
|
846
|
+
*/
|
|
847
|
+
async verAvance() {
|
|
848
|
+
try {
|
|
849
|
+
// 0. Visitar pe_avance1 y enviar formulario a pe_avance2 para "activar" la actualización
|
|
850
|
+
// El SII parece requerir este flujo para refrescar el estado internamente
|
|
851
|
+
let activateResponse = await this.session.ensureSession('/cvc_cgi/dte/pe_avance1');
|
|
852
|
+
activateResponse = await this._handleRepresentacionPage(activateResponse);
|
|
853
|
+
|
|
854
|
+
// Enviar formulario a pe_avance2 con RUT (esto es lo que hace el portal web)
|
|
855
|
+
await this.session.submitForm(
|
|
856
|
+
'/cvc_cgi/dte/pe_avance2',
|
|
857
|
+
{
|
|
858
|
+
RUT_EMP: this.rutEmpresa,
|
|
859
|
+
DV_EMP: this.dvEmpresa,
|
|
860
|
+
ACEPTAR: 'Continuar',
|
|
861
|
+
},
|
|
862
|
+
'https://maullin.sii.cl/cvc_cgi/dte/pe_avance1'
|
|
863
|
+
);
|
|
864
|
+
|
|
865
|
+
// 1. Acceder a la página de ver avance
|
|
866
|
+
let response = await this.session.ensureSession('/cvc_cgi/dte/pe_avance5');
|
|
867
|
+
response = await this._handleRepresentacionPage(response);
|
|
868
|
+
|
|
869
|
+
// 2. Enviar formulario con RUT empresa
|
|
870
|
+
const formResponse = await this.session.submitForm(
|
|
871
|
+
'/cvc_cgi/dte/pe_avance6',
|
|
872
|
+
{
|
|
873
|
+
RUT_EMP: this.rutEmpresa,
|
|
874
|
+
DV_EMP: this.dvEmpresa,
|
|
875
|
+
ACEPTAR: 'Continuar',
|
|
876
|
+
},
|
|
877
|
+
'https://maullin.sii.cl/cvc_cgi/dte/pe_avance5'
|
|
878
|
+
);
|
|
879
|
+
|
|
880
|
+
// 3. Parsear el estado de avance
|
|
881
|
+
const body = formResponse.body || '';
|
|
882
|
+
const avance = this._parseEstadoAvance(body);
|
|
883
|
+
|
|
884
|
+
return {
|
|
885
|
+
success: true,
|
|
886
|
+
avance,
|
|
887
|
+
rawHtml: body,
|
|
888
|
+
};
|
|
889
|
+
|
|
890
|
+
} catch (error) {
|
|
891
|
+
return {
|
|
892
|
+
success: false,
|
|
893
|
+
error: error.message,
|
|
894
|
+
};
|
|
895
|
+
}
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
/**
|
|
899
|
+
* Parsea el HTML de estado de avance para extraer información estructurada
|
|
900
|
+
* @private
|
|
901
|
+
*/
|
|
902
|
+
_parseEstadoAvance(html) {
|
|
903
|
+
const estado = {
|
|
904
|
+
etapas: [],
|
|
905
|
+
porcentajeTotal: null,
|
|
906
|
+
fechaInicio: null,
|
|
907
|
+
fechaUltimoAvance: null,
|
|
908
|
+
};
|
|
909
|
+
|
|
910
|
+
// Buscar tabla de etapas
|
|
911
|
+
const filaRegex = /<tr[^>]*>([\s\S]*?)<\/tr>/gi;
|
|
912
|
+
const celdaRegex = /<td[^>]*>([\s\S]*?)<\/td>/gi;
|
|
913
|
+
|
|
914
|
+
let filaMatch;
|
|
915
|
+
while ((filaMatch = filaRegex.exec(html)) !== null) {
|
|
916
|
+
const fila = filaMatch[1];
|
|
917
|
+
const celdas = [];
|
|
918
|
+
let celdaMatch;
|
|
919
|
+
while ((celdaMatch = celdaRegex.exec(fila)) !== null) {
|
|
920
|
+
// Limpiar HTML de la celda
|
|
921
|
+
const texto = celdaMatch[1]
|
|
922
|
+
.replace(/<[^>]+>/g, '')
|
|
923
|
+
.replace(/ /g, ' ')
|
|
924
|
+
.trim();
|
|
925
|
+
celdas.push(texto);
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
if (celdas.length >= 2) {
|
|
929
|
+
estado.etapas.push({
|
|
930
|
+
nombre: celdas[0],
|
|
931
|
+
estado: celdas[1],
|
|
932
|
+
fecha: celdas[2] || null,
|
|
933
|
+
});
|
|
934
|
+
}
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
return estado;
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
/**
|
|
941
|
+
* Declara cumplimiento de requisitos (etapa final)
|
|
942
|
+
* @returns {Promise<Object>} Resultado de la declaración
|
|
943
|
+
*/
|
|
944
|
+
async declararCumplimiento() {
|
|
945
|
+
try {
|
|
946
|
+
// 1. Acceder a la página de declarar cumplimiento
|
|
947
|
+
let response = await this.session.ensureSession('/cvc_cgi/dte/pe_avance7');
|
|
948
|
+
response = await this._handleRepresentacionPage(response);
|
|
949
|
+
|
|
950
|
+
// 2. Enviar formulario
|
|
951
|
+
const formResponse = await this.session.submitForm(
|
|
952
|
+
'/cvc_cgi/dte/pe_avance8',
|
|
953
|
+
{
|
|
954
|
+
RUT_EMP: this.rutEmpresa,
|
|
955
|
+
DV_EMP: this.dvEmpresa,
|
|
956
|
+
ACEPTAR: 'Continuar',
|
|
957
|
+
},
|
|
958
|
+
'https://maullin.sii.cl/cvc_cgi/dte/pe_avance7'
|
|
959
|
+
);
|
|
960
|
+
|
|
961
|
+
const body = formResponse.body || '';
|
|
962
|
+
const hasError = body.includes('Error') || body.includes('error');
|
|
963
|
+
|
|
964
|
+
return {
|
|
965
|
+
success: !hasError,
|
|
966
|
+
rawHtml: body,
|
|
967
|
+
};
|
|
968
|
+
|
|
969
|
+
} catch (error) {
|
|
970
|
+
return {
|
|
971
|
+
success: false,
|
|
972
|
+
error: error.message,
|
|
973
|
+
};
|
|
974
|
+
}
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
/**
|
|
978
|
+
* Flujo completo de certificación automática
|
|
979
|
+
* @param {Object} options - Opciones del flujo
|
|
980
|
+
* @param {Function} options.onProgress - Callback para reportar progreso
|
|
981
|
+
* @returns {Promise<Object>} Resultado del flujo completo
|
|
982
|
+
*/
|
|
983
|
+
async flujoCompleto(options = {}) {
|
|
984
|
+
const onProgress = options.onProgress || (() => {});
|
|
985
|
+
const resultados = {
|
|
986
|
+
success: true,
|
|
987
|
+
pasos: [],
|
|
988
|
+
};
|
|
989
|
+
|
|
990
|
+
const pasos = [
|
|
991
|
+
{ nombre: 'Verificar estado actual', fn: () => this.verAvance() },
|
|
992
|
+
{ nombre: 'Generar set básico', fn: () => this.generarSetPruebas({ tipoSet: 'SET_BASICO' }) },
|
|
993
|
+
// Los demás pasos dependen del resultado de verAvance
|
|
994
|
+
];
|
|
995
|
+
|
|
996
|
+
for (const paso of pasos) {
|
|
997
|
+
onProgress({ paso: paso.nombre, estado: 'iniciando' });
|
|
998
|
+
|
|
999
|
+
try {
|
|
1000
|
+
const resultado = await paso.fn();
|
|
1001
|
+
resultados.pasos.push({
|
|
1002
|
+
nombre: paso.nombre,
|
|
1003
|
+
success: resultado.success,
|
|
1004
|
+
detalle: resultado,
|
|
1005
|
+
});
|
|
1006
|
+
|
|
1007
|
+
onProgress({ paso: paso.nombre, estado: resultado.success ? 'completado' : 'error' });
|
|
1008
|
+
|
|
1009
|
+
if (!resultado.success) {
|
|
1010
|
+
resultados.success = false;
|
|
1011
|
+
break;
|
|
1012
|
+
}
|
|
1013
|
+
} catch (error) {
|
|
1014
|
+
resultados.pasos.push({
|
|
1015
|
+
nombre: paso.nombre,
|
|
1016
|
+
success: false,
|
|
1017
|
+
error: error.message,
|
|
1018
|
+
});
|
|
1019
|
+
resultados.success = false;
|
|
1020
|
+
onProgress({ paso: paso.nombre, estado: 'error', error: error.message });
|
|
1021
|
+
break;
|
|
1022
|
+
}
|
|
1023
|
+
}
|
|
1024
|
+
|
|
1025
|
+
return resultados;
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
// ═══════════════════════════════════════════════════════════════
|
|
1029
|
+
// Métodos de estado de certificación (parsing y polling)
|
|
1030
|
+
// ═══════════════════════════════════════════════════════════════
|
|
1031
|
+
|
|
1032
|
+
/**
|
|
1033
|
+
* Patrones para extraer estados del HTML de avance
|
|
1034
|
+
* @private
|
|
1035
|
+
*/
|
|
1036
|
+
static get ESTADO_PATTERNS() {
|
|
1037
|
+
return {
|
|
1038
|
+
setBasico: { nombre: 'SET BASICO', regex: /SET BASICO[\s\S]*?<b>([^<]+)<\/b>/i },
|
|
1039
|
+
setGuiaDespacho: { nombre: 'SET GUIA DESPACHO', regex: /SET GUIA[\s\S]*?DESP[\s\S]*?<b>([^<]+)<\/b>/i },
|
|
1040
|
+
setFacturaExenta: { nombre: 'SET FACTURA EXENTA', regex: /SET FACTURA EXENTA[\s\S]*?<b>([^<]+)<\/b>/i },
|
|
1041
|
+
setFacturaCompra: { nombre: 'SET FACTURA COMPRA', regex: /SET CASO GENERAL FACTURA COMPRA[\s\S]*?<b>([^<]+)<\/b>/i },
|
|
1042
|
+
setSimulacion: { nombre: 'SET SIMULACION', regex: /SET(?:\s+DE)?\s+SIMULACION[\s\S]*?<b>([^<]+)<\/b>/i },
|
|
1043
|
+
libroVentas: { nombre: 'LIBRO VENTAS', regex: /LIBRO[\s\S]*?VENTA[\s\S]*?<b>([^<]+)<\/b>/i },
|
|
1044
|
+
libroCompras: { nombre: 'LIBRO COMPRAS', regex: /LIBRO DE COMPRAS(?!\s+PARA EXENTOS)[\s\S]*?<b>([^<]+)<\/b>/i },
|
|
1045
|
+
libroComprasExentos: { nombre: 'LIBRO COMPRAS EXENTOS', regex: /LIBRO DE COMPRAS PARA EXENTOS[\s\S]*?<b>([^<]+)<\/b>/i },
|
|
1046
|
+
libroGuias: { nombre: 'LIBRO GUIAS', regex: /LIBRO[\s\S]*?GUIA[\s\S]*?<b>([^<]+)<\/b>/i },
|
|
1047
|
+
};
|
|
1048
|
+
}
|
|
1049
|
+
|
|
1050
|
+
/**
|
|
1051
|
+
* Consulta y parsea el estado de avance
|
|
1052
|
+
* @returns {Promise<Object>} { success, etapaActual, estados: { setBasico: 'REVISADO CONFORME', ... }, rawHtml }
|
|
1053
|
+
*/
|
|
1054
|
+
async verAvanceParsed() {
|
|
1055
|
+
const result = await this.verAvance();
|
|
1056
|
+
if (!result.success) return result;
|
|
1057
|
+
|
|
1058
|
+
// Detectar etapa actual del proceso (múltiples formatos)
|
|
1059
|
+
let etapaActual = null;
|
|
1060
|
+
const etapaPatterns = [
|
|
1061
|
+
/paso\s*<b>\s*([^<]+)<\/b>/i,
|
|
1062
|
+
/etapa[:\s]+<b>([^<]+)<\/b>/i,
|
|
1063
|
+
/etapa\s*<b>\s*([^<]+)<\/b>/i,
|
|
1064
|
+
/ETAPA\s+DE\s+([A-ZÁÉÍÓÚÑ\s]+)/i,
|
|
1065
|
+
];
|
|
1066
|
+
for (const pattern of etapaPatterns) {
|
|
1067
|
+
const match = result.rawHtml?.match(pattern);
|
|
1068
|
+
if (match) {
|
|
1069
|
+
etapaActual = match[1].trim().toUpperCase();
|
|
1070
|
+
break;
|
|
1071
|
+
}
|
|
1072
|
+
}
|
|
1073
|
+
|
|
1074
|
+
// Detectar si está esperando "Confirmar Revisión" de simulación
|
|
1075
|
+
// El formulario pe_avance3 con "SET DE SIMULACION" indica que simulación está aprobada
|
|
1076
|
+
let simulacionAprobadaIndicador = result.rawHtml?.includes('pe_avance3') &&
|
|
1077
|
+
result.rawHtml?.includes('SET DE SIMULACION') &&
|
|
1078
|
+
result.rawHtml?.includes('Confirmar Revisi');
|
|
1079
|
+
|
|
1080
|
+
// Si la etapa es SIMULACION, también verificar en pe_avance2 si hay formulario de confirmación
|
|
1081
|
+
if (etapaActual === 'SIMULACION' && !simulacionAprobadaIndicador) {
|
|
1082
|
+
try {
|
|
1083
|
+
const estadoSets = await this.consultarEstadoSets();
|
|
1084
|
+
if (estadoSets.success && estadoSets.rawHtml) {
|
|
1085
|
+
const html = estadoSets.rawHtml;
|
|
1086
|
+
// Buscar el formulario de confirmación de simulación en pe_avance2
|
|
1087
|
+
if (html.includes('pe_avance3') &&
|
|
1088
|
+
html.includes('SET DE SIMULACION') &&
|
|
1089
|
+
html.includes('Confirmar Revisi')) {
|
|
1090
|
+
simulacionAprobadaIndicador = true;
|
|
1091
|
+
}
|
|
1092
|
+
}
|
|
1093
|
+
} catch (e) {
|
|
1094
|
+
// Ignorar error, continuar con la detección normal
|
|
1095
|
+
}
|
|
1096
|
+
}
|
|
1097
|
+
|
|
1098
|
+
const estados = {};
|
|
1099
|
+
for (const [key, { nombre, regex }] of Object.entries(SiiCertificacion.ESTADO_PATTERNS)) {
|
|
1100
|
+
const match = result.rawHtml?.match(regex);
|
|
1101
|
+
if (match) {
|
|
1102
|
+
estados[key] = {
|
|
1103
|
+
nombre,
|
|
1104
|
+
estado: match[1].trim(),
|
|
1105
|
+
esConforme: match[1].trim().toUpperCase().includes('REVISADO CONFORME'),
|
|
1106
|
+
enRevision: match[1].trim().toUpperCase().includes('EN REVISION'),
|
|
1107
|
+
esRechazado: match[1].trim().toUpperCase().includes('RECHAZADO') ||
|
|
1108
|
+
match[1].trim().toUpperCase().includes('ERRORES'),
|
|
1109
|
+
};
|
|
1110
|
+
}
|
|
1111
|
+
}
|
|
1112
|
+
|
|
1113
|
+
// Si detectamos el formulario de confirmación de simulación pero no hay estado,
|
|
1114
|
+
// solo indica que hay una declaración pendiente por confirmar (no aprobada aún).
|
|
1115
|
+
if (simulacionAprobadaIndicador && !estados.setSimulacion) {
|
|
1116
|
+
estados.setSimulacion = {
|
|
1117
|
+
nombre: 'SET SIMULACION',
|
|
1118
|
+
estado: 'PENDIENTE CONFIRMAR',
|
|
1119
|
+
esConforme: false,
|
|
1120
|
+
enRevision: true,
|
|
1121
|
+
esRechazado: false,
|
|
1122
|
+
pendienteConfirmar: true,
|
|
1123
|
+
};
|
|
1124
|
+
}
|
|
1125
|
+
|
|
1126
|
+
return {
|
|
1127
|
+
success: true,
|
|
1128
|
+
etapaActual,
|
|
1129
|
+
estados,
|
|
1130
|
+
simulacionAprobadaIndicador,
|
|
1131
|
+
rawHtml: result.rawHtml,
|
|
1132
|
+
};
|
|
1133
|
+
}
|
|
1134
|
+
|
|
1135
|
+
/**
|
|
1136
|
+
* Espera hasta que los sets especificados sean aprobados
|
|
1137
|
+
* @param {string[]} setsAEsperar - Array de keys: ['setBasico', 'setGuiaDespacho', ...]
|
|
1138
|
+
* @param {Object} options - Opciones de polling
|
|
1139
|
+
* @param {number} options.maxIntentos - Máximo de intentos (default: 30)
|
|
1140
|
+
* @param {number} options.intervalo - Intervalo entre intentos en ms (default: 10000)
|
|
1141
|
+
* @param {Function} options.onProgress - Callback de progreso (opcional)
|
|
1142
|
+
* @returns {Promise<Object>} { success, estados, timedOut }
|
|
1143
|
+
*/
|
|
1144
|
+
async waitForApproval(setsAEsperar = [], options = {}) {
|
|
1145
|
+
const { maxIntentos = 30, intervalo = 10000, onProgress } = options;
|
|
1146
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
1147
|
+
|
|
1148
|
+
for (let intento = 1; intento <= maxIntentos; intento++) {
|
|
1149
|
+
await sleep(intervalo);
|
|
1150
|
+
|
|
1151
|
+
if (onProgress) {
|
|
1152
|
+
onProgress({ intento, maxIntentos, estado: 'polling' });
|
|
1153
|
+
}
|
|
1154
|
+
|
|
1155
|
+
const result = await this.verAvanceParsed();
|
|
1156
|
+
if (!result.success) continue;
|
|
1157
|
+
|
|
1158
|
+
// Filtrar solo los sets que nos interesan
|
|
1159
|
+
const estadosRelevantes = setsAEsperar.length > 0
|
|
1160
|
+
? Object.fromEntries(
|
|
1161
|
+
Object.entries(result.estados).filter(([key]) => setsAEsperar.includes(key))
|
|
1162
|
+
)
|
|
1163
|
+
: result.estados;
|
|
1164
|
+
|
|
1165
|
+
const todosConformes = Object.values(estadosRelevantes).every(e => e.esConforme);
|
|
1166
|
+
const algunoRechazado = Object.values(estadosRelevantes).some(e => e.esRechazado);
|
|
1167
|
+
|
|
1168
|
+
if (onProgress) {
|
|
1169
|
+
onProgress({ intento, maxIntentos, estado: 'resultado', estados: estadosRelevantes });
|
|
1170
|
+
}
|
|
1171
|
+
|
|
1172
|
+
if (todosConformes) {
|
|
1173
|
+
return { success: true, estados: estadosRelevantes };
|
|
1174
|
+
}
|
|
1175
|
+
|
|
1176
|
+
if (algunoRechazado) {
|
|
1177
|
+
return { success: false, error: 'Sets rechazados', estados: estadosRelevantes };
|
|
1178
|
+
}
|
|
1179
|
+
}
|
|
1180
|
+
|
|
1181
|
+
return { success: false, timedOut: true };
|
|
1182
|
+
}
|
|
1183
|
+
}
|
|
1184
|
+
|
|
1185
|
+
// Exportar constantes junto con la clase
|
|
1186
|
+
SiiCertificacion.ETAPAS = ETAPAS_CERTIFICACION;
|
|
1187
|
+
SiiCertificacion.TIPOS_DTE = TIPOS_DTE;
|
|
1188
|
+
|
|
1189
|
+
module.exports = SiiCertificacion;
|