@devlas/dte-sii 2.5.15 → 2.6.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/ConsumoFolio.js CHANGED
@@ -223,8 +223,9 @@ class ConsumoFolio {
223
223
  }
224
224
 
225
225
  // Construir XML SIN indentación para C14N consistente
226
+ // NOTA: SII DTEUpload requiere ISO-8859-1 (endpoint SOAP/CGI antiguo)
226
227
  const schemaLoc = 'http://www.sii.cl/SiiDte ConsumoFolio_v10.xsd';
227
- const xmlSinFirma = `<?xml version="1.0" encoding="UTF-8"?>
228
+ const xmlSinFirma = `<?xml version="1.0" encoding="ISO-8859-1"?>
228
229
  <ConsumoFolios xmlns="http://www.sii.cl/SiiDte" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="${schemaLoc}" version="1.0"><DocumentoConsumoFolios ID="${this.id}"><Caratula version="1.0"><RutEmisor>${this.caratula.RutEmisor}</RutEmisor><RutEnvia>${this.caratula.RutEnvia}</RutEnvia><FchResol>${this.caratula.FchResol}</FchResol><NroResol>${this.caratula.NroResol}</NroResol><FchInicio>${this.caratula.FchInicio}</FchInicio><FchFinal>${this.caratula.FchFinal}</FchFinal><SecEnvio>${this.caratula.SecEnvio}</SecEnvio><TmstFirmaEnv>${this.caratula.TmstFirmaEnv}</TmstFirmaEnv></Caratula>${resumenXml}</DocumentoConsumoFolios></ConsumoFolios>`;
229
230
 
230
231
  // Firmar el documento
package/EnviadorSII.js CHANGED
@@ -1132,7 +1132,9 @@ class EnviadorSII {
1132
1132
  body += xml;
1133
1133
  body += `\r\n--${boundary}--\r\n`;
1134
1134
 
1135
- return await this._enviarMultipart(url, body, boundary, xml, 'RCOF');
1135
+ // SII DTEUpload requiere ISO-8859-1 (igual que EnvioBOLETA y EnvioDTE SOAP)
1136
+ const bodyBuffer = Buffer.from(body, 'latin1');
1137
+ return await this._enviarMultipart(url, bodyBuffer, boundary, xml, 'RCOF');
1136
1138
  }
1137
1139
 
1138
1140
  /**
@@ -1337,9 +1339,30 @@ class EnviadorSII {
1337
1339
  3: 'Error en XML',
1338
1340
  4: 'Error de firma',
1339
1341
  5: 'Error de sistema',
1342
+ 7: 'Envío duplicado — el SII ya recibió este set anteriormente',
1340
1343
  99: 'Error desconocido',
1341
1344
  };
1342
1345
 
1346
+ // STATUS 7 = Envío duplicado (para EnvioBOLETA/EnvioDTE).
1347
+ // EXCEPCIÓN: CHR-00001 en <DETAIL> = error de charset, NO es duplicado.
1348
+ if (status === 7) {
1349
+ const detailMatch = responseText.match(/<ERROR>([^<]*)<\/ERROR>/i);
1350
+ const detailMsg = detailMatch ? detailMatch[1] : '';
1351
+ if (detailMsg.includes('CHR-00001')) {
1352
+ return {
1353
+ ok: false,
1354
+ status,
1355
+ error: `Error de charset en XML enviado (${detailMsg}). Verifique encoding="ISO-8859-1" en la declaración XML.`,
1356
+ respuesta: responseText,
1357
+ duplicado: false,
1358
+ };
1359
+ }
1360
+ if (trackId) {
1361
+ return { ok: true, status, trackId, archivo: fileName, mensaje: `[DUPLICADO] TrackID recuperado: ${trackId}`, respuesta: responseText };
1362
+ }
1363
+ return { ok: false, status, error: errorMessages[7], respuesta: responseText, duplicado: true };
1364
+ }
1365
+
1343
1366
  return {
1344
1367
  ok: false,
1345
1368
  status: status,
@@ -247,31 +247,41 @@ class BoletaCert {
247
247
  */
248
248
  generarConsumoFolio(envioBoleta, options = {}) {
249
249
  const { ConsumoFolio } = this._lib();
250
+ const { DOMParser } = require('@xmldom/xmldom');
250
251
 
251
252
  const consumoFolio = new ConsumoFolio(this.certificado);
252
253
 
253
- // Agregar documentos desde el EnvioBOLETA
254
- // Necesitamos extraer la info de cada boleta
255
- const boletas = envioBoleta.dtes || [];
256
- for (const dte of boletas) {
257
- const tipo = dte.datos.Encabezado.IdDoc.TipoDTE;
258
- const folio = dte.datos.Encabezado.IdDoc.Folio;
259
- const fechaEmision = dte.datos.Encabezado.IdDoc.FchEmis;
260
- const totales = dte.datos.Encabezado.Totales;
254
+ // Parsear el XML generado para obtener los totales exactos que quedaron firmados.
255
+ // NO leer de dte.datos.Encabezado.Totales: son valores pre-cálculo (input del constructor)
256
+ // y no coinciden con los totales calculados que el SII valida contra el RCOF.
257
+ const parser = new DOMParser();
258
+ const doc = parser.parseFromString(envioBoleta.xml, 'text/xml');
259
+ const dteEls = doc.getElementsByTagName('Documento');
260
+
261
+ for (let i = 0; i < dteEls.length; i++) {
262
+ const dteEl = dteEls[i];
263
+ const tipo = parseInt(dteEl.getElementsByTagName('TipoDTE')[0]?.textContent || '39', 10);
264
+ const folio = parseInt(dteEl.getElementsByTagName('Folio')[0]?.textContent || '0', 10);
265
+ const fchEmis = dteEl.getElementsByTagName('FchEmis')[0]?.textContent || '';
266
+
267
+ const totalesEl = dteEl.getElementsByTagName('Totales')[0];
268
+ const mntNeto = parseInt(totalesEl?.getElementsByTagName('MntNeto')[0]?.textContent || '0', 10);
269
+ const mntExe = parseInt(totalesEl?.getElementsByTagName('MntExe')[0]?.textContent || '0', 10);
270
+ const iva = parseInt(totalesEl?.getElementsByTagName('IVA')[0]?.textContent || '0', 10);
271
+ const mntTotal = parseInt(totalesEl?.getElementsByTagName('MntTotal')[0]?.textContent || '0', 10);
261
272
 
262
- // Estructura que espera ConsumoFolio: Totales como hijo de Encabezado
263
273
  consumoFolio.agregar(tipo, {
264
274
  Encabezado: {
265
275
  IdDoc: {
266
276
  TipoDTE: tipo,
267
277
  Folio: folio,
268
- FchEmis: fechaEmision,
278
+ FchEmis: fchEmis,
269
279
  },
270
280
  Totales: {
271
- MntNeto: totales.MntNeto || 0,
272
- MntExe: totales.MntExe || 0,
273
- IVA: totales.IVA || 0,
274
- MntTotal: totales.MntTotal || 0,
281
+ MntNeto: mntNeto,
282
+ MntExe: mntExe,
283
+ IVA: iva,
284
+ MntTotal: mntTotal,
275
285
  }
276
286
  },
277
287
  });
@@ -384,6 +394,7 @@ class BoletaCert {
384
394
  * @param {string} options.setPath - Ruta al archivo del set de pruebas
385
395
  * @param {Object} options.cafBoleta - CAF para boletas tipo 39
386
396
  * @param {number} options.folioInicial - Folio inicial a usar
397
+ * @param {number} [options.secEnvio=1] - Número de secuencia del RCOF (auto-incrementar por run)
387
398
  * @returns {Promise<Object>} Resultado con trackIds
388
399
  */
389
400
  async ejecutarCertificacion(options) {
@@ -431,52 +442,125 @@ class BoletaCert {
431
442
  const resultadoBoleta = await enviador.enviarBoletaSoap(envioBoleta);
432
443
 
433
444
  if (!resultadoBoleta.ok) {
434
- console.log(` [ERR] Error: ${resultadoBoleta.error}`);
435
- return { success: false, error: resultadoBoleta.error, fase: 'EnvioBOLETA' };
445
+ if (resultadoBoleta.duplicado) {
446
+ // STATUS 7 = Envío duplicado el mismo set ya fue enviado al SII con anterioridad.
447
+ // Continuamos con el RCOF y la declaración; el SII ya tiene el set.
448
+ console.log(` [⚠️ DUPLICADO] EnvioBOLETA ya enviado anteriormente — continuando con RCOF y declaración...`);
449
+ resultadoBoleta = { ok: false, duplicado: true, trackId: null };
450
+ } else {
451
+ console.log(` [ERR] Error: ${resultadoBoleta.error}`);
452
+ return { success: false, error: resultadoBoleta.error, fase: 'EnvioBOLETA' };
453
+ }
454
+ } else {
455
+ console.log(` [OK] Enviado - TrackId: ${resultadoBoleta.trackId}`);
436
456
  }
437
- console.log(` [OK] Enviado - TrackId: ${resultadoBoleta.trackId}`);
438
457
 
439
- // 5. Generar RCOF
440
- console.log('\nGenerando RCOF (ConsumoFolio)...');
441
- const consumoFolio = this.generarConsumoFolio(envioBoleta);
442
- consumoFolio.generar(); // generar() ya incluye firmar() internamente
443
- console.log(` XML generado: ${consumoFolio.xml.length} bytes`);
444
-
445
- // Guardar debug
446
- if (this.debugDir) {
447
- const debugPath = path.join(this.debugDir, 'boleta-cert');
448
- fs.writeFileSync(path.join(debugPath, 'ConsumoFolio.xml'), consumoFolio.xml, 'utf-8');
449
- console.log(` Guardado en: ${path.join(debugPath, 'ConsumoFolio.xml')}`);
458
+ // 5 & 6. Generar y enviar RCOF — loop hasta que SII lo acepte o se agoten intentos.
459
+ // Estrategia: enviar → si ok, esperar 30s → consultar estado (EPR o RPR = éxito).
460
+ // Si DUPLICADO: el SII ya tiene un RCOF para este RUT/período. El entorno de certificación
461
+ // acepta solo 1 RCOF por RUT por día — tras 3 duplicados consecutivos se asume ya enviado.
462
+ console.log('\nGenerando y enviando RCOF (ConsumoFolio)...');
463
+ const secInicio = options.secEnvio || 100;
464
+ const MAX_INTENTOS_RCOF = 10;
465
+ const MAX_DUPLICADOS_CONSECUTIVOS = 3;
466
+ let secUsado = secInicio;
467
+ let resultadoRCOF = { ok: false };
468
+ let trackIdRCOFloop = null;
469
+ let duplicadosConsecutivos = 0;
470
+
471
+ for (let intento = 0; intento < MAX_INTENTOS_RCOF; intento++) {
472
+ const secActual = secInicio + intento;
473
+ const cf = this.generarConsumoFolio(envioBoleta, { secEnvio: secActual });
474
+ cf.generar();
475
+
476
+ if (this.debugDir) {
477
+ const debugPath = path.join(this.debugDir, 'boleta-cert');
478
+ fs.mkdirSync(debugPath, { recursive: true });
479
+ const fname = intento === 0 ? 'ConsumoFolio.xml' : `ConsumoFolio-sec${secActual}.xml`;
480
+ fs.writeFileSync(path.join(debugPath, fname), cf.xml, 'utf-8');
481
+ }
482
+
483
+ console.log(` → Enviando RCOF SecEnvio=${secActual} (intento ${intento + 1}/${MAX_INTENTOS_RCOF})...`);
484
+ const res = await enviador.enviarConsumoFolios(cf);
485
+ secUsado = secActual;
486
+
487
+ if (!res.ok && res.duplicado) {
488
+ duplicadosConsecutivos++;
489
+ console.log(` [⚠️ DUPLICADO ${duplicadosConsecutivos}/${MAX_DUPLICADOS_CONSECUTIVOS}] SecEnvio=${secActual} ya existe en SII.`);
490
+ if (duplicadosConsecutivos >= MAX_DUPLICADOS_CONSECUTIVOS) {
491
+ // El entorno SII solo permite 1 RCOF por RUT/día → ya hay uno registrado. Continuar.
492
+ console.log(` [ℹ️] SII ya tiene un RCOF para hoy (${MAX_DUPLICADOS_CONSECUTIVOS} duplicados consecutivos). Continuando sin nuevo trackId RCOF.`);
493
+ resultadoRCOF = { ok: true, trackId: null, rcofYaEnviado: true };
494
+ break;
495
+ }
496
+ console.log(` → Probando sec=${secActual + 1}...`);
497
+ continue;
498
+ }
499
+
500
+ duplicadosConsecutivos = 0;
501
+
502
+ if (!res.ok) {
503
+ console.log(` [ERR] Error al enviar RCOF (sec=${secActual}): ${res.error}`);
504
+ resultadoRCOF = res;
505
+ break;
506
+ }
507
+
508
+ // Enviado correctamente — esperar 30s y verificar que SII lo aceptó
509
+ // Nota: EPR (Envío Procesado) Y RPR (Aceptado con Reparos) son ambos válidos para RCOF.
510
+ trackIdRCOFloop = res.trackId;
511
+ console.log(` ✓ RCOF recibido por SII — TrackId: ${trackIdRCOFloop} (SecEnvio=${secActual})`);
512
+ console.log(` ⏳ Esperando 30s para verificar estado RCOF en SII...`);
513
+ await new Promise(r => setTimeout(r, 30000));
514
+
515
+ let estadoRcof;
516
+ try {
517
+ estadoRcof = await enviador.consultarEstadoSoap(trackIdRCOFloop, this.emisor.rut);
518
+ console.log(` [Estado RCOF] ${estadoRcof.estado} — ${estadoRcof.mensaje}`);
519
+ } catch (e) {
520
+ console.log(` [!] No se pudo consultar estado RCOF: ${e.message} — asumiendo aceptado`);
521
+ resultadoRCOF = { ok: true, trackId: trackIdRCOFloop };
522
+ break;
523
+ }
524
+
525
+ // EPR = procesado, RPR = aceptado con reparos (ambos válidos para RCOF), estados intermedios = OK para continuar
526
+ if (estadoRcof.esExitoso || estadoRcof.esIntermedio) {
527
+ resultadoRCOF = { ok: true, trackId: trackIdRCOFloop, estado: estadoRcof.estado };
528
+ break;
529
+ }
530
+
531
+ // SII lo rechazó explícitamente — probar con el siguiente sec
532
+ console.log(` [⚠️ RECHAZADO] RCOF sec=${secActual} rechazado (${estadoRcof.estado}: ${estadoRcof.glosa || estadoRcof.mensaje}). Probando sec=${secActual + 1}...`);
533
+ trackIdRCOFloop = null;
450
534
  }
451
-
452
- // 6. Enviar RCOF
453
- console.log('\nEnviando RCOF al SII...');
454
- const resultadoRCOF = await enviador.enviarConsumoFolios(consumoFolio);
455
-
535
+
456
536
  if (!resultadoRCOF.ok) {
457
- console.log(` [ERR] Error: ${resultadoRCOF.error}`);
458
- return {
459
- success: false,
460
- error: resultadoRCOF.error,
461
- fase: 'RCOF',
462
- trackIdBoleta: resultadoBoleta.trackId
463
- };
537
+ if (!resultadoRCOF.error) {
538
+ console.log(` [⚠️] RCOF no aceptado tras ${MAX_INTENTOS_RCOF} intentos. Continuando sin trackId RCOF.`);
539
+ } else {
540
+ console.log(` [ERR] Error RCOF: ${resultadoRCOF.error}`);
541
+ return {
542
+ success: false,
543
+ error: resultadoRCOF.error,
544
+ fase: 'RCOF',
545
+ trackIdBoleta: resultadoBoleta.trackId
546
+ };
547
+ }
464
548
  }
465
- console.log(` [OK] Enviado - TrackId: ${resultadoRCOF.trackId}`);
466
549
 
467
550
  // Resumen
468
551
  console.log('\n' + '═'.repeat(60));
469
552
  console.log('[OK] CERTIFICACIÓN BOLETAS COMPLETADA');
470
553
  console.log('═'.repeat(60));
471
- console.log(` EnvioBOLETA: ${resultadoBoleta.trackId}`);
472
- console.log(` RCOF: ${resultadoRCOF.trackId}`);
554
+ console.log(` EnvioBOLETA: ${resultadoBoleta.trackId ?? '(enviado previamente)'}`);
555
+ console.log(` RCOF: ${resultadoRCOF.trackId ?? '(enviado previamente)'}`);
473
556
  console.log(` Boletas: ${boletas.length}`);
474
557
  console.log(` Folios: ${folioInicial} - ${folioFinal}`);
475
558
 
476
559
  return {
477
560
  success: true,
478
- trackIdBoleta: resultadoBoleta.trackId,
479
- trackIdRCOF: resultadoRCOF.trackId,
561
+ trackIdBoleta: resultadoBoleta.trackId ?? null,
562
+ trackIdRCOF: resultadoRCOF.trackId ?? null,
563
+ secEnvioRCOF: secUsado,
480
564
  boletas: boletas.length,
481
565
  folioInicial,
482
566
  folioFinal,
@@ -782,6 +782,7 @@ class CertRunner {
782
782
  this._decrementarPeriodoLibros();
783
783
  const _nuevoPeriodo = this._getPeriodoLibros();
784
784
  console.log(`\n[!] Período rechazado por SII. Reintentando con ${_nuevoPeriodo} (${_pRetry + 1}/${MAX_PERIOD_RETRIES})...`);
785
+ emitProgress(STEPS.BOOK_PERIOD_RETRY, { periodo: _nuevoPeriodo, intento: String(_pRetry + 1) });
785
786
  await _reenviarLibros(_nuevoPeriodo);
786
787
  declaracion = await this.declararLibros({ ...resultados, ...(options.setsResultados || {}) });
787
788
  resultados.declaracion = declaracion;
@@ -806,6 +807,7 @@ class CertRunner {
806
807
  const _nuevoPeriodo = this._getPeriodoLibros();
807
808
  const _s21Nombres = _s21Keys.map(k => _KEY_A_SII_NOMBRE[k]).filter(Boolean);
808
809
  console.log(`\n[!] ${_s21Nombres.join(', ')} bloqueados en S21. Reintentando con período ${_nuevoPeriodo} (${_pRetry + 1}/${MAX_PERIOD_RETRIES})...`);
810
+ emitProgress(STEPS.BOOK_PERIOD_RETRY, { periodo: _nuevoPeriodo, intento: String(_pRetry + 1) });
809
811
 
810
812
  await _reenviarLibros(_nuevoPeriodo, new Set(_s21Keys));
811
813
  declaracion = await this.declararLibros({ ...resultados, ...(options.setsResultados || {}) });
@@ -1550,9 +1552,16 @@ class CertRunner {
1550
1552
  const yaIntercambio = Boolean(verificacion?.etapaActual?.includes('INTERCAMBIO'));
1551
1553
  const simConforme = Boolean(estadoSim?.esConforme || estadoSim?.estado?.toUpperCase()?.includes('REVISADO CONFORME'));
1552
1554
 
1553
- if (yaIntercambio || simConforme || !sigueFormulario) {
1554
- console.log('\n ¡SIMULACIÓN CONFIRMADA! Certificación completa.');
1555
- return { success: true, confirmada: true };
1555
+ if (yaIntercambio) {
1556
+ console.log('\n ¡SIMULACIÓN CONFIRMADA! Empresa ya en etapa INTERCAMBIO.');
1557
+ return { success: true, confirmada: true, etapa: 'INTERCAMBIO' };
1558
+ }
1559
+
1560
+ if (simConforme || !sigueFormulario) {
1561
+ // La empresa pasó a la siguiente etapa automáticamente (INTERCAMBIO → DOCUMENTOS IMPRESOS → DECLARAR CUMPLIMIENTO)
1562
+ const etapaActual = verificacion?.etapaActual || 'INTERCAMBIO';
1563
+ console.log(`\n ¡SIMULACIÓN APROBADA! Etapa actual: ${etapaActual}`);
1564
+ return { success: true, confirmada: true, etapa: etapaActual };
1556
1565
  }
1557
1566
 
1558
1567
  console.log(' [!] SII aún mantiene formulario de simulación pendiente; se reintentará...');
@@ -1585,8 +1594,11 @@ class CertRunner {
1585
1594
 
1586
1595
  if (esAprobado) {
1587
1596
  console.log(` [OK] SIMULACIÓN: REVISADO CONFORME`);
1588
- console.log('\n ¡SIMULACIÓN APROBADA! Certificación completa.');
1589
- return { success: true };
1597
+ // Consultar etapa actual (INTERCAMBIO es el siguiente paso tras simulación)
1598
+ const postSimAvance = await this.siiCert.verAvanceParsed().catch(() => null);
1599
+ const etapaActual = postSimAvance?.etapaActual || 'INTERCAMBIO';
1600
+ console.log(`\n ¡SIMULACIÓN APROBADA! Etapa actual: ${etapaActual}`);
1601
+ return { success: true, etapa: etapaActual };
1590
1602
  } else if (esRechazado) {
1591
1603
  console.log(` [ERR] SIMULACIÓN: ${simEstado.estado}`);
1592
1604
  return { success: false, error: 'Simulación rechazada' };
@@ -1914,11 +1926,7 @@ class CertRunner {
1914
1926
 
1915
1927
  let browser;
1916
1928
  try {
1917
- browser = await puppeteer.launch({
1918
- headless: true,
1919
- ignoreHTTPSErrors: true,
1920
- args: ['--no-sandbox', '--disable-setuid-sandbox', '--ignore-certificate-errors'],
1921
- });
1929
+ browser = await puppeteer.launch(this._getPuppeteerLaunchOptions());
1922
1930
 
1923
1931
  const page = await browser.newPage();
1924
1932
  await page.setCookie(...puppeteerCookies);
@@ -2102,11 +2110,7 @@ class CertRunner {
2102
2110
  const [rutNum, dvChar] = this.config.emisor.rut.split('-');
2103
2111
  let browser;
2104
2112
  try {
2105
- browser = await puppeteer.launch({
2106
- headless: true,
2107
- ignoreHTTPSErrors: true,
2108
- args: ['--no-sandbox', '--disable-setuid-sandbox', '--ignore-certificate-errors'],
2109
- });
2113
+ browser = await puppeteer.launch(this._getPuppeteerLaunchOptions());
2110
2114
  const page = await browser.newPage();
2111
2115
  await page.setCookie(...puppeteerCookies);
2112
2116
  await page.goto('https://www4.sii.cl/pdfdteInternet/', { waitUntil: 'networkidle2', timeout: 60000 });
@@ -2125,23 +2129,32 @@ class CertRunner {
2125
2129
  if (btn) btn.click();
2126
2130
  }, 'Rut');
2127
2131
 
2128
- // Descartar diálogo "ya existe revisión" si aparece
2129
- await new Promise(r => setTimeout(r, 1500));
2130
- await page.evaluate(() => {
2131
- const si = Array.from(document.querySelectorAll('button.x-btn-text'))
2132
- .find(b => /^s[ií]$/i.test(b.textContent.trim()));
2133
- if (si) si.click();
2134
- }).catch(() => {});
2132
+ // IMPORTANTE: NO hacer click en "Sí" del diálogo "ya existe revisión".
2133
+ // Hacerlo crea una revisión nueva vacía y borra el estado "POR REVISAR" del DOM.
2134
+ // El estado de la revisión existente es visible en el body aunque el diálogo esté abierto.
2135
+ await new Promise(r => setTimeout(r, 3000));
2135
2136
 
2136
- // Esperar hasta 8s a que aparezca el estado en el DOM
2137
+ // Esperar hasta 10s a que aparezca el estado en el DOM (detrás del diálogo si aplica)
2137
2138
  await page.waitForFunction(() => {
2138
2139
  const t = (document.body.textContent || '').toUpperCase();
2139
- return t.includes('ESTADO DE LA REVISI') ||
2140
- t.includes('POR REVISAR') || t.includes('APROBADO') ||
2141
- t.includes('EN REVISI') || t.includes('RECHAZADO');
2142
- }, { timeout: 8000, polling: 500 }).catch(() => {});
2140
+ return t.includes('POR REVISAR') || t.includes('APROBADO') ||
2141
+ t.includes('EN REVISI') || t.includes('RECHAZADO') ||
2142
+ t.includes('ENVIADO AL SII');
2143
+ }, { timeout: 10000, polling: 500 }).catch(() => {});
2143
2144
 
2145
+ // Leer estado directamente del label del formulario en el DOM
2144
2146
  const estado = await page.evaluate(() => {
2147
+ // Primero intentar leer el label específico de estado (más preciso)
2148
+ const labels = Array.from(document.querySelectorAll('.x-form-label'));
2149
+ for (const lbl of labels) {
2150
+ const txt = (lbl.textContent || '').trim().toUpperCase();
2151
+ if (txt === 'POR REVISAR') return 'POR REVISAR';
2152
+ if (txt === 'APROBADO') return 'APROBADO';
2153
+ if (txt.startsWith('EN REVISI')) return 'EN REVISIÓN';
2154
+ if (txt === 'RECHAZADO') return 'RECHAZADO';
2155
+ if (txt.includes('ENVIADO AL SII')) return 'ENVIADO AL SII';
2156
+ }
2157
+ // Fallback: buscar en todo el body
2145
2158
  const t = (document.body.textContent || '').toUpperCase();
2146
2159
  if (t.includes('APROBADO')) return 'APROBADO';
2147
2160
  if (t.includes('POR REVISAR')) return 'POR REVISAR';
@@ -2155,7 +2168,13 @@ class CertRunner {
2155
2168
  } catch (err) {
2156
2169
  return { estado: null, error: err.message };
2157
2170
  } finally {
2158
- if (browser) await browser.close().catch(() => {});
2171
+ if (browser) {
2172
+ await Promise.race([
2173
+ browser.close().catch(() => {}),
2174
+ new Promise(r => setTimeout(r, 8000)),
2175
+ ]);
2176
+ try { browser.process()?.kill('SIGKILL'); } catch { /* ignorar */ }
2177
+ }
2159
2178
  }
2160
2179
  }
2161
2180
 
@@ -2210,12 +2229,7 @@ class CertRunner {
2210
2229
 
2211
2230
  let browser;
2212
2231
  try {
2213
- browser = await puppeteer.launch({
2214
- headless: true,
2215
- ignoreHTTPSErrors: true,
2216
- protocolTimeout: 300000, // 5 min — DOM.setFileInputFiles con 256 archivos supera el default de 30s
2217
- args: ['--no-sandbox', '--disable-setuid-sandbox', '--ignore-certificate-errors'],
2218
- });
2232
+ browser = await puppeteer.launch(this._getPuppeteerLaunchOptions([], { protocolTimeout: 300000 }));
2219
2233
  const page = await browser.newPage();
2220
2234
  await page.setCookie(...puppeteerCookies);
2221
2235
 
@@ -2227,6 +2241,26 @@ class CertRunner {
2227
2241
  await new Promise(r => setTimeout(r, 3000));
2228
2242
  if (debugDir) await page.screenshot({ path: path.join(debugDir, 'pdfte-01-loaded.png'), fullPage: true }).catch(() => {});
2229
2243
 
2244
+ // Hookear SWFUpload para capturar post_params.id cuando GWT inicialice el uploader
2245
+ await page.evaluate(() => {
2246
+ if (window.SWFUpload) {
2247
+ const _Orig = window.SWFUpload;
2248
+ window.SWFUpload = function(settings) {
2249
+ const pp = (settings && settings.post_params) || {};
2250
+ if (pp.id) window.__swfCapturedRevId = String(pp.id);
2251
+ const inst = new _Orig(settings);
2252
+ // Copiar propiedades estáticas
2253
+ Object.assign(window.SWFUpload, _Orig);
2254
+ return inst;
2255
+ };
2256
+ window.SWFUpload.prototype = _Orig.prototype;
2257
+ // Copiar constantes estáticas del prototipo original
2258
+ for (const k of Object.getOwnPropertyNames(_Orig)) {
2259
+ try { window.SWFUpload[k] = _Orig[k]; } catch { }
2260
+ }
2261
+ }
2262
+ }).catch(() => {});
2263
+
2230
2264
  // Paso 1: RUT Empresa
2231
2265
  const rutInputs = await page.$$('input[name="rut"]');
2232
2266
  const dvInputs = await page.$$('input[name="dv"]');
@@ -2290,21 +2324,50 @@ class CertRunner {
2290
2324
  console.log(` → Ingresando RUT proveedor: ${rutNum}-${dvChar}`);
2291
2325
  await pRut.click({ clickCount: 3 }); await pRut.type(rutNum);
2292
2326
  await pDv.click({ clickCount: 3 }); await pDv.type(dvChar);
2327
+
2328
+ // Interceptar respuestas del portal durante "Consultar" para capturar el ID de revisión
2329
+ let _capturedRevId = null;
2330
+ const _onPortalResponse = async (resp) => {
2331
+ const url = resp.url();
2332
+ if (!url.includes('sii.cl/pdfdteInternet')) return;
2333
+ try {
2334
+ const body = await resp.text();
2335
+ const matches = body.match(/\b(\d{5,7})\b/g);
2336
+ if (matches) {
2337
+ for (const m of matches) {
2338
+ const n = +m;
2339
+ if (n > 10000 && n < 9999999 && !_capturedRevId) {
2340
+ _capturedRevId = m;
2341
+ console.log(` [DEBUG] Posible nroRevision capturado de red: ${m} (url: ${url.split('?')[0]})`);
2342
+ }
2343
+ }
2344
+ }
2345
+ } catch { /* skip */ }
2346
+ };
2347
+ page.on('response', _onPortalResponse);
2293
2348
  await clickBoton(page, 'Consultar');
2294
2349
  await new Promise(r => setTimeout(r, 2500));
2350
+ page.off('response', _onPortalResponse);
2295
2351
  if (debugDir) await page.screenshot({ path: path.join(debugDir, 'pdfte-03-after-consultar.png'), fullPage: true }).catch(() => {});
2296
2352
 
2297
2353
  // ── Re-ejecución: detectar estado terminal antes de proceder ──
2298
2354
  // Solo aplicable cuando NO se creó una nueva revisión.
2299
- // Si se creó una nueva (nuevaRevisionCreada=true), el texto "EN REVISIÓN"
2300
- // que aparece pertenece a la revisión anterior y no es válido como early-exit.
2355
+ // NOTA: el tab de navegación "Muestras Impresas en revisión" siempre contiene
2356
+ // el texto "en revisión", por lo que NO se puede usar textContent global para
2357
+ // detectar ese estado. Solo hacer early-exit si el formulario de upload
2358
+ // (input.gwt-FileUpload o botón "Crear") NO está visible, lo que indica que
2359
+ // la revisión está en un estado terminal irreversible.
2301
2360
  const _estadoYaSubido = nuevaRevisionCreada ? null : await page.evaluate(() => {
2361
+ // Si el formulario de carga está presente, proceder siempre con el upload
2362
+ const tieneFormulario = !!document.querySelector('input.gwt-FileUpload') ||
2363
+ Array.from(document.querySelectorAll('button.x-btn-text'))
2364
+ .some(b => ['Crear', 'Limpiar', 'Eliminar'].includes(b.textContent.trim()) && !b.disabled);
2365
+ if (tieneFormulario) return null;
2366
+ // Solo si NO hay formulario, verificar estado terminal
2302
2367
  const t = (document.body.textContent || '').toUpperCase();
2303
2368
  if (t.includes('APROBADO')) return 'APROBADO';
2304
- if (t.includes('POR REVISAR')) return 'POR REVISAR';
2305
- if (t.includes('EN REVISI')) return 'EN REVISIÓN';
2306
- if (t.includes('RECHAZADO')) return 'RECHAZADO';
2307
2369
  if (t.includes('ENVIADO AL SII')) return 'ENVIADO AL SII';
2370
+ if (t.includes('RECHAZADO')) return 'RECHAZADO';
2308
2371
  return null;
2309
2372
  }).catch(() => null);
2310
2373
  if (_estadoYaSubido) {
@@ -2368,86 +2431,121 @@ class CertRunner {
2368
2431
  if (!_hayInp) { await clickBoton(page, 'Crear'); await new Promise(r => setTimeout(r, 2500)); }
2369
2432
  };
2370
2433
 
2371
- // Cargar todos los PDFs a la vez en el input de archivo.
2372
- // GWT no tiene atributo "multiple" por defecto — se fuerza vía DOM.
2373
- // Puppeteer dispara el evento "change" internamente tras uploadFile(),
2374
- // lo que dispara el handler GWT que encola todos los archivos para
2375
- // procesarlos en batch (una POST por archivo vía iframe, sin re-navegación).
2376
- console.log(` → Cargando ${pdfPaths.length} PDFs de una vez en el input...`);
2377
2434
  const fileInput = await page.$('input.gwt-FileUpload');
2378
2435
  if (!fileInput) throw new Error('pdfdteInternet: input.gwt-FileUpload no encontrado');
2379
2436
 
2380
- // Forzar múltiple selección y quitar restrict de accept (solo .PDF uppercase falla en algunos OS)
2381
- await fileInput.evaluate(el => {
2382
- el.setAttribute('multiple', '');
2383
- el.removeAttribute('accept');
2384
- });
2437
+ // ── Obtener nroRevision y subir todos los archivos vía fetch() ──────────────────
2438
+ // ── Primer upload para obtener nroRevision ─────────────────────────────────────
2439
+ // Puppeteer 22+ usa waitForFileChooser() para file uploads.
2440
+ // Triggear el input via click + page.waitForFileChooser() es el API nativo.
2441
+ // GWT procesa el onChange, somete el FormPanel a /upload con id de revisión.
2442
+ // Capturamos nroRevision desde la respuesta del servidor.
2443
+ // ────────────────────────────────────────────────────────────────────────────
2444
+
2445
+ let nroRevision = null;
2446
+
2447
+ // Listener de respuesta para capturar nroRevision del primer upload GWT
2448
+ const _onFirstUploadResp = async (resp) => {
2449
+ if (resp.url().includes('/pdfdteInternet/upload')) {
2450
+ const txt = await resp.text().catch(() => '');
2451
+ const m = txt.trim().match(/^(\d+),(\d+)$/);
2452
+ if (m && !nroRevision) {
2453
+ nroRevision = m[2];
2454
+ console.log(` ✓ ID de revisión obtenido: ${nroRevision} (1/${pdfPaths.length} subido)`);
2455
+ }
2456
+ }
2457
+ };
2458
+ page.on('response', _onFirstUploadResp);
2385
2459
 
2386
- await fileInput.uploadFile(...pdfPaths);
2387
- console.log(` → ${pdfPaths.length} PDFs seleccionados, esperando que GWT encole los uploads...`);
2460
+ console.log(` → Disparando primer upload vía fileChooser para obtener ID de revisión...`);
2461
+ try {
2462
+ const _chooserPromise = page.waitForFileChooser({ timeout: 15000 });
2463
+ // Triggear el file input: click en el wrapper visible O directamente en el input
2464
+ await page.evaluate(() => {
2465
+ // a) intentar click en el botón visible (wrapper div con overflow: hidden)
2466
+ const wrapper = document.querySelector('div[style*="position: relative"][style*="overflow: hidden"] div.gwt-Label');
2467
+ if (wrapper) { wrapper.click(); return; }
2468
+ // b) click directo en el input oculto (funciona en headless Chrome)
2469
+ const inp = document.querySelector('input.gwt-FileUpload');
2470
+ if (inp) inp.click();
2471
+ });
2472
+ const _chooser = await _chooserPromise;
2473
+ await _chooser.accept([path.resolve(pdfPaths[0])]);
2474
+ console.log(` [DEBUG] fileChooser.accept → OK (${path.basename(pdfPaths[0])})`);
2475
+ } catch (e) {
2476
+ console.warn(` [!] waitForFileChooser falló (${e.message}), intentando CDP DOM.setFileInputFiles...`);
2477
+ // Fallback: CDP DOM.setFileInputFiles
2478
+ const _cdp = await page.createCDPSession();
2479
+ try {
2480
+ const { root } = await _cdp.send('DOM.getDocument', { depth: 0 });
2481
+ const { nodeId } = await _cdp.send('DOM.querySelector', {
2482
+ nodeId: root.nodeId, selector: 'input.gwt-FileUpload',
2483
+ });
2484
+ if (nodeId) {
2485
+ await _cdp.send('DOM.setFileInputFiles', { files: [path.resolve(pdfPaths[0])], nodeId });
2486
+ console.log(` [DEBUG] CDP setFileInputFiles → OK`);
2487
+ }
2488
+ } catch (cdpErr) {
2489
+ console.warn(` [!] CDP también falló: ${cdpErr.message}`);
2490
+ } finally {
2491
+ await _cdp.detach().catch(() => {});
2492
+ }
2493
+ }
2388
2494
 
2389
- // GWT necesita un pequeño tick antes de comenzar a procesar la cola
2390
- await new Promise(r => setTimeout(r, 1500));
2391
- if (debugDir) await page.screenshot({ path: path.join(debugDir, 'pdfte-04b-despues-upload-file.png'), fullPage: true }).catch(() => {});
2495
+ // Esperar hasta 60s para que el FormPanel iframe complete el upload
2496
+ const _t0 = Date.now();
2497
+ while (!nroRevision && Date.now() - _t0 < 60000) {
2498
+ await new Promise(r => setTimeout(r, 500));
2499
+ }
2500
+ page.off('response', _onFirstUploadResp);
2392
2501
 
2393
- // ── Fase 1: esperar hasta 45s por primera señal de progreso o estado terminal ──
2394
- // Si el portal ya está en "POR REVISAR" (re-ejecución), lo detectamos aquí inmediatamente.
2395
- // Si el drop inició normalmente, "Procesados 1" aparece en pocos segundos.
2396
- await page.waitForFunction(() => {
2397
- for (const el of document.querySelectorAll('.x-progress-text')) {
2398
- const m = el.textContent.match(/Procesados\s+(\d+)/);
2399
- if (m && +m[1] > 0) return true;
2400
- }
2401
- const t = (document.body.textContent || '').toUpperCase();
2402
- if (t.includes('APROBADO') || t.includes('POR REVISAR') || t.includes('EN REVISI') || t.includes('RECHAZADO')) return true;
2403
- return false;
2404
- }, { timeout: 45000, polling: 1000 }).catch(() => {});
2405
-
2406
- // Leer estado real tras la fase 1
2407
- const _fase1 = await page.evaluate(() => {
2408
- let procesados = 0;
2409
- for (const el of document.querySelectorAll('.x-progress-text')) {
2410
- const m = el.textContent.match(/Procesados\s+(\d+)/);
2411
- if (m) { procesados = +m[1]; break; }
2502
+ if (!nroRevision) {
2503
+ if (debugDir) {
2504
+ await page.screenshot({ path: path.join(debugDir, 'pdfte-error-no-revid.png'), fullPage: true }).catch(() => {});
2505
+ fs.writeFileSync(path.join(debugDir, 'pdfte-error-no-revid.html'), await page.content(), 'utf8');
2412
2506
  }
2413
- const t = (document.body.textContent || '').toUpperCase();
2414
- let estado = null;
2415
- if (t.includes('APROBADO')) estado = 'APROBADO';
2416
- else if (t.includes('POR REVISAR')) estado = 'POR REVISAR';
2417
- else if (t.includes('EN REVISI')) estado = 'EN REVISIÓN';
2418
- else if (t.includes('RECHAZADO')) estado = 'RECHAZADO';
2419
- return { procesados, estado };
2420
- }).catch(() => ({ procesados: 0, estado: null }));
2421
-
2422
- if (_fase1.estado) {
2423
- console.log(` [OK] Portal en estado "${_fase1.estado}" — muestras ya procesadas previamente.`);
2424
- return { success: true, alreadyCompleted: true, estado: _fase1.estado };
2507
+ throw new Error('pdfdteInternet: no se pudo obtener ID de revisión. El file chooser o CDP no disparó el handler GWT o el servidor rechazó la petición.');
2425
2508
  }
2426
2509
 
2427
- if (_fase1.procesados === 0) {
2428
- // Sin progreso y sin estado terminal: el portal puede no estar procesando
2429
- console.warn(' [!] Sin progreso en 45s y sin estado terminal. Continuando al paso siguiente...');
2430
- } else {
2431
- // ── Fase 2: progreso iniciado — esperar al total ──
2432
- await page.waitForFunction((total) => {
2433
- for (const el of document.querySelectorAll('.x-progress-text')) {
2434
- const m = el.textContent.match(/Procesados\s+(\d+)/);
2435
- if (m && +m[1] >= total) return true;
2510
+ if (debugDir) await page.screenshot({ path: path.join(debugDir, 'pdfte-04b-primer-upload.png'), fullPage: true }).catch(() => {});
2511
+
2512
+ // ── Subir archivos restantes en paralelo vía fetch directo ──
2513
+ const CONCURRENCIA = 5;
2514
+ const _remaining = pdfPaths.slice(1);
2515
+ let _procesados = 1;
2516
+
2517
+ for (let i = 0; i < _remaining.length; i += CONCURRENCIA) {
2518
+ const _chunk = _remaining.slice(i, i + CONCURRENCIA);
2519
+ const _fileData = _chunk.map(p => ({
2520
+ name: path.basename(p),
2521
+ b64: fs.readFileSync(p).toString('base64'),
2522
+ }));
2523
+ const _responses = await page.evaluate(async (files, revId) => {
2524
+ return Promise.all(files.map(async ({ name, b64 }) => {
2525
+ const bytes = Uint8Array.from(atob(b64), c => c.charCodeAt(0));
2526
+ const blob = new Blob([bytes], { type: 'application/pdf' });
2527
+ const fd = new FormData();
2528
+ fd.append('Filedata', blob, name);
2529
+ fd.append('id', String(revId));
2530
+ fd.append('ambiente', 'false');
2531
+ const resp = await fetch('/pdfdteInternet/upload', { method: 'POST', body: fd });
2532
+ const text = await resp.text();
2533
+ return { name, ok: resp.ok, status: resp.status, text };
2534
+ }));
2535
+ }, _fileData, nroRevision);
2536
+
2537
+ _procesados += _chunk.length;
2538
+ console.log(` → Procesados ${_procesados}/${pdfPaths.length}`);
2539
+ for (const r of _responses) {
2540
+ if (!r.ok || !r.text.match(/^\d+,\d+$/)) {
2541
+ console.warn(` [!] ${r.name}: HTTP ${r.status} → ${r.text.substring(0, 120)}`);
2436
2542
  }
2437
- return false;
2438
- }, { timeout: pdfPaths.length * 15000, polling: 1000 }, pdfPaths.length).catch(async () => {
2439
- const procesados = await page.evaluate(() => {
2440
- for (const el of document.querySelectorAll('.x-progress-text')) {
2441
- const m = el.textContent.match(/Procesados\s+(\d+)/);
2442
- if (m) return +m[1];
2443
- }
2444
- return 0;
2445
- }).catch(() => 0);
2446
- console.warn(` [!] Timeout: solo se procesaron ${procesados}/${pdfPaths.length} antes del timeout`);
2447
- });
2543
+ }
2448
2544
  }
2449
2545
 
2450
- // Esperar que todos los requests de leeImpresoById (validación) terminen
2546
+ console.log(` ${pdfPaths.length} PDFs subidos al portal`);
2547
+
2548
+ // Esperar que las validaciones del portal (leeImpresoById) terminen
2451
2549
  await page.waitForNetworkIdle({ timeout: 60000, idleTime: 2000 }).catch(() => {});
2452
2550
  await new Promise(r => setTimeout(r, 2000));
2453
2551
 
@@ -2466,9 +2564,53 @@ class CertRunner {
2466
2564
 
2467
2565
  if (debugDir) await page.screenshot({ path: path.join(debugDir, 'pdfte-05b-antes-enviar.png'), fullPage: true }).catch(() => {});
2468
2566
 
2567
+ // Debug: listar todos los botones visibles y sus estados
2568
+ const _btnsDebug = await page.evaluate(() =>
2569
+ Array.from(document.querySelectorAll('button.x-btn-text')).map(b => ({
2570
+ text: b.textContent.trim(),
2571
+ disabled: b.disabled,
2572
+ aria: b.getAttribute('aria-disabled'),
2573
+ visible: b.offsetParent !== null,
2574
+ }))
2575
+ ).catch(() => []);
2576
+ console.log(` [DEBUG] Botones en página: ${JSON.stringify(_btnsDebug.filter(b => b.text))}`);
2577
+
2469
2578
  console.log(' → Click "Enviar al SII"...');
2470
- const enviado = await clickBoton(page, 'Enviar al SII');
2471
- if (!enviado) throw new Error('pdfdteInternet: botón "Enviar al SII" no disponible o deshabilitado');
2579
+
2580
+ // Intentar click en el botón directo (ignorar aria-disabled en esta etapa)
2581
+ const _clickedEnviar = await page.evaluate(() => {
2582
+ // a) Buscar button.x-btn-text exacto
2583
+ for (const btn of document.querySelectorAll('button.x-btn-text')) {
2584
+ if (btn.textContent.trim() === 'Enviar al SII') {
2585
+ btn.click();
2586
+ return 'direct';
2587
+ }
2588
+ }
2589
+ // b) Abrir overflow ">" y buscar en el menú desplegable
2590
+ const overflow = document.querySelector('td.x-toolbar-overflow-region, .x-toolbar-more-icon, button[class*="overflow"]');
2591
+ if (overflow) {
2592
+ overflow.click();
2593
+ return 'overflow-click';
2594
+ }
2595
+ return false;
2596
+ });
2597
+
2598
+ if (_clickedEnviar === 'overflow-click') {
2599
+ // Esperar que aparezca el menú y hacer click en "Enviar al SII"
2600
+ await new Promise(r => setTimeout(r, 500));
2601
+ const _menuClicked = await page.evaluate(() => {
2602
+ for (const el of document.querySelectorAll('.x-menu-item-text')) {
2603
+ if (el.textContent.trim() === 'Enviar al SII') {
2604
+ el.click();
2605
+ return true;
2606
+ }
2607
+ }
2608
+ return false;
2609
+ });
2610
+ if (!_menuClicked) throw new Error('pdfdteInternet: botón "Enviar al SII" no encontrado en overflow menu');
2611
+ } else if (!_clickedEnviar) {
2612
+ throw new Error('pdfdteInternet: botón "Enviar al SII" no disponible o deshabilitado');
2613
+ }
2472
2614
 
2473
2615
  await page.waitForNetworkIdle({ timeout: 30000, idleTime: 1000 }).catch(() => {});
2474
2616
  await new Promise(r => setTimeout(r, 3000));
@@ -2478,14 +2620,26 @@ class CertRunner {
2478
2620
  }
2479
2621
 
2480
2622
  const pageText = await page.$eval('body', el => el.textContent).catch(() => '');
2481
- const exitoso = /revision.*creada|solicitud.*enviada|documentos.*enviados|fue.*enviado|[eé]xito/i.test(pageText);
2623
+ const exitoso = /revision.*creada|solicitud.*enviada|documentos.*enviados|fue.*enviado|[eé]xito|por revisar/i.test(pageText);
2482
2624
  console.log(` → Resultado: ${exitoso ? '[OK] enviado correctamente' : '[!] sin confirmación explícita'}`);
2625
+ // Imprimir el marcador de éxito ANTES de cerrar el browser para que quede en stdout
2626
+ // aunque browser.close() cuelgue y el proceso sea forzado a terminar.
2627
+ if (exitoso) process.stdout.write('\nMUESTRAS SUBIDAS EXITOSAMENTE\n');
2483
2628
  return { success: exitoso, pageText: pageText.substring(0, 800) };
2484
2629
 
2485
2630
  } catch (err) {
2486
2631
  return { success: false, error: err.message };
2487
2632
  } finally {
2488
- if (browser) await browser.close().catch(() => {});
2633
+ if (browser) {
2634
+ // browser.close() puede colgarse indefinidamente si Chromium no responde.
2635
+ // Limitar a 8 segundos; si no cierra, matar el proceso del browser directamente.
2636
+ await Promise.race([
2637
+ browser.close().catch(() => {}),
2638
+ new Promise(r => setTimeout(r, 8000)),
2639
+ ]);
2640
+ // Forzar cierre del proceso de Chromium si quedó colgado
2641
+ try { browser.process()?.kill('SIGKILL'); } catch { /* ignorar */ }
2642
+ }
2489
2643
  }
2490
2644
  }
2491
2645
 
@@ -2515,25 +2669,37 @@ class CertRunner {
2515
2669
 
2516
2670
  let browser;
2517
2671
  try {
2518
- browser = await puppeteer.launch({
2519
- headless: true,
2520
- ignoreHTTPSErrors: true,
2521
- args: ['--no-sandbox', '--disable-setuid-sandbox', '--ignore-certificate-errors'],
2522
- });
2672
+ browser = await puppeteer.launch(this._getPuppeteerLaunchOptions());
2523
2673
  const page = await browser.newPage();
2524
2674
  await page.setCookie(...puppeteerCookies);
2525
2675
  page.on('dialog', async dlg => { console.log(` → [dialog SET=1] ${dlg.message()}`); await dlg.accept(); });
2526
2676
 
2677
+ // Helper debug: guarda screenshot + HTML con timestamp
2678
+ const dbgDirSet1 = this.config.debugDir ? path.join(this.config.debugDir, 'boleta-set1') : null;
2679
+ if (dbgDirSet1) fs.mkdirSync(dbgDirSet1, { recursive: true });
2680
+ const saveDebugSet1 = async (label) => {
2681
+ if (!dbgDirSet1) return;
2682
+ const ts = new Date().toISOString().replace(/[:.]/g, '-');
2683
+ const base = path.join(dbgDirSet1, `${label}-${ts}`);
2684
+ try { await page.screenshot({ path: `${base}.png`, fullPage: true }); } catch {}
2685
+ try { fs.writeFileSync(`${base}.html`, await page.content(), 'utf-8'); } catch {}
2686
+ console.log(` [debug] Captura guardada: ${base}.png`);
2687
+ };
2688
+
2527
2689
  console.log(' → Cargando portal certBolElectDteInternet (SET=1)...');
2528
2690
  await page.goto('https://www4.sii.cl/certBolElectDteInternet/?SET=1', {
2529
2691
  waitUntil: 'networkidle2', timeout: 60000,
2530
2692
  });
2531
2693
  await new Promise(r => setTimeout(r, 2000));
2694
+ await saveDebugSet1('01-carga');
2532
2695
 
2533
2696
  // Paso 1: RUT empresa → "Confirmar Empresa"
2534
- const rutInput = await page.$('input[maxlength="8"]');
2535
- const dvInput = await page.$('input[maxlength="1"]');
2536
- if (!rutInput) throw new Error('certBolElectDteInternet/?SET=1: no se encontró campo RUT');
2697
+ const rutInput = await page.waitForSelector('input[maxlength="8"]', { timeout: 15000 }).catch(() => null);
2698
+ const dvInput = await page.waitForSelector('input[maxlength="1"]', { timeout: 5000 }).catch(() => null);
2699
+ if (!rutInput) {
2700
+ await saveDebugSet1('ERROR-norut');
2701
+ throw new Error('certBolElectDteInternet/?SET=1: no se encontró campo RUT');
2702
+ }
2537
2703
  console.log(` → Ingresando RUT empresa: ${rutNum}-${dvChar}`);
2538
2704
  await rutInput.click({ clickCount: 3 }); await rutInput.type(rutNum);
2539
2705
  await dvInput.click({ clickCount: 3 }); await dvInput.type(dvChar);
@@ -2550,6 +2716,7 @@ class CertRunner {
2550
2716
  return document.querySelector('input[type="checkbox"]') !== null;
2551
2717
  }, { timeout: 40000, polling: 500 }).catch(() => {});
2552
2718
  await new Promise(r => setTimeout(r, 500));
2719
+ await saveDebugSet1('02-post-confirm');
2553
2720
 
2554
2721
  // Paso 2: Marcar todos los checkboxes
2555
2722
  const nCbs = await page.evaluate(() => {
@@ -2636,9 +2803,9 @@ class CertRunner {
2636
2803
  });
2637
2804
 
2638
2805
  if (!setText || setText.trim().length < 10) {
2639
- if (this.config.debugDir) {
2640
- fs.mkdirSync(this.config.debugDir, { recursive: true });
2641
- fs.writeFileSync(path.join(this.config.debugDir, 'boleta-set-debug.txt'), setText || '', 'utf-8');
2806
+ if (dbgDirSet1) {
2807
+ fs.writeFileSync(path.join(dbgDirSet1, 'boleta-set-contenido-vacio.txt'), setText || '', 'utf-8');
2808
+ await saveDebugSet1('ERROR-descarga-vacia');
2642
2809
  }
2643
2810
  throw new Error(`DownloadFileServlet devolvió contenido vacío (${setText?.length ?? 0} chars). Verificar cookies.`);
2644
2811
  }
@@ -2653,6 +2820,15 @@ class CertRunner {
2653
2820
  console.log(` ✓ Set de pruebas obtenido (${setText.length} chars)`);
2654
2821
  return { success: true, setText };
2655
2822
  } catch (err) {
2823
+ try {
2824
+ if (dbgDirSet1) {
2825
+ const ts = new Date().toISOString().replace(/[:.]/g, '-');
2826
+ const base = path.join(dbgDirSet1, `ERROR-catch-${ts}`);
2827
+ await page.screenshot({ path: `${base}.png`, fullPage: true }).catch(() => {});
2828
+ fs.writeFileSync(`${base}.html`, await page.content().catch(() => ''), 'utf-8');
2829
+ console.log(` [debug] Captura de error guardada: ${base}.png`);
2830
+ }
2831
+ } catch {}
2656
2832
  return { success: false, error: err.message };
2657
2833
  } finally {
2658
2834
  if (browser) await browser.close().catch(() => {});
@@ -2681,20 +2857,27 @@ class CertRunner {
2681
2857
 
2682
2858
  let browser;
2683
2859
  try {
2684
- browser = await puppeteer.launch({
2685
- headless: true,
2686
- ignoreHTTPSErrors: true,
2687
- args: ['--no-sandbox', '--disable-setuid-sandbox', '--ignore-certificate-errors'],
2688
- });
2860
+ browser = await puppeteer.launch(this._getPuppeteerLaunchOptions());
2689
2861
  const page = await browser.newPage();
2690
2862
  await page.setCookie(...puppeteerCookies);
2691
2863
  page.on('dialog', async dlg => { console.log(` → [dialog SET=2] ${dlg.message()}`); await dlg.accept(); });
2692
2864
 
2865
+ const debugSet2Dir = this.config.debugDir ? require('path').join(this.config.debugDir, 'boleta-set2') : null;
2866
+ if (debugSet2Dir) require('fs').mkdirSync(debugSet2Dir, { recursive: true });
2867
+ const snapSet2 = async (nombre) => {
2868
+ if (!debugSet2Dir) return;
2869
+ const ts = new Date().toISOString().replace(/[:.]/g, '-');
2870
+ const p = require('path').join(debugSet2Dir, `${nombre}-${ts}.png`);
2871
+ await page.screenshot({ path: p, fullPage: true }).catch(() => {});
2872
+ console.log(` [debug] Captura guardada: ${p}`);
2873
+ };
2874
+
2693
2875
  console.log(' → Cargando portal certBolElectDteInternet (SET=2)...');
2694
2876
  await page.goto('https://www4.sii.cl/certBolElectDteInternet/?SET=2', {
2695
2877
  waitUntil: 'networkidle2', timeout: 60000,
2696
2878
  });
2697
2879
  await new Promise(r => setTimeout(r, 2000));
2880
+ await snapSet2('01-carga');
2698
2881
 
2699
2882
  // Paso 1: RUT empresa → "Confirmar Empresa"
2700
2883
  const rutInput = await page.$('input[maxlength="8"]');
@@ -2716,10 +2899,15 @@ class CertRunner {
2716
2899
  return document.querySelector('input[maxlength="15"]') !== null;
2717
2900
  }, { timeout: 40000, polling: 500 }).catch(() => {});
2718
2901
  await new Promise(r => setTimeout(r, 500));
2902
+ await snapSet2('02-post-confirm');
2719
2903
 
2720
2904
  // Paso 2: Ingresar TrackId
2721
2905
  const trackInput = await page.$('input[maxlength="15"]');
2722
- if (!trackInput) throw new Error('No se encontró campo "Identificador de Envio" en certBolElectDteInternet/?SET=2');
2906
+ if (!trackInput) {
2907
+ const pageText = await page.evaluate(() => (document.body.innerText || '').trim().substring(0, 600));
2908
+ console.log(` [!] Texto del portal al fallar:\n${pageText}`);
2909
+ throw new Error('No se encontró campo "Identificador de Envio" en certBolElectDteInternet/?SET=2');
2910
+ }
2723
2911
  console.log(` → Ingresando TrackId: ${trackId}`);
2724
2912
  await trackInput.click({ clickCount: 3 });
2725
2913
  await trackInput.type(String(trackId));
@@ -2738,6 +2926,7 @@ class CertRunner {
2738
2926
  return t.includes('ENVI') || t.includes('CORREO') || t.includes('ERROR') ||
2739
2927
  t.includes('VALIDACI') || t.includes('SOLICITUD');
2740
2928
  }, { timeout: 15000, polling: 500 }).catch(() => {});
2929
+ await snapSet2('03-respuesta');
2741
2930
 
2742
2931
  const respuesta = await page.evaluate(() => (document.body.innerText || '').trim().substring(0, 500));
2743
2932
  console.log(` ✓ Validación solicitada. Respuesta: ${respuesta.substring(0, 120)}`);
@@ -2780,11 +2969,7 @@ class CertRunner {
2780
2969
 
2781
2970
  let browser;
2782
2971
  try {
2783
- browser = await puppeteer.launch({
2784
- headless: true,
2785
- ignoreHTTPSErrors: true,
2786
- args: ['--no-sandbox', '--disable-setuid-sandbox', '--ignore-certificate-errors'],
2787
- });
2972
+ browser = await puppeteer.launch(this._getPuppeteerLaunchOptions());
2788
2973
  const page = await browser.newPage();
2789
2974
  await page.setCookie(...puppeteerCookies);
2790
2975
 
@@ -2796,6 +2981,18 @@ class CertRunner {
2796
2981
  await dlg.accept();
2797
2982
  });
2798
2983
 
2984
+ // Helper debug: guarda screenshot + HTML con timestamp
2985
+ const dbgDirDecl = this.config.debugDir ? path.join(this.config.debugDir, 'boleta-declaracion') : null;
2986
+ if (dbgDirDecl) fs.mkdirSync(dbgDirDecl, { recursive: true });
2987
+ const saveDebugDecl = async (label) => {
2988
+ if (!dbgDirDecl) return;
2989
+ const ts = new Date().toISOString().replace(/[:.]/g, '-');
2990
+ const base = path.join(dbgDirDecl, `${label}-${ts}`);
2991
+ try { await page.screenshot({ path: `${base}.png`, fullPage: true }); } catch {}
2992
+ try { fs.writeFileSync(`${base}.html`, await page.content(), 'utf-8'); } catch {}
2993
+ console.log(` [debug] Captura guardada: ${base}.png`);
2994
+ };
2995
+
2799
2996
  // ── PASOS 1+2: Confirmar Empresa → esperar formulario (con retry) ──
2800
2997
  // El portal GWT a veces responde con error transitorio ("empresa no autorizada")
2801
2998
  // que se resuelve recargando la página y reintentando.
@@ -2810,11 +3007,15 @@ class CertRunner {
2810
3007
  waitUntil: 'networkidle2', timeout: 60000,
2811
3008
  });
2812
3009
  await new Promise(r => setTimeout(r, 2500));
3010
+ await saveDebugDecl(`intento${intento}-01-carga`);
2813
3011
 
2814
3012
  // GWT requiere eventos de teclado reales — NO funciona con .value = ...
2815
- const rutInput = await page.$('input[maxlength="8"]');
2816
- const dvInput = await page.$('input[maxlength="1"]');
2817
- if (!rutInput) throw new Error('certBolElectDteInternet/: no se encontró campo RUT empresa');
3013
+ const rutInput = await page.waitForSelector('input[maxlength="8"]', { timeout: 15000 }).catch(() => null);
3014
+ const dvInput = await page.waitForSelector('input[maxlength="1"]', { timeout: 5000 }).catch(() => null);
3015
+ if (!rutInput) {
3016
+ await saveDebugDecl(`intento${intento}-ERROR-norut`);
3017
+ throw new Error('certBolElectDteInternet/: no se encontró campo RUT empresa');
3018
+ }
2818
3019
  console.log(` → Ingresando RUT empresa: ${rutEmpresaRaw}`);
2819
3020
  await rutInput.click({ clickCount: 3 }); await rutInput.type(rutEmpNum);
2820
3021
  await dvInput.click({ clickCount: 3 }); await dvInput.type(rutEmpDv);
@@ -2832,10 +3033,12 @@ class CertRunner {
2832
3033
  await page.waitForFunction(() => {
2833
3034
  return document.querySelector('input[type="checkbox"]') !== null;
2834
3035
  }, { timeout: 40000, polling: 500 }).catch(() => {});
3036
+ await saveDebugDecl(`intento${intento}-02-post-confirm`);
2835
3037
 
2836
3038
  if (dialogMsg) {
2837
3039
  // Portal lanzó alert — puede ser transitorio. Guardar y reintentar.
2838
3040
  lastDialogMsg = dialogMsg;
3041
+ await saveDebugDecl(`intento${intento}-DIALOG`);
2839
3042
  console.log(` [!] Portal respondió con alerta en intento ${intento}: ${dialogMsg.substring(0, 100)}`);
2840
3043
  if (intento < MAX_INTENTOS) {
2841
3044
  console.log(' → Recargando y reintentando en 3s...');
@@ -2843,6 +3046,7 @@ class CertRunner {
2843
3046
  continue;
2844
3047
  }
2845
3048
  // Agotados los intentos con alerta — el SII puede requerir esperar SOK
3049
+ await saveDebugDecl('PENDINGSOK-final');
2846
3050
  return { success: false, pendingSok: true, error: lastDialogMsg };
2847
3051
  }
2848
3052
 
@@ -2923,9 +3127,7 @@ class CertRunner {
2923
3127
  await fillByLabel('Correo electrónico Proveedor', correoProveedor);
2924
3128
 
2925
3129
  // Captura pre-submit
2926
- if (this.config.debugDir) {
2927
- await page.screenshot({ path: path.join(this.config.debugDir, 'boleta-declaracion-pre-submit.png'), fullPage: true }).catch(() => {});
2928
- }
3130
+ await saveDebugDecl('03-pre-submit');
2929
3131
 
2930
3132
  // ── PASO 4: Click "Grabar Declaración" ───────────────────────
2931
3133
  const submitOk = await page.evaluate(() => {
@@ -2934,7 +3136,10 @@ class CertRunner {
2934
3136
  if (btn) { btn.click(); return true; }
2935
3137
  return false;
2936
3138
  });
2937
- if (!submitOk) throw new Error('No se encontró botón "Grabar Declaración" en certBolElectDteInternet/');
3139
+ if (!submitOk) {
3140
+ await saveDebugDecl('ERROR-no-boton-grabar');
3141
+ throw new Error('No se encontró botón "Grabar Declaración" en certBolElectDteInternet/');
3142
+ }
2938
3143
  console.log(' → Click "Grabar Declaración"...');
2939
3144
 
2940
3145
  // Esperar confirmación del SII
@@ -2946,23 +3151,154 @@ class CertRunner {
2946
3151
 
2947
3152
  const msgFinal = await page.evaluate(() => (document.body.textContent || '').trim().substring(0, 300));
2948
3153
  console.log(` ✓ Declaración completada. Respuesta: ${msgFinal.substring(0, 150)}`);
2949
-
2950
- if (this.config.debugDir) {
2951
- await page.screenshot({ path: path.join(this.config.debugDir, 'boleta-declaracion-post-submit.png'), fullPage: true }).catch(() => {});
2952
- }
3154
+ await saveDebugDecl('04-post-submit-OK');
2953
3155
 
2954
3156
  return { success: true, mensaje: msgFinal };
2955
3157
  } catch (err) {
3158
+ // Guardar captura del estado de error si page sigue abierta
3159
+ try {
3160
+ if (dbgDirDecl) {
3161
+ const ts = new Date().toISOString().replace(/[:.]/g, '-');
3162
+ const base = path.join(dbgDirDecl, `ERROR-catch-${ts}`);
3163
+ await page.screenshot({ path: `${base}.png`, fullPage: true }).catch(() => {});
3164
+ fs.writeFileSync(`${base}.html`, await page.content().catch(() => ''), 'utf-8');
3165
+ console.log(` [debug] Captura de error guardada: ${base}.png`);
3166
+ }
3167
+ } catch {}
2956
3168
  return { success: false, error: err.message };
2957
3169
  } finally {
2958
3170
  if (browser) await browser.close().catch(() => {});
2959
3171
  }
2960
3172
  }
2961
3173
 
3174
+ /**
3175
+ * Verifica si la empresa ya está autorizada para emitir Boletas Electrónicas tipo 39
3176
+ * en PRODUCCIÓN (palena.sii.cl). Si el tipo 39 aparece en el select de
3177
+ * of_solicita_folios_dcto significa que el SII ya certificó la empresa.
3178
+ *
3179
+ * También consulta el avance en certificación (maullin) como dato adicional.
3180
+ *
3181
+ * @returns {Promise<{
3182
+ * autorizadaProduccion: boolean,
3183
+ * autorizadaCertificacion: boolean,
3184
+ * mensaje: string,
3185
+ * tiposDisponiblesProduccion: number[],
3186
+ * estadoCertificacion: string|null
3187
+ * }>}
3188
+ */
3189
+ async verificarAutorizacionBoleta() {
3190
+ const https = require('https');
3191
+ const cookieJar = await this._obtenerCookiesSII();
3192
+ const cookieStr = Object.entries(cookieJar).map(([k, v]) => `${k}=${v}`).join('; ');
3193
+
3194
+ const fetchHtml = (url) => new Promise((resolve, reject) => {
3195
+ const req = https.get(url, {
3196
+ headers: {
3197
+ 'Cookie': cookieStr,
3198
+ 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)',
3199
+ 'Accept': 'text/html,*/*',
3200
+ },
3201
+ rejectUnauthorized: false,
3202
+ }, (res) => {
3203
+ const chunks = [];
3204
+ res.on('data', c => chunks.push(c));
3205
+ res.on('end', () => resolve(Buffer.concat(chunks).toString('utf-8')));
3206
+ });
3207
+ req.on('error', reject);
3208
+ req.setTimeout(20000, () => { req.destroy(); reject(new Error('Timeout')); });
3209
+ });
3210
+
3211
+ const result = {
3212
+ autorizadaProduccion: false,
3213
+ autorizadaCertificacion: false,
3214
+ mensaje: '',
3215
+ tiposDisponiblesProduccion: [],
3216
+ estadoCertificacion: null,
3217
+ };
3218
+
3219
+ // ── 1. Verificar producción: of_solicita_folios_dcto en palena ─────────
3220
+ try {
3221
+ const htmlProd = await fetchHtml('https://palena.sii.cl/cvc_cgi/dte/of_solicita_folios_dcto');
3222
+ if (this.config.debugDir) {
3223
+ const ts = new Date().toISOString().replace(/[:.]/g, '-');
3224
+ fs.writeFileSync(
3225
+ path.join(this.config.debugDir, `verificar-boleta-produccion-${ts}.html`),
3226
+ htmlProd, 'utf-8'
3227
+ );
3228
+ }
3229
+ const matches = [...htmlProd.matchAll(/<option[^>]+value="(\d+)"[^>]*>/gi)];
3230
+ result.tiposDisponiblesProduccion = matches.map(m => parseInt(m[1], 10)).filter(n => n > 0);
3231
+ result.autorizadaProduccion = result.tiposDisponiblesProduccion.includes(39);
3232
+ console.log(` ✓ Producción — tipos disponibles: [${result.tiposDisponiblesProduccion.join(', ')}] | Boleta 39: ${result.autorizadaProduccion ? '✅ SÍ' : '❌ NO'}`);
3233
+ } catch (e) {
3234
+ console.log(` [!] No se pudo verificar producción: ${e.message}`);
3235
+ }
3236
+
3237
+ // ── 2. Verificar certificación: pe_avance6 en maullin ─────────────────
3238
+ try {
3239
+ const htmlCert = await fetchHtml('https://maullin.sii.cl/cvc_cgi/dte/pe_avance6');
3240
+ if (this.config.debugDir) {
3241
+ const ts = new Date().toISOString().replace(/[:.]/g, '-');
3242
+ fs.writeFileSync(
3243
+ path.join(this.config.debugDir, `verificar-boleta-certificacion-${ts}.html`),
3244
+ htmlCert, 'utf-8'
3245
+ );
3246
+ }
3247
+ // Buscar fila de boleta en tabla de avance
3248
+ const boletaRow = htmlCert.match(/BOLETA[^<]*<\/[^>]+>\s*<[^>]+><b>([^<]+)<\/b>/i);
3249
+ if (boletaRow) {
3250
+ result.estadoCertificacion = boletaRow[1].trim();
3251
+ result.autorizadaCertificacion = /conform|aprobad|autoriz/i.test(result.estadoCertificacion);
3252
+ }
3253
+ if (!result.estadoCertificacion && /autorizada.*boleta|boleta.*autorizada/i.test(htmlCert)) {
3254
+ result.autorizadaCertificacion = true;
3255
+ result.estadoCertificacion = 'AUTORIZADA';
3256
+ }
3257
+ console.log(` ✓ Certificación — estado boleta: ${result.estadoCertificacion || '(no encontrado)'} | Autorizada: ${result.autorizadaCertificacion ? '✅ SÍ' : '❌ NO'}`);
3258
+ } catch (e) {
3259
+ console.log(` [!] No se pudo verificar certificación: ${e.message}`);
3260
+ }
3261
+
3262
+ result.mensaje = result.autorizadaProduccion
3263
+ ? '✅ Empresa autorizada para emitir Boleta Electrónica (tipo 39) en producción'
3264
+ : result.autorizadaCertificacion
3265
+ ? '⏳ Boleta certificada en SII pero aún no disponible en producción'
3266
+ : '❌ Boleta NO autorizada — certificación incompleta';
3267
+
3268
+ return result;
3269
+ }
3270
+
2962
3271
  // ═══════════════════════════════════════════════════════════════
2963
3272
  // Helpers privados
2964
3273
  // ═══════════════════════════════════════════════════════════════
2965
3274
 
3275
+ /**
3276
+ * Opciones base para puppeteer.launch.
3277
+ * Usa Chrome del sistema (executablePath) para evitar crashes del Chromium
3278
+ * bundled en Windows (0xC0000409). Respeta this.config.puppeteerHeadless.
3279
+ */
3280
+ _getPuppeteerLaunchOptions(extraArgs = [], extraOpts = {}) {
3281
+ const fs = require('fs');
3282
+ const chromePaths = [
3283
+ 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe',
3284
+ 'C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe',
3285
+ process.env.LOCALAPPDATA ? process.env.LOCALAPPDATA + '\\Google\\Chrome\\Application\\chrome.exe' : null,
3286
+ ].filter(Boolean);
3287
+ const systemChrome = chromePaths.find(p => fs.existsSync(p));
3288
+
3289
+ const opts = {
3290
+ headless: this.config.puppeteerHeadless !== false,
3291
+ ignoreHTTPSErrors: true,
3292
+ args: ['--no-sandbox', '--disable-setuid-sandbox', '--ignore-certificate-errors', ...extraArgs],
3293
+ ...extraOpts,
3294
+ };
3295
+ if (systemChrome) {
3296
+ opts.executablePath = systemChrome;
3297
+ console.log(` [browser] Usando Chrome del sistema: ${systemChrome}`);
3298
+ }
3299
+ return opts;
3300
+ }
3301
+
2966
3302
  _getFechaHoy() {
2967
3303
  const now = new Date();
2968
3304
  return `${String(now.getDate()).padStart(2, '0')}-${String(now.getMonth() + 1).padStart(2, '0')}-${now.getFullYear()}`;
@@ -54,7 +54,7 @@ function loadConfig(options = {}) {
54
54
  console.log(`[ConfigLoader] Resolución: NroResol=${nro_resol} FchResol=${fch_resol} (AMBIENTE=${AMBIENTE})`);
55
55
 
56
56
  const EMISOR = {
57
- rut: process.env.EMISOR_RUT,
57
+ rut: process.env.EMISOR_RUT || process.env.EMISOR_RUT_EMPRESA,
58
58
  razon_social: process.env.EMISOR_RAZON_SOCIAL,
59
59
  giro: process.env.EMISOR_GIRO || 'ACTIVIDADES DE PROGRAMACION INFORMATICA',
60
60
  acteco: process.env.EMISOR_ACTECO || '620200',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@devlas/dte-sii",
3
- "version": "2.5.15",
3
+ "version": "2.6.0",
4
4
  "description": "Facturación y boletas electrónicas para el SII de Chile. Genera, timbra, firma y envía DTEs, libros electrónicos y automatiza la certificación.",
5
5
  "main": "index.js",
6
6
  "types": "dte-sii.d.ts",
package/utils/progress.js CHANGED
@@ -35,6 +35,7 @@ const STEPS = {
35
35
  BOOK_ERROR: 'BOOK_ERROR', // data: { book, error }
36
36
  BOOKS_DECLARING: 'BOOKS_DECLARING',
37
37
  BOOKS_DONE: 'BOOKS_DONE',
38
+ BOOK_PERIOD_RETRY: 'BOOK_PERIOD_RETRY', // data: { periodo, intento }
38
39
  // Avance (Fase 5)
39
40
  ADVANCE_WAITING: 'ADVANCE_WAITING',
40
41
  ADVANCE_DONE: 'ADVANCE_DONE',