@devlas/dte-sii 2.9.8 → 2.11.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.
@@ -1,4 +1,4 @@
1
- // Copyright (c) 2026 Devlas SpA — https://devlas.cl
1
+ // Copyright (c) 2026 Devlas SpA — https://devlas.cl
2
2
  // Licencia MIT. Ver archivo LICENSE para mas detalles.
3
3
  /**
4
4
  * CertRunner - Orquestador del proceso de certificación SII
@@ -399,8 +399,19 @@ class CertRunner {
399
399
  if (noProcessedError && intento < maxIntentos) {
400
400
  console.log(` [...] SII aún procesando, reintentando en ${intervalo / 1000}s...`);
401
401
  await sleep(intervalo);
402
+ } else if (result.conErrores) {
403
+ // SII reporta errores de contenido.
404
+ // Si verificado === false (campos vacíos), los TrackIds aún no están en el portal → reintentar.
405
+ // Si los campos sí aparecieron, es un error real de contenido → no reintentar.
406
+ if (result.verificado === false && intento < maxIntentos) {
407
+ console.log(` [!] ENVIO CON ERRORES pero campos vacíos — TrackIds aún no disponibles. Reintentando en ${intervalo / 1000}s...`);
408
+ await sleep(intervalo);
409
+ } else {
410
+ console.log(` [ERR] Contenido rechazado por SII (ENVIO CON ERRORES O REPAROS): ${(result.nombresConError || []).join(', ')}`);
411
+ break;
412
+ }
402
413
  } else if (result.allRejected) {
403
- // SII rechazó todos los sets/libros — puede ser período incorrecto (libros) o TrackID no procesado aún (simulación)
414
+ // SII rechazó todos los sets/libros — período incorrecto, no tiene sentido reintentar
404
415
  if (retryOnAllRejected && intento < maxIntentos) {
405
416
  console.log(` [!] S21 — SII aún procesando TrackID, reintentando en ${intervalo / 1000}s...`);
406
417
  await sleep(intervalo);
@@ -517,10 +528,97 @@ class CertRunner {
517
528
  const resultados = {};
518
529
  const errores = [];
519
530
 
520
- // Decrementar período UNA VEZ para todos los libros (todos usan el mismo período)
521
- this._decrementarPeriodoLibros();
531
+ // Consultar QEstLibro hacia atrás hasta encontrar 2 años consecutivos sin entradas.
532
+ // En entornos con historial extenso (ej: pruebas desde 2003) esto cubre todo el rango.
533
+ const _nowFase4 = new Date();
534
+ const _añoActual = _nowFase4.getFullYear();
535
+ const _ocupados = new Set();
536
+ let _librosPortal = {};
537
+ // QEstLibro siempre devuelve "Recibido" como estado — no hay distinción entre LTC/LRH.
538
+ // Cualquier período con entrada existente se considera ocupado (hay un libro previo enviado).
539
+ const _esConforme = (_estadoStr) => true; // toda entrada en QEstLibro = período ocupado
540
+ {
541
+ let _yq = _añoActual;
542
+ let _añosSinEntradas = 0;
543
+ while (_yq >= 1990) {
544
+ const _yStr = String(_yq);
545
+ let _nPeriodos = 0;
546
+ try {
547
+ // Timeout de 12s por petición para evitar colgarse en años sin datos del SII.
548
+ const _timeoutProm = new Promise((_, r) => setTimeout(() => r(new Error('timeout QEstLibro')), 12000));
549
+ const _data = await Promise.race([this.siiCert.consultarLibrosExistentes(_yStr), _timeoutProm]);
550
+ _nPeriodos = Object.keys(_data).length;
551
+ let _conformes = 0;
552
+ for (const [_p, _libros] of Object.entries(_data)) {
553
+ if (Object.values(_libros).some(l => _esConforme(l.estado))) {
554
+ _ocupados.add(_p);
555
+ _conformes++;
556
+ }
557
+ }
558
+ if (_yq === _añoActual) _librosPortal = _data;
559
+ if (_nPeriodos > 0) console.log(`[INFO] QEstLibro ${_yStr}: ${_nPeriodos} períodos (${_conformes} bloqueados)`);
560
+ } catch (_e) {
561
+ console.warn(`[!] No se pudo consultar QEstLibro ${_yStr}: ${_e.message}`);
562
+ }
563
+ // Early exit: 2 años consecutivos sin entradas → no hay más historial que leer.
564
+ if (_nPeriodos === 0) {
565
+ _añosSinEntradas++;
566
+ if (_añosSinEntradas >= 2) {
567
+ console.log(`[INFO] QEstLibro: 2 años consecutivos sin datos (${_yStr}) — fin de escaneo.`);
568
+ break;
569
+ }
570
+ } else {
571
+ _añosSinEntradas = 0;
572
+ }
573
+ _yq--;
574
+ }
575
+ }
576
+ console.log(`[INFO] Períodos bloqueados (QEstLibro): ${_ocupados.size} — rango ${[..._ocupados].sort()[0] ?? '-'} a ${[..._ocupados].sort().at(-1) ?? '-'}`);
577
+
578
+ // Período = derivado del FchDoc real de los documentos del SET.
579
+ // El SII genera el SET con fecha = hoy, por lo que PeriodoTributario debe coincidir
580
+ // con el mes de esas fechas. Usar currentMonth-1 causa "ENVIO CON ERRORES O REPAROS".
581
+ let _periodoBase = null;
582
+ const _fchDocMuestra =
583
+ this._estructuras?.libroCompras?.detalle?.[0]?.FchDoc ||
584
+ this._estructuras?.libroComprasExentos?.detalle?.[0]?.FchDoc;
585
+ if (_fchDocMuestra) {
586
+ const _dt = new Date(_fchDocMuestra);
587
+ if (!isNaN(_dt.getTime())) {
588
+ // Usar UTC para evitar que el offset de zona horaria local (ej: UTC-3 en Chile)
589
+ // desplace la fecha al mes anterior (ej: '2026-06-01' → 2026-05-31 local → mes 5).
590
+ _periodoBase = `${_dt.getUTCFullYear()}-${String(_dt.getUTCMonth() + 1).padStart(2, '0')}`;
591
+ console.log(`[INFO] Período derivado de FchDoc en estructuras (${_fchDocMuestra}): ${_periodoBase}`);
592
+ }
593
+ }
594
+ if (!_periodoBase) {
595
+ // Fallback: mes actual UTC
596
+ _periodoBase = `${_nowFase4.getUTCFullYear()}-${String(_nowFase4.getUTCMonth() + 1).padStart(2, '0')}`;
597
+ console.log(`[INFO] Período por fallback (mes actual UTC): ${_periodoBase}`);
598
+ }
599
+
600
+ // Helper: salta períodos conocidos en QEstLibro (ocupados) al buscar uno libre.
601
+ // Usa el set _ocupados ya construido. Siempre llama a _decrementarPeriodoLibros al menos una vez
602
+ // antes de entrar, así que solo sirve para saltar DESPUÉS de haber decrementado.
603
+ const _saltarOcupados = (tag = '') => {
604
+ let _saltos = 0;
605
+ while (_ocupados.has(this._getPeriodoLibros()) && _saltos < 400) {
606
+ console.log(` [skip${tag}] ${this._getPeriodoLibros()} está en QEstLibro — saltando...`);
607
+ this._decrementarPeriodoLibros();
608
+ _saltos++;
609
+ }
610
+ return _saltos;
611
+ };
612
+
613
+ // Usar siempre el período derivado de FchDoc como punto de partida.
614
+ // En entornos de certificación con historial de años, QEstLibro está lleno de
615
+ // entradas antiguas y saltar sobre ellas lleva a períodos LTC inesperados.
616
+ // Con _ocupados completo (todos los años hasta 1990), el skip inicial es seguro:
617
+ // salta directamente al primer período genuinamente libre.
618
+ this.resetPeriodoLibros(_periodoBase);
619
+ const _busquedaHuecos = _saltarOcupados();
522
620
  const _periodoComunLibros = this._getPeriodoLibros();
523
- console.log(` Período para todos los libros: ${_periodoComunLibros}`);
621
+ console.log(` Período inicial: ${_periodoComunLibros}${_busquedaHuecos > 0 ? ` (saltados ${_busquedaHuecos} períodos ocupados desde ${_periodoBase})` : ''}`);
524
622
 
525
623
  // Verificar cuáles libros ya están REVISADO CONFORME en el portal (no re-enviar)
526
624
  let _estadoActual = {};
@@ -564,8 +662,19 @@ class CertRunner {
564
662
  console.log('\nEnviando Libro de Compras...');
565
663
  resultados.libroCompras = await this.ejecutarLibroCompras({ ...options, periodo: _periodoComunLibros });
566
664
  if (!resultados.libroCompras.success) {
567
- emitProgress(STEPS.BOOK_ERROR, { book: 'libroCompras', error: resultados.libroCompras.error });
568
- errores.push(`Libro Compras: ${resultados.libroCompras.error}`);
665
+ const _errLC = resultados.libroCompras.error || '';
666
+ if (/duplicado|cvc-/i.test(_errLC)) {
667
+ console.log('\n[!] LibroCompras: SII ya tiene envío TOTAL para este período — reintentando con AJUSTE...');
668
+ emitProgress(STEPS.BOOK_SENDING, { book: 'libroCompras' });
669
+ resultados.libroCompras = await this.ejecutarLibroCompras({ ...options, periodo: _periodoComunLibros, tipoEnvio: 'AJUSTE' });
670
+ }
671
+ if (!resultados.libroCompras.success) {
672
+ emitProgress(STEPS.BOOK_ERROR, { book: 'libroCompras', error: resultados.libroCompras.error });
673
+ errores.push(`Libro Compras: ${resultados.libroCompras.error}`);
674
+ } else {
675
+ emitProgress(STEPS.BOOK_OK, { book: 'libroCompras', trackId: resultados.libroCompras.trackId });
676
+ _guardarResultadosParciales();
677
+ }
569
678
  } else {
570
679
  emitProgress(STEPS.BOOK_OK, { book: 'libroCompras', trackId: resultados.libroCompras.trackId });
571
680
  _guardarResultadosParciales();
@@ -679,19 +788,20 @@ class CertRunner {
679
788
 
680
789
  // Helper para re-enviar los libros no conformes con un nuevo período
681
790
  // keysAReenviar: Set opcional — si se pasa, solo re-envía las claves del Set
682
- const _reenviarLibros = async (nuevoPeriodo, keysAReenviar) => {
791
+ // tipoEnvio: 'TOTAL' (default) o 'AJUSTE' (fallback cuando todos los períodos están agotados)
792
+ const _reenviarLibros = async (nuevoPeriodo, keysAReenviar, tipoEnvio) => {
683
793
  const _orden = [
684
- { key: 'libroCompras', fn: (p) => this.ejecutarLibroCompras({ ...options, periodo: p }) },
685
- { key: 'libroVentas', fn: (p) => this.ejecutarLibroVentas({ ...options, periodo: p }) },
686
- { key: 'libroGuias', fn: (p) => this.ejecutarLibroGuias({ ...options, periodo: p }) },
687
- { key: 'libroComprasExentos', fn: (p) => this.ejecutarLibroComprasExentos({ ...options, periodo: p }) },
794
+ { key: 'libroCompras', fn: (p, te) => this.ejecutarLibroCompras({ ...options, periodo: p, tipoEnvio: te }) },
795
+ { key: 'libroVentas', fn: (p, te) => this.ejecutarLibroVentas({ ...options, periodo: p, tipoEnvio: te }) },
796
+ { key: 'libroGuias', fn: (p, te) => this.ejecutarLibroGuias({ ...options, periodo: p, tipoEnvio: te }) },
797
+ { key: 'libroComprasExentos', fn: (p, te) => this.ejecutarLibroComprasExentos({ ...options, periodo: p, tipoEnvio: te }) },
688
798
  ];
689
799
  for (const { key, fn } of _orden) {
690
800
  if (resultados[key]?.conforme) continue; // ya conforme en SII
691
801
  if (keysAReenviar && !keysAReenviar.has(key)) continue; // filtro por S21
692
802
  emitProgress(STEPS.BOOK_SENDING, { book: key });
693
803
  try {
694
- resultados[key] = await fn(nuevoPeriodo);
804
+ resultados[key] = await fn(nuevoPeriodo, tipoEnvio);
695
805
  if (!resultados[key].success) {
696
806
  emitProgress(STEPS.BOOK_ERROR, { book: key, error: resultados[key].error });
697
807
  } else {
@@ -701,27 +811,74 @@ class CertRunner {
701
811
  resultados[key] = { success: false, error: e.message };
702
812
  }
703
813
  }
814
+
815
+ // Polling SOAP para detectar LNC antes de intentar declarar.
816
+ // Si el SII rechaza el envío (LNC), limpiamos el trackId para que declararLibros
817
+ // no incluya ese libro, y marcamos el período como ocupado para no reintentarlo.
818
+ const _reenvTids = _orden.filter(({ key }) =>
819
+ !resultados[key]?.conforme &&
820
+ (!keysAReenviar || keysAReenviar.has(key)) &&
821
+ resultados[key]?.trackId
822
+ );
823
+ if (_reenvTids.length > 0) {
824
+ const _nombresReenv = _reenvTids.map(({ key }) => _KEY_A_SII_NOMBRE[key] || key);
825
+ console.log(`\n[SOAP-retry] Verificando estado de libros re-enviados (${_nombresReenv.join(', ')})...`);
826
+ await sleep(15000);
827
+ const _sRPend = new Set(_reenvTids.map(b => b.key));
828
+ const _sRErr = {};
829
+ for (let _ri = 0; _ri < 20 && _sRPend.size > 0; _ri++) {
830
+ for (const { key } of _reenvTids) {
831
+ if (!_sRPend.has(key)) continue;
832
+ const _tid = resultados[key].trackId;
833
+ const _nom = _KEY_A_SII_NOMBRE[key] || key;
834
+ try {
835
+ const _est = await _enviadorSoap.consultarEstadoSoap(_tid, _rutEmisor);
836
+ if (_est.ok === false) {
837
+ _sRErr[key] = (_sRErr[key] || 0) + 1;
838
+ if (_sRErr[key] >= 5) _sRPend.delete(key);
839
+ } else {
840
+ _sRErr[key] = 0;
841
+ if (!_est.esIntermedio) {
842
+ _sRPend.delete(key);
843
+ const _glosa = _est.glosa || _est.estado || '?';
844
+ if (_est.esRechazado) {
845
+ console.log(` [SOAP-retry] ${_nom} (${_tid}): [LNC] ${_glosa} — período ${nuevoPeriodo} ocupado`);
846
+ _ocupados.add(nuevoPeriodo);
847
+ resultados[key] = { success: false, error: `LNC: ${_glosa}`, periodo: nuevoPeriodo };
848
+ } else {
849
+ console.log(` [SOAP-retry] ${_nom} (${_tid}): [LOK] ${_glosa}`);
850
+ }
851
+ }
852
+ }
853
+ } catch (_e) {
854
+ _sRErr[key] = (_sRErr[key] || 0) + 1;
855
+ if (_sRErr[key] >= 5) _sRPend.delete(key);
856
+ }
857
+ }
858
+ if (_sRPend.size > 0) await sleep(15000);
859
+ }
860
+ }
704
861
  };
705
862
 
706
863
  // Polling de aprobación (reutilizable)
707
864
  // librosAVerificar: array de nombres SII a esperar. Si se omite, usa todos los no-conformes.
708
865
  // Devuelve { ok, estadosFinal }
709
- // Bail-out anticipado: si todos los pendientes llevan 5 polls consecutivos en S21 período incorrecto
866
+ // S21 = libro enviado al portal, PENDIENTE DE REVISIÓN estado normal de espera.
867
+ // NO es un error de período. Solo LNC/LRH son errores reales que requieren acción.
710
868
  const _esperarAprobacion = async (librosAVerificar) => {
711
869
  const _todosCandidatos = ['LIBRO DE VENTAS', 'LIBRO DE COMPRAS', 'LIBRO DE GUIAS'];
712
870
  if (this._estructuras?.libroComprasExentos) _todosCandidatos.push('LIBRO DE COMPRAS PARA EXENTOS');
713
871
  const _librosAVerif = librosAVerificar || _todosCandidatos.filter(n => !_estaConforme(n));
714
872
  console.log(`\nEsperando aprobacion del SII para: ${_librosAVerif.join(', ')}`);
715
873
  let _ss = {};
716
- let _consecutivosS21 = 0;
717
- for (let _i = 0; _i < 20; _i++) {
874
+ for (let _i = 0; _i < 40; _i++) {
718
875
  await sleep(15000);
719
- emitProgress(STEPS.POLLING, { intento: _i + 1, max: 20, label: 'libros' });
876
+ emitProgress(STEPS.POLLING, { intento: _i + 1, max: 40, label: 'libros' });
720
877
  const _poll = await this.siiCert.consultarEstadoSets();
721
878
  if (!_poll.success) continue;
722
879
  _ss = _poll.estadoSets || {};
723
880
  const _info = Object.entries(_ss).filter(([k]) => k.toUpperCase().includes('LIBRO')).map(([k, v]) => `${k.trim()}: ${v}`);
724
- if (_info.length) console.log(` [...] Intento ${_i + 1}/20: ${_info.join(' | ')}`);
881
+ if (_info.length) console.log(` [...] Intento ${_i + 1}/40: ${_info.join(' | ')}`);
725
882
  const _librosObs = ['LIBRO DE VENTAS', 'LIBRO DE COMPRAS', 'LIBRO DE GUIAS'];
726
883
  const _todosObligatoriosOk = _librosObs.every(n => {
727
884
  const e = _findEntry(_ss, n);
@@ -736,6 +893,11 @@ class CertRunner {
736
893
  return e && (e[1] === 'LNC' || e[1] === 'LRH' || e[1].includes('RECHAZADO') || e[1].includes('ERROR'));
737
894
  });
738
895
  if (_todosOk) {
896
+ // Marcar conforme en resultados para que el RESUMEN final sea correcto.
897
+ for (const n of _librosAVerif) {
898
+ const _cKey = _SII_NOMBRE_A_KEY[n];
899
+ if (_cKey && resultados[_cKey]) resultados[_cKey].conforme = true;
900
+ }
739
901
  emitProgress(STEPS.BOOKS_DONE);
740
902
  console.log('\n[OK] LIBROS APROBADOS POR EL SII!');
741
903
  return { ok: true, estadosFinal: _ss };
@@ -744,46 +906,136 @@ class CertRunner {
744
906
  console.log('\n[ERR] Hay libros rechazados. Revisar emails del SII.');
745
907
  return { ok: false, estadosFinal: _ss };
746
908
  }
747
- // Bail-out anticipado: todos los pendientes llevan N polls en S21 → período incorrecto
909
+ // S21 = procesando. Informar pero seguir esperando (no bail-out).
748
910
  const _pendientesAun = _librosAVerif.filter(n => {
749
911
  const e = _findEntry(_ss, n);
750
912
  return !e || (e[1] !== 'REVISADO CONFORME' && e[1] !== 'S25');
751
913
  });
752
- const _todosS21 = _pendientesAun.length > 0 && _pendientesAun.every(n => {
753
- const e = _findEntry(_ss, n);
754
- return e && e[1] === 'S21';
755
- });
756
- if (_todosS21) {
757
- _consecutivosS21++;
758
- if (_consecutivosS21 >= 5) {
759
- console.log(`\n[!] ${_pendientesAun.join(', ')} llevan ${_consecutivosS21} polls en S21 — período incorrecto.`);
760
- return { ok: false, estadosFinal: _ss, stuckS21: true };
761
- }
762
- } else {
763
- _consecutivosS21 = 0;
914
+ if (_pendientesAun.length > 0 && (_i + 1) % 4 === 0) {
915
+ console.log(` [...] Aún esperando (${Math.round((_i + 1) * 15 / 60)} min): ${_pendientesAun.join(', ')}`);
764
916
  }
765
917
  }
766
- console.log('\n[!] Timeout (5 min). El SII aún no responde. Verifica con --avance más tarde.');
918
+ console.log('\n[!] Timeout (10 min). El SII aún no responde. Verifica con --avance más tarde.');
767
919
  return { ok: false, estadosFinal: _ss };
768
920
  };
769
921
 
922
+ // Polling SOAP: esperar que el SII procese los XMLs antes de declarar.
923
+ // Solo se sigue esperando mientras esIntermedio === true (REC, SOK, FOK, PRD, CRT, DNK...).
924
+ // Estados como LSO (no catalogado) no tienen esIntermedio = true → se consideran "listos".
925
+ const _librosParaConsultar = [
926
+ { key: 'libroCompras', nombre: 'Libro Compras' },
927
+ { key: 'libroVentas', nombre: 'Libro Ventas' },
928
+ { key: 'libroGuias', nombre: 'Libro Guias' },
929
+ { key: 'libroComprasExentos', nombre: 'Libro Compras Exentos' },
930
+ ].filter(({ key }) => resultados[key]?.trackId);
931
+
932
+ const _enviadorSoap = this._createLibroEnviador();
933
+ const _rutEmisor = this.config.emisor.rut;
934
+
935
+ // Polling hasta 30 minutos (120 intentos × 15s).
936
+ // LSO (schema OK, procesando contenido) y otros intermedios esperan hasta LOK o rechazo.
937
+ const _MAX_SOAP_POLLS = 120;
938
+ const _SOAP_INTERVAL_MS = 15000;
939
+ let _soapPendientes = new Set(_librosParaConsultar.map(l => l.key));
940
+ const _soapFinalStates = {}; // key → resultado SOAP terminal (LOK/LNC/LRH/etc.)
941
+ const _soapErrCount = {}; // key → contador de errores SOAP consecutivos (ok:false o throw)
942
+ const _MAX_SOAP_ERR = 10; // reintentos antes de desistir por errores transitorios
943
+
944
+ console.log('\n[SOAP] Consultando estado de envíos (espera hasta 15 min)...');
945
+ await sleep(15000); // espera inicial — SII tarda al menos 15s en validar schema
946
+ for (let _pi = 0; _pi < _MAX_SOAP_POLLS && _soapPendientes.size > 0; _pi++) {
947
+ for (const { key, nombre } of _librosParaConsultar) {
948
+ if (!_soapPendientes.has(key)) continue;
949
+ const _tid = resultados[key].trackId;
950
+ try {
951
+ const _est = await _enviadorSoap.consultarEstadoSoap(_tid, _rutEmisor);
952
+ const _estado = _est.estado || '?';
953
+ const _detalle = _est.glosa || _est.mensaje || _est.error || 'sin detalle';
954
+ if (_est.ok === false) {
955
+ // Sin tag <ESTADO> en respuesta — error de parseo/red, no un rechazo real.
956
+ // Reintentar hasta _MAX_SOAP_ERR veces consecutivas antes de desistir.
957
+ _soapErrCount[key] = (_soapErrCount[key] || 0) + 1;
958
+ if (_soapErrCount[key] >= _MAX_SOAP_ERR) {
959
+ _soapPendientes.delete(key);
960
+ console.log(` [SOAP] ${nombre} (${_tid}): [?] demasiados errores — desistiendo`);
961
+ } else {
962
+ console.log(` [SOAP] ${nombre} (${_tid}): [?] error transitorio (${_soapErrCount[key]}/${_MAX_SOAP_ERR}) — reintentando...`);
963
+ }
964
+ } else {
965
+ _soapErrCount[key] = 0; // reset en cualquier respuesta válida
966
+ if (!_est.esIntermedio) {
967
+ // Estado terminal: LOK (exitoso), LNC/LRH (rechazado), u otro no catalogado
968
+ _soapPendientes.delete(key);
969
+ _soapFinalStates[key] = _est;
970
+ const _tag = _est.esExitoso ? '[OK]' : _est.esRechazado ? '[ERR]' : '[->]';
971
+ console.log(` [SOAP] ${nombre} (${_tid}): ${_tag} ${_estado} — ${_detalle}`);
972
+ } else {
973
+ const _elapsed = Math.round((_pi * _SOAP_INTERVAL_MS + 15000) / 1000);
974
+ console.log(` [SOAP] ${nombre} (${_tid}): [...] ${_estado} — aún procesando (${_elapsed}s / ${_pi + 1}/${_MAX_SOAP_POLLS})`);
975
+ }
976
+ }
977
+ } catch (_e) {
978
+ _soapErrCount[key] = (_soapErrCount[key] || 0) + 1;
979
+ if (_soapErrCount[key] >= _MAX_SOAP_ERR) {
980
+ _soapPendientes.delete(key);
981
+ console.log(` [SOAP] ${nombre} (${_tid}): error — máx reintentos: ${_e.message}`);
982
+ } else {
983
+ console.log(` [SOAP] ${nombre} (${_tid}): error (${_soapErrCount[key]}/${_MAX_SOAP_ERR}) — reintentando: ${_e.message}`);
984
+ }
985
+ }
986
+ }
987
+ if (_soapPendientes.size > 0) await sleep(_SOAP_INTERVAL_MS);
988
+ }
989
+ if (_soapPendientes.size > 0) {
990
+ const _nombresTimeout = _librosParaConsultar.filter(l => _soapPendientes.has(l.key)).map(l => l.nombre);
991
+ console.log(` [SOAP] ${_nombresTimeout.join(', ')} siguen en proceso tras 15 min. Declarando igual — verifica correos del SII.`);
992
+ }
993
+
770
994
  // 4. Declarar + retry automático:
771
- // a) si allRejected al declarar → decrementar período y re-enviar todos
995
+ // a) si allRejected al declarar → decrementar período y re-enviar TOTAL
996
+ // (el período ya fue procesado en otra sesión → buscar uno libre)
772
997
  // b) si libros quedan en S21 tras polling → decrementar y re-enviar solo los S21
773
998
  emitProgress(STEPS.BOOKS_DECLARING);
774
999
  console.log('\nDeclarando libros...');
775
- const MAX_PERIOD_RETRIES = 120;
1000
+ // 24 = ~2 años de meses. En entorno cert con histórico extenso puede haber
1001
+ // muchos períodos LTC consecutivos antes de encontrar uno libre.
1002
+ const MAX_PERIOD_RETRIES = 24;
1003
+
1004
+ // _librosPortal ya fue consultado arriba (antes de fijar período inicial)
1005
+
776
1006
  try {
777
1007
  let declaracion = await this.declararLibros({ ...resultados, ...(options.setsResultados || {}) });
778
1008
  resultados.declaracion = declaracion;
779
1009
 
780
- // Fase a: allRejected al declarar (período rechazado en pe_avance3)
1010
+ // Fase a: allRejected el período ya tiene data en el portal.
1011
+ // Estrategia: decrementar uno a uno desde el período actual.
1012
+ // - Si tenemos LTC guardado localmente → AJUSTE una vez antes de decrementar.
1013
+ // - Si no hay LTC o AJUSTE también falló → decrementar y probar TOTAL.
1014
+ // No hacemos saltos basados en el portal: QEstLibro solo cubre el año actual
1015
+ // y puede haber entradas en años anteriores que no conocemos.
1016
+ const _intentadoAjuste = new Set();
1017
+
781
1018
  for (let _pRetry = 0; _pRetry < MAX_PERIOD_RETRIES && declaracion.allRejected; _pRetry++) {
782
- this._decrementarPeriodoLibros();
783
- const _nuevoPeriodo = this._getPeriodoLibros();
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) });
786
- await _reenviarLibros(_nuevoPeriodo);
1019
+ const _periodoActual = this._getPeriodoLibros();
1020
+ const _tenemoLtc = !!this._leerLtcTotales(_periodoActual, 'COMPRA') || !!this._leerLtcTotales(_periodoActual, 'VENTA');
1021
+ const _yaIntentadoAjuste = _intentadoAjuste.has(_periodoActual);
1022
+
1023
+ if (_tenemoLtc && !_yaIntentadoAjuste) {
1024
+ console.log(`\n[!] ${_periodoActual}: LTC guardado — re-enviando con AJUSTE (intento ${_pRetry + 1})...`);
1025
+ emitProgress(STEPS.BOOK_PERIOD_RETRY, { periodo: _periodoActual, intento: String(_pRetry + 1) });
1026
+ _intentadoAjuste.add(_periodoActual);
1027
+ await _reenviarLibros(_periodoActual, undefined, 'AJUSTE');
1028
+ } else {
1029
+ // Marcar este período como ocupado dinámicamente y saltar al siguiente libre.
1030
+ _ocupados.add(_periodoActual);
1031
+ this._decrementarPeriodoLibros();
1032
+ _saltarOcupados('a'); // salta períodos conocidos (QEstLibro + descubiertos en tiempo real)
1033
+ const _nuevoPeriodo = this._getPeriodoLibros();
1034
+ const _razon = _yaIntentadoAjuste ? 'AJUSTE falló' : 'sin LTC local';
1035
+ console.log(`\n[!] ${_periodoActual} (${_razon}) — probando TOTAL con ${_nuevoPeriodo} (intento ${_pRetry + 1})...`);
1036
+ emitProgress(STEPS.BOOK_PERIOD_RETRY, { periodo: _nuevoPeriodo, intento: String(_pRetry + 1) });
1037
+ await _reenviarLibros(_nuevoPeriodo, undefined, undefined);
1038
+ }
787
1039
  declaracion = await this.declararLibros({ ...resultados, ...(options.setsResultados || {}) });
788
1040
  resultados.declaracion = declaracion;
789
1041
  }
@@ -796,33 +1048,103 @@ class CertRunner {
796
1048
  if (this._estructuras?.libroComprasExentos) _todosLibrosNombres.push('LIBRO DE COMPRAS PARA EXENTOS');
797
1049
  let _librosAVerificar = _todosLibrosNombres.filter(n => !_estaConforme(n));
798
1050
 
799
- let { ok, estadosFinal } = await _esperarAprobacion(_librosAVerificar);
1051
+ // Si SOAP detectó LNC/LRH para algún libro → excluirlos de la espera portal.
1052
+ // El portal mostrará S21 para ellos (nunca resolverá) → phase b los maneja directamente.
1053
+ const _soapLncNombres = Object.entries(_soapFinalStates)
1054
+ .filter(([, est]) => est?.esRechazado)
1055
+ .map(([k]) => _KEY_A_SII_NOMBRE[k])
1056
+ .filter(Boolean);
1057
+ // Si la declaración reportó errores de contenido para algún libro → también excluirlos.
1058
+ // Esos libros tienen S21 en portal pero nunca resolverán a REVISADO CONFORME desde
1059
+ // esta declaración → phase b los maneja directamente con nuevo período.
1060
+ const _declaracionConErrorNombres = (declaracion.nombresConError || []);
1061
+ const _excluirDeEspera = [...new Set([..._soapLncNombres, ..._declaracionConErrorNombres])];
1062
+ const _librosAEsperar = _librosAVerificar.filter(n => !_excluirDeEspera.includes(n));
1063
+ if (_soapLncNombres.length > 0) {
1064
+ console.log(`\n[!] LNC vía SOAP en: ${_soapLncNombres.join(', ')} — esperando portal solo para: ${_librosAEsperar.join(', ') || 'ninguno'}`);
1065
+ }
1066
+ if (_declaracionConErrorNombres.length > 0) {
1067
+ console.log(`\n[!] Errores de contenido en declaración: ${_declaracionConErrorNombres.join(', ')} — no esperando portal para ellos`);
1068
+ }
800
1069
 
801
- // Fase b: algunos libros quedaron en S21 → re-enviar solo esos con período decrementado
1070
+ let ok, estadosFinal;
1071
+ if (_librosAEsperar.length > 0) {
1072
+ ({ ok, estadosFinal } = await _esperarAprobacion(_librosAEsperar));
1073
+ } else {
1074
+ ok = false;
1075
+ estadosFinal = {};
1076
+ }
1077
+ // Inyectar LNC de SOAP y errores de contenido de declaración en estadosFinal
1078
+ for (const n of _soapLncNombres) estadosFinal[n] = 'LNC';
1079
+ // Libros con errores de contenido en declaración = mismo tratamiento que LNC: period incorrecto
1080
+ for (const n of _declaracionConErrorNombres) if (!estadosFinal[n]) estadosFinal[n] = 'LNC';
1081
+ if (_excluirDeEspera.length > 0) ok = false;
1082
+
1083
+ // Fase b: libros con LNC/LRH/S21-timeout → reintentar con AJUSTE (si tenemos LTC) o nuevo período.
1084
+ // - LNC/LRH = período tiene LTC → intentar AJUSTE si hay datos, sino decrementar.
1085
+ // - S21 después de timeout = upload probablemente rechazado (SOAP LNC no reflejado en pe_avance2)
1086
+ // → mismo tratamiento que LNC.
1087
+ const _intentadoAjusteB = new Set();
802
1088
  for (let _pRetry = 0; !ok && _pRetry < MAX_PERIOD_RETRIES; _pRetry++) {
803
- const _s21Keys = _getS21Keys(estadosFinal, _librosAVerificar);
804
- if (_s21Keys.length === 0) break; // errores reales (LNC/LRH), no de período
805
-
806
- this._decrementarPeriodoLibros();
807
- const _nuevoPeriodo = this._getPeriodoLibros();
808
- const _s21Nombres = _s21Keys.map(k => _KEY_A_SII_NOMBRE[k]).filter(Boolean);
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) });
811
-
812
- await _reenviarLibros(_nuevoPeriodo, new Set(_s21Keys));
1089
+ // Libros con LNC/LRH o S21-timeout (no resuelto → asumimos error de período)
1090
+ const _fallidos = Object.entries(estadosFinal)
1091
+ .filter(([k, v]) => {
1092
+ const ku = k.toUpperCase();
1093
+ const esLibro = ku.includes('LIBRO');
1094
+ const esFallido = (v === 'LNC' || v === 'LRH' || String(v).includes('RECHAZADO') || v === 'S21');
1095
+ return esLibro && esFallido;
1096
+ });
1097
+ const _fallidosKeys = _fallidos.map(([k]) => _SII_NOMBRE_A_KEY[k]).filter(Boolean);
1098
+ if (_fallidosKeys.length === 0) break; // todos conformes → salir
1099
+
1100
+ const _periodoFase = this._getPeriodoLibros();
1101
+ // Solo AJUSTE para libros que tienen su propio LTC guardado.
1102
+ // LibroGuias y LibroComprasExentos nunca tienen LTC → van siempre a TOTAL en período nuevo.
1103
+ const _LTC_TIPO_POR_LIBRO_B = { libroCompras: 'COMPRA', libroVentas: 'VENTA' };
1104
+ const _hayLtcB = _fallidosKeys.some(k => {
1105
+ const tipo = _LTC_TIPO_POR_LIBRO_B[k];
1106
+ return tipo && !!this._leerLtcTotales(_periodoFase, tipo);
1107
+ });
1108
+ const _yaAjusteB = _intentadoAjusteB.has(_periodoFase);
1109
+ const _fallidosNombres = _fallidosKeys.map(k => _KEY_A_SII_NOMBRE[k]).filter(Boolean);
1110
+
1111
+ if (_hayLtcB && !_yaAjusteB) {
1112
+ // Tenemos LTC guardado para este período → intentar AJUSTE antes de cambiar período
1113
+ console.log(`\n[!] ${_fallidosNombres.join(', ')} fallidos en ${_periodoFase} — LTC local existe, probando AJUSTE (intento ${_pRetry + 1})...`);
1114
+ emitProgress(STEPS.BOOK_PERIOD_RETRY, { periodo: _periodoFase, intento: String(_pRetry + 1) });
1115
+ _intentadoAjusteB.add(_periodoFase);
1116
+ await _reenviarLibros(_periodoFase, new Set(_fallidosKeys), 'AJUSTE');
1117
+ } else {
1118
+ // Sin LTC o AJUSTE ya intentado → marcar como ocupado y buscar período libre
1119
+ _ocupados.add(_periodoFase);
1120
+ this._decrementarPeriodoLibros();
1121
+ _saltarOcupados('b');
1122
+ const _nuevoPeriodo = this._getPeriodoLibros();
1123
+ const _razonB = _yaAjusteB ? 'AJUSTE falló' : 'sin LTC local';
1124
+ console.log(`\n[!] ${_fallidosNombres.join(', ')} (${_razonB}) — probando TOTAL en ${_nuevoPeriodo} (intento ${_pRetry + 1})...`);
1125
+ emitProgress(STEPS.BOOK_PERIOD_RETRY, { periodo: _nuevoPeriodo, intento: String(_pRetry + 1) });
1126
+ await _reenviarLibros(_nuevoPeriodo, new Set(_fallidosKeys), undefined);
1127
+ }
813
1128
  declaracion = await this.declararLibros({ ...resultados, ...(options.setsResultados || {}) });
814
1129
  resultados.declaracion = declaracion;
815
1130
 
816
1131
  if (!declaracion.success && !declaracion.allRejected) {
817
- console.log(`\n[ERR] Declaración fallida: ${declaracion.error}`);
818
- break;
1132
+ // Casos en que NO se debe cortar el loop:
1133
+ // 1. Tras intentar AJUSTE → la siguiente iteración buscará período libre.
1134
+ // 2. 'No hay libros para declarar' → todos los re-enviados tuvieron LNC en SOAP → continuar.
1135
+ const _todosSoapLnc = (declaracion.error || '').includes('No hay libros para declarar');
1136
+ if (_intentadoAjusteB.size > 0 || _todosSoapLnc) {
1137
+ const _msg = _todosSoapLnc ? 'todos LNC en SOAP' : `AJUSTE falló (${declaracion.error})`;
1138
+ console.log(`\n[!] Declaración fallida (${_msg}) — buscando período libre...`);
1139
+ } else {
1140
+ console.log(`\n[ERR] Declaración fallida: ${declaracion.error}`);
1141
+ break;
1142
+ }
819
1143
  }
820
1144
  if (declaracion.success) {
821
- // Solo verificar los libros que acabamos de re-enviar
822
- _librosAVerificar = _s21Nombres;
1145
+ _librosAVerificar = _fallidosNombres;
823
1146
  ;({ ok, estadosFinal } = await _esperarAprobacion(_librosAVerificar));
824
1147
  }
825
- // si allRejected → continuar loop (decrementar de nuevo)
826
1148
  }
827
1149
 
828
1150
  if (!ok) {
@@ -984,6 +1306,47 @@ class CertRunner {
984
1306
  }
985
1307
  }
986
1308
 
1309
+ /**
1310
+ * Guarda los totales del LTC (envío TOTAL/MENSUAL) para usar en futuros AJUSTE.
1311
+ * @param {string} periodo - Período YYYY-MM
1312
+ * @param {string} tipo - 'COMPRA' | 'VENTA'
1313
+ * @param {Array} resumen - Array de totales por TpoDoc (igual estructura que setResumen)
1314
+ * @private
1315
+ */
1316
+ _guardarLtcTotales(periodo, tipo, resumen) {
1317
+ const ltcFile = path.join(this.debugDir, 'ltc-totales.json');
1318
+ let data = {};
1319
+ try {
1320
+ if (fs.existsSync(ltcFile)) data = JSON.parse(fs.readFileSync(ltcFile, 'utf8'));
1321
+ } catch (e) { /* usar vacío */ }
1322
+ if (!data[periodo]) data[periodo] = {};
1323
+ data[periodo][tipo] = resumen;
1324
+ try {
1325
+ fs.writeFileSync(ltcFile, JSON.stringify(data, null, 2));
1326
+ console.log(` [LTC] Totales guardados: ${periodo}/${tipo} (${resumen.length} tipos doc)`);
1327
+ } catch (e) {
1328
+ console.warn(` [!] No se pudo guardar ltcTotales: ${e.message}`);
1329
+ }
1330
+ }
1331
+
1332
+ /**
1333
+ * Lee los totales LTC guardados para un período y tipo.
1334
+ * @param {string} periodo - Período YYYY-MM
1335
+ * @param {string} tipo - 'COMPRA' | 'VENTA'
1336
+ * @returns {Array|null} Array de totales o null si no hay datos
1337
+ * @private
1338
+ */
1339
+ _leerLtcTotales(periodo, tipo) {
1340
+ const ltcFile = path.join(this.debugDir, 'ltc-totales.json');
1341
+ try {
1342
+ if (fs.existsSync(ltcFile)) {
1343
+ const data = JSON.parse(fs.readFileSync(ltcFile, 'utf8'));
1344
+ return data[periodo]?.[tipo] || null;
1345
+ }
1346
+ } catch (e) { /* ignorar */ }
1347
+ return null;
1348
+ }
1349
+
987
1350
  /**
988
1351
  * Crea un enviador de libros
989
1352
  * @private
@@ -1016,9 +1379,21 @@ class CertRunner {
1016
1379
  periodo,
1017
1380
  certificado: this.certificado,
1018
1381
  signoNC: options.signoNC || 'POSITIVO',
1382
+ tipoEnvio: options.tipoEnvio || 'TOTAL',
1019
1383
  });
1020
1384
 
1021
- const { libro, xml, detalle, resumen } = libroVentas.generar(setBasicoResult);
1385
+ const { libro, xml: _xmlVentas, detalle, resumen } = libroVentas.generar(setBasicoResult);
1386
+
1387
+ // Si es AJUSTE, inyectar LTC para que TotalesPeriodo sea acumulado correcto
1388
+ const _tipoEnvioVentas = options.tipoEnvio || 'TOTAL';
1389
+ if (_tipoEnvioVentas === 'AJUSTE') {
1390
+ const _ltcVentas = this._leerLtcTotales(periodo, 'VENTA');
1391
+ if (_ltcVentas) {
1392
+ libro.setLtcTotales(_ltcVentas);
1393
+ libro.generar(); // re-firma con TotalesPeriodo correcto
1394
+ }
1395
+ }
1396
+ const xml = libro.getXML() || _xmlVentas;
1022
1397
 
1023
1398
  // Guardar XML de debug
1024
1399
  const outPath = path.join(this.debugDir, 'libro-ventas.xml');
@@ -1037,9 +1412,13 @@ class CertRunner {
1037
1412
  };
1038
1413
 
1039
1414
  this.resultados.libroVentas = result;
1040
-
1415
+
1041
1416
  if (result.success) {
1042
1417
  console.log(` [OK] Libro de Ventas enviado - TrackId: ${result.trackId}`);
1418
+ // Persistir totales LTC para futuros AJUSTE de este período
1419
+ if (_tipoEnvioVentas === 'TOTAL') {
1420
+ this._guardarLtcTotales(periodo, 'VENTA', resumen);
1421
+ }
1043
1422
  } else {
1044
1423
  console.log(` [ERR] Error enviando Libro de Ventas: ${result.error}`);
1045
1424
  }
@@ -1062,6 +1441,7 @@ class CertRunner {
1062
1441
  emisor: this.config.emisor,
1063
1442
  periodo,
1064
1443
  certificado: this.certificado,
1444
+ tipoEnvio: options.tipoEnvio || 'TOTAL',
1065
1445
  });
1066
1446
 
1067
1447
  if (!libroComprasData?.detalle) {
@@ -1069,7 +1449,18 @@ class CertRunner {
1069
1449
  }
1070
1450
 
1071
1451
  console.log(` Generando Libro de Compras para período ${periodo} (${libroComprasData.detalle.length} documentos del SII)...`);
1072
- const { libro, xml, detalle, resumen } = libroCompras.generarDesdeEstructuras(libroComprasData, periodo);
1452
+ const { libro, xml: _xmlCompras, detalle, resumen } = libroCompras.generarDesdeEstructuras(libroComprasData, periodo);
1453
+
1454
+ // Si es AJUSTE, inyectar LTC para que TotalesPeriodo sea acumulado correcto
1455
+ const _tipoEnvioCompras = options.tipoEnvio || 'TOTAL';
1456
+ if (_tipoEnvioCompras === 'AJUSTE') {
1457
+ const _ltcCompras = this._leerLtcTotales(periodo, 'COMPRA');
1458
+ if (_ltcCompras) {
1459
+ libro.setLtcTotales(_ltcCompras);
1460
+ libro.generar(); // re-firma con TotalesPeriodo correcto
1461
+ }
1462
+ }
1463
+ const xml = libro.getXML() || _xmlCompras;
1073
1464
 
1074
1465
  // Guardar XML de debug
1075
1466
  const outPath = path.join(this.debugDir, 'libro-compras.xml');
@@ -1088,9 +1479,13 @@ class CertRunner {
1088
1479
  };
1089
1480
 
1090
1481
  this.resultados.libroCompras = result;
1091
-
1482
+
1092
1483
  if (result.success) {
1093
1484
  console.log(` [OK] Libro de Compras enviado - TrackId: ${result.trackId}`);
1485
+ // Persistir totales LTC para futuros AJUSTE de este período
1486
+ if (_tipoEnvioCompras === 'TOTAL') {
1487
+ this._guardarLtcTotales(periodo, 'COMPRA', resumen);
1488
+ }
1094
1489
  } else {
1095
1490
  console.log(` [ERR] Error enviando Libro de Compras: ${result.error}`);
1096
1491
  }
@@ -1115,6 +1510,7 @@ class CertRunner {
1115
1510
  emisor: this.config.emisor,
1116
1511
  periodo,
1117
1512
  certificado: this.certificado,
1513
+ tipoEnvio: options.tipoEnvio || 'TOTAL',
1118
1514
  });
1119
1515
 
1120
1516
  console.log(` Generando Libro de Compras para Exentos para período ${periodo} (${libroData.detalle.length} documentos del SII)...`);
@@ -1168,6 +1564,7 @@ class CertRunner {
1168
1564
  periodo,
1169
1565
  certificado: this.certificado,
1170
1566
  folioNotificacion: options.folioNotificacion || 3,
1567
+ tipoEnvio: options.tipoEnvio || 'TOTAL',
1171
1568
  });
1172
1569
 
1173
1570
  const { libro, xml, detalle } = libroGuias.generar(setGuiaResult, {
@@ -1894,292 +2291,341 @@ class CertRunner {
1894
2291
  }
1895
2292
 
1896
2293
  /**
1897
- * Sube las 3 respuestas de intercambio a www4.sii.cl/pfeInternet usando Puppeteer.
1898
- * El portal GWT requiere que el JavaScript inicialice la sesión antes de aceptar uploads.
1899
- * Con Puppeteer el browser real ejecuta el JS del portal y los uploads quedan registrados.
2294
+ * Sube las 3 respuestas de intercambio a www4.sii.cl/pfeInternet vía HTTP puro (sin Puppeteer).
2295
+ * Flujo: warm-up validarUsuario (GWT RPC) uploadFile1/2/3 (multipart POST).
2296
+ * Hashes GWT capturados 2026-06-01 desde INTERCAMBIO_SET.har.
1900
2297
  * @private
1901
2298
  */
1902
2299
  async _subirRespuestasPfeInternet({ recepcionXml, aprobacionXml, recibosXml, debugDir }) {
1903
- const puppeteer = require('puppeteer');
1904
- const os = require('os');
1905
-
1906
- // Guardar XMLs en archivos temporales para que Puppeteer pueda subirlos
1907
- const tmpDir = debugDir || path.join(os.tmpdir(), 'pfe-intercambio');
1908
- fs.mkdirSync(tmpDir, { recursive: true });
1909
- // Los labels deben coincidir con el texto del portal GWT (Archivo N: ...)
1910
- const archivos = [
1911
- { label: 'Respuesta de Intercambio', filename: 'respuesta-recepcion-envio.xml', content: recepcionXml, uploadN: 1 },
1912
- { label: 'Recibo de Mercaderias', filename: 'envio-recibos.xml', content: recibosXml, uploadN: 2 },
1913
- { label: 'Resultado Aprobaci\u00f3n Comercial de Documento', filename: 'respuesta-aprobacion-comercial.xml', content: aprobacionXml, uploadN: 3 },
1914
- ];
1915
- for (const a of archivos) {
1916
- fs.writeFileSync(path.join(tmpDir, a.filename), a.content, 'utf8');
1917
- }
2300
+ const PFE_BASE = 'https://www4.sii.cl/pfeInternet/';
2301
+ const PFE_PERM = 'E487C7488217509D4EDCE9D341782C20'; // pfe.nocache.js 2026-06-01
2302
+ const PFE_POLICY = '4EB230A83E74980F353E4FCC209543CB'; // capturado 2026-06-01
2303
+ const PFE_SVC = 'cl.sii.sdi.dim.pfe.web.client.service.Facade';
2304
+
2305
+ const { cookies, makeReq } = await this._autenticarPfeInternet();
2306
+
2307
+ const [empRut, empDv] = this.config.emisor.rut.replace(/\./g, '').split('-');
2308
+ // Extraer RUT del certificado desde la ruta del PFX (e.g. "19925444-8.pfx")
2309
+ const certBase = path.basename(this.config.certificado.path, '.pfx');
2310
+ const [certRut, certDv = '0'] = certBase.includes('-') ? certBase.split('-') : [certBase, '0'];
2311
+
2312
+ const gwtHeaders = {
2313
+ 'Content-Type': 'text/x-gwt-rpc; charset=UTF-8',
2314
+ 'X-GWT-Module-Base': PFE_BASE,
2315
+ 'X-GWT-Permutation': PFE_PERM,
2316
+ 'Origin': 'https://www4.sii.cl',
2317
+ 'Referer': PFE_BASE,
2318
+ };
1918
2319
 
1919
- // Obtener cookies de sesión SII (reutiliza caché en memoria/disco)
1920
- const cookieJar = await this._obtenerCookiesSII();
2320
+ // Parsear string table GWT (igual que pdfdteInternet)
2321
+ const parseGwtTable = (body) => {
2322
+ const stIdx = body.lastIndexOf(',["');
2323
+ const stEnd = body.lastIndexOf('],');
2324
+ if (stIdx === -1 || stEnd <= stIdx) return null;
2325
+ try {
2326
+ const raw = body.substring(stIdx + 1, stEnd + 1);
2327
+ return JSON.parse(raw.replace(/\\x([0-9a-fA-F]{2})/g, (_, h) => `\\u00${h}`));
2328
+ } catch (_) { return null; }
2329
+ };
1921
2330
 
1922
- // Convertir cookieJar a formato Puppeteer para dominio .sii.cl
1923
- const puppeteerCookies = Object.entries(cookieJar).map(([name, value]) => ({
1924
- name, value, domain: '.sii.cl', path: '/', httpOnly: false, secure: true,
1925
- }));
2331
+ // === PASO 1: validarUsuario verifica que empresa esté en estado P06 ===
2332
+ // Payload GWT RPC de validarUsuario(UsuarioTo{empRut,empDv,certRut,certDv})
2333
+ // String table: [base, policy, svc, method, UsuarioTo type, certDv, empDv, Integer type]
2334
+ const validPayload = [
2335
+ '7|0|8',
2336
+ PFE_BASE, PFE_POLICY, PFE_SVC, 'validarUsuario',
2337
+ 'cl.sii.sdi.dim.pfe.to.UsuarioTo/3723336533',
2338
+ certDv, empDv, 'java.lang.Integer/3438268394',
2339
+ '1|2|3|4|1|5|5|0|0|0|0|6|7|8', empRut,
2340
+ '0|0|0|0|0|8', certRut, '',
2341
+ ].join('|');
2342
+
2343
+ console.log(' → pfeInternet: validarUsuario...');
2344
+ const validResp = await makeReq(`${PFE_BASE}facade`, {
2345
+ method: 'POST', body: validPayload, headers: gwtHeaders, cookies,
2346
+ });
1926
2347
 
1927
- let browser;
1928
- try {
1929
- browser = await puppeteer.launch(this._getPuppeteerLaunchOptions());
2348
+ if (debugDir) fs.writeFileSync(path.join(debugDir, 'pfe-validar-resp.txt'), validResp.body.substring(0, 1000), 'utf8');
2349
+
2350
+ const validTable = parseGwtTable(validResp.body);
2351
+ // MensajeTo con mensaje de error → empresa no está en P06
2352
+ const errorMsg = validTable?.find(s => typeof s === 'string' && s.length > 5 && !/^(cl\.|java\.)/.test(s));
2353
+ if (errorMsg && /no esta en estado|error/i.test(errorMsg)) {
2354
+ const estadoMatch = errorMsg.match(/estado:\s*(\w+)/i);
2355
+ const estadoActual = estadoMatch ? estadoMatch[1] : 'desconocido';
2356
+ console.log(` → pfeInternet: empresa NO en P06 (${estadoActual}) — ${errorMsg}`);
2357
+ return {
2358
+ success: false,
2359
+ estadoPortal: estadoActual,
2360
+ error: errorMsg,
2361
+ hint: estadoActual === 'P90'
2362
+ ? 'Fase de intercambio ya completada (empresa en P90)'
2363
+ : `Estado SII pfeInternet: ${estadoActual}`,
2364
+ };
2365
+ }
2366
+ console.log(' ✓ pfeInternet: empresa en P06, subiendo archivos XML...');
1930
2367
 
1931
- const page = await browser.newPage();
1932
- await page.setCookie(...puppeteerCookies);
2368
+ // === PASO 2: Upload de los 3 XMLs vía multipart/form-data ===
2369
+ const archivos = [
2370
+ { filename: 'respuesta-recepcion-envio.xml', content: recepcionXml, uploadN: 1 },
2371
+ { filename: 'envio-recibos.xml', content: recibosXml, uploadN: 2 },
2372
+ { filename: 'respuesta-aprobacion-comercial.xml', content: aprobacionXml, uploadN: 3 },
2373
+ ];
1933
2374
 
1934
- // Navegar al portal pfeInternet
1935
- console.log(' Cargando portal pfeInternet...');
1936
- await page.goto('https://www4.sii.cl/pfeInternet/', {
1937
- waitUntil: 'networkidle2',
1938
- timeout: 60000,
2375
+ for (const archivo of archivos) {
2376
+ const boundary = `----WebKitFormBoundary${Date.now().toString(16)}`;
2377
+ const CRLF = '\r\n';
2378
+ const multipartBody = [
2379
+ `--${boundary}`,
2380
+ `Content-Disposition: form-data; name="uploadFormElement"; filename="${archivo.filename}"`,
2381
+ 'Content-Type: text/xml',
2382
+ '',
2383
+ archivo.content,
2384
+ `--${boundary}--`,
2385
+ '',
2386
+ ].join(CRLF);
2387
+
2388
+ console.log(` → Subiendo ${archivo.filename}...`);
2389
+ const uploadResp = await makeReq(`${PFE_BASE}uploadFile${archivo.uploadN}`, {
2390
+ method: 'POST',
2391
+ body: multipartBody,
2392
+ headers: {
2393
+ 'Content-Type': `multipart/form-data; boundary=${boundary}`,
2394
+ 'Origin': 'https://www4.sii.cl',
2395
+ 'Referer': PFE_BASE,
2396
+ },
2397
+ cookies,
1939
2398
  });
1940
2399
 
1941
- // Dar 3 segundos extra para que GWT renderice el menú inicial
1942
- await new Promise(r => setTimeout(r, 3000));
1943
-
1944
- // Hacer click en el enlace "Subir archivos XML de respuesta de Intercambio"
1945
- // El href es javascript:openForm('opt-ingresoEmpresaUp') — necesita click real para GWT
1946
- console.log(' → Clickeando "Subir archivos XML de respuesta de Intercambio"...');
1947
- const linkClicked = await page.click('a[href*="ingresoEmpresaUp"]').then(() => true).catch(() => false);
1948
- if (!linkClicked) {
1949
- // Fallback: evaluar click con dispatchEvent
1950
- await page.evaluate(() => {
1951
- const link = document.querySelector('a[href*="ingresoEmpresaUp"]');
1952
- if (link) link.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
1953
- });
1954
- }
1955
-
1956
- // Esperar a que GWT complete el RPC y re-renderice la vista de upload
1957
- await page.waitForNetworkIdle({ timeout: 15000, idleTime: 1000 }).catch(() => {});
1958
-
1959
- // === PASO INTERMEDIO: ingresar RUT y confirmar empresa ===
1960
- // GWT muestra "Ingrese el RUT de la empresa" antes de mostrar el formulario de upload
1961
- const rutInput = await page.$('input.gwt-TextBox[maxlength="10"]');
1962
- if (rutInput) {
1963
- const [rutNum, dv] = this.config.emisor.rut.split('-');
1964
- const rutConDv = `${rutNum}-${dv}`;
1965
- console.log(` → Ingresando RUT empresa: ${rutConDv}`);
1966
- await rutInput.click({ clickCount: 3 }); // seleccionar todo
1967
- await rutInput.type(rutConDv);
1968
-
1969
- // Click en "Confirmar Empresa"
1970
- const confirmBtn = await page.evaluateHandle(() => {
1971
- return Array.from(document.querySelectorAll('button.gwt-Button'))
1972
- .find(b => b.textContent.trim() === 'Confirmar Empresa');
1973
- });
1974
- if (confirmBtn) {
1975
- await confirmBtn.asElement().click();
1976
- console.log(' → Click "Confirmar Empresa", esperando formulario de upload...');
1977
- await page.waitForNetworkIdle({ timeout: 15000, idleTime: 1000 }).catch(() => {});
1978
- }
1979
- }
1980
-
1981
- // Esperar que GWT renderice el formulario con los 3 inputs de upload
1982
- const inputFound = await page.waitForSelector('input[name="uploadFormElement"]', { timeout: 30000 }).catch(() => null);
1983
- if (!inputFound) {
1984
- if (debugDir) {
1985
- await page.screenshot({ path: path.join(debugDir, 'pfeInternet-error.png'), fullPage: true }).catch(() => {});
1986
- fs.writeFileSync(path.join(debugDir, 'pfeInternet-error.html'), await page.content().catch(() => ''), 'utf8');
1987
- }
1988
- const pageText = await page.$eval('body', el => el.textContent).catch(() => '');
1989
- if (pageText.includes('DOCUMENTOS IMPRESOS') || pageText.includes('fue cargado exitosamente')) {
1990
- throw new Error('PASO_YA_COMPLETADO');
1991
- }
1992
- throw new Error('pfeInternet no mostró formulario de upload tras openForm — ver pfeInternet-error.png/.html');
1993
- }
1994
- console.log(' → Formulario de upload listo');
1995
-
1996
- // ── DEBUG: screenshot del formulario con los inputs listos ──
2400
+ const respBody = uploadResp.body || '';
1997
2401
  if (debugDir) {
1998
- await page.screenshot({ path: path.join(debugDir, 'pfeInternet-form-listo.png'), fullPage: true }).catch(() => {});
1999
- fs.writeFileSync(path.join(debugDir, 'pfeInternet-form-listo.html'), await page.content().catch(() => ''), 'utf8');
2402
+ fs.writeFileSync(path.join(debugDir, `pfe-upload${archivo.uploadN}-resp.txt`), respBody.substring(0, 2000), 'utf8');
2000
2403
  }
2001
2404
 
2002
- // Subir cada archivo en secuencia.
2003
- // GWT mantiene solo los inputs de archivos pendientes: "procesado con exito anteriormente"
2004
- // reemplaza al input. Así que iteramos solo los archivos pendientes en orden.
2005
- for (const archivo of archivos) {
2006
- const filePath = path.join(tmpDir, archivo.filename);
2007
-
2008
- // Asegurarse de que no haya diálogo abierto del upload anterior
2009
- await page.waitForFunction(
2010
- () => !document.querySelector('.gwt-DialogBox'),
2011
- { timeout: 10000 }
2012
- ).catch(() => {});
2013
-
2014
- // Verificar si este archivo ya fue procesado (GWT reemplaza el input con texto)
2015
- // Estructura DOM: <td class="filter-label">Archivo N: Label</td> en una <tr>
2016
- // La siguiente <tr> tiene <td class="filter-widget"> con el input O el texto "procesado"
2017
- const yaProcessado = await page.evaluate((labelText) => {
2018
- const allTds = Array.from(document.querySelectorAll('td.filter-label'));
2019
- const labelTd = allTds.find(el => el.textContent.includes(labelText));
2020
- if (!labelTd) return false;
2021
- // Subir al <tr> padre y tomar el siguiente <tr>
2022
- const tr = labelTd.closest('tr');
2023
- if (!tr) return false;
2024
- const nextTr = tr.nextElementSibling;
2025
- if (!nextTr) return false;
2026
- return nextTr.textContent.includes('procesado con exito anteriormente');
2027
- }, archivo.label);
2028
-
2029
- if (yaProcessado) {
2030
- console.log(` → ${archivo.filename}: ya procesado anteriormente, saltando...`);
2031
- continue;
2032
- }
2033
-
2034
- console.log(` → Subiendo ${archivo.filename}...`);
2405
+ const respLow = respBody.toLowerCase();
2406
+ const hasError = uploadResp.status >= 400
2407
+ || (respLow.includes('error') && !respLow.includes('procesado'))
2408
+ || respLow.includes('rechaz');
2409
+ const hasSuccess = respLow.includes('procesado') || respLow.includes('exitosamente')
2410
+ || respLow.includes('cargado') || uploadResp.status === 200;
2035
2411
 
2036
- // Cada archivo tiene su propio form con action uploadFile1/2/3
2037
- // Usamos el selector específico para no confundir entre los 3 inputs que pueden
2038
- // estar presentes simultáneamente en el DOM
2039
- const formSel = `form[action*="uploadFile${archivo.uploadN}"]`;
2040
- await page.waitForSelector(`${formSel} input[name="uploadFormElement"]`, { timeout: 15000 });
2412
+ if (hasError && !hasSuccess) {
2413
+ throw new Error(`Error al subir ${archivo.filename}: HTTP ${uploadResp.status} ${respBody.substring(0, 200)}`);
2414
+ }
2415
+ console.log(` ${archivo.filename} subido (HTTP ${uploadResp.status})`);
2416
+ }
2041
2417
 
2042
- const input = await page.$(`${formSel} input[name="uploadFormElement"]`);
2043
- if (!input) throw new Error(`No se encontró input uploadFile${archivo.uploadN}`);
2044
- await input.uploadFile(filePath);
2418
+ return { success: true, resultado: 'Los 3 archivos XML de intercambio subidos correctamente' };
2419
+ }
2045
2420
 
2046
- const submitBtn = await page.$(`${formSel} button.button-little`);
2047
- if (!submitBtn) throw new Error(`No se encontró botón Subir para uploadFile${archivo.uploadN}`);
2048
- await submitBtn.click();
2421
+ // ═══════════════════════════════════════════════════════════════
2422
+ // FASE 8: MUESTRAS IMPRESAS subir PDFs a pe_avance5
2423
+ // ═══════════════════════════════════════════════════════════════
2049
2424
 
2050
- // Esperar el diálogo GWT de confirmación
2051
- await page.waitForSelector('.gwt-DialogBox .msgeDialogBox', { timeout: 30000 });
2052
- const msgText = await page.$eval('.gwt-DialogBox .msgeDialogBox', el => el.textContent.trim());
2053
- console.log(` ${msgText}`);
2425
+ /**
2426
+ * Autentica contra pdfdteInternet reutilizando la sesión SII cacheada.
2427
+ * Hace warm-up GET, obtiene el hash de política GWT desde nocache.js + cache.html
2428
+ * y retorna los utilitarios HTTP listos para hacer llamadas GWT RPC.
2429
+ * @private
2430
+ * @returns {Promise<{cookies: string, makeReq: Function, permHash: string, policyHash: string}>}
2431
+ */
2432
+ async _autenticarPdfDteInternet() {
2433
+ const https = require('https');
2434
+ const crypto = require('crypto');
2435
+ const { URL } = require('url');
2054
2436
 
2055
- if (debugDir) {
2056
- fs.writeFileSync(
2057
- path.join(debugDir, `upload-resp-${archivo.filename}.txt`),
2058
- `Puppeteer: ${msgText}`, 'utf8'
2059
- );
2060
- }
2437
+ const tlsOpts = {
2438
+ rejectUnauthorized: false,
2439
+ maxVersion: 'TLSv1.2',
2440
+ secureOptions: crypto.constants.SSL_OP_ALLOW_UNSAFE_LEGACY_RENEGOTIATION
2441
+ | crypto.constants.SSL_OP_LEGACY_SERVER_CONNECT,
2442
+ };
2061
2443
 
2062
- if (msgText.toLowerCase().includes('error') || msgText.toLowerCase().includes('rechaz')) {
2063
- throw new Error(`Error en archivo ${archivo.filename}: ${msgText}`);
2064
- }
2444
+ const UA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36';
2065
2445
 
2066
- // Cerrar el diálogo usando el botón "Cerrar" dentro del gwt-DialogBox
2067
- await page.evaluate(() => {
2068
- const dlg = document.querySelector('.gwt-DialogBox');
2069
- if (!dlg) return;
2070
- const btn = Array.from(dlg.querySelectorAll('button')).find(
2071
- b => b.textContent.trim() === 'Cerrar'
2072
- );
2073
- if (btn) btn.click();
2446
+ const makeReq = (urlStr, { method = 'GET', body = null, headers = {}, cookies: reqCookies = '' }) =>
2447
+ new Promise((resolve, reject) => {
2448
+ const u = new URL(urlStr);
2449
+ const agent = new https.Agent(tlsOpts);
2450
+ const bodyBuf = body ? (Buffer.isBuffer(body) ? body : Buffer.from(body, 'utf8')) : null;
2451
+ const opts = {
2452
+ hostname: u.hostname, port: u.port || 443,
2453
+ path: u.pathname + u.search, method, agent,
2454
+ headers: {
2455
+ 'User-Agent': UA,
2456
+ 'Accept': '*/*',
2457
+ 'Connection': 'keep-alive',
2458
+ 'Origin': 'https://www4.sii.cl',
2459
+ 'Referer': 'https://www4.sii.cl/pdfdteInternet/',
2460
+ ...(reqCookies ? { 'Cookie': reqCookies } : {}),
2461
+ ...headers,
2462
+ },
2463
+ };
2464
+ if (bodyBuf) opts.headers['Content-Length'] = bodyBuf.length;
2465
+ const req = https.request(opts, (res) => {
2466
+ const chunks = [];
2467
+ res.on('data', c => chunks.push(Buffer.isBuffer(c) ? c : Buffer.from(c)));
2468
+ res.on('end', () => resolve({ status: res.statusCode, body: Buffer.concat(chunks).toString('utf8'), headers: res.headers }));
2074
2469
  });
2470
+ req.on('error', reject);
2471
+ if (bodyBuf) req.write(bodyBuf);
2472
+ req.end();
2473
+ });
2075
2474
 
2076
- // Esperar que el diálogo desaparezca antes del siguiente archivo
2077
- await page.waitForFunction(
2078
- () => !document.querySelector('.gwt-DialogBox'),
2079
- { timeout: 10000 }
2080
- ).catch(() => {});
2081
-
2082
- // Esperar que GWT termine de actualizar el estado (RPC post-upload)
2083
- await page.waitForNetworkIdle({ timeout: 10000, idleTime: 500 }).catch(() => {});
2475
+ const collectNewCookies = (headers, existing) => {
2476
+ const merged = {};
2477
+ existing.split(';').forEach(c => { const [k, v] = c.trim().split('='); if (k) merged[k.trim()] = (v || '').trim(); });
2478
+ for (const c of (headers['set-cookie'] || [])) {
2479
+ const [kv] = c.split(';');
2480
+ const eq = kv.indexOf('=');
2481
+ if (eq > 0) merged[kv.slice(0, eq).trim()] = kv.slice(eq + 1).trim();
2084
2482
  }
2483
+ return Object.entries(merged).map(([k, v]) => `${k}=${v}`).join('; ');
2484
+ };
2085
2485
 
2086
- return { success: true, resultado: 'Los 3 archivos subidos y registrados correctamente' };
2486
+ const cookieJar = await this._obtenerCookiesSII();
2487
+ let cookies = Object.entries(cookieJar).map(([k, v]) => `${k}=${v}`).join('; ');
2488
+
2489
+ // Hashes GWT conocidos del portal pdfdteInternet (capturados 2026-06-01 vía HAR).
2490
+ // permHash = nombre del .cache.html cargado por el browser (permutation del GWT module)
2491
+ // policyHash = hash de política de serialización GWT embebido dentro del cache.html
2492
+ // Si el SII redespliega el portal se deben actualizar estos valores.
2493
+ const KNOWN_PERM_HASH = 'D86ACF99AE5C17F0B0F673A9872EF6CB';
2494
+ const KNOWN_POLICY_HASH = '5459B93B9D030A67564300FBD346270F';
2495
+
2496
+ // Warm-up: GET /pdfdteInternet/ para inicializar contexto del portal en el servidor.
2497
+ // Aprovechamos para intentar descubrir el permHash dinámicamente desde el HTML.
2498
+ let permHash = KNOWN_PERM_HASH;
2499
+ let policyHash = KNOWN_POLICY_HASH;
2500
+ try {
2501
+ const warmup = await makeReq('https://www4.sii.cl/pdfdteInternet/', { cookies });
2502
+ cookies = collectNewCookies(warmup.headers, cookies);
2503
+ if ((warmup.status === 301 || warmup.status === 302) && warmup.headers?.location) {
2504
+ const loc = warmup.headers.location;
2505
+ const absLoc = loc.startsWith('http') ? loc : `https://www4.sii.cl${loc}`;
2506
+ const warmup2 = await makeReq(absLoc, { cookies });
2507
+ cookies = collectNewCookies(warmup2.headers, cookies);
2508
+ }
2509
+ console.log(`[pdfdteInternet Auth] warm-up → HTTP ${warmup.status}`);
2087
2510
 
2088
- } catch (err) {
2089
- return { success: false, error: err.message };
2090
- } finally {
2091
- if (browser) await browser.close();
2511
+ // Descubrir permHash dinámicamente desde pdfdte.nocache.js
2512
+ // (El módulo GWT se llama 'pdfdte', no 'pdfdteInternet' como podría esperarse)
2513
+ try {
2514
+ const ncResp = await makeReq('https://www4.sii.cl/pdfdteInternet/pdfdte.nocache.js', { cookies });
2515
+ if (ncResp.status === 200 && ncResp.body) {
2516
+ const ncHashes = [...new Set(
2517
+ [...ncResp.body.matchAll(/[=',]([0-9A-Fa-f]{32})[',]/g)].map(m => m[1].toUpperCase())
2518
+ )];
2519
+ if (ncHashes.includes(KNOWN_PERM_HASH)) {
2520
+ // Hash conocido sigue vigente
2521
+ console.log(`[pdfdteInternet Auth] pdfdte.nocache.js confirma hash conocido (${ncHashes.length} permutaciones)`);
2522
+ } else if (ncHashes.length > 0) {
2523
+ // Portal redesplegado — actualizar hashes
2524
+ const newPerm = ncHashes[0];
2525
+ console.log(`[pdfdteInternet Auth] Nuevo permHash detectado: ${newPerm.substring(0, 8)}... (portal redesplegado)`);
2526
+ try {
2527
+ const cacheResp = await makeReq(`https://www4.sii.cl/pdfdteInternet/${newPerm}.cache.html`, {
2528
+ cookies, headers: { 'Referer': 'https://www4.sii.cl/pdfdteInternet/' },
2529
+ });
2530
+ const hexInCache = [...new Set([...cacheResp.body.matchAll(/["']([0-9A-Fa-f]{32})["']/g)].map(m => m[1].toUpperCase()))];
2531
+ const newPolicy = hexInCache.find(h => h !== newPerm);
2532
+ if (newPolicy) {
2533
+ permHash = newPerm;
2534
+ policyHash = newPolicy;
2535
+ console.log(`[pdfdteInternet Auth] Hashes actualizados correctamente.`);
2536
+ }
2537
+ } catch (e2) {
2538
+ console.log(`[pdfdteInternet Auth] No se pudo obtener nuevo cache.html: ${e2.message}. Usando hashes conocidos.`);
2539
+ }
2540
+ }
2541
+ }
2542
+ } catch (eNc) {
2543
+ console.log(`[pdfdteInternet Auth] pdfdte.nocache.js no accesible: ${eNc.message}. Usando hashes conocidos.`);
2544
+ }
2545
+ } catch (e) {
2546
+ console.log(`[pdfdteInternet Auth] warm-up falló (no crítico): ${e.message}`);
2092
2547
  }
2093
- }
2094
2548
 
2095
- // ═══════════════════════════════════════════════════════════════
2096
- // FASE 8: MUESTRAS IMPRESAS subir PDFs a pe_avance5
2097
- // ═══════════════════════════════════════════════════════════════
2549
+ console.log(`[pdfdteInternet Auth] permHash=${permHash.substring(0, 8)}... policyHash=${policyHash.substring(0, 8)}...`);
2550
+ return { cookies, makeReq, permHash, policyHash };
2551
+ }
2098
2552
 
2099
2553
  /**
2100
2554
  * Consulta el estado actual del portal pdfdteInternet sin subir nada.
2101
2555
  * Útil para saltarse la generación de PDFs si ya están enviados.
2556
+ * Implementación HTTP pura — llama a leeImpreso() via GWT RPC.
2102
2557
  * @returns {Promise<{estado: string|null, error?: string}>}
2103
2558
  */
2104
2559
  async verificarEstadoPortalMuestras() {
2105
- const puppeteer = require('puppeteer');
2106
- const cookieJar = await this._obtenerCookiesSII();
2107
- const puppeteerCookies = Object.entries(cookieJar).map(([name, value]) => ({
2108
- name, value, domain: '.sii.cl', path: '/', httpOnly: false, secure: true,
2109
- }));
2110
- const [rutNum, dvChar] = this.config.emisor.rut.split('-');
2111
- let browser;
2112
2560
  try {
2113
- browser = await puppeteer.launch(this._getPuppeteerLaunchOptions());
2114
- const page = await browser.newPage();
2115
- await page.setCookie(...puppeteerCookies);
2116
- await page.goto('https://www4.sii.cl/pdfdteInternet/', { waitUntil: 'networkidle2', timeout: 60000 });
2117
- await new Promise(r => setTimeout(r, 3000));
2118
-
2119
- const rutInputs = await page.$$('input[name="rut"]');
2120
- const dvInputs = await page.$$('input[name="dv"]');
2121
- if (!rutInputs.length) return { estado: null, error: 'sin campos RUT (¿sesión expirada?)' };
2122
-
2123
- // Solo RUT empresa → "Rut": el portal ya muestra "Estado de la Revisión" en el DOM
2124
- await rutInputs[0].click({ clickCount: 3 }); await rutInputs[0].type(rutNum);
2125
- await dvInputs[0].click({ clickCount: 3 }); await dvInputs[0].type(dvChar);
2126
- await page.evaluate((t) => {
2127
- const btn = Array.from(document.querySelectorAll('button.x-btn-text'))
2128
- .find(b => b.textContent.trim() === t && !b.disabled && b.getAttribute('aria-disabled') !== 'true');
2129
- if (btn) btn.click();
2130
- }, 'Rut');
2131
-
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));
2136
-
2137
- // Esperar hasta 10s a que aparezca el estado en el DOM (detrás del diálogo si aplica)
2138
- await page.waitForFunction(() => {
2139
- const t = (document.body.textContent || '').toUpperCase();
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(() => {});
2144
-
2145
- // Leer estado directamente del label del formulario en el DOM
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
2158
- const t = (document.body.textContent || '').toUpperCase();
2159
- if (t.includes('APROBADO')) return 'APROBADO';
2160
- if (t.includes('POR REVISAR')) return 'POR REVISAR';
2161
- if (t.includes('EN REVISI')) return 'EN REVISIÓN';
2162
- if (t.includes('RECHAZADO')) return 'RECHAZADO';
2163
- if (t.includes('ENVIADO AL SII')) return 'ENVIADO AL SII';
2164
- return null;
2165
- }).catch(() => null);
2166
-
2167
- return { estado };
2561
+ const { cookies, makeReq, permHash, policyHash } = await this._autenticarPdfDteInternet();
2562
+ const [rutNum, dvChar] = this.config.emisor.rut.split('-');
2563
+
2564
+ const body =
2565
+ `7|0|6|https://www4.sii.cl/pdfdteInternet/|${policyHash}|` +
2566
+ `cl.sii.sdi.dim.validaPdfDte.web.client.service.ServicePdfDte|leeImpreso|` +
2567
+ `java.lang.String/2004016611|${rutNum}-${dvChar}|1|2|3|4|1|5|6|`;
2568
+
2569
+ const resp = await makeReq('https://www4.sii.cl/pdfdteInternet/ServicePdfDte', {
2570
+ method: 'POST',
2571
+ body,
2572
+ headers: {
2573
+ 'Content-Type': 'text/x-gwt-rpc; charset=UTF-8',
2574
+ 'X-GWT-Module-Base': 'https://www4.sii.cl/pdfdteInternet/',
2575
+ 'X-GWT-Permutation': permHash,
2576
+ 'Cache-Control': 'no-cache',
2577
+ 'Pragma': 'no-cache',
2578
+ },
2579
+ cookies,
2580
+ });
2581
+
2582
+ if (resp.status !== 200 || !resp.body.startsWith('//OK')) {
2583
+ return { estado: null, error: `GWT leeImpreso HTTP ${resp.status}: ${resp.body.substring(0, 150)}` };
2584
+ }
2585
+
2586
+ // Parsear string table GWT: siempre el penúltimo elemento (antes de flags,version)
2587
+ // GWT usa \x27 etc. (no válido en JSON) → normalizar antes de parsear
2588
+ const stIdx2 = resp.body.lastIndexOf(',["');
2589
+ const stEnd = stIdx2 !== -1 ? resp.body.lastIndexOf('],') : -1;
2590
+ let table = null;
2591
+ if (stIdx2 !== -1 && stEnd > stIdx2) {
2592
+ try {
2593
+ const raw = resp.body.substring(stIdx2 + 1, stEnd + 1);
2594
+ const normalized = raw.replace(/\\x([0-9a-fA-F]{2})/g, (_, h) => `\\u00${h}`);
2595
+ table = JSON.parse(normalized);
2596
+ } catch (_) {}
2597
+ }
2598
+
2599
+ let estado = null, numImpresos = null, fechaEnvio = null, revId = null;
2600
+ if (table) {
2601
+ const estadoStr = table.find(s => typeof s === 'string' && /APROBADO|POR REVISAR|RECHAZADO|INGRESO|EN REVISI/i.test(s));
2602
+ const errorStr = table.find(s => typeof s === 'string' && /El estado de la postulacion/i.test(s));
2603
+ const fechaStr = table.find(s => typeof s === 'string' && /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}/.test(s));
2604
+ const nStr = table.find(s => typeof s === 'string' && /^\d+$/.test(s) && +s > 0 && +s < 500);
2605
+ estado = estadoStr ? estadoStr.toUpperCase() : (errorStr ? 'BLOQUEADO' : null);
2606
+ fechaEnvio = fechaStr || null;
2607
+ numImpresos = nStr ? parseInt(nStr, 10) : null;
2608
+ }
2609
+ // Fallback: regex directo en el body
2610
+ if (!estado) {
2611
+ const m = resp.body.match(/"(APROBADO|POR REVISAR|EN REVISI[OÓ]N|RECHAZADO|INGRESO|ENVIADO AL SII)"/i);
2612
+ if (m) estado = m[1].toUpperCase();
2613
+ else if (/El estado de la postulacion/i.test(resp.body)) estado = 'BLOQUEADO';
2614
+ }
2615
+ // Extraer revId (Long GWT-base64 en rango plausible de IDs de revisión)
2616
+ const GWT_B64_V = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789$_';
2617
+ for (const m of resp.body.matchAll(/'([A-Za-z0-9$_]{3,7})'/g)) {
2618
+ let n = 0; for (const c of m[1]) n = n * 64 + GWT_B64_V.indexOf(c);
2619
+ if (n >= 100000 && n <= 9999999) { revId = n; break; }
2620
+ }
2621
+ return { estado, revId, numImpresos, fechaEnvio };
2168
2622
  } catch (err) {
2169
2623
  return { estado: null, error: err.message };
2170
- } finally {
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
- }
2178
2624
  }
2179
2625
  }
2180
2626
 
2181
2627
  /**
2182
- * Sube los PDFs de muestras impresas al portal pe_avance5 via Puppeteer.
2628
+ * Sube los PDFs de muestras impresas al portal pe_avance5 via HTTP puro.
2183
2629
  * @param {Object} opts
2184
2630
  * @param {string} opts.pdfDir - Directorio con los PDFs generados
2185
2631
  * @returns {Promise<Object>} { success, error? }
@@ -2204,443 +2650,193 @@ class CertRunner {
2204
2650
  }
2205
2651
 
2206
2652
  /**
2207
- * Sube PDFs al portal https://www4.sii.cl/pdfdteInternet/ via Puppeteer.
2208
- * El portal usa ExtJS 3 botones buscados por texto, no por ID dinámico.
2653
+ * Sube PDFs al portal https://www4.sii.cl/pdfdteInternet/ via HTTP puro (sin Puppeteer).
2654
+ * Flujo GWT RPC: leeImpreso creaLista upload×N solicitaRevisionSII.
2209
2655
  * @private
2210
2656
  */
2211
2657
  async _subirMuestrasImpresasPortal({ pdfPaths, debugDir }) {
2212
- const puppeteer = require('puppeteer');
2213
-
2214
- // Reutiliza sesión SII en memoria/disco (misma sesión que intercambio u otros pasos)
2215
- const cookieJar = await this._obtenerCookiesSII();
2216
- const puppeteerCookies = Object.entries(cookieJar).map(([name, value]) => ({
2217
- name, value, domain: '.sii.cl', path: '/', httpOnly: false, secure: true,
2218
- }));
2658
+ // ── Helpers GWT base64 para codificar/decodificar java.lang.Long ──────────
2659
+ const GWT_B64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789$_';
2660
+ const decodeGwtLong = (s) => { let n = 0; for (const c of s) n = n * 64 + GWT_B64.indexOf(c); return n; };
2661
+ const encodeGwtLong = (n) => {
2662
+ const chars = []; let r = n;
2663
+ while (r > 0) { chars.unshift(GWT_B64[r & 63]); r = Math.floor(r / 64); }
2664
+ while (chars.length < 4) chars.unshift('A');
2665
+ return chars.join('');
2666
+ };
2219
2667
 
2668
+ // ── Auth + obtención de hashes GWT ────────────────────────────────────────
2669
+ const { cookies, makeReq, permHash, policyHash } = await this._autenticarPdfDteInternet();
2220
2670
  const [rutNum, dvChar] = this.config.emisor.rut.split('-');
2221
2671
 
2222
- // Helper: click botón ExtJS por texto
2223
- const clickBoton = (page, texto) => page.evaluate((t) => {
2224
- const btn = Array.from(document.querySelectorAll('button.x-btn-text'))
2225
- .find(b => b.textContent.trim() === t && !b.disabled && b.getAttribute('aria-disabled') !== 'true');
2226
- if (btn) { btn.click(); return true; }
2227
- return false;
2228
- }, texto);
2229
-
2230
- let browser;
2231
- try {
2232
- browser = await puppeteer.launch(this._getPuppeteerLaunchOptions([], { protocolTimeout: 300000 }));
2233
- const page = await browser.newPage();
2234
- await page.setCookie(...puppeteerCookies);
2235
-
2236
- // Navegar directamente a www4.sii.cl/pdfdteInternet/ con las cookies de sesión SII
2237
- console.log(' → Cargando portal pdfdteInternet...');
2238
- await page.goto('https://www4.sii.cl/pdfdteInternet/', {
2239
- waitUntil: 'networkidle2', timeout: 60000,
2240
- });
2241
- await new Promise(r => setTimeout(r, 3000));
2242
- if (debugDir) await page.screenshot({ path: path.join(debugDir, 'pdfte-01-loaded.png'), fullPage: true }).catch(() => {});
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
-
2264
- // Paso 1: RUT Empresa
2265
- const rutInputs = await page.$$('input[name="rut"]');
2266
- const dvInputs = await page.$$('input[name="dv"]');
2267
- if (!rutInputs.length) {
2268
- if (debugDir) {
2269
- await page.screenshot({ path: path.join(debugDir, 'pdfte-error-no-rut.png'), fullPage: true }).catch(() => {});
2270
- fs.writeFileSync(path.join(debugDir, 'pdfte-error.html'), await page.content(), 'utf8');
2271
- }
2272
- throw new Error('pdfdteInternet: no se encontraron campos de RUT (¿sesión expirada?)');
2273
- }
2274
- console.log(` → Ingresando RUT empresa: ${rutNum}-${dvChar}`);
2275
- await rutInputs[0].click({ clickCount: 3 }); await rutInputs[0].type(rutNum);
2276
- await dvInputs[0].click({ clickCount: 3 }); await dvInputs[0].type(dvChar);
2277
- await clickBoton(page, 'Rut');
2278
- await new Promise(r => setTimeout(r, 2500));
2279
- if (debugDir) await page.screenshot({ path: path.join(debugDir, 'pdfte-02-after-rut.png'), fullPage: true }).catch(() => {});
2280
-
2281
- // Paso 2: Diálogo "ya existe revisión" → click "Sí"
2282
- // nuevaRevisionCreada=true cuando el usuario acepta crear una nueva revisión;
2283
- // en ese caso NO se hace el early-exit por estado previo (el texto "EN REVISIÓN"
2284
- // que queda visible corresponde a la revisión antigua, no a la nueva vacía).
2285
- let nuevaRevisionCreada = false;
2286
- const hayDialog = await page.evaluate(() => {
2287
- const dlg = document.querySelector('.x-window');
2288
- return !!(dlg && dlg.offsetParent !== null);
2289
- });
2290
- if (hayDialog) {
2291
- console.log(' → Diálogo "Ya existe revisión" detectado → click "Sí"');
2292
- if (debugDir) {
2293
- await page.screenshot({ path: path.join(debugDir, 'pdfte-dialog-ya-existe.png'), fullPage: true }).catch(() => {});
2294
- fs.writeFileSync(path.join(debugDir, 'pdfte-dialog-ya-existe.html'), await page.content().catch(() => ''), 'utf8');
2295
- // Log texto del diálogo para debug
2296
- const dlgText = await page.evaluate(() => {
2297
- const dlg = document.querySelector('.x-window');
2298
- return dlg ? dlg.textContent.trim().replace(/\s+/g, ' ') : '';
2299
- }).catch(() => '');
2300
- console.log(` [DEBUG] Texto del diálogo: "${dlgText}"`);
2301
- }
2302
- const clicked = await page.evaluate(() => {
2303
- const si = Array.from(document.querySelectorAll('button.x-btn-text'))
2304
- .find(b => /^s[ií]$/i.test(b.textContent.trim()));
2305
- if (si) { si.click(); return true; }
2306
- return false;
2307
- });
2308
- if (!clicked) await page.evaluate(() => { const b = document.querySelector('.x-window button'); if (b) b.click(); });
2309
- await new Promise(r => setTimeout(r, 2500));
2310
- if (debugDir) await page.screenshot({ path: path.join(debugDir, 'pdfte-dialog-despues-si.png'), fullPage: true }).catch(() => {});
2311
- nuevaRevisionCreada = true;
2312
- }
2672
+ const GWT_SVC = 'cl.sii.sdi.dim.validaPdfDte.web.client.service.ServicePdfDte';
2673
+ const GWT_BASE = 'https://www4.sii.cl/pdfdteInternet/';
2674
+ const SVC_URL = `${GWT_BASE}ServicePdfDte`;
2675
+
2676
+ const gwtPost = (body) => makeReq(SVC_URL, {
2677
+ method: 'POST',
2678
+ body,
2679
+ headers: {
2680
+ 'Content-Type': 'text/x-gwt-rpc; charset=UTF-8',
2681
+ 'X-GWT-Module-Base': GWT_BASE,
2682
+ 'X-GWT-Permutation': permHash,
2683
+ 'Cache-Control': 'no-cache',
2684
+ 'Pragma': 'no-cache',
2685
+ },
2686
+ cookies,
2687
+ });
2313
2688
 
2314
- // Paso 3: RUT Proveedor (mismo RUT empresa)
2315
- await page.waitForFunction(() => {
2316
- const ins = document.querySelectorAll('input[name="rut"]');
2317
- return ins.length >= 2 && !ins[1].disabled;
2318
- }, { timeout: 15000 }).catch(() => {});
2319
-
2320
- const rutNow = await page.$$('input[name="rut"]');
2321
- const dvNow = await page.$$('input[name="dv"]');
2322
- const pRut = rutNow.length >= 2 ? rutNow[1] : rutNow[0];
2323
- const pDv = dvNow.length >= 2 ? dvNow[1] : dvNow[0];
2324
- console.log(` Ingresando RUT proveedor: ${rutNum}-${dvChar}`);
2325
- await pRut.click({ clickCount: 3 }); await pRut.type(rutNum);
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);
2348
- await clickBoton(page, 'Consultar');
2349
- await new Promise(r => setTimeout(r, 2500));
2350
- page.off('response', _onPortalResponse);
2351
- if (debugDir) await page.screenshot({ path: path.join(debugDir, 'pdfte-03-after-consultar.png'), fullPage: true }).catch(() => {});
2352
-
2353
- // ── Re-ejecución: detectar estado terminal antes de proceder ──
2354
- // Solo aplicable cuando NO se creó una nueva revisión.
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.
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
2367
- const t = (document.body.textContent || '').toUpperCase();
2368
- if (t.includes('APROBADO')) return 'APROBADO';
2369
- if (t.includes('ENVIADO AL SII')) return 'ENVIADO AL SII';
2370
- if (t.includes('RECHAZADO')) return 'RECHAZADO';
2371
- return null;
2372
- }).catch(() => null);
2373
- if (_estadoYaSubido) {
2374
- console.log(` [OK] Portal ya muestra estado "${_estadoYaSubido}" — muestras subidas previamente. Proceso completado.`);
2375
- return { success: true, alreadyCompleted: true, estado: _estadoYaSubido };
2376
- }
2689
+ // ── Paso 1: verificar si existe revisión previa (leeImpreso) ─────────────
2690
+ const leeBody =
2691
+ `7|0|6|${GWT_BASE}|${policyHash}|${GWT_SVC}|leeImpreso|` +
2692
+ `java.lang.String/2004016611|${rutNum}-${dvChar}|1|2|3|4|1|5|6|`;
2693
+ const leeResp = await gwtPost(leeBody);
2694
+ if (leeResp.status !== 200) throw new Error(`pdfdteInternet leeImpreso HTTP ${leeResp.status}`);
2695
+
2696
+ // ── Parsear string table GWT (siempre antes de flags/version al final) ─────
2697
+ const _parseGwtStringTable = (body) => {
2698
+ const stIdx = body.lastIndexOf(',["');
2699
+ if (stIdx === -1) return null;
2700
+ const stEnd = body.lastIndexOf('],'); // ÚLTIMO ], = cierre del string table
2701
+ if (stEnd <= stIdx) return null;
2702
+ try {
2703
+ // GWT usa \x27 etc. (no válido en JSON) convertir a \uXXXX
2704
+ const raw = body.substring(stIdx + 1, stEnd + 1);
2705
+ const normalized = raw.replace(/\\x([0-9a-fA-F]{2})/g, (_, h) => `\\u00${h}`);
2706
+ return JSON.parse(normalized);
2707
+ } catch (_) { return null; }
2708
+ };
2377
2709
 
2378
- // Paso 4: "Crear" → habilita el input de archivo
2379
- console.log(' Click "Crear"...');
2380
- await clickBoton(page, 'Crear');
2381
- await new Promise(r => setTimeout(r, 2500));
2382
- if (debugDir) await page.screenshot({ path: path.join(debugDir, 'pdfte-04-after-crear.png'), fullPage: true }).catch(() => {});
2383
-
2384
- // Paso 5: Verificar que el input de archivo existe antes de empezar
2385
- const inputCheck = await page.waitForSelector('input.gwt-FileUpload', { timeout: 30000 }).catch(() => null);
2386
- if (!inputCheck) {
2387
- if (debugDir) {
2388
- await page.screenshot({ path: path.join(debugDir, 'pdfte-error-no-fileinput.png'), fullPage: true }).catch(() => {});
2389
- fs.writeFileSync(path.join(debugDir, 'pdfte-error.html'), await page.content(), 'utf8');
2390
- }
2391
- throw new Error('pdfdteInternet: no apareció el input de archivo tras "Crear"');
2392
- }
2710
+ const leeTable = _parseGwtStringTable(leeResp.body);
2711
+ const estadoMatch = leeResp.body.match(/"(APROBADO|POR REVISAR|EN REVISI[OÓ]N|RECHAZADO|INGRESO|ENVIADO AL SII)"/i);
2712
+ const estadoActual = estadoMatch ? estadoMatch[1].toUpperCase() : null;
2393
2713
 
2394
- // El portal sólo acepta un PDF a la vez (input sin atributo "multiple").
2395
- // Tras el submit GWT no recrea el input dentro de la misma carga de página.
2396
- // Solución: re-navegar al portal antes de cada archivo 2 (misma revisión,
2397
- // misma sesión con cookies); el diálogo "ya existe revisión " la abre.
2398
- const navegarAlFormulario = async () => {
2399
- await page.goto('https://www4.sii.cl/pdfdteInternet/', { waitUntil: 'networkidle2', timeout: 60000 });
2400
- await new Promise(r => setTimeout(r, 2000));
2401
- const _ruts = await page.$$('input[name="rut"]');
2402
- const _dvs = await page.$$('input[name="dv"]');
2403
- if (!_ruts.length) throw new Error('pdfdteInternet: sin campos RUT en re-navegación (¿sesión expirada?)');
2404
- await _ruts[0].click({ clickCount: 3 }); await _ruts[0].type(rutNum);
2405
- await _dvs[0].click({ clickCount: 3 }); await _dvs[0].type(dvChar);
2406
- await clickBoton(page, 'Rut');
2407
- await new Promise(r => setTimeout(r, 2000));
2408
- // Diálogo "ya existe revisión" → click "Sí" para abrirla
2409
- const _dlg = await page.evaluate(() => { const d = document.querySelector('.x-window'); return !!(d && d.offsetParent !== null); });
2410
- if (_dlg) {
2411
- const _ok = await page.evaluate(() => {
2412
- const si = Array.from(document.querySelectorAll('button.x-btn-text')).find(b => /^s[ií]$/i.test(b.textContent.trim()));
2413
- if (si) { si.click(); return true; }
2414
- return false;
2415
- });
2416
- if (!_ok) await page.evaluate(() => { const b = document.querySelector('.x-window button'); if (b) b.click(); });
2417
- await new Promise(r => setTimeout(r, 2000));
2418
- }
2419
- // RUT proveedor (mismo que empresa)
2420
- await page.waitForFunction(() => { const ins = document.querySelectorAll('input[name="rut"]'); return ins.length >= 2 && !ins[1].disabled; }, { timeout: 10000 }).catch(() => {});
2421
- const _rutsN = await page.$$('input[name="rut"]');
2422
- const _dvsN = await page.$$('input[name="dv"]');
2423
- const _pRut = _rutsN.length >= 2 ? _rutsN[1] : _rutsN[0];
2424
- const _pDv = _dvsN.length >= 2 ? _dvsN[1] : _dvsN[0];
2425
- await _pRut.click({ clickCount: 3 }); await _pRut.type(rutNum);
2426
- await _pDv.click({ clickCount: 3 }); await _pDv.type(dvChar);
2427
- await clickBoton(page, 'Consultar');
2428
- await new Promise(r => setTimeout(r, 2500));
2429
- // Si aún no hay formulario de subida (primera vez), crear revisión
2430
- const _hayInp = await page.$('input.gwt-FileUpload').catch(() => null);
2431
- if (!_hayInp) { await clickBoton(page, 'Crear'); await new Promise(r => setTimeout(r, 2500)); }
2432
- };
2433
-
2434
- const fileInput = await page.$('input.gwt-FileUpload');
2435
- if (!fileInput) throw new Error('pdfdteInternet: input.gwt-FileUpload no encontrado');
2436
-
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
- }
2714
+ // Detectar mensaje de error del SII: tabla parseada o regex directo en body (por si \x27 falla)
2715
+ const errorEstadoMsg = !estadoActual && (
2716
+ (leeTable && leeTable.find(s => typeof s === 'string' && /El estado de la postulacion/i.test(s))) ||
2717
+ (() => { const m = leeResp.body.match(/"(El estado de la postulacion[^"\\]*(?:\\.[^"\\]*)*)"/i); return m ? m[1].replace(/\\x27/g, "'").replace(/\\x22/g, '"') : null; })()
2718
+ );
2719
+ if (errorEstadoMsg) {
2720
+ console.log(` Portal SII bloqueado: ${errorEstadoMsg}`);
2721
+ return {
2722
+ success: false, blocked: true, estado: 'BLOQUEADO',
2723
+ error: errorEstadoMsg,
2724
+ hint: 'Espere a que el SII procese la revisión en curso (APROBADO/RECHAZADO) antes de re-subir.',
2457
2725
  };
2458
- page.on('response', _onFirstUploadResp);
2459
-
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
- }
2494
-
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);
2726
+ }
2501
2727
 
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');
2506
- }
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.');
2728
+ // Si ya hay una revisión activa (no rechazada ni aprobada), no se puede re-subir.
2729
+ // El SII sólo permite crear nueva revisión cuando el estado es RECHAZADO o APROBADO.
2730
+ if (estadoActual && estadoActual !== 'RECHAZADO' && estadoActual !== 'APROBADO') {
2731
+ // Extraer detalles adicionales de la respuesta leeImpreso para diagnóstico
2732
+ let numImpresos = null, fechaEnvio = null, revId = null;
2733
+ if (leeTable) {
2734
+ const fechaStr = leeTable.find(s => typeof s === 'string' && /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}/.test(s));
2735
+ const nStr = leeTable.find(s => typeof s === 'string' && /^\d+$/.test(s) && +s > 0 && +s < 500);
2736
+ fechaEnvio = fechaStr || null;
2737
+ numImpresos = nStr ? parseInt(nStr, 10) : null;
2508
2738
  }
2509
-
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)}`);
2542
- }
2543
- }
2739
+ for (const m of leeResp.body.matchAll(/'([A-Za-z0-9$_]{3,7})'/g)) {
2740
+ let n = 0; for (const c of m[1]) n = n * 64 + GWT_B64.indexOf(c);
2741
+ if (n >= 100000 && n <= 9999999) { revId = n; break; }
2544
2742
  }
2545
-
2546
- console.log(` ✓ ${pdfPaths.length} PDFs subidos al portal`);
2547
-
2548
- // Esperar que las validaciones del portal (leeImpresoById) terminen
2549
- await page.waitForNetworkIdle({ timeout: 60000, idleTime: 2000 }).catch(() => {});
2550
- await new Promise(r => setTimeout(r, 2000));
2551
-
2552
- if (debugDir) await page.screenshot({ path: path.join(debugDir, 'pdfte-05-archivos-listos.png'), fullPage: true }).catch(() => {});
2553
-
2554
- // Paso 6: re-navegar al estado limpio y Enviar al SII
2555
- console.log(' Re-navegando para "Enviar al SII"...');
2556
- await navegarAlFormulario();
2557
-
2558
- // Esperar a que el botón esté habilitado (aria-disabled="false")
2559
- await page.waitForFunction(() => {
2560
- const btn = Array.from(document.querySelectorAll('button.x-btn-text'))
2561
- .find(b => b.textContent.trim() === 'Enviar al SII' && b.getAttribute('aria-disabled') !== 'true');
2562
- return !!btn;
2563
- }, { timeout: 15000 }).catch(() => {});
2564
-
2565
- if (debugDir) await page.screenshot({ path: path.join(debugDir, 'pdfte-05b-antes-enviar.png'), fullPage: true }).catch(() => {});
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
-
2578
- console.log(' → Click "Enviar al SII"...');
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;
2743
+ console.log(` → Estado: ${estadoActual}${revId ? ` | Revisión #${revId}` : ''}${fechaEnvio ? ` | Enviado: ${fechaEnvio}` : ''}${numImpresos ? ` | ${numImpresos} documentos` : ''} — ya enviadas, no se requiere re-subida`);
2744
+ return { success: true, alreadyCompleted: true, estado: estadoActual, revId, numImpresos, fechaEnvio };
2745
+ }
2746
+ if (estadoActual) {
2747
+ console.log(` Estado previo: ${estadoActual} creando nueva revisión`);
2748
+ } else {
2749
+ console.log(` → No hay revisión previa — creando primera revisión`);
2750
+ }
2751
+
2752
+ // ── Paso 2: crear nueva lista/revisión (creaLista) ────────────────────────
2753
+ // Params (6): amb=Z, rutNum, dv, proveedor=rutNum, provDv=dv, provNomre=""
2754
+ // String table (9): module, policy, svc, method, String type, Z, rutNum, dv, ""
2755
+ const creaBody =
2756
+ `7|0|9|${GWT_BASE}|${policyHash}|${GWT_SVC}|creaLista|` +
2757
+ `java.lang.String/2004016611|Z|${rutNum}|${dvChar}||` +
2758
+ `1|2|3|4|6|5|5|5|5|5|6|7|8|9|7|8|0|`;
2759
+ const creaResp = await gwtPost(creaBody);
2760
+ if (creaResp.status !== 200 || !creaResp.body.startsWith('//OK')) {
2761
+ throw new Error(`pdfdteInternet creaLista falló HTTP ${creaResp.status}: ${creaResp.body.substring(0, 200)}`);
2762
+ }
2763
+ // Detectar error de SII dentro del //OK (e.g. revisión en curso bloqueando)
2764
+ const creaTable = _parseGwtStringTable(creaResp.body);
2765
+ const creaErrorMsg = creaTable && creaTable.find(s => typeof s === 'string' && /El estado de la postulacion/i.test(s));
2766
+ if (creaErrorMsg) {
2767
+ throw new Error(`pdfdteInternet creaLista: ${creaErrorMsg} — Espere a que el SII procese la revisión en curso.`);
2768
+ }
2769
+
2770
+ // Extraer ID de revisión (Long codificado en GWT base64, ej: 'BAqo' = 264872)
2771
+ let revId = null;
2772
+ let revIdEncoded = null;
2773
+ for (const m of creaResp.body.matchAll(/'([A-Za-z0-9$_]{3,7})'/g)) {
2774
+ const n = decodeGwtLong(m[1]);
2775
+ if (n >= 10000 && n <= 9999999) { revId = n; revIdEncoded = m[1]; break; }
2776
+ }
2777
+ if (!revId) throw new Error(`pdfdteInternet: no se pudo extraer ID de revisión de: ${creaResp.body.substring(0, 300)}`);
2778
+ console.log(` ID de revisión: ${revId} (GWT: ${revIdEncoded})`);
2779
+
2780
+ if (debugDir) {
2781
+ fs.writeFileSync(path.join(debugDir, `pdfte-crea-lista-resp.txt`), creaResp.body, 'utf8');
2782
+ }
2783
+
2784
+ // ── Paso 3: subir cada PDF ────────────────────────────────────────────────
2785
+ const uploadUrl = `${GWT_BASE}upload`;
2786
+ let uploadedCount = 0;
2787
+ for (const pdfPath of pdfPaths) {
2788
+ const filename = path.basename(pdfPath);
2789
+ const pdfData = fs.readFileSync(pdfPath);
2790
+ const boundary = `----WebKitFormBoundary${Date.now().toString(16)}`;
2791
+
2792
+ // Construir multipart/form-data manualmente para soportar datos binarios
2793
+ const partFile = Buffer.concat([
2794
+ Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="Filedata"; filename="${filename}"\r\nContent-Type: application/octet-stream\r\n\r\n`),
2795
+ pdfData,
2796
+ Buffer.from('\r\n'),
2797
+ ]);
2798
+ const partId = Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="id"\r\n\r\n${revId}\r\n`);
2799
+ const partAmb = Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="ambiente"\r\n\r\nfalse\r\n`);
2800
+ const closing = Buffer.from(`--${boundary}--\r\n`);
2801
+ const formBody = Buffer.concat([partFile, partId, partAmb, closing]);
2802
+
2803
+ const upResp = await makeReq(uploadUrl, {
2804
+ method: 'POST',
2805
+ body: formBody,
2806
+ headers: {
2807
+ 'Content-Type': `multipart/form-data; boundary=${boundary}`,
2808
+ 'Referer': `${GWT_BASE}${permHash}.cache.html`,
2809
+ 'x-dtreferer': GWT_BASE,
2810
+ 'Cache-Control': 'no-cache',
2811
+ 'Pragma': 'no-cache',
2812
+ },
2813
+ cookies,
2596
2814
  });
2597
2815
 
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
- }
2614
-
2615
- await page.waitForNetworkIdle({ timeout: 30000, idleTime: 1000 }).catch(() => {});
2616
- await new Promise(r => setTimeout(r, 3000));
2617
- if (debugDir) {
2618
- await page.screenshot({ path: path.join(debugDir, 'pdfte-06-enviado.png'), fullPage: true }).catch(() => {});
2619
- fs.writeFileSync(path.join(debugDir, 'pdfte-06-enviado.html'), await page.content(), 'utf8');
2816
+ if (upResp.status !== 200) {
2817
+ throw new Error(`pdfdteInternet upload ${filename} HTTP ${upResp.status}: ${upResp.body.substring(0, 150)}`);
2620
2818
  }
2819
+ uploadedCount++;
2820
+ console.log(` ✓ [${uploadedCount}/${pdfPaths.length}] ${filename} → ${upResp.body.trim()}`);
2821
+ }
2621
2822
 
2622
- const pageText = await page.$eval('body', el => el.textContent).catch(() => '');
2623
- const exitoso = /revision.*creada|solicitud.*enviada|documentos.*enviados|fue.*enviado|[eé]xito|por revisar/i.test(pageText);
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');
2628
- return { success: exitoso, pageText: pageText.substring(0, 800) };
2823
+ // ── Paso 4: enviar al SII (solicitaRevisionSII) ────────────────────────
2824
+ // Params: Long id (revIdEncoded), String amb=Z
2825
+ const enviarBody =
2826
+ `7|0|6|${GWT_BASE}|${policyHash}|${GWT_SVC}|solicitaRevisionSII|` +
2827
+ `java.lang.Long/4227064769|Z|1|2|3|4|2|5|6|5|${revIdEncoded}|0|`;
2828
+ const enviarResp = await gwtPost(enviarBody);
2829
+ if (enviarResp.status !== 200 || !enviarResp.body.startsWith('//OK')) {
2830
+ throw new Error(`pdfdteInternet solicitaRevisionSII falló HTTP ${enviarResp.status}: ${enviarResp.body.substring(0, 200)}`);
2831
+ }
2629
2832
 
2630
- } catch (err) {
2631
- return { success: false, error: err.message };
2632
- } finally {
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
- }
2833
+ if (debugDir) {
2834
+ fs.writeFileSync(path.join(debugDir, `pdfte-enviar-sii-resp.txt`), enviarResp.body, 'utf8');
2643
2835
  }
2836
+
2837
+ console.log(` ✓ Solicitud enviada al SII correctamente (${pdfPaths.length} PDFs, revisión ${revId})`);
2838
+ process.stdout.write('\nMUESTRAS SUBIDAS EXITOSAMENTE\n');
2839
+ return { success: true, revId };
2644
2840
  }
2645
2841
 
2646
2842
  // ═══════════════════════════════════════════════════════════════
@@ -2660,179 +2856,85 @@ class CertRunner {
2660
2856
  * @returns {Promise<{success: boolean, setText?: string, error?: string}>}
2661
2857
  */
2662
2858
  async obtenerSetBoletaPortal({ setPath, correoSet = '' } = {}) {
2663
- const puppeteer = require('puppeteer');
2664
- const cookieJar = await this._obtenerCookiesSII();
2665
- const puppeteerCookies = Object.entries(cookieJar).map(([name, value]) => ({
2666
- name, value, domain: '.sii.cl', path: '/', httpOnly: false, secure: true,
2667
- }));
2668
- const [rutNum, dvChar] = this.config.emisor.rut.split('-');
2669
-
2670
- let browser;
2671
- try {
2672
- browser = await puppeteer.launch(this._getPuppeteerLaunchOptions());
2673
- const page = await browser.newPage();
2674
- await page.setCookie(...puppeteerCookies);
2675
- page.on('dialog', async dlg => { console.log(` → [dialog SET=1] ${dlg.message()}`); await dlg.accept(); });
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
-
2689
- console.log(' → Cargando portal certBolElectDteInternet (SET=1)...');
2690
- await page.goto('https://www4.sii.cl/certBolElectDteInternet/?SET=1', {
2691
- waitUntil: 'networkidle2', timeout: 60000,
2692
- });
2693
- await new Promise(r => setTimeout(r, 2000));
2694
- await saveDebugSet1('01-carga');
2695
-
2696
- // Paso 1: RUT empresa → "Confirmar Empresa"
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
- }
2703
- console.log(` → Ingresando RUT empresa: ${rutNum}-${dvChar}`);
2704
- await rutInput.click({ clickCount: 3 }); await rutInput.type(rutNum);
2705
- await dvInput.click({ clickCount: 3 }); await dvInput.type(dvChar);
2706
- await page.evaluate(() => {
2707
- const btn = Array.from(document.querySelectorAll('button'))
2708
- .find(b => /confirmar/i.test(b.textContent));
2709
- if (btn) btn.click();
2710
- });
2711
- console.log(' → Click "Confirmar Empresa"');
2712
-
2713
- // Esperar checkboxes — GWT dispara ~8-10 POST /facade en paralelo
2714
- await page.waitForNetworkIdle({ idleTime: 800, timeout: 40000 }).catch(() => {});
2715
- await page.waitForFunction(() => {
2716
- return document.querySelector('input[type="checkbox"]') !== null;
2717
- }, { timeout: 40000, polling: 500 }).catch(() => {});
2718
- await new Promise(r => setTimeout(r, 500));
2719
- await saveDebugSet1('02-post-confirm');
2720
-
2721
- // Paso 2: Marcar todos los checkboxes
2722
- const nCbs = await page.evaluate(() => {
2723
- const cbs = Array.from(document.querySelectorAll('input[type="checkbox"]'));
2724
- cbs.forEach(cb => { if (!cb.checked) cb.click(); });
2725
- return cbs.length;
2726
- });
2727
- console.log(` → ${nCbs} checkbox(es) marcados`);
2728
- await new Promise(r => setTimeout(r, 300));
2729
-
2730
- // Paso 3: Rellenar correo proveedor
2731
- // El campo de email es el input visible que NO tiene maxlength pequeño
2732
- const allInputs = await page.$$('input[type="text"].form-control');
2733
- let emailInput = null;
2734
- for (const inp of allInputs) {
2735
- const ml = await page.evaluate(el => el.maxLength, inp);
2736
- const visible = await page.evaluate(el => el.offsetParent !== null, inp);
2737
- if (visible && (ml <= 0 || ml > 8)) { emailInput = inp; break; }
2738
- }
2739
- if (emailInput) {
2740
- await emailInput.click({ clickCount: 3 });
2741
- await emailInput.type(correoSet);
2742
- console.log(` → Correo proveedor: ${correoSet}`);
2743
- } else {
2744
- console.log(' [!] No se encontró campo de correo — continuando sin él');
2745
- }
2746
-
2747
- // Paso 4: Click "Bajar Nuevo Set" — esperar POST /facade (GWT RPC) y luego
2748
- // construir la URL de DownloadFileServlet directamente con los parámetros conocidos.
2749
- // La descarga real es un GET:
2750
- // DownloadFileServlet?rutEmpresa=X&dvEmpresa=X&rutRepre=X&dvRepre=X&mailProvSw=X
2751
- // donde rutRepre/dvRepre vienen de las cookies NETSCAPE_LIVEWIRE.rut / .dv
2752
-
2753
- const rutRepreNum = cookieJar['NETSCAPE_LIVEWIRE.rut'] || cookieJar['RUT_NS'] || '';
2754
- const dvRepreChar = cookieJar['NETSCAPE_LIVEWIRE.dv'] || cookieJar['DV_NS'] || '';
2755
- if (!rutRepreNum) throw new Error('No se pudo obtener rutRepre de las cookies SII (NETSCAPE_LIVEWIRE.rut)');
2756
-
2757
- // Registrar listener de facade ANTES de hacer click
2758
- const facadePromise = page.waitForResponse(
2759
- resp => resp.url().includes('/certBolElectDteInternet/facade'),
2760
- { timeout: 20000 }
2761
- ).catch(() => null);
2762
-
2763
- console.log(' → Click "Bajar Nuevo Set" — esperando GWT facade...');
2764
- await page.evaluate(() => {
2765
- const btn = Array.from(document.querySelectorAll('button'))
2766
- .find(b => /bajar/i.test(b.textContent));
2767
- if (btn) btn.click();
2768
- });
2859
+ const https = require('https');
2860
+ const crypto = require('crypto');
2861
+ const CBE_BASE = 'https://www4.sii.cl/certBolElectDteInternet/';
2862
+ const CBE_PERM = '0FC3D987613537E6E13E9BB93A406F13';
2863
+ const CBE_POLICY = '082D0AC4BC4D75A5DF38F116C53877D4';
2864
+ const CBE_SVC = 'cl.sii.sdi.diii.certBolElectDte.web.client.service.Facade';
2769
2865
 
2770
- // Esperar que el facade GWT procese la solicitud
2771
- await facadePromise;
2772
- await new Promise(r => setTimeout(r, 1000));
2866
+ const cookieJar = await this._obtenerCookiesSII();
2867
+ const cookieStr = Object.entries(cookieJar).map(([k, v]) => `${k}=${v}`).join('; ');
2868
+ const [rutNum, dvChar] = this.config.emisor.rut.replace(/\./g, '').split('-');
2869
+ const dvUp = dvChar.toUpperCase();
2773
2870
 
2774
- // Construir URL de descarga e ir directo con https.get + cookies
2775
- const https = require('https');
2776
- const cookieStr = Object.entries(cookieJar).map(([k, v]) => `${k}=${v}`).join('; ');
2777
- const downloadUrl = `https://www4.sii.cl/certBolElectDteInternet/DownloadFileServlet` +
2778
- `?rutEmpresa=${rutNum}&dvEmpresa=${dvChar}` +
2779
- `&rutRepre=${rutRepreNum}&dvRepre=${dvRepreChar}` +
2780
- `&mailProvSw=${encodeURIComponent(correoSet)}`;
2871
+ const tlsOpts = {
2872
+ rejectUnauthorized: false, maxVersion: 'TLSv1.2',
2873
+ secureOptions: crypto.constants.SSL_OP_ALLOW_UNSAFE_LEGACY_RENEGOTIATION
2874
+ | crypto.constants.SSL_OP_LEGACY_SERVER_CONNECT,
2875
+ };
2876
+ const gwtPost = (bodyStr) => new Promise((resolve, reject) => {
2877
+ const buf = Buffer.from(bodyStr, 'utf-8');
2878
+ const req = https.request({
2879
+ hostname: 'www4.sii.cl', port: 443, path: '/certBolElectDteInternet/facade', method: 'POST',
2880
+ headers: {
2881
+ 'Content-Type': 'text/x-gwt-rpc; charset=UTF-8',
2882
+ 'X-GWT-Permutation': CBE_PERM,
2883
+ 'X-GWT-Module-Base': CBE_BASE,
2884
+ 'Cookie': cookieStr,
2885
+ 'Content-Length': buf.length,
2886
+ },
2887
+ ...tlsOpts,
2888
+ }, (res) => { const ch = []; res.on('data', c => ch.push(c)); res.on('end', () => resolve(Buffer.concat(ch).toString('utf-8'))); });
2889
+ req.on('error', reject);
2890
+ req.setTimeout(30000, () => { req.destroy(); reject(new Error('timeout gwtPost CBE')); });
2891
+ req.write(buf); req.end();
2892
+ });
2781
2893
 
2782
- console.log(` Descargando set directamente: DownloadFileServlet?rutEmpresa=${rutNum}&dvEmpresa=${dvChar}&rutRepre=${rutRepreNum}&dvRepre=${dvRepreChar}&mailProvSw=${correoSet}`);
2894
+ // recuperarRepresentantesVigentesUsuariosAutorizados -> obtener rutRepre/dvRepre
2895
+ const reprResp = await gwtPost(
2896
+ `7|0|7|${CBE_BASE}|${CBE_POLICY}|${CBE_SVC}|recuperarRepresentantesVigentesUsuariosAutorizados|java.lang.Integer/3438268394|java.lang.String/2004016611|${dvUp}|1|2|3|4|2|5|6|5|${rutNum}|7|`
2897
+ );
2898
+ // Response: //OK[...,["...","...RepreTo/...","<dv>","<rut>"],0,7]
2899
+ const reprMatch = reprResp.match(/"(d{1,2})","(d{7,8})"],0/);
2900
+ const dvRepreChar = reprMatch ? reprMatch[1] : (cookieJar['NETSCAPE_LIVEWIRE.dv'] || cookieJar['DV_NS'] || '');
2901
+ const rutRepreNum = reprMatch ? reprMatch[2] : (cookieJar['NETSCAPE_LIVEWIRE.rut'] || cookieJar['RUT_NS'] || '');
2902
+ if (!rutRepreNum) throw new Error('No se pudo obtener rutRepre (facade + cookies fallaron)');
2903
+
2904
+ // obtenerPostulacionSeg -> requerido por portal antes de descarga
2905
+ await gwtPost(
2906
+ `7|0|9|${CBE_BASE}|${CBE_POLICY}|${CBE_SVC}|obtenerPostulacionSeg|java.lang.Integer/3438268394|java.lang.String/2004016611|${dvUp}|90|P90|1|2|3|4|4|5|6|6|6|5|${rutNum}|7|8|9|`
2907
+ );
2783
2908
 
2784
- const setText = await new Promise((resolve, reject) => {
2785
- const req = https.get(downloadUrl, {
2786
- headers: {
2787
- 'Cookie': cookieStr,
2788
- 'Referer': 'https://www4.sii.cl/certBolElectDteInternet/?SET=1',
2789
- 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
2790
- 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
2791
- },
2792
- rejectUnauthorized: false,
2793
- }, (res) => {
2794
- const chunks = [];
2795
- res.on('data', chunk => chunks.push(chunk));
2796
- res.on('end', () => {
2797
- const body = Buffer.concat(chunks).toString('utf-8');
2798
- resolve(body);
2799
- });
2800
- });
2801
- req.on('error', reject);
2802
- req.setTimeout(30000, () => { req.destroy(); reject(new Error('Timeout descargando DownloadFileServlet')); });
2803
- });
2909
+ // DownloadFileServlet GET
2910
+ const dlUrl = `${CBE_BASE}DownloadFileServlet?rutEmpresa=${rutNum}&dvEmpresa=${dvUp}&rutRepre=${rutRepreNum}&dvRepre=${dvRepreChar}&mailProvSw=${encodeURIComponent(correoSet)}`;
2911
+ console.log(` -> Descargando set boleta: DownloadFileServlet?rutEmpresa=${rutNum}&dvEmpresa=${dvUp}...`);
2804
2912
 
2805
- if (!setText || setText.trim().length < 10) {
2806
- if (dbgDirSet1) {
2807
- fs.writeFileSync(path.join(dbgDirSet1, 'boleta-set-contenido-vacio.txt'), setText || '', 'utf-8');
2808
- await saveDebugSet1('ERROR-descarga-vacia');
2809
- }
2810
- throw new Error(`DownloadFileServlet devolvió contenido vacío (${setText?.length ?? 0} chars). Verificar cookies.`);
2811
- }
2913
+ const setText = await new Promise((resolve, reject) => {
2914
+ const req = https.get(dlUrl, {
2915
+ headers: {
2916
+ 'Cookie': cookieStr,
2917
+ 'Referer': `${CBE_BASE}?SET=1`,
2918
+ 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
2919
+ 'Accept': 'text/plain,text/html,*/*',
2920
+ },
2921
+ ...tlsOpts,
2922
+ }, (res) => { const ch = []; res.on('data', c => ch.push(c)); res.on('end', () => resolve(Buffer.concat(ch).toString('utf-8'))); });
2923
+ req.on('error', reject);
2924
+ req.setTimeout(30000, () => { req.destroy(); reject(new Error('Timeout DownloadFileServlet boleta')); });
2925
+ });
2812
2926
 
2813
- if (setPath) {
2814
- const nodePath = require('path');
2815
- fs.mkdirSync(nodePath.dirname(setPath), { recursive: true });
2816
- fs.writeFileSync(setPath, setText, 'utf-8');
2817
- console.log(` ✓ Set guardado en: ${setPath}`);
2818
- }
2927
+ if (!setText || setText.trim().length < 10)
2928
+ throw new Error(`DownloadFileServlet boleta vacio (${setText?.length ?? 0} chars). Verificar sesion SII.`);
2819
2929
 
2820
- console.log(` ✓ Set de pruebas obtenido (${setText.length} chars)`);
2821
- return { success: true, setText };
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 {}
2832
- return { success: false, error: err.message };
2833
- } finally {
2834
- if (browser) await browser.close().catch(() => {});
2930
+ if (setPath) {
2931
+ const nodePath = require('path');
2932
+ fs.mkdirSync(nodePath.dirname(setPath), { recursive: true });
2933
+ fs.writeFileSync(setPath, setText, 'utf-8');
2934
+ console.log(` OK Set boleta guardado en: ${setPath}`);
2835
2935
  }
2936
+ console.log(` OK Set de pruebas boleta obtenido (${setText.length} chars)`);
2937
+ return { success: true, setText };
2836
2938
  }
2837
2939
 
2838
2940
  /**
@@ -2848,95 +2950,53 @@ class CertRunner {
2848
2950
  */
2849
2951
  async solicitarValidacionBoletaPortal({ trackId } = {}) {
2850
2952
  if (!trackId) throw new Error('solicitarValidacionBoletaPortal: trackId es obligatorio');
2851
- const puppeteer = require('puppeteer');
2852
- const cookieJar = await this._obtenerCookiesSII();
2853
- const puppeteerCookies = Object.entries(cookieJar).map(([name, value]) => ({
2854
- name, value, domain: '.sii.cl', path: '/', httpOnly: false, secure: true,
2855
- }));
2856
- const [rutNum, dvChar] = this.config.emisor.rut.split('-');
2953
+ const https = require('https');
2954
+ const crypto = require('crypto');
2955
+ const CBE_BASE = 'https://www4.sii.cl/certBolElectDteInternet/';
2956
+ const CBE_PERM = '0FC3D987613537E6E13E9BB93A406F13';
2957
+ const CBE_POLICY = '082D0AC4BC4D75A5DF38F116C53877D4';
2958
+ const CBE_SVC = 'cl.sii.sdi.diii.certBolElectDte.web.client.service.Facade';
2857
2959
 
2858
- let browser;
2859
- try {
2860
- browser = await puppeteer.launch(this._getPuppeteerLaunchOptions());
2861
- const page = await browser.newPage();
2862
- await page.setCookie(...puppeteerCookies);
2863
- page.on('dialog', async dlg => { console.log(` → [dialog SET=2] ${dlg.message()}`); await dlg.accept(); });
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
- };
2960
+ const cookieJar = await this._obtenerCookiesSII();
2961
+ const cookieStr = Object.entries(cookieJar).map(([k, v]) => `${k}=${v}`).join('; ');
2962
+ const [rutNum, dvChar] = this.config.emisor.rut.replace(/\./g, '').split('-');
2963
+ const dvUp = dvChar.toUpperCase();
2964
+ const trackIdStr = String(trackId);
2874
2965
 
2875
- console.log(' Cargando portal certBolElectDteInternet (SET=2)...');
2876
- await page.goto('https://www4.sii.cl/certBolElectDteInternet/?SET=2', {
2877
- waitUntil: 'networkidle2', timeout: 60000,
2878
- });
2879
- await new Promise(r => setTimeout(r, 2000));
2880
- await snapSet2('01-carga');
2881
-
2882
- // Paso 1: RUT empresa → "Confirmar Empresa"
2883
- const rutInput = await page.$('input[maxlength="8"]');
2884
- const dvInput = await page.$('input[maxlength="1"]');
2885
- if (!rutInput) throw new Error('certBolElectDteInternet/?SET=2: no se encontró campo RUT');
2886
- console.log(` → Ingresando RUT empresa: ${rutNum}-${dvChar}`);
2887
- await rutInput.click({ clickCount: 3 }); await rutInput.type(rutNum);
2888
- await dvInput.click({ clickCount: 3 }); await dvInput.type(dvChar);
2889
- await page.evaluate(() => {
2890
- const btn = Array.from(document.querySelectorAll('button'))
2891
- .find(b => /confirmar/i.test(b.textContent));
2892
- if (btn) btn.click();
2893
- });
2894
- console.log(' Click "Confirmar Empresa"');
2895
-
2896
- // Esperar que aparezca el campo "Identificador de Envio" — GWT dispara ~8-10 POST /facade en paralelo
2897
- await page.waitForNetworkIdle({ idleTime: 800, timeout: 40000 }).catch(() => {});
2898
- await page.waitForFunction(() => {
2899
- return document.querySelector('input[maxlength="15"]') !== null;
2900
- }, { timeout: 40000, polling: 500 }).catch(() => {});
2901
- await new Promise(r => setTimeout(r, 500));
2902
- await snapSet2('02-post-confirm');
2903
-
2904
- // Paso 2: Ingresar TrackId
2905
- const trackInput = await page.$('input[maxlength="15"]');
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
- }
2911
- console.log(` → Ingresando TrackId: ${trackId}`);
2912
- await trackInput.click({ clickCount: 3 });
2913
- await trackInput.type(String(trackId));
2914
-
2915
- // Paso 3: Click "Solicitar validación"
2916
- await page.evaluate(() => {
2917
- const btn = Array.from(document.querySelectorAll('button'))
2918
- .find(b => /solicitar/i.test(b.textContent));
2919
- if (btn) btn.click();
2920
- });
2921
- console.log(' → Click "Solicitar validación" — esperando respuesta...');
2966
+ const tlsOpts = {
2967
+ rejectUnauthorized: false, maxVersion: 'TLSv1.2',
2968
+ secureOptions: crypto.constants.SSL_OP_ALLOW_UNSAFE_LEGACY_RENEGOTIATION
2969
+ | crypto.constants.SSL_OP_LEGACY_SERVER_CONNECT,
2970
+ };
2971
+ const gwtPost = (bodyStr) => new Promise((resolve, reject) => {
2972
+ const buf = Buffer.from(bodyStr, 'utf-8');
2973
+ const req = https.request({
2974
+ hostname: 'www4.sii.cl', port: 443, path: '/certBolElectDteInternet/facade', method: 'POST',
2975
+ headers: {
2976
+ 'Content-Type': 'text/x-gwt-rpc; charset=UTF-8',
2977
+ 'X-GWT-Permutation': CBE_PERM,
2978
+ 'X-GWT-Module-Base': CBE_BASE,
2979
+ 'Cookie': cookieStr,
2980
+ 'Content-Length': buf.length,
2981
+ },
2982
+ ...tlsOpts,
2983
+ }, (res) => { const ch = []; res.on('data', c => ch.push(c)); res.on('end', () => resolve(Buffer.concat(ch).toString('utf-8'))); });
2984
+ req.on('error', reject);
2985
+ req.setTimeout(30000, () => { req.destroy(); reject(new Error('timeout gwtPost CBE')); });
2986
+ req.write(buf); req.end();
2987
+ });
2922
2988
 
2923
- // Esperar respuesta del portal (puede ser confirmación o error)
2924
- await page.waitForFunction(() => {
2925
- const t = (document.body.textContent || '').toUpperCase();
2926
- return t.includes('ENVI') || t.includes('CORREO') || t.includes('ERROR') ||
2927
- t.includes('VALIDACI') || t.includes('SOLICITUD');
2928
- }, { timeout: 15000, polling: 500 }).catch(() => {});
2929
- await snapSet2('03-respuesta');
2989
+ // ingresarTrackId GWT body format from BOLETA_SET2.har
2990
+ // 7 params: (Integer rutNum, String dvChar, String null, String "90", Integer 0, String null, String trackId)
2991
+ const body = `7|0|9|${CBE_BASE}|${CBE_POLICY}|${CBE_SVC}|ingresarTrackId|java.lang.Integer/3438268394|java.lang.String/2004016611|${dvUp}|90|${trackIdStr}|1|2|3|4|7|5|6|6|6|5|6|6|5|${rutNum}|7|0|8|0|0|9|`;
2992
+ console.log(` -> certBolElectDteInternet/?SET=2: ingresarTrackId trackId=${trackIdStr}...`);
2993
+ const resp = await gwtPost(body);
2994
+ console.log(` OK ingresarTrackId respuesta: ${resp.substring(0, 120)}`);
2930
2995
 
2931
- const respuesta = await page.evaluate(() => (document.body.innerText || '').trim().substring(0, 500));
2932
- console.log(` Validación solicitada. Respuesta: ${respuesta.substring(0, 120)}`);
2996
+ if (!resp.startsWith('//OK'))
2997
+ return { success: false, error: `ingresarTrackId fallo: ${resp.substring(0, 200)}` };
2933
2998
 
2934
- return { success: true, respuesta };
2935
- } catch (err) {
2936
- return { success: false, error: err.message };
2937
- } finally {
2938
- if (browser) await browser.close().catch(() => {});
2939
- }
2999
+ return { success: true, respuesta: resp };
2940
3000
  }
2941
3001
 
2942
3002
  /**
@@ -2955,220 +3015,101 @@ class CertRunner {
2955
3015
  nombreProveedor = '',
2956
3016
  correoProveedor = '',
2957
3017
  } = {}) {
2958
- const puppeteer = require('puppeteer');
2959
- const cookieJar = await this._obtenerCookiesSII();
3018
+ const https = require('https');
3019
+ const crypto = require('crypto');
3020
+ const CBE_BASE = 'https://www4.sii.cl/certBolElectDteInternet/';
3021
+ const CBE_PERM = '0FC3D987613537E6E13E9BB93A406F13';
3022
+ const CBE_POLICY = '082D0AC4BC4D75A5DF38F116C53877D4';
3023
+ const CBE_SVC = 'cl.sii.sdi.diii.certBolElectDte.web.client.service.Facade';
2960
3024
 
2961
- // RUT empresa desde config (ej: "78206276-K")
2962
- const rutEmpresaRaw = (this.config.emisor?.rut || '').replace(/\./g, '');
2963
- const [rutEmpNum, rutEmpDvRaw = 'K'] = rutEmpresaRaw.split('-');
2964
- const rutEmpDv = rutEmpDvRaw.toUpperCase();
3025
+ const cookieJar = await this._obtenerCookiesSII();
3026
+ const cookieStr = Object.entries(cookieJar).map(([k, v]) => `${k}=${v}`).join('; ');
3027
+ const [rutNum, dvChar] = this.config.emisor.rut.replace(/\./g, '').split('-');
3028
+ const dvUp = dvChar.toUpperCase();
3029
+ const razonSocial = this.config.emisor.razon_social || this.config.emisor.razonSocial || '';
2965
3030
 
2966
- const puppeteerCookies = Object.entries(cookieJar).map(([name, value]) => ({
2967
- name, value, domain: '.sii.cl', path: '/', httpOnly: false, secure: true,
2968
- }));
3031
+ const tlsOpts = {
3032
+ rejectUnauthorized: false,
3033
+ maxVersion: 'TLSv1.2',
3034
+ secureOptions: crypto.constants.SSL_OP_ALLOW_UNSAFE_LEGACY_RENEGOTIATION
3035
+ | crypto.constants.SSL_OP_LEGACY_SERVER_CONNECT,
3036
+ };
3037
+ const gwtPost = (bodyStr) => new Promise((resolve, reject) => {
3038
+ const buf = Buffer.from(bodyStr, 'utf-8');
3039
+ const req = https.request({
3040
+ hostname: 'www4.sii.cl', port: 443, path: '/certBolElectDteInternet/facade', method: 'POST',
3041
+ headers: {
3042
+ 'Content-Type': 'text/x-gwt-rpc; charset=UTF-8',
3043
+ 'X-GWT-Permutation': CBE_PERM,
3044
+ 'X-GWT-Module-Base': CBE_BASE,
3045
+ 'Cookie': cookieStr,
3046
+ 'Content-Length': buf.length,
3047
+ },
3048
+ ...tlsOpts,
3049
+ }, (res) => { const ch = []; res.on('data', c => ch.push(c)); res.on('end', () => resolve(Buffer.concat(ch).toString('utf-8'))); });
3050
+ req.on('error', reject);
3051
+ req.setTimeout(30000, () => { req.destroy(); reject(new Error('timeout gwtPost CBE declaracion')); });
3052
+ req.write(buf); req.end();
3053
+ });
2969
3054
 
2970
- let browser;
3055
+ // 1. Obtener representante legal vigente
3056
+ const reprResp = await gwtPost(
3057
+ `7|0|7|${CBE_BASE}|${CBE_POLICY}|${CBE_SVC}|recuperarRepresentantesVigentesUsuariosAutorizados|java.lang.Integer/3438268394|java.lang.String/2004016611|${dvUp}|1|2|3|4|2|5|6|5|${rutNum}|7|`
3058
+ );
3059
+ const reprTableStr = reprResp.substring(reprResp.lastIndexOf(',[') + 1, reprResp.lastIndexOf('],0,7]') + 1);
3060
+ let rutRepreNum = '';
2971
3061
  try {
2972
- browser = await puppeteer.launch(this._getPuppeteerLaunchOptions());
2973
- const page = await browser.newPage();
2974
- await page.setCookie(...puppeteerCookies);
2975
-
2976
- // Capturar alerts/confirms de GWT — sin handler quedan bloqueados
2977
- let dialogMsg = null;
2978
- page.on('dialog', async dlg => {
2979
- dialogMsg = dlg.message();
2980
- console.log(` → [dialog] ${dialogMsg}`);
2981
- await dlg.accept();
2982
- });
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
-
2996
- // ── PASOS 1+2: Confirmar Empresa → esperar formulario (con retry) ──
2997
- // El portal GWT a veces responde con error transitorio ("empresa no autorizada")
2998
- // que se resuelve recargando la página y reintentando.
2999
- const MAX_INTENTOS = 3;
3000
- let checkboxOk = false;
3001
- let lastDialogMsg = null;
3002
- for (let intento = 1; intento <= MAX_INTENTOS; intento++) {
3003
- dialogMsg = null; // resetear entre intentos
3004
-
3005
- console.log(` → Navegando a certBolElectDteInternet (declaración) [intento ${intento}/${MAX_INTENTOS}]...`);
3006
- await page.goto('https://www4.sii.cl/certBolElectDteInternet/', {
3007
- waitUntil: 'networkidle2', timeout: 60000,
3008
- });
3009
- await new Promise(r => setTimeout(r, 2500));
3010
- await saveDebugDecl(`intento${intento}-01-carga`);
3011
-
3012
- // GWT requiere eventos de teclado reales — NO funciona con .value = ...
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
- }
3019
- console.log(` → Ingresando RUT empresa: ${rutEmpresaRaw}`);
3020
- await rutInput.click({ clickCount: 3 }); await rutInput.type(rutEmpNum);
3021
- await dvInput.click({ clickCount: 3 }); await dvInput.type(rutEmpDv);
3022
-
3023
- await page.evaluate(() => {
3024
- const btn = Array.from(document.querySelectorAll('button'))
3025
- .find(b => /confirmar empresa/i.test(b.textContent));
3026
- if (btn) btn.click();
3027
- });
3028
- console.log(' → Click "Confirmar Empresa"...');
3029
-
3030
- // GWT dispara ~8-10 POST /facade EN PARALELO al confirmar.
3031
- // Esperar red inactiva y luego DOM con checkboxes.
3032
- await page.waitForNetworkIdle({ idleTime: 800, timeout: 40000 }).catch(() => {});
3033
- await page.waitForFunction(() => {
3034
- return document.querySelector('input[type="checkbox"]') !== null;
3035
- }, { timeout: 40000, polling: 500 }).catch(() => {});
3036
- await saveDebugDecl(`intento${intento}-02-post-confirm`);
3037
-
3038
- if (dialogMsg) {
3039
- // Portal lanzó alert — puede ser transitorio. Guardar y reintentar.
3040
- lastDialogMsg = dialogMsg;
3041
- await saveDebugDecl(`intento${intento}-DIALOG`);
3042
- console.log(` [!] Portal respondió con alerta en intento ${intento}: ${dialogMsg.substring(0, 100)}`);
3043
- if (intento < MAX_INTENTOS) {
3044
- console.log(' → Recargando y reintentando en 3s...');
3045
- await new Promise(r => setTimeout(r, 3000));
3046
- continue;
3047
- }
3048
- // Agotados los intentos con alerta — el SII puede requerir esperar SOK
3049
- await saveDebugDecl('PENDINGSOK-final');
3050
- return { success: false, pendingSok: true, error: lastDialogMsg };
3051
- }
3052
-
3053
- if (await page.$('input[type="checkbox"]')) {
3054
- checkboxOk = true;
3055
- break;
3056
- }
3057
-
3058
- console.log(` [!] Formulario no cargó en intento ${intento}${intento < MAX_INTENTOS ? ` — reintentando...` : ''}`);
3059
- await new Promise(r => setTimeout(r, 2000));
3060
- }
3061
-
3062
- if (!checkboxOk) {
3063
- throw new Error('Formulario de declaración no cargó tras confirmar empresa (3 intentos)');
3064
- }
3065
-
3066
- const totalCbs = await page.evaluate(() => {
3067
- const todos = Array.from(document.querySelectorAll('input[type="checkbox"]'));
3068
- todos.forEach(cb => { if (!cb.checked) cb.click(); });
3069
- return todos.length;
3070
- });
3071
- console.log(` → ${totalCbs} checkbox(es) marcados`);
3072
- await new Promise(r => setTimeout(r, 500));
3073
-
3074
- // ── PASO 3: Rellenar campos proveedor software ────────────────
3075
- // GWT requiere page.type() real — NOT funciona con .value + dispatchEvent
3076
- const fillByLabel = async (labelFragment, value) => {
3077
- const handle = await page.evaluateHandle((frag) => {
3078
- for (const td of document.querySelectorAll('td')) {
3079
- if (!td.textContent.includes(frag)) continue;
3080
- const next = td.nextElementSibling;
3081
- if (!next) continue;
3082
- const inp = next.querySelector('input[type="text"]');
3083
- if (inp) return inp;
3084
- }
3085
- return null;
3086
- }, labelFragment);
3087
- const elem = handle && await handle.asElement();
3088
- if (!elem) return false;
3089
- await elem.click({ clickCount: 3 });
3090
- await elem.type(value);
3091
- return true;
3092
- };
3093
-
3094
- // Link de Consulta (maxlength=100)
3095
- const linkOk = await fillByLabel('Link de Consulta', linkConsulta);
3096
- if (!linkOk) {
3097
- const inp = await page.$('input[type="text"][maxlength="100"]');
3098
- if (inp) { await inp.click({ clickCount: 3 }); await inp.type(linkConsulta); }
3099
- }
3100
-
3101
- // RUT Proveedor — dos inputs (num + DV) en la fila "Rut Proveedor"
3102
- const [rutProvNum, rutProvDvRaw] = rutProveedor.replace(/\./g, '').split('-');
3103
- const rutProvDv = (rutProvDvRaw || 'K').toUpperCase();
3104
- const rutProvH = await page.evaluateHandle(() => {
3105
- for (const td of document.querySelectorAll('td')) {
3106
- if (!td.textContent.includes('Rut Proveedor')) continue;
3107
- const next = td.nextElementSibling;
3108
- if (next) { const ins = next.querySelectorAll('input[type="text"]'); if (ins[0]) return ins[0]; }
3109
- }
3110
- return null;
3111
- });
3112
- const dvProvH = await page.evaluateHandle(() => {
3113
- for (const td of document.querySelectorAll('td')) {
3114
- if (!td.textContent.includes('Rut Proveedor')) continue;
3115
- const next = td.nextElementSibling;
3116
- if (next) { const ins = next.querySelectorAll('input[type="text"]'); if (ins[1]) return ins[1]; }
3117
- }
3118
- return null;
3119
- });
3120
- if (rutProvH && await rutProvH.asElement()) { const e = rutProvH.asElement(); await e.click({ clickCount: 3 }); await e.type(rutProvNum); }
3121
- if (dvProvH && await dvProvH.asElement()) { const e = dvProvH.asElement(); await e.click({ clickCount: 3 }); await e.type(rutProvDv); }
3122
-
3123
- // Nombre Proveedor
3124
- await fillByLabel('Nombre Proveedor', nombreProveedor);
3125
-
3126
- // Correo Proveedor Software
3127
- await fillByLabel('Correo electrónico Proveedor', correoProveedor);
3128
-
3129
- // Captura pre-submit
3130
- await saveDebugDecl('03-pre-submit');
3131
-
3132
- // ── PASO 4: Click "Grabar Declaración" ───────────────────────
3133
- const submitOk = await page.evaluate(() => {
3134
- const btn = Array.from(document.querySelectorAll('button'))
3135
- .find(b => /grabar declaraci/i.test(b.textContent));
3136
- if (btn) { btn.click(); return true; }
3137
- return false;
3138
- });
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
- }
3143
- console.log(' → Click "Grabar Declaración"...');
3144
-
3145
- // Esperar confirmación del SII
3146
- await page.waitForFunction(() => {
3147
- const t = (document.body.textContent || '').toUpperCase();
3148
- return t.includes('GRABADA') || t.includes('GRABADO') || t.includes('COMPLETADA') ||
3149
- t.includes('EXITOSA') || t.includes('GUARDADA') || t.includes('REGISTRADA');
3150
- }, { timeout: 20000, polling: 1000 }).catch(() => {});
3151
-
3152
- const msgFinal = await page.evaluate(() => (document.body.textContent || '').trim().substring(0, 300));
3153
- console.log(` ✓ Declaración completada. Respuesta: ${msgFinal.substring(0, 150)}`);
3154
- await saveDebugDecl('04-post-submit-OK');
3155
-
3156
- return { success: true, mensaje: msgFinal };
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 {}
3168
- return { success: false, error: err.message };
3169
- } finally {
3170
- if (browser) await browser.close().catch(() => {});
3062
+ const reprTable = JSON.parse(reprTableStr);
3063
+ rutRepreNum = reprTable.filter(s => /^\d{7,8}$/.test(s)).pop() || '';
3064
+ } catch {}
3065
+ if (!rutRepreNum) return { success: false, error: 'No se pudo obtener representante vigente (facade CBE)' };
3066
+
3067
+ // 2. Verificar estado portal — debe ser P90 (SOK recibido, listo para declarar)
3068
+ const estadoResp = await gwtPost(
3069
+ `7|0|7|${CBE_BASE}|${CBE_POLICY}|${CBE_SVC}|obtenerEstadoAutorizaEmp|java.lang.Integer/3438268394|java.lang.String/2004016611|${dvUp}|1|2|3|4|4|5|6|5|6|5|${rutNum}|7|5|90|0|`
3070
+ );
3071
+ if (!estadoResp.includes('P90')) {
3072
+ const estadoMatch = estadoResp.match(/"(P\d+)"/);
3073
+ const estado = estadoMatch ? estadoMatch[1] : '(desconocido)';
3074
+ return { success: false, pendingSok: true, error: `Estado portal no es P90 (es ${estado}) — espere SOK del SII` };
3171
3075
  }
3076
+
3077
+ // 3. Obtener datos de postulacion: fchAutorizacion y longCharValue
3078
+ const postulResp = await gwtPost(
3079
+ `7|0|9|${CBE_BASE}|${CBE_POLICY}|${CBE_SVC}|obtenerPostulacionSeg|java.lang.Integer/3438268394|java.lang.String/2004016611|${dvUp}|90|P90|1|2|3|4|4|5|6|6|6|5|${rutNum}|7|8|9|`
3080
+ );
3081
+ const postulTableStr = postulResp.substring(postulResp.lastIndexOf(',[') + 1, postulResp.lastIndexOf('],0,7]') + 1);
3082
+ let fchAutorizacion = '';
3083
+ try {
3084
+ const postulTable = JSON.parse(postulTableStr);
3085
+ fchAutorizacion = postulTable.find(s => /^\d{2}\/\d{2}\/\d{4}$/.test(s)) || '';
3086
+ } catch {}
3087
+ const longMatch = /'([^']+)'/.exec(postulResp);
3088
+ const longCharValue = longMatch ? longMatch[1] : '0';
3089
+
3090
+ // 4. Autorizar empresa boleta produccion
3091
+ const fechaHoy = this._getFechaHoy();
3092
+ const authBody =
3093
+ `7|0|23|${CBE_BASE}|${CBE_POLICY}|${CBE_SVC}|autorizarEmpresaBolProd|` +
3094
+ `cl.sii.sdi.diii.certBolElectDte.to.PostulSegHistInsUpdTo/138688689|P91|BVE|` +
3095
+ `java.lang.Integer/3438268394|${dvUp}|` +
3096
+ `cl.sii.sdi.diii.certBolElectDte.to.TdtEmpresaAutorizadaTo/2240026086|8|SII|` +
3097
+ `${fechaHoy}|${correoProveedor}|${nombreProveedor}|19|S|${linkConsulta}|` +
3098
+ `java.util.ArrayList/4159755760|cl.sii.sdi.diii.certBolElectDte.to.DocumentoAutorizadoTo/493967287|` +
3099
+ `${fchAutorizacion}|java.lang.Long/4227064769|${razonSocial}|` +
3100
+ `1|2|3|4|1|5|5|6|0|7|8|90|0|0|0|0|9|0|10|0|7|9|9|11|12|13|13|13|14|15|16|17|8|` +
3101
+ `${rutNum}|8|${rutNum}|8|${rutRepreNum}|18|13|13|0|19|2|20|0|0|9|11|21|0|0|0|0|0|0|8|` +
3102
+ `${rutNum}|8|${rutRepreNum}|22|${longCharValue}|-11|8|39|0|20|0|0|9|11|21|0|0|0|0|0|0|8|` +
3103
+ `${rutNum}|8|${rutRepreNum}|-11|-11|8|41|0|0|23|-11|0|8|${rutNum}|0|0|`;
3104
+
3105
+ console.log(` -> autorizarEmpresaBolProd rutNum=${rutNum} rutRepreNum=${rutRepreNum} fchAutorizacion=${fchAutorizacion} longCharValue=${longCharValue}`);
3106
+ const authResp = await gwtPost(authBody);
3107
+ console.log(` OK autorizarEmpresaBolProd respuesta: ${authResp.substring(0, 200)}`);
3108
+
3109
+ if (!authResp.startsWith('//OK') || !authResp.includes('DECLARACION EFECTUADA')) {
3110
+ return { success: false, error: `autorizarEmpresaBolProd respuesta inesperada: ${authResp.substring(0, 300)}` };
3111
+ }
3112
+ return { success: true, mensaje: 'DECLARACION EFECTUADA' };
3172
3113
  }
3173
3114
 
3174
3115
  /**
@@ -3272,33 +3213,6 @@ class CertRunner {
3272
3213
  // Helpers privados
3273
3214
  // ═══════════════════════════════════════════════════════════════
3274
3215
 
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
-
3302
3216
  _getFechaHoy() {
3303
3217
  const now = new Date();
3304
3218
  return `${String(now.getDate()).padStart(2, '0')}-${String(now.getMonth() + 1).padStart(2, '0')}-${now.getFullYear()}`;