@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.
Files changed (60) hide show
  1. package/BoletaService.js +109 -0
  2. package/CAF.js +173 -0
  3. package/CafSolicitor.js +380 -0
  4. package/Certificado.js +123 -0
  5. package/ConsumoFolio.js +376 -0
  6. package/DTE.js +399 -0
  7. package/EnviadorSII.js +1304 -0
  8. package/Envio.js +196 -0
  9. package/FolioRegistry.js +553 -0
  10. package/FolioService.js +703 -0
  11. package/LICENSE +27 -0
  12. package/LibroBase.js +134 -0
  13. package/LibroCompraVenta.js +205 -0
  14. package/LibroGuia.js +225 -0
  15. package/README.md +239 -0
  16. package/Signer.js +94 -0
  17. package/SiiCertificacion.js +1189 -0
  18. package/SiiPortalAuth.js +460 -0
  19. package/SiiSession.js +499 -0
  20. package/cert/BoletaCert.js +731 -0
  21. package/cert/CertFolioHelper.js +185 -0
  22. package/cert/CertRunner.js +2658 -0
  23. package/cert/ConfigLoader.js +133 -0
  24. package/cert/IntercambioCert.js +429 -0
  25. package/cert/LibroCompras.js +359 -0
  26. package/cert/LibroGuias.js +171 -0
  27. package/cert/LibroVentas.js +153 -0
  28. package/cert/MuestrasImpresas.js +676 -0
  29. package/cert/SetBase.js +321 -0
  30. package/cert/SetBasico.js +413 -0
  31. package/cert/SetCompra.js +472 -0
  32. package/cert/SetExenta.js +490 -0
  33. package/cert/SetGuia.js +283 -0
  34. package/cert/SetParser.js +1184 -0
  35. package/cert/SetsProvider.js +499 -0
  36. package/cert/Simulacion.js +521 -0
  37. package/cert/comunaOficina.js +460 -0
  38. package/cert/index.js +124 -0
  39. package/cert/types.js +330 -0
  40. package/dte-sii.d.ts +458 -0
  41. package/index.js +428 -0
  42. package/package.json +48 -0
  43. package/utils/c14n.js +275 -0
  44. package/utils/calculo.js +396 -0
  45. package/utils/config.js +276 -0
  46. package/utils/constants.js +302 -0
  47. package/utils/emisor.js +174 -0
  48. package/utils/endpoints.js +225 -0
  49. package/utils/error.js +235 -0
  50. package/utils/index.js +339 -0
  51. package/utils/logger.js +239 -0
  52. package/utils/pfx.js +203 -0
  53. package/utils/receptor.js +218 -0
  54. package/utils/referencia.js +169 -0
  55. package/utils/resolucion.js +119 -0
  56. package/utils/rut.js +169 -0
  57. package/utils/sanitize.js +124 -0
  58. package/utils/tokenCache.js +214 -0
  59. package/utils/xml.js +358 -0
  60. 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&aacute;') || 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(/&nbsp;/g, ' ')
327
+ .replace(/&aacute;/g, 'á')
328
+ .replace(/&eacute;/g, 'é')
329
+ .replace(/&iacute;/g, 'í')
330
+ .replace(/&oacute;/g, 'ó')
331
+ .replace(/&uacute;/g, 'ú')
332
+ .replace(/&ntilde;/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(/&nbsp;/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;