@devlas/dte-sii 2.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/BoletaService.js +109 -0
  2. package/CAF.js +173 -0
  3. package/CafSolicitor.js +380 -0
  4. package/Certificado.js +123 -0
  5. package/ConsumoFolio.js +376 -0
  6. package/DTE.js +399 -0
  7. package/EnviadorSII.js +1304 -0
  8. package/Envio.js +196 -0
  9. package/FolioRegistry.js +553 -0
  10. package/FolioService.js +703 -0
  11. package/LICENSE +27 -0
  12. package/LibroBase.js +134 -0
  13. package/LibroCompraVenta.js +205 -0
  14. package/LibroGuia.js +225 -0
  15. package/README.md +239 -0
  16. package/Signer.js +94 -0
  17. package/SiiCertificacion.js +1189 -0
  18. package/SiiPortalAuth.js +460 -0
  19. package/SiiSession.js +499 -0
  20. package/cert/BoletaCert.js +731 -0
  21. package/cert/CertFolioHelper.js +185 -0
  22. package/cert/CertRunner.js +2658 -0
  23. package/cert/ConfigLoader.js +133 -0
  24. package/cert/IntercambioCert.js +429 -0
  25. package/cert/LibroCompras.js +359 -0
  26. package/cert/LibroGuias.js +171 -0
  27. package/cert/LibroVentas.js +153 -0
  28. package/cert/MuestrasImpresas.js +676 -0
  29. package/cert/SetBase.js +321 -0
  30. package/cert/SetBasico.js +413 -0
  31. package/cert/SetCompra.js +472 -0
  32. package/cert/SetExenta.js +490 -0
  33. package/cert/SetGuia.js +283 -0
  34. package/cert/SetParser.js +1184 -0
  35. package/cert/SetsProvider.js +499 -0
  36. package/cert/Simulacion.js +521 -0
  37. package/cert/comunaOficina.js +460 -0
  38. package/cert/index.js +124 -0
  39. package/cert/types.js +330 -0
  40. package/dte-sii.d.ts +458 -0
  41. package/index.js +428 -0
  42. package/package.json +48 -0
  43. package/utils/c14n.js +275 -0
  44. package/utils/calculo.js +396 -0
  45. package/utils/config.js +276 -0
  46. package/utils/constants.js +302 -0
  47. package/utils/emisor.js +174 -0
  48. package/utils/endpoints.js +225 -0
  49. package/utils/error.js +235 -0
  50. package/utils/index.js +339 -0
  51. package/utils/logger.js +239 -0
  52. package/utils/pfx.js +203 -0
  53. package/utils/receptor.js +218 -0
  54. package/utils/referencia.js +169 -0
  55. package/utils/resolucion.js +119 -0
  56. package/utils/rut.js +169 -0
  57. package/utils/sanitize.js +124 -0
  58. package/utils/tokenCache.js +214 -0
  59. package/utils/xml.js +358 -0
  60. package/utils.js +4 -0
@@ -0,0 +1,2658 @@
1
+ // Copyright (c) 2026 Devlas SpA — https://devlas.cl
2
+ // Licencia MIT. Ver archivo LICENSE para mas detalles.
3
+ /**
4
+ * CertRunner - Orquestador del proceso de certificación SII
5
+ *
6
+ * Encapsula todo el flujo de certificación:
7
+ * - Obtención de sets de prueba
8
+ * - Solicitud de CAFs
9
+ * - Ejecución de sets (Básico, Guía, Exenta, Compra)
10
+ * - Envío de libros
11
+ * - Declaración de avance
12
+ * - Polling de estado
13
+ *
14
+ * @module dte-sii/cert/CertRunner
15
+ */
16
+
17
+ const fs = require('fs');
18
+ const path = require('path');
19
+
20
+ // Core
21
+ const { Certificado, EnvioDTE } = require('../index');
22
+ const EnviadorSII = require('../EnviadorSII');
23
+ const FolioService = require('../FolioService');
24
+ const SiiCertificacion = require('../SiiCertificacion');
25
+
26
+ // Módulo cert
27
+ const SetsProvider = require('./SetsProvider');
28
+ const CertFolioHelper = require('./CertFolioHelper');
29
+ const SetBasico = require('./SetBasico');
30
+ const SetGuia = require('./SetGuia');
31
+ const SetExenta = require('./SetExenta');
32
+ const SetCompra = require('./SetCompra');
33
+
34
+ // Libros (Fase 4)
35
+ const LibroVentas = require('./LibroVentas');
36
+ const LibroCompras = require('./LibroCompras');
37
+ const LibroGuias = require('./LibroGuias');
38
+
39
+ // Simulación (Fase 6)
40
+ const Simulacion = require('./Simulacion');
41
+
42
+ // Intercambio (Fase 7)
43
+ const IntercambioCert = require('./IntercambioCert');
44
+
45
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
46
+
47
+ /**
48
+ * @typedef {Object} CertConfig
49
+ * @property {Object} certificado - { path, password }
50
+ * @property {Object} emisor - { rut, razon_social, giro, acteco, direccion, comuna, ciudad, fch_resol, nro_resol }
51
+ * @property {Object} receptor - { rut, razon_social, giro, direccion, comuna, ciudad }
52
+ * @property {string} [ambiente='certificacion']
53
+ * @property {string} [debugDir]
54
+ * @property {string} [sessionPath]
55
+ */
56
+
57
+ class CertRunner {
58
+ /**
59
+ * @param {CertConfig} config - Configuración del runner
60
+ */
61
+ constructor(config) {
62
+ this._validateConfig(config);
63
+
64
+ this.config = config;
65
+ this.ambiente = config.ambiente || 'certificacion';
66
+ this.debugDir = config.debugDir || path.join(process.cwd(), 'debug', 'cert-v2');
67
+ this.sessionPath = config.sessionPath || path.join(this.debugDir, 'session.json');
68
+
69
+ // Componentes (lazy init)
70
+ this._certificado = null;
71
+ this._folioService = null;
72
+ this._folioHelper = null;
73
+ this._siiCert = null;
74
+ this._setsProvider = null;
75
+ this._estructuras = null;
76
+
77
+ // Caché de sesión SII en memoria (evita logins múltiples durante la misma ejecución)
78
+ // Se puede inyectar un cookieJar ya obtenido vía config.cookieJar para reutilizar sesión.
79
+ this._siiCookieJar = config.cookieJar || null;
80
+
81
+ // Resultados de ejecución
82
+ this.resultados = {};
83
+ }
84
+
85
+ _validateConfig(config) {
86
+ if (!config.certificado?.path) throw new Error('CertRunner: config.certificado.path es obligatorio');
87
+ if (!config.certificado?.password && config.certificado?.password !== '') {
88
+ throw new Error('CertRunner: config.certificado.password es obligatorio');
89
+ }
90
+ if (!config.emisor?.rut) throw new Error('CertRunner: config.emisor.rut es obligatorio');
91
+ // receptor solo es obligatorio para flujos de emisión DTE (no para métodos de portal Puppeteer)
92
+ if (config.receptor && !config.receptor.rut) {
93
+ throw new Error('CertRunner: config.receptor.rut es obligatorio cuando se proporciona config.receptor');
94
+ }
95
+ }
96
+
97
+ // ═══════════════════════════════════════════════════════════════
98
+ // Getters lazy para componentes
99
+ // ═══════════════════════════════════════════════════════════════
100
+
101
+ get certificado() {
102
+ if (!this._certificado) {
103
+ const pfxBuffer = fs.readFileSync(this.config.certificado.path);
104
+ this._certificado = new Certificado(pfxBuffer, this.config.certificado.password);
105
+ }
106
+ return this._certificado;
107
+ }
108
+
109
+ get folioService() {
110
+ if (!this._folioService) {
111
+ this._folioService = new FolioService({
112
+ ambiente: this.ambiente,
113
+ rutEmisor: this.config.emisor.rut,
114
+ pfxPath: this.config.certificado.path,
115
+ pfxPassword: this.config.certificado.password,
116
+ debugDir: this.debugDir,
117
+ sessionPath: this.sessionPath, // Usar sesión compartida
118
+ retries: 3,
119
+ });
120
+ }
121
+ return this._folioService;
122
+ }
123
+
124
+ get folioHelper() {
125
+ if (!this._folioHelper) {
126
+ this._folioHelper = new CertFolioHelper({ ambiente: this.ambiente });
127
+ }
128
+ return this._folioHelper;
129
+ }
130
+
131
+ get siiCert() {
132
+ if (!this._siiCert) {
133
+ const [rut, dv] = this.config.emisor.rut.split('-');
134
+ this._siiCert = new SiiCertificacion({
135
+ pfxPath: this.config.certificado.path,
136
+ pfxPassword: this.config.certificado.password,
137
+ rutEmpresa: rut,
138
+ dvEmpresa: dv,
139
+ });
140
+ }
141
+ return this._siiCert;
142
+ }
143
+
144
+ get setsProvider() {
145
+ if (!this._setsProvider) {
146
+ const [rut, dv] = this.config.emisor.rut.split('-');
147
+ this._setsProvider = new SetsProvider({
148
+ pfxPath: this.config.certificado.path,
149
+ pfxPassword: this.config.certificado.password,
150
+ rutEmpresa: rut,
151
+ dvEmpresa: dv,
152
+ sessionPath: this.sessionPath,
153
+ debugDir: this.debugDir,
154
+ });
155
+ }
156
+ return this._setsProvider;
157
+ }
158
+
159
+ // ═══════════════════════════════════════════════════════════════
160
+ // Métodos públicos
161
+ // ═══════════════════════════════════════════════════════════════
162
+
163
+ /**
164
+ * Obtiene los sets de prueba del SII
165
+ * @param {Object} [options] - { setsOpcionales }
166
+ * @returns {Promise<Object>} { success, estructuras, error }
167
+ */
168
+ async obtenerSets(options = {}) {
169
+ const resultado = await this.setsProvider.obtenerSets({
170
+ setsOpcionales: options.setsOpcionales || {
171
+ SET03: 'S', // SET GUIA DE DESPACHO
172
+ SET06: 'S', // SET FACTURA EXENTA
173
+ SET07: 'S', // LIBRO DE VENTAS
174
+ SET08: 'S', // LIBRO DE COMPRAS
175
+ SET09: 'S', // LIBRO DE GUIAS
176
+ SET15: 'S', // LIBRO DE COMPRAS PARA EXENTOS
177
+ SET72: 'S', // SET CASO GENERAL FACTURA COMPRA
178
+ },
179
+ forceRefresh: true, // siempre fresco — nunca reusar caché de ejecuciones anteriores
180
+ });
181
+
182
+ if (!resultado.success) {
183
+ return { success: false, error: resultado.error || 'Error desconocido al obtener sets' };
184
+ }
185
+
186
+ this._estructuras = resultado.estructuras;
187
+
188
+ // Guardar debug — solo si hay estructuras reales (no sobreescribir con null)
189
+ if (resultado.estructuras) {
190
+ fs.mkdirSync(this.debugDir, { recursive: true });
191
+ fs.writeFileSync(
192
+ path.join(this.debugDir, 'estructuras.json'),
193
+ JSON.stringify(resultado.estructuras, null, 2)
194
+ );
195
+ }
196
+
197
+ return { success: true, estructuras: resultado.estructuras };
198
+ }
199
+
200
+ /**
201
+ * Solicita CAFs frescos para los tipos especificados
202
+ * @param {Object} cafRequired - { 33: 4, 56: 1, 61: 3 }
203
+ * @returns {Promise<Object>} { 33: cafPath, 56: cafPath, ... }
204
+ */
205
+ async solicitarCafs(cafRequired) {
206
+ const cafs = {};
207
+
208
+ // Limpiar contadores para nuevos CAFs
209
+ this.folioHelper.counters.clear();
210
+ this.folioHelper.usedFolios.clear();
211
+
212
+ for (const [tipoDte, cantidad] of Object.entries(cafRequired)) {
213
+ console.log(` Tipo ${tipoDte}: ${cantidad} folios...`);
214
+
215
+ // Usar solicitarCafConFallback que solicita y retorna el path
216
+ const cafPath = await this.folioService.solicitarCafConFallback({
217
+ tipoDte: Number(tipoDte),
218
+ cantidad: Number(cantidad),
219
+ });
220
+
221
+ if (!cafPath) {
222
+ throw new Error(`No se pudo obtener CAF para tipo ${tipoDte}`);
223
+ }
224
+
225
+ cafs[tipoDte] = cafPath;
226
+ console.log(` ✓ CAF tipo ${tipoDte}`);
227
+ }
228
+
229
+ return cafs;
230
+ }
231
+
232
+ /**
233
+ * Crea un enviador para enviar DTEs al SII
234
+ * @param {string} [setName] - Nombre del set para guardar XML (ej: 'basico', 'guia')
235
+ * @returns {Object} Enviador compatible con Sets
236
+ */
237
+ _createEnviador(setName = null) {
238
+ const enviador = new EnviadorSII(this.certificado, this.ambiente);
239
+ const debugDir = this.debugDir;
240
+
241
+ return {
242
+ async enviar(envio) {
243
+ // Guardar XML del set antes de enviar (para muestras impresas)
244
+ if (setName && debugDir && envio.xml) {
245
+ const setsDir = path.join(debugDir, 'sets-prueba');
246
+ fs.mkdirSync(setsDir, { recursive: true });
247
+
248
+ // Guardar envío consolidado
249
+ const envioPath = path.join(setsDir, `envio-set-${setName}.xml`);
250
+ fs.writeFileSync(envioPath, envio.xml, 'utf8');
251
+ console.log(` 📄 XML guardado: ${envioPath}`);
252
+
253
+ // Guardar DTEs individuales
254
+ if (envio.dtes && envio.dtes.length > 0) {
255
+ const dtesDir = path.join(setsDir, 'dtes');
256
+ fs.mkdirSync(dtesDir, { recursive: true });
257
+ for (const dte of envio.dtes) {
258
+ const tipoDte = dte.getTipoDTE ? dte.getTipoDTE() : dte.tipoDte;
259
+ const folio = dte.getFolio ? dte.getFolio() : dte.folio;
260
+ const dteXml = dte.xml || (dte.getXml ? dte.getXml() : null);
261
+ if (dteXml) {
262
+ const filename = `dte-${String(tipoDte).padStart(2, '0')}-${String(folio).padStart(6, '0')}.xml`;
263
+ fs.writeFileSync(path.join(dtesDir, filename), dteXml, 'utf8');
264
+ }
265
+ }
266
+ }
267
+ }
268
+
269
+ const resultado = await enviador.enviarDteSoap(envio);
270
+ return {
271
+ success: !!resultado?.trackId,
272
+ trackId: resultado?.trackId,
273
+ error: resultado?.error,
274
+ };
275
+ },
276
+ };
277
+ }
278
+
279
+ /**
280
+ * Ejecuta un set genérico — toda la lógica común entre los 4 sets.
281
+ * @private
282
+ */
283
+ async _ejecutarSet(ClaseSet, estructuraKey, resultadoKey, cafFallback, enviadorNombre, casosExterno) {
284
+ const setData = casosExterno || this._estructuras?.[estructuraKey];
285
+ if (!setData) {
286
+ throw new Error(`No hay casos para ${ClaseSet.name}. Ejecutar obtenerSets() primero.`);
287
+ }
288
+
289
+ const cafRequired = setData.cafRequired ||
290
+ (typeof cafFallback === 'function' ? cafFallback(setData) : cafFallback);
291
+ const cafs = await this.solicitarCafs(cafRequired);
292
+
293
+ const set = new ClaseSet({
294
+ config: {
295
+ emisor: this.config.emisor,
296
+ receptor: this.config.receptor,
297
+ certificado: this.config.certificado,
298
+ ambiente: this.ambiente,
299
+ resolucion: {
300
+ fecha: this.config.emisor.fch_resol,
301
+ numero: this.config.emisor.nro_resol,
302
+ },
303
+ },
304
+ cafManager: { ensureCaf: ({ tipoDte }) => cafs[tipoDte] },
305
+ folioHelper: this.folioHelper,
306
+ enviador: this._createEnviador(enviadorNombre),
307
+ });
308
+
309
+ const resultado = await set.ejecutar(setData, cafs);
310
+ this.resultados[resultadoKey] = resultado;
311
+ return resultado;
312
+ }
313
+
314
+ async ejecutarSetBasico(casos) {
315
+ return this._ejecutarSet(SetBasico, 'setBasico', 'basico', { 33: 4, 56: 1, 61: 3 }, 'basico', casos);
316
+ }
317
+
318
+ async ejecutarSetGuia(casos) {
319
+ return this._ejecutarSet(SetGuia, 'setGuiaDespacho', 'guia',
320
+ (setData) => ({ 52: setData.casos?.length || 1 }), 'guia', casos);
321
+ }
322
+
323
+ async ejecutarSetExenta(casos) {
324
+ return this._ejecutarSet(SetExenta, 'setFacturaExenta', 'exenta', { 34: 3, 56: 1, 61: 4 }, 'exenta', casos);
325
+ }
326
+
327
+ async ejecutarSetCompra(casos) {
328
+ return this._ejecutarSet(SetCompra, 'setFacturaCompra', 'compra', { 46: 1, 56: 1, 61: 1 }, 'compra', casos);
329
+ }
330
+
331
+ /**
332
+ * Bucle de reintentos común para declarar avance/libros/simulación.
333
+ * @private
334
+ * @param {Object} sets - Sets a declarar (pasados a siiCert.declararAvance)
335
+ * @param {string} debugPrefix - Prefijo del archivo HTML de debug (ej: 'declaracion-response')
336
+ * @param {Object} [options] - { maxIntentos, intervalo, label }
337
+ */
338
+ async _declararConReintentos(sets, debugPrefix, options = {}) {
339
+ const { maxIntentos = 10, intervalo = 5000, label = 'avance' } = options;
340
+
341
+ console.log(` ⏳ Esperando 10s para que SII procese los envíos...`);
342
+ await sleep(10000);
343
+
344
+ let lastResult = null;
345
+ for (let intento = 1; intento <= maxIntentos; intento++) {
346
+ console.log(` 🔄 Declarando ${label} (intento ${intento}/${maxIntentos})...`);
347
+
348
+ const result = await this.siiCert.declararAvance({ sets });
349
+ lastResult = result;
350
+
351
+ if (result.rawHtml) {
352
+ fs.writeFileSync(
353
+ path.join(this.debugDir, `${debugPrefix}-${intento}.html`),
354
+ result.rawHtml,
355
+ 'utf8'
356
+ );
357
+ }
358
+
359
+ const html = result.rawHtml || '';
360
+ const noProcessedError =
361
+ html.includes('no ha sido procesado') ||
362
+ html.includes('aún no está disponible') ||
363
+ html.includes('intente más tarde') ||
364
+ html.includes('no se encuentra');
365
+
366
+ if (result.success && !noProcessedError) {
367
+ return result;
368
+ }
369
+
370
+ if (noProcessedError && intento < maxIntentos) {
371
+ console.log(` ⏳ SII aún procesando, reintentando en ${intervalo / 1000}s...`);
372
+ await sleep(intervalo);
373
+ } else if (!result.success) {
374
+ console.log(` ⚠️ Error declarando ${label}: ${result.error || 'desconocido'}`);
375
+ break;
376
+ }
377
+ }
378
+
379
+ return lastResult;
380
+ }
381
+
382
+ /**
383
+ * Declara avance de los sets ejecutados con reintentos automáticos
384
+ * @param {Object} [resultadosExt] - Resultados externos (usa this.resultados si no se pasa)
385
+ * @param {Object} [estructurasExt] - Estructuras externas (usa this._estructuras si no se pasa)
386
+ * @param {Object} [options] - { maxIntentos, intervalo }
387
+ * @returns {Promise<Object>} Resultado de la declaración
388
+ */
389
+ async declararAvance(resultadosExt, estructurasExt, options = {}) {
390
+ const resultados = resultadosExt || this.resultados;
391
+ const estructuras = estructurasExt || this._estructuras;
392
+ const { maxIntentos = 10, intervalo = 5000 } = options;
393
+
394
+ if (!estructuras) {
395
+ throw new Error('No hay estructuras. Ejecutar obtenerSets() primero.');
396
+ }
397
+
398
+ const fecha = this._getFechaHoy();
399
+ const sets = {};
400
+
401
+ const mapping = {
402
+ basico: 'setBasico',
403
+ guia: 'setGuiaDespacho',
404
+ exenta: 'setFacturaExenta',
405
+ compra: 'setFacturaCompra',
406
+ };
407
+
408
+ for (const [resKey, setKey] of Object.entries(mapping)) {
409
+ if (resultados[resKey]?.trackId) {
410
+ sets[setKey] = {
411
+ trackId: resultados[resKey].trackId,
412
+ fecha,
413
+ numeroAtencion: estructuras[setKey]?.numeroAtencion,
414
+ };
415
+ }
416
+ }
417
+
418
+ if (Object.keys(sets).length === 0) {
419
+ return { success: false, error: 'No hay sets para declarar' };
420
+ }
421
+
422
+ const result = await this._declararConReintentos(sets, 'declaracion-response', { maxIntentos, intervalo, label: 'avance de sets' });
423
+ if (result?.success) console.log(' ✓ Declaración de sets enviada');
424
+ return result;
425
+ }
426
+
427
+ /**
428
+ * Espera a que los sets sean aprobados
429
+ * @param {string[]} [sets] - Sets a esperar (default: todos los ejecutados)
430
+ * @param {Object} [options] - { maxIntentos, intervalo, onProgress }
431
+ * @returns {Promise<Object>} Resultado del polling
432
+ */
433
+ async esperarAprobacion(sets, options = {}) {
434
+ const setsAEsperar = sets || Object.entries(this.resultados)
435
+ .filter(([_, r]) => r?.trackId)
436
+ .map(([k]) => {
437
+ const mapping = { basico: 'setBasico', guia: 'setGuiaDespacho', exenta: 'setFacturaExenta', compra: 'setFacturaCompra' };
438
+ return mapping[k];
439
+ })
440
+ .filter(Boolean);
441
+
442
+ return this.siiCert.waitForApproval(setsAEsperar, options);
443
+ }
444
+
445
+ /**
446
+ * Consulta el estado actual de avance
447
+ * @returns {Promise<Object>} Estados parseados
448
+ */
449
+ async consultarAvance() {
450
+ return this.siiCert.verAvanceParsed();
451
+ }
452
+
453
+ // ═══════════════════════════════════════════════════════════════
454
+ // LIBROS (Fase 4)
455
+ // ═══════════════════════════════════════════════════════════════
456
+
457
+ /**
458
+ * Ejecuta toda la Fase 4: Envío y declaración de libros
459
+ * NOTA: Cada libro decrementa su propio período para evitar "LNC - Libro Cerrado"
460
+ * @param {Object} [options] - Opciones
461
+ * @param {Object} [options.setBasicoResult] - Resultado del SetBasico
462
+ * @param {Object} [options.setGuiaResult] - Resultado del SetGuia
463
+ * @returns {Promise<Object>} Resultado con todos los libros
464
+ */
465
+ async ejecutarFase4Libros(options = {}) {
466
+ // NOTA: Ya NO decrementamos aquí - cada libro decrementa su propio período
467
+ console.log('\n' + '═'.repeat(60));
468
+ console.log(`📚 FASE 4: LIBROS (cada libro usará período diferente)`);
469
+ console.log('═'.repeat(60) + '\n');
470
+
471
+ const resultados = {};
472
+ const errores = [];
473
+
474
+ try {
475
+ // 1. Libro de Compras (usa datos del SII)
476
+ console.log('\n📖 Enviando Libro de Compras...');
477
+ resultados.libroCompras = await this.ejecutarLibroCompras(options);
478
+ if (!resultados.libroCompras.success) {
479
+ errores.push(`Libro Compras: ${resultados.libroCompras.error}`);
480
+ }
481
+ } catch (e) {
482
+ errores.push(`Libro Compras: ${e.message}`);
483
+ }
484
+
485
+ try {
486
+ // 2. Libro de Ventas (usa SetBasico)
487
+ console.log('\n📖 Enviando Libro de Ventas...');
488
+ resultados.libroVentas = await this.ejecutarLibroVentas(options);
489
+ if (!resultados.libroVentas.success) {
490
+ errores.push(`Libro Ventas: ${resultados.libroVentas.error}`);
491
+ }
492
+ } catch (e) {
493
+ errores.push(`Libro Ventas: ${e.message}`);
494
+ }
495
+
496
+ try {
497
+ // 3. Libro de Guías (usa SetGuia)
498
+ console.log('\n📖 Enviando Libro de Guías...');
499
+ resultados.libroGuias = await this.ejecutarLibroGuias(options);
500
+ if (!resultados.libroGuias.success) {
501
+ errores.push(`Libro Guías: ${resultados.libroGuias.error}`);
502
+ }
503
+ } catch (e) {
504
+ errores.push(`Libro Guías: ${e.message}`);
505
+ }
506
+
507
+ // 4. Libro de Compras para Exentos (solo si el SII lo entregó)
508
+ if (this._estructuras?.libroComprasExentos) {
509
+ try {
510
+ console.log('\n📖 Enviando Libro de Compras para Exentos...');
511
+ resultados.libroComprasExentos = await this.ejecutarLibroComprasExentos(options);
512
+ if (!resultados.libroComprasExentos.success) {
513
+ errores.push(`Libro Compras Exentos: ${resultados.libroComprasExentos.error}`);
514
+ }
515
+ } catch (e) {
516
+ errores.push(`Libro Compras Exentos: ${e.message}`);
517
+ }
518
+ }
519
+
520
+ // Contar libros obligatorios (ventas + compras + guías)
521
+ const librosObligatorios = ['libroVentas', 'libroCompras', 'libroGuias'];
522
+ const librosEnviados = librosObligatorios.filter(k => resultados[k]?.success).length;
523
+
524
+ if (librosEnviados === 3) {
525
+ // 4. Declarar los libros
526
+ console.log('\n📝 Declarando libros...');
527
+ try {
528
+ const declaracion = await this.declararLibros();
529
+ resultados.declaracion = declaracion;
530
+
531
+ if (declaracion.success) {
532
+ console.log('\n✅ FASE 4 COMPLETADA: Todos los libros enviados y declarados');
533
+ } else {
534
+ console.log(`\n⚠️ Libros enviados pero declaración con error: ${declaracion.error}`);
535
+ }
536
+ } catch (e) {
537
+ console.log(`\n⚠️ Error declarando libros: ${e.message}`);
538
+ resultados.declaracion = { success: false, error: e.message };
539
+ }
540
+ } else {
541
+ console.log(`\n⚠️ Solo ${librosEnviados}/3 libros enviados. Errores: ${errores.join('; ')}`);
542
+ }
543
+
544
+ return {
545
+ success: librosEnviados === 3,
546
+ librosEnviados,
547
+ resultados,
548
+ errores,
549
+ };
550
+ }
551
+
552
+ /**
553
+ * Declara avance de los libros ejecutados con reintentos automáticos
554
+ * @param {Object} [resultadosExt] - Resultados externos (usa this.resultados si no se pasa)
555
+ * @param {Object} [options] - { maxIntentos, intervalo }
556
+ * @returns {Promise<Object>} Resultado de la declaración
557
+ */
558
+ async declararLibros(resultadosExt, options = {}) {
559
+ const resultados = resultadosExt || this.resultados;
560
+ const { maxIntentos = 10, intervalo = 5000 } = options;
561
+
562
+ const fecha = this._getFechaHoy();
563
+ const sets = {};
564
+
565
+ const mapping = {
566
+ libroVentas: 'libroVentas',
567
+ libroCompras: 'libroCompras',
568
+ libroGuias: 'libroGuias',
569
+ libroComprasExentos: 'libroComprasExentos',
570
+ };
571
+
572
+ for (const [resKey, setKey] of Object.entries(mapping)) {
573
+ if (resultados[resKey]?.trackId) {
574
+ sets[setKey] = { trackId: resultados[resKey].trackId, fecha };
575
+ }
576
+ }
577
+
578
+ if (Object.keys(sets).length === 0) {
579
+ return { success: false, error: 'No hay libros para declarar' };
580
+ }
581
+
582
+ const result = await this._declararConReintentos(sets, 'declaracion-libros-response', { maxIntentos, intervalo, label: 'libros' });
583
+ if (result?.success) {
584
+ const declarados = result.setsDeclarados || [];
585
+ console.log(` ✅ Libros declarados: ${declarados.join(', ')}`);
586
+ }
587
+ return result;
588
+ }
589
+
590
+ /**
591
+ * Obtiene el período para libros (período fijo para certificación)
592
+ * El período se decrementa cada vez que el SII rechaza con "LNC - Libro Cerrado"
593
+ * Se guarda el período en un archivo de estado para persistir entre ejecuciones
594
+ * @returns {string} Período en formato YYYY-MM
595
+ */
596
+ _getPeriodoLibros() {
597
+ const stateFile = path.join(this.debugDir, 'periodo-libros.json');
598
+
599
+ // Cargar estado existente o crear nuevo
600
+ let state = { periodo: null, lastRun: null };
601
+ try {
602
+ if (fs.existsSync(stateFile)) {
603
+ state = JSON.parse(fs.readFileSync(stateFile, 'utf8'));
604
+ }
605
+ } catch (e) {
606
+ // Usar default
607
+ }
608
+
609
+ // Si no hay período guardado, usar el mes anterior al actual
610
+ if (!state.periodo) {
611
+ const now = new Date();
612
+ let year = now.getFullYear();
613
+ let month = now.getMonth(); // 0-indexed, así que es el mes anterior
614
+
615
+ if (month < 1) {
616
+ month = 12;
617
+ year -= 1;
618
+ }
619
+
620
+ state.periodo = `${year}-${String(month).padStart(2, '0')}`;
621
+ }
622
+
623
+ return state.periodo;
624
+ }
625
+
626
+ /**
627
+ * Decrementa el período de libros (llamar cuando falla con LNC)
628
+ * @returns {string} Nuevo período
629
+ */
630
+ _decrementarPeriodoLibros() {
631
+ const stateFile = path.join(this.debugDir, 'periodo-libros.json');
632
+ const currentPeriodo = this._getPeriodoLibros();
633
+
634
+ const [year, month] = currentPeriodo.split('-').map(Number);
635
+ let newMonth = month - 1;
636
+ let newYear = year;
637
+
638
+ if (newMonth < 1) {
639
+ newMonth = 12;
640
+ newYear -= 1;
641
+ }
642
+
643
+ const newPeriodo = `${newYear}-${String(newMonth).padStart(2, '0')}`;
644
+
645
+ const state = {
646
+ periodo: newPeriodo,
647
+ lastRun: new Date().toISOString(),
648
+ previousPeriodo: currentPeriodo,
649
+ };
650
+
651
+ try {
652
+ fs.writeFileSync(stateFile, JSON.stringify(state, null, 2));
653
+ console.log(` 📅 Período decrementado: ${currentPeriodo} → ${newPeriodo}`);
654
+ } catch (e) {
655
+ console.warn(` ⚠️ No se pudo guardar período: ${e.message}`);
656
+ }
657
+
658
+ return newPeriodo;
659
+ }
660
+
661
+ /**
662
+ * Resetea el período de libros a un valor específico
663
+ * @param {string} periodo - Período en formato YYYY-MM
664
+ */
665
+ resetPeriodoLibros(periodo) {
666
+ const stateFile = path.join(this.debugDir, 'periodo-libros.json');
667
+ const state = { periodo, lastRun: new Date().toISOString() };
668
+
669
+ try {
670
+ fs.writeFileSync(stateFile, JSON.stringify(state, null, 2));
671
+ console.log(` 📅 Período reseteado a: ${periodo}`);
672
+ } catch (e) {
673
+ console.warn(` ⚠️ No se pudo guardar período: ${e.message}`);
674
+ }
675
+ }
676
+
677
+ /**
678
+ * Crea un enviador de libros
679
+ * @private
680
+ */
681
+ _createLibroEnviador() {
682
+ return new EnviadorSII(this.certificado, this.ambiente);
683
+ }
684
+
685
+ /**
686
+ * Ejecuta el Libro de Ventas
687
+ * @param {Object} [options] - Opciones
688
+ * @param {Object} [options.setBasicoResult] - Resultado del SetBasico (usa this.resultados.basico si no se pasa)
689
+ * @param {string} [options.signoNC='POSITIVO'] - Signo para NC
690
+ * @returns {Promise<Object>} Resultado con trackId
691
+ */
692
+ async ejecutarLibroVentas(options = {}) {
693
+ const setBasicoResult = options.setBasicoResult || this.resultados.basico;
694
+
695
+ if (!setBasicoResult?.documentos) {
696
+ throw new Error('No hay resultado del SetBasico. Ejecutar ejecutarSetBasico() primero.');
697
+ }
698
+
699
+ // IMPORTANTE: Decrementar período ANTES de usar para evitar "LNC - Libro Cerrado"
700
+ this._decrementarPeriodoLibros();
701
+ const periodo = this._getPeriodoLibros();
702
+ console.log(` 📚 Generando Libro de Ventas para período ${periodo}...`);
703
+
704
+ const libroVentas = new LibroVentas({
705
+ emisor: this.config.emisor,
706
+ receptor: this.config.receptor,
707
+ periodo,
708
+ certificado: this.certificado,
709
+ signoNC: options.signoNC || 'POSITIVO',
710
+ });
711
+
712
+ const { libro, xml, detalle, resumen } = libroVentas.generar(setBasicoResult);
713
+
714
+ // Guardar XML de debug
715
+ const outPath = path.join(this.debugDir, 'libro-ventas.xml');
716
+ fs.writeFileSync(outPath, xml, 'utf-8');
717
+ console.log(` XML guardado: ${outPath}`);
718
+
719
+ // Enviar al SII
720
+ const enviador = this._createLibroEnviador();
721
+ const resultado = await enviador.enviarLibro(libro, 'LibroCV.xml');
722
+
723
+ const result = {
724
+ success: !!resultado?.trackId,
725
+ trackId: resultado?.trackId,
726
+ error: resultado?.error,
727
+ periodo,
728
+ totalDetalle: detalle.length,
729
+ };
730
+
731
+ this.resultados.libroVentas = result;
732
+
733
+ if (result.success) {
734
+ console.log(` ✅ Libro de Ventas enviado - TrackId: ${result.trackId}`);
735
+ } else {
736
+ console.log(` ❌ Error enviando Libro de Ventas: ${result.error}`);
737
+ }
738
+
739
+ return result;
740
+ }
741
+
742
+ /**
743
+ * Ejecuta el Libro de Compras
744
+ * @param {Object} [options] - Opciones
745
+ * @param {Object} [options.libroComprasData] - Datos del libro (usa this._estructuras.libroCompras si no se pasa)
746
+ * @returns {Promise<Object>} Resultado con trackId
747
+ */
748
+ async ejecutarLibroCompras(options = {}) {
749
+ const libroComprasData = options.libroComprasData || this._estructuras?.libroCompras;
750
+
751
+ // IMPORTANTE: Decrementar período ANTES de usar para evitar "LNC - Libro Cerrado"
752
+ this._decrementarPeriodoLibros();
753
+ const periodo = this._getPeriodoLibros();
754
+
755
+ const libroCompras = new LibroCompras({
756
+ emisor: this.config.emisor,
757
+ periodo,
758
+ certificado: this.certificado,
759
+ });
760
+
761
+ if (!libroComprasData?.detalle) {
762
+ throw new Error('No hay datos del libro de compras. El SII no entregó el set LIBRO_COMPRAS al obtener las estructuras.');
763
+ }
764
+
765
+ console.log(` 📚 Generando Libro de Compras para período ${periodo} (${libroComprasData.detalle.length} documentos del SII)...`);
766
+ const { libro, xml, detalle, resumen } = libroCompras.generarDesdeEstructuras(libroComprasData, periodo);
767
+
768
+ // Guardar XML de debug
769
+ const outPath = path.join(this.debugDir, 'libro-compras.xml');
770
+ fs.writeFileSync(outPath, xml, 'utf-8');
771
+ console.log(` XML guardado: ${outPath}`);
772
+
773
+ // Enviar al SII
774
+ const enviador = this._createLibroEnviador();
775
+ const resultado = await enviador.enviarLibro(libro, 'LibroCV.xml');
776
+
777
+ const result = {
778
+ success: !!resultado?.trackId,
779
+ trackId: resultado?.trackId,
780
+ error: resultado?.error,
781
+ periodo,
782
+ totalDetalle: detalle.length,
783
+ };
784
+
785
+ this.resultados.libroCompras = result;
786
+
787
+ if (result.success) {
788
+ console.log(` ✅ Libro de Compras enviado - TrackId: ${result.trackId}`);
789
+ } else {
790
+ console.log(` ❌ Error enviando Libro de Compras: ${result.error}`);
791
+ }
792
+
793
+ return result;
794
+ }
795
+
796
+ /**
797
+ * Ejecuta el Libro de Compras para Exentos (SET15)
798
+ * Misma lógica que ejecutarLibroCompras pero usando estructuras.libroComprasExentos
799
+ */
800
+ async ejecutarLibroComprasExentos(options = {}) {
801
+ const libroData = options.libroComprasExentosData || this._estructuras?.libroComprasExentos;
802
+
803
+ if (!libroData?.detalle) {
804
+ throw new Error('No hay datos del libro de compras para exentos. El SII no entregó el set LIBRO_COMPRAS_EXENTOS.');
805
+ }
806
+
807
+ this._decrementarPeriodoLibros();
808
+ const periodo = this._getPeriodoLibros();
809
+
810
+ const libroCompras = new LibroCompras({
811
+ emisor: this.config.emisor,
812
+ periodo,
813
+ certificado: this.certificado,
814
+ });
815
+
816
+ console.log(` 📚 Generando Libro de Compras para Exentos para período ${periodo} (${libroData.detalle.length} documentos del SII)...`);
817
+ const { libro, xml, detalle } = libroCompras.generarDesdeEstructuras(libroData, periodo);
818
+
819
+ const outPath = path.join(this.debugDir, 'libro-compras-exentos.xml');
820
+ fs.writeFileSync(outPath, xml, 'utf-8');
821
+ console.log(` XML guardado: ${outPath}`);
822
+
823
+ const enviador = this._createLibroEnviador();
824
+ const resultado = await enviador.enviarLibro(libro, 'LibroCVExentos.xml');
825
+
826
+ const result = {
827
+ success: !!resultado?.trackId,
828
+ trackId: resultado?.trackId,
829
+ error: resultado?.error,
830
+ periodo,
831
+ totalDetalle: detalle.length,
832
+ };
833
+
834
+ this.resultados.libroComprasExentos = result;
835
+
836
+ if (result.success) {
837
+ console.log(` ✅ Libro de Compras para Exentos enviado - TrackId: ${result.trackId}`);
838
+ } else {
839
+ console.log(` ❌ Error enviando Libro de Compras para Exentos: ${result.error}`);
840
+ }
841
+
842
+ return result;
843
+ }
844
+
845
+ /**
846
+ * Ejecuta el Libro de Guías
847
+ * @param {Object} [options] - Opciones
848
+ * @param {Object} [options.setGuiaResult] - Resultado del SetGuia (usa this.resultados.guia si no se pasa)
849
+ * @param {number} [options.folioNotificacion=3] - Folio de notificación
850
+ * @returns {Promise<Object>} Resultado con trackId
851
+ */
852
+ async ejecutarLibroGuias(options = {}) {
853
+ const setGuiaResult = options.setGuiaResult || this.resultados.guia;
854
+
855
+ if (!setGuiaResult?.documentos) {
856
+ throw new Error('No hay resultado del SetGuia. Ejecutar ejecutarSetGuia() primero.');
857
+ }
858
+
859
+ // IMPORTANTE: Decrementar período ANTES de usar para evitar "LNC - Libro Cerrado"
860
+ this._decrementarPeriodoLibros();
861
+ const periodo = this._getPeriodoLibros();
862
+ console.log(` 📚 Generando Libro de Guías para período ${periodo}...`);
863
+
864
+ const libroGuias = new LibroGuias({
865
+ emisor: this.config.emisor,
866
+ receptor: this.config.receptor,
867
+ periodo,
868
+ certificado: this.certificado,
869
+ folioNotificacion: options.folioNotificacion || 3,
870
+ });
871
+
872
+ const { libro, xml, detalle } = libroGuias.generar(setGuiaResult, {
873
+ casosLibro: options.casosLibro,
874
+ });
875
+
876
+ // Guardar XML de debug
877
+ const outPath = path.join(this.debugDir, 'libro-guias.xml');
878
+ fs.writeFileSync(outPath, xml, 'utf-8');
879
+ console.log(` XML guardado: ${outPath}`);
880
+
881
+ // Enviar al SII
882
+ const enviador = this._createLibroEnviador();
883
+ const resultado = await enviador.enviarLibro(libro, 'LibroGuia.xml');
884
+
885
+ const result = {
886
+ success: !!resultado?.trackId,
887
+ trackId: resultado?.trackId,
888
+ error: resultado?.error,
889
+ periodo,
890
+ totalDetalle: detalle.length,
891
+ };
892
+
893
+ this.resultados.libroGuias = result;
894
+
895
+ if (result.success) {
896
+ console.log(` ✅ Libro de Guías enviado - TrackId: ${result.trackId}`);
897
+ } else {
898
+ console.log(` ❌ Error enviando Libro de Guías: ${result.error}`);
899
+ }
900
+
901
+ return result;
902
+ }
903
+
904
+ // ═══════════════════════════════════════════════════════════════
905
+ // AVANZAR SIGUIENTE PASO (Fase 5)
906
+ // ═══════════════════════════════════════════════════════════════
907
+
908
+ /**
909
+ * Avanza al siguiente paso de certificación cuando todos los ítems están REVISADO CONFORME
910
+ * @returns {Promise<Object>} Resultado del avance
911
+ */
912
+ async avanzarSiguientePaso() {
913
+ console.log('\n' + '═'.repeat(60));
914
+ console.log('🚀 AVANZAR SIGUIENTE PASO');
915
+ console.log('═'.repeat(60) + '\n');
916
+
917
+ try {
918
+ console.log(' 📋 Enviando solicitud de avance...');
919
+ const result = await this.siiCert.avanzarSiguientePaso();
920
+
921
+ if (result.rawHtml) {
922
+ fs.writeFileSync(
923
+ path.join(this.debugDir, 'avanzar-siguiente-paso-response.html'),
924
+ result.rawHtml,
925
+ 'utf8'
926
+ );
927
+ console.log(` 📄 Respuesta guardada en: ${path.join(this.debugDir, 'avanzar-siguiente-paso-response.html')}`);
928
+ }
929
+
930
+ if (result.success) {
931
+ console.log(' ✅ Avance al siguiente paso exitoso');
932
+ this.resultados.avanceSiguientePaso = { success: true };
933
+ } else {
934
+ console.log(` ❌ Error en avance: ${result.error || 'Error desconocido'}`);
935
+ this.resultados.avanceSiguientePaso = { success: false, error: result.error };
936
+ }
937
+
938
+ return result;
939
+ } catch (error) {
940
+ console.log(` ❌ Error: ${error.message}`);
941
+ this.resultados.avanceSiguientePaso = { success: false, error: error.message };
942
+ return { success: false, error: error.message };
943
+ }
944
+ }
945
+
946
+ /**
947
+ * Espera a que los libros sean aprobados y luego avanza al siguiente paso
948
+ * @param {Object} [options] - { maxIntentos, intervalo }
949
+ * @returns {Promise<Object>} Resultado del avance
950
+ */
951
+ async esperarLibrosYAvanzar(options = {}) {
952
+ const { maxIntentos = 30, intervalo = 10000 } = options;
953
+
954
+ console.log('\n⏳ Esperando aprobación de libros...');
955
+
956
+ for (let i = 1; i <= maxIntentos; i++) {
957
+ console.log(`\n 🔄 Intento ${i}/${maxIntentos}...`);
958
+
959
+ const avance = await this.siiCert.verAvanceParsed();
960
+
961
+ if (!avance.success) {
962
+ console.log(` ⚠️ Error consultando avance: ${avance.error}`);
963
+ await sleep(intervalo);
964
+ continue;
965
+ }
966
+
967
+ const libros = ['LIBRO DE VENTAS', 'LIBRO DE COMPRAS', 'LIBRO DE GUIAS'];
968
+ const estados = avance.sets || [];
969
+
970
+ let todosAprobados = true;
971
+ let hayRechazados = false;
972
+
973
+ for (const libro of libros) {
974
+ const estado = estados.find(s => s.nombre?.toUpperCase().includes(libro.replace('DE ', '')));
975
+ if (!estado) continue;
976
+
977
+ const esAprobado = estado.estado?.toUpperCase().includes('REVISADO CONFORME');
978
+ const esRechazado = estado.estado?.toUpperCase().includes('RECHAZADO') ||
979
+ estado.estado?.toUpperCase().includes('REPARO');
980
+
981
+ if (esAprobado) {
982
+ console.log(` ✅ ${libro}: REVISADO CONFORME`);
983
+ } else if (esRechazado) {
984
+ console.log(` ❌ ${libro}: ${estado.estado}`);
985
+ hayRechazados = true;
986
+ } else {
987
+ console.log(` 🔄 ${libro}: ${estado.estado || 'EN REVISION'}`);
988
+ todosAprobados = false;
989
+ }
990
+ }
991
+
992
+ if (hayRechazados) {
993
+ console.log('\n ❌ Hay libros rechazados. No se puede avanzar.');
994
+ return { success: false, error: 'Hay libros rechazados' };
995
+ }
996
+
997
+ if (todosAprobados) {
998
+ console.log('\n 🎉 ¡Todos los libros aprobados!');
999
+ return await this.avanzarSiguientePaso();
1000
+ }
1001
+
1002
+ await sleep(intervalo);
1003
+ }
1004
+
1005
+ console.log('\n ⚠️ Timeout esperando aprobación de libros');
1006
+ return { success: false, error: 'Timeout esperando aprobación' };
1007
+ }
1008
+
1009
+ // ═══════════════════════════════════════════════════════════════
1010
+ // SIMULACIÓN (Fase 6)
1011
+ // ═══════════════════════════════════════════════════════════════
1012
+
1013
+ /**
1014
+ * Ejecuta el Set de Simulación (Fase 6)
1015
+ * Genera un envío con todos los DTEs de las estructuras
1016
+ * @param {Object} [options] - Opciones
1017
+ * @param {Object} [options.estructuras] - Estructuras (usa this._estructuras si no se pasa)
1018
+ * @returns {Promise<Object>} Resultado con trackId
1019
+ */
1020
+ async ejecutarSimulacion(options = {}) {
1021
+ const estructuras = options.estructuras || this._estructuras;
1022
+ if (!estructuras) {
1023
+ throw new Error('No hay estructuras para simulación. Ejecutar obtenerSets() primero.');
1024
+ }
1025
+
1026
+ console.log('\n' + '═'.repeat(60));
1027
+ console.log('🧪 FASE 6: SIMULACIÓN');
1028
+ console.log('═'.repeat(60) + '\n');
1029
+
1030
+ // Calcular CAFs necesarios
1031
+ const cafRequired = this._calcularCafsSimulacion(estructuras);
1032
+ console.log(' Solicitando CAFs para simulación...');
1033
+
1034
+ // Solicitar CAFs frescos
1035
+ const cafs = await this.solicitarCafs(cafRequired);
1036
+
1037
+ // Cargar objetos CAF
1038
+ const cafObjects = {};
1039
+ const { CAF } = require('../index');
1040
+ for (const [tipo, cafPath] of Object.entries(cafs)) {
1041
+ const cafXml = fs.readFileSync(cafPath, 'utf8');
1042
+ cafObjects[tipo] = new CAF(cafXml);
1043
+ }
1044
+
1045
+ // Crear simulación
1046
+ const simulacion = new Simulacion({
1047
+ emisor: this.config.emisor,
1048
+ receptor: this.config.receptor,
1049
+ certificado: this.certificado,
1050
+ resolucion: {
1051
+ fecha: this.config.emisor.fch_resol,
1052
+ numero: this.config.emisor.nro_resol,
1053
+ },
1054
+ });
1055
+
1056
+ // Generar
1057
+ console.log(' Generando DTEs de simulación...');
1058
+ const { envioDte, dtes, xml, plan, tiposUsados } = simulacion.generar(
1059
+ estructuras,
1060
+ cafObjects,
1061
+ this.folioHelper,
1062
+ );
1063
+
1064
+ console.log(` 📦 Plan de simulación: ${plan.length} documentos`);
1065
+ console.log(` 📄 Tipos usados: ${tiposUsados.join(', ')}`);
1066
+
1067
+ // Guardar XML de debug
1068
+ const runDir = path.join(this.debugDir, 'simulacion');
1069
+ fs.mkdirSync(runDir, { recursive: true });
1070
+ const outPath = path.join(runDir, 'envio-simulacion.xml');
1071
+ fs.writeFileSync(outPath, xml, 'utf-8');
1072
+ console.log(` XML guardado: ${outPath}`);
1073
+
1074
+ // Guardar DTEs individuales
1075
+ const dtesDir = path.join(runDir, 'dtes');
1076
+ fs.mkdirSync(dtesDir, { recursive: true });
1077
+ dtes.forEach((dteItem) => {
1078
+ const filename = `dte-${String(dteItem.tipoDte).padStart(2, '0')}-${String(dteItem.folio).padStart(6, '0')}.xml`;
1079
+ fs.writeFileSync(path.join(dtesDir, filename), dteItem.xml, 'utf8');
1080
+ });
1081
+
1082
+ // Enviar al SII
1083
+ console.log('\n 📤 Enviando al SII...');
1084
+ const enviador = this._createEnviador();
1085
+ const resultado = await enviador.enviar(envioDte);
1086
+
1087
+ const result = {
1088
+ success: !!resultado?.trackId,
1089
+ trackId: resultado?.trackId,
1090
+ error: resultado?.error,
1091
+ documentos: plan.length,
1092
+ tiposUsados,
1093
+ };
1094
+
1095
+ this.resultados.simulacion = result;
1096
+
1097
+ // Guardar info
1098
+ const infoPath = path.join(runDir, 'envio-simulacion-info.json');
1099
+ fs.writeFileSync(infoPath, JSON.stringify({
1100
+ ...result,
1101
+ dtes: dtes.map(d => ({ tipoDte: d.tipoDte, folio: d.folio })),
1102
+ }, null, 2), 'utf8');
1103
+
1104
+ if (result.success) {
1105
+ console.log(`\n✅ Simulación enviada - TrackId: ${result.trackId}`);
1106
+ } else {
1107
+ console.log(`\n❌ Error en simulación: ${result.error}`);
1108
+ }
1109
+
1110
+ return result;
1111
+ }
1112
+
1113
+ /**
1114
+ * Calcula los CAFs necesarios para simulación
1115
+ * @private
1116
+ */
1117
+ _calcularCafsSimulacion(estructuras) {
1118
+ const cafRequired = {};
1119
+
1120
+ const contarTipo = (tipo) => {
1121
+ cafRequired[tipo] = (cafRequired[tipo] || 0) + 1;
1122
+ };
1123
+
1124
+ // Set Básico
1125
+ const sb = estructuras?.setBasico;
1126
+ if (sb) {
1127
+ (sb.casosFactura || []).forEach(() => contarTipo(33));
1128
+ (sb.casosNC || []).forEach(() => contarTipo(61));
1129
+ (sb.casosND || []).forEach(() => contarTipo(56));
1130
+ }
1131
+
1132
+ // Set Exenta
1133
+ const se = estructuras?.setFacturaExenta;
1134
+ if (se) {
1135
+ (se.casosFactura || []).forEach(() => contarTipo(34));
1136
+ (se.casosNC || []).forEach(() => contarTipo(61));
1137
+ (se.casosND || []).forEach(() => contarTipo(56));
1138
+ }
1139
+
1140
+ // Set Guía
1141
+ const sg = estructuras?.setGuiaDespacho;
1142
+ if (sg) {
1143
+ (sg.casos || []).forEach(() => contarTipo(52));
1144
+ }
1145
+
1146
+ // Set Compra
1147
+ const sc = estructuras?.setFacturaCompra;
1148
+ if (sc) {
1149
+ if (sc.casoFactura) contarTipo(46);
1150
+ if (sc.casoNC) contarTipo(61);
1151
+ if (sc.casoND) contarTipo(56);
1152
+ }
1153
+
1154
+ return cafRequired;
1155
+ }
1156
+
1157
+ /**
1158
+ * Declara avance del set de simulación con reintentos automáticos
1159
+ * @param {Object} [resultadosExt] - Resultados externos
1160
+ * @param {Object} [options] - { maxIntentos, intervalo }
1161
+ * @returns {Promise<Object>} Resultado de la declaración
1162
+ */
1163
+ async declararSimulacion(resultadosExt, options = {}) {
1164
+ const resultados = resultadosExt || this.resultados;
1165
+ const { maxIntentos = 10, intervalo = 5000 } = options;
1166
+
1167
+ if (!resultados.simulacion?.trackId) {
1168
+ return { success: false, error: 'No hay TrackId de simulación para declarar' };
1169
+ }
1170
+
1171
+ // Verificar si ya pasamos a INTERCAMBIO (simulación ya aprobada)
1172
+ console.log(' 🔍 Verificando etapa actual...');
1173
+ const avance = await this.siiCert.verAvanceParsed();
1174
+ if (avance.rawHtml && /paso\s*<b>\s*INTERCAMBIO/i.test(avance.rawHtml)) {
1175
+ console.log(' ✅ Simulación ya aprobada - empresa en etapa INTERCAMBIO');
1176
+ return { success: true, skipped: true, message: 'Ya en etapa INTERCAMBIO' };
1177
+ }
1178
+
1179
+ const fecha = this._getFechaHoy();
1180
+ const sets = {
1181
+ setSimulacion: {
1182
+ trackId: resultados.simulacion.trackId,
1183
+ fecha,
1184
+ },
1185
+ };
1186
+
1187
+ const result = await this._declararConReintentos(sets, 'declaracion-simulacion-response', { maxIntentos, intervalo, label: 'simulación' });
1188
+ if (result?.success) console.log(' ✅ Simulación declarada exitosamente');
1189
+ return result;
1190
+ }
1191
+
1192
+ /**
1193
+ * Espera a que la simulación sea aprobada
1194
+ * @param {Object} [options] - { maxIntentos, intervalo }
1195
+ * @returns {Promise<Object>} Resultado del polling
1196
+ */
1197
+ async esperarSimulacionAprobada(options = {}) {
1198
+ const { maxIntentos = 30, intervalo = 10000 } = options;
1199
+
1200
+ console.log('\n⏳ Esperando aprobación de simulación...');
1201
+
1202
+ for (let i = 1; i <= maxIntentos; i++) {
1203
+ console.log(`\n 🔄 Intento ${i}/${maxIntentos}...`);
1204
+
1205
+ const avance = await this.siiCert.verAvanceParsed();
1206
+
1207
+ if (!avance.success) {
1208
+ console.log(` ⚠️ Error consultando avance: ${avance.error}`);
1209
+ await sleep(intervalo);
1210
+ continue;
1211
+ }
1212
+
1213
+ // ✅ PRIMERO: Verificar si ya pasó a INTERCAMBIO (significa que simulación fue aprobada)
1214
+ if (avance.etapaActual && avance.etapaActual.includes('INTERCAMBIO')) {
1215
+ console.log(` ✅ Etapa actual: ${avance.etapaActual}`);
1216
+ console.log('\n 🎉 ¡SIMULACIÓN APROBADA! Empresa pasó a etapa INTERCAMBIO.');
1217
+ return { success: true, etapa: 'INTERCAMBIO' };
1218
+ }
1219
+
1220
+ // ✅ SEGUNDO: Verificar indicador de formulario de confirmación (simulación aprobada pendiente confirmar)
1221
+ if (avance.simulacionAprobadaIndicador) {
1222
+ console.log(` ✅ Formulario de confirmación detectado`);
1223
+
1224
+ // Confirmar automáticamente la simulación
1225
+ if (this.resultados.simulacion?.trackId) {
1226
+ console.log(`\n 📝 Confirmando revisión de simulación (TrackId: ${this.resultados.simulacion.trackId})...`);
1227
+
1228
+ const fecha = this._getFechaHoy();
1229
+ const confirmResult = await this.siiCert.declararAvance({
1230
+ sets: {
1231
+ setSimulacion: {
1232
+ trackId: this.resultados.simulacion.trackId,
1233
+ fecha,
1234
+ },
1235
+ },
1236
+ });
1237
+
1238
+ if (confirmResult.success) {
1239
+ console.log(' ✅ Confirmación enviada exitosamente');
1240
+
1241
+ // Revalidar contra SII para evitar falso positivo de confirmación
1242
+ const verificacion = await this.siiCert.verAvanceParsed();
1243
+ const estadoSim = verificacion?.estados?.setSimulacion;
1244
+ const sigueFormulario = Boolean(verificacion?.simulacionAprobadaIndicador);
1245
+ const yaIntercambio = Boolean(verificacion?.etapaActual?.includes('INTERCAMBIO'));
1246
+ const simConforme = Boolean(estadoSim?.esConforme || estadoSim?.estado?.toUpperCase()?.includes('REVISADO CONFORME'));
1247
+
1248
+ if (yaIntercambio || simConforme || !sigueFormulario) {
1249
+ console.log('\n 🎉 ¡SIMULACIÓN CONFIRMADA! Certificación completa.');
1250
+ return { success: true, confirmada: true };
1251
+ }
1252
+
1253
+ console.log(' ⚠️ SII aún mantiene formulario de simulación pendiente; se reintentará...');
1254
+ await sleep(intervalo);
1255
+ continue;
1256
+ } else {
1257
+ console.log(` ⚠️ Error en confirmación: ${confirmResult.error}`);
1258
+ // Continuar el loop para reintentar
1259
+ }
1260
+ } else {
1261
+ console.log('\n 🎉 ¡SIMULACIÓN APROBADA! Lista para confirmar revisión.');
1262
+ return { success: true, pendienteConfirmar: true };
1263
+ }
1264
+ }
1265
+
1266
+ // Buscar estado de simulación en los estados parseados
1267
+ const estados = avance.estados || {};
1268
+ const simKey = Object.keys(estados).find(k =>
1269
+ k.toLowerCase().includes('simulacion') ||
1270
+ estados[k]?.nombre?.toUpperCase().includes('SIMULACION') ||
1271
+ estados[k]?.nombre?.toUpperCase().includes('SIMULACIÓN')
1272
+ );
1273
+
1274
+ if (simKey && estados[simKey]) {
1275
+ const simEstado = estados[simKey];
1276
+ const esAprobado = simEstado.esConforme || simEstado.estado?.toUpperCase().includes('REVISADO CONFORME');
1277
+ const esRechazado = simEstado.esRechazado ||
1278
+ simEstado.estado?.toUpperCase().includes('RECHAZADO') ||
1279
+ simEstado.estado?.toUpperCase().includes('REPARO');
1280
+
1281
+ if (esAprobado) {
1282
+ console.log(` ✅ SIMULACIÓN: REVISADO CONFORME`);
1283
+ console.log('\n 🎉 ¡SIMULACIÓN APROBADA! Certificación completa.');
1284
+ return { success: true };
1285
+ } else if (esRechazado) {
1286
+ console.log(` ❌ SIMULACIÓN: ${simEstado.estado}`);
1287
+ return { success: false, error: 'Simulación rechazada' };
1288
+ } else {
1289
+ console.log(` 🔄 SIMULACIÓN: ${simEstado.estado || 'EN REVISION'}`);
1290
+ }
1291
+ } else {
1292
+ // No hay estado de simulación, pero verificar etapa actual
1293
+ if (avance.etapaActual) {
1294
+ console.log(` 📍 Etapa actual: ${avance.etapaActual}`);
1295
+ } else {
1296
+ console.log(' ⏳ Simulación aún no registrada...');
1297
+ }
1298
+ }
1299
+
1300
+ await sleep(intervalo);
1301
+ }
1302
+
1303
+ console.log('\n ⚠️ Timeout esperando aprobación de simulación');
1304
+ return { success: false, error: 'Timeout esperando aprobación' };
1305
+ }
1306
+
1307
+ // ═══════════════════════════════════════════════════════════════
1308
+ // Helpers privados
1309
+ // ═══════════════════════════════════════════════════════════════
1310
+
1311
+ _getFechaHoy() {
1312
+ const now = new Date();
1313
+ return `${String(now.getDate()).padStart(2, '0')}-${String(now.getMonth() + 1).padStart(2, '0')}-${now.getFullYear()}`;
1314
+ }
1315
+
1316
+ // ═══════════════════════════════════════════════════════════════
1317
+ // FASE 7: INTERCAMBIO DE INFORMACIÓN
1318
+ // ═══════════════════════════════════════════════════════════════
1319
+
1320
+ /**
1321
+ * Ejecuta el proceso completo de intercambio de información DTE.
1322
+ * 1. Descarga el SET de intercambio desde www4.sii.cl/pfeInternet (auto o manual)
1323
+ * 2. Genera los 3 XMLs de respuesta firmados
1324
+ * 3. Sube las respuestas al portal
1325
+ * @param {Object} [options]
1326
+ * @param {string} [options.inputPath] - Ruta manual al XML del SET de intercambio
1327
+ * @returns {Promise<Object>}
1328
+ */
1329
+ async ejecutarFase7Intercambio(options = {}) {
1330
+ const intercambioDir = path.join(this.debugDir, 'intercambio');
1331
+ fs.mkdirSync(intercambioDir, { recursive: true });
1332
+
1333
+ console.log('\n' + '═'.repeat(60));
1334
+ console.log('📬 FASE 7: INTERCAMBIO DE INFORMACIÓN');
1335
+ console.log('═'.repeat(60));
1336
+
1337
+ // ── PASO 1: Obtener el SET XML ─────────────────────────────
1338
+ const setInputPath = options.inputPath ||
1339
+ path.join(intercambioDir, 'set-intercambio.xml');
1340
+
1341
+ let setXml = null;
1342
+
1343
+ // Ruta persistente donde siempre guardamos el XML (independiente de options.inputPath)
1344
+ const setDownloadPath = path.join(intercambioDir, 'set-intercambio.xml');
1345
+
1346
+ if (setInputPath && fs.existsSync(setInputPath)) {
1347
+ console.log(`\n📂 Leyendo SET desde: ${setInputPath}`);
1348
+ setXml = fs.readFileSync(setInputPath, 'utf8');
1349
+ console.log(` ✓ ${setXml.length} bytes`);
1350
+ } else if (fs.existsSync(setDownloadPath)) {
1351
+ console.log(`\n📂 Leyendo SET guardado: ${setDownloadPath}`);
1352
+ setXml = fs.readFileSync(setDownloadPath, 'utf8');
1353
+ console.log(` ✓ ${setXml.length} bytes`);
1354
+ } else {
1355
+ console.log('\n📡 Descargando SET desde www4.sii.cl/pfeInternet...');
1356
+ const dl = await this._descargarSetPfeInternet(intercambioDir);
1357
+ if (dl.success) {
1358
+ setXml = dl.xml;
1359
+ fs.writeFileSync(setDownloadPath, setXml, 'utf8');
1360
+ console.log(` ✅ SET descargado (${setXml.length} bytes) → ${setDownloadPath}`);
1361
+ } else {
1362
+ console.log(` ⚠️ No se pudo descargar: ${dl.error}`);
1363
+ console.log('\n' + '─'.repeat(60));
1364
+ console.log('📋 DESCARGA MANUAL REQUERIDA:');
1365
+ console.log(' 1. Si aparece error de sesiones: ingresa a https://www4.sii.cl/ → Cerrar Sesión');
1366
+ console.log(' 2. Ir a: https://www4.sii.cl/pfeInternet/ y descargar el SET XML');
1367
+ console.log(` 3. Guardarlo en: ${setDownloadPath}`);
1368
+ console.log(' 4. Volver a ejecutar el runner');
1369
+ console.log('─'.repeat(60));
1370
+ return { success: false, error: 'SET no disponible - descarga manual requerida', requiresManual: true, manualPath: setInputPath };
1371
+ }
1372
+ }
1373
+
1374
+ // ── PASO 2: Generar XMLs de respuesta ─────────────────────
1375
+ console.log('\n📝 Generando respuestas firmadas...');
1376
+ const intercambioCert = new IntercambioCert({
1377
+ certificado: this.certificado,
1378
+ emisor: {
1379
+ rut: this.config.emisor.rut,
1380
+ razonSocial: this.config.emisor.razon_social || this.config.emisor.razonSocial || '',
1381
+ },
1382
+ contacto: this.config.contacto || {},
1383
+ debugDir: intercambioDir,
1384
+ });
1385
+
1386
+ const genResult = await intercambioCert.generarIntercambio(setXml, { outDir: intercambioDir });
1387
+ if (!genResult.success) {
1388
+ return { success: false, error: genResult.error || 'Error generando XMLs' };
1389
+ }
1390
+
1391
+ // ── PASO 3: Subir respuestas ───────────────────────────────
1392
+ console.log('\n📤 Subiendo respuestas a www4.sii.cl/pfeInternet...');
1393
+ const uploadResult = await this._subirRespuestasPfeInternet({
1394
+ recepcionXml: fs.readFileSync(genResult.files.recepcion, 'utf8'),
1395
+ aprobacionXml: fs.readFileSync(genResult.files.aprobacion, 'utf8'),
1396
+ recibosXml: fs.readFileSync(genResult.files.recibos, 'utf8'),
1397
+ debugDir: intercambioDir,
1398
+ });
1399
+
1400
+ if (uploadResult.success) {
1401
+ console.log('\n' + '═'.repeat(60));
1402
+ console.log('✅ INTERCAMBIO COMPLETADO');
1403
+ console.log('═'.repeat(60));
1404
+ if (uploadResult.resultado) console.log(` Resultado SII: ${uploadResult.resultado}`);
1405
+ } else {
1406
+ console.log(` ⚠️ No se pudo subir automáticamente: ${uploadResult.error}`);
1407
+ console.log('\n' + '─'.repeat(60));
1408
+ console.log('📋 SUBIDA MANUAL REQUERIDA:');
1409
+ console.log(' 1. Ir a: https://www4.sii.cl/pfeInternet/ → "Subir archivos"');
1410
+ console.log(` 2. Subir: ${genResult.files.recepcion}`);
1411
+ console.log(` 3. Subir: ${genResult.files.aprobacion}`);
1412
+ console.log(` 4. Subir: ${genResult.files.recibos}`);
1413
+ console.log('─'.repeat(60));
1414
+ }
1415
+
1416
+ return {
1417
+ success: true,
1418
+ files: genResult.files,
1419
+ meta: genResult.meta,
1420
+ uploaded: uploadResult.success,
1421
+ requiresManual: !uploadResult.success,
1422
+ };
1423
+ }
1424
+
1425
+ /**
1426
+ * Obtiene el cookieJar de sesión SII reutilizando caché en memoria primero,
1427
+ * luego caché en disco (TTL 25 min) gestionada por SiiPortalAuth.
1428
+ * Evita crear múltiples sesiones simultáneas (el SII limita a ~3).
1429
+ * @private
1430
+ * @returns {Promise<Object>} cookieJar con cookies NETSCAPE_LIVEWIRE.*
1431
+ */
1432
+ async _obtenerCookiesSII() {
1433
+ if (this._siiCookieJar) {
1434
+ console.log('[SII Auth] ♻️ Reutilizando sesión SII en memoria');
1435
+ return this._siiCookieJar;
1436
+ }
1437
+ const SiiPortalAuth = require('../SiiPortalAuth');
1438
+ const pfxBuffer = fs.readFileSync(this.config.certificado.path);
1439
+ const password = this.config.certificado.password;
1440
+ const siiAuth = new SiiPortalAuth({ pfxBuffer, pfxPassword: password });
1441
+ this._siiCookieJar = await siiAuth.autenticar();
1442
+ const nSession = Object.keys(this._siiCookieJar).filter(k => k.startsWith('NETSCAPE')).length;
1443
+ console.log(`[SII Auth] ✅ Sesión SII activa (cookies NETSCAPE: ${nSession})`);
1444
+ return this._siiCookieJar;
1445
+ }
1446
+
1447
+ /**
1448
+ * Autentica contra pfeInternet reutilizando la sesión cacheada por SiiPortalAuth.
1449
+ * Tras obtener las cookies, hace un GET inicial a www4.sii.cl/pfeInternet/ para
1450
+ * inicializar el contexto del portal GWT del lado del servidor (igual que el browser).
1451
+ * @private
1452
+ */
1453
+ async _autenticarPfeInternet() {
1454
+ const https = require('https');
1455
+ const crypto = require('crypto');
1456
+ const { URL } = require('url');
1457
+
1458
+ const tlsOpts = {
1459
+ rejectUnauthorized: false,
1460
+ maxVersion: 'TLSv1.2',
1461
+ secureOptions: crypto.constants.SSL_OP_ALLOW_UNSAFE_LEGACY_RENEGOTIATION
1462
+ | crypto.constants.SSL_OP_LEGACY_SERVER_CONNECT,
1463
+ };
1464
+
1465
+ // makeReq: helper HTTP para requests pfeInternet — solo lleva las cookies, no hace auth
1466
+ const makeReq = (urlStr, { method = 'GET', body = null, headers = {}, cookies: reqCookies = '' }) =>
1467
+ new Promise((resolve, reject) => {
1468
+ const u = new URL(urlStr);
1469
+ const agent = new https.Agent(tlsOpts);
1470
+ const opts = {
1471
+ hostname: u.hostname, port: u.port || 443,
1472
+ path: u.pathname + u.search, method, agent,
1473
+ headers: {
1474
+ 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
1475
+ 'Accept': 'text/html,application/xhtml+xml,*/*;q=0.8',
1476
+ 'Connection': 'keep-alive',
1477
+ ...(reqCookies ? { 'Cookie': reqCookies } : {}),
1478
+ ...headers,
1479
+ },
1480
+ };
1481
+ if (body) opts.headers['Content-Length'] = Buffer.byteLength(body);
1482
+ const req = https.request(opts, (res) => {
1483
+ let data = '';
1484
+ res.on('data', c => data += c);
1485
+ res.on('end', () => resolve({ status: res.statusCode, body: data, headers: res.headers }));
1486
+ });
1487
+ req.on('error', reject);
1488
+ if (body) req.write(body);
1489
+ req.end();
1490
+ });
1491
+
1492
+ const collectNewCookies = (headers, existing) => {
1493
+ const merged = {};
1494
+ existing.split(';').forEach(c => { const [k, v] = c.trim().split('='); if (k) merged[k.trim()] = (v||'').trim(); });
1495
+ for (const c of (headers['set-cookie'] || [])) {
1496
+ const [kv] = c.split(';');
1497
+ const eq = kv.indexOf('=');
1498
+ if (eq > 0) merged[kv.slice(0, eq).trim()] = kv.slice(eq + 1).trim();
1499
+ }
1500
+ return Object.entries(merged).map(([k, v]) => `${k}=${v}`).join('; ');
1501
+ };
1502
+
1503
+ // Reutiliza sesión en memoria o disco via _obtenerCookiesSII()
1504
+ const cookieJar = await this._obtenerCookiesSII();
1505
+ let cookies = Object.entries(cookieJar).map(([k, v]) => `${k}=${v}`).join('; ');
1506
+
1507
+ // Warm-up: visitar www4.sii.cl/pfeInternet/ para inicializar contexto del portal GWT
1508
+ // en el servidor, exactamente como haría el browser al navegar a la página.
1509
+ try {
1510
+ const warmup = await makeReq('https://www4.sii.cl/pfeInternet/', { cookies });
1511
+ cookies = collectNewCookies(warmup.headers, cookies);
1512
+ // Si redirige (302), seguir el redirect una vez más
1513
+ if ((warmup.status === 301 || warmup.status === 302) && warmup.headers?.location) {
1514
+ const loc = warmup.headers.location;
1515
+ const absLoc = loc.startsWith('http') ? loc : `https://www4.sii.cl${loc}`;
1516
+ const warmup2 = await makeReq(absLoc, { cookies });
1517
+ cookies = collectNewCookies(warmup2.headers, cookies);
1518
+ }
1519
+ console.log(`[pfeInternet Auth] warm-up → HTTP ${warmup.status}`);
1520
+ } catch (e) {
1521
+ console.log(`[pfeInternet Auth] warm-up falló (no crítico): ${e.message}`);
1522
+ }
1523
+
1524
+ return { cookies, makeReq };
1525
+ }
1526
+
1527
+ /**
1528
+ * Descarga el SET de intercambio desde www4.sii.cl/pfeInternet
1529
+ * @private
1530
+ */
1531
+ async _descargarSetPfeInternet(debugDir) {
1532
+ try {
1533
+ const { cookies, makeReq } = await this._autenticarPfeInternet();
1534
+ const [rutNum, dv] = this.config.emisor.rut.split('-');
1535
+
1536
+ // Endpoint real capturado via F12:
1537
+ // POST https://www4.sii.cl/pfeInternet/downloadFile?re={rutSinDv}&dve={dv}
1538
+ // Body: multipart vacío (solo el closing boundary, sin campos de formulario)
1539
+ const boundary = `----WebKitFormBoundary${Date.now()}`;
1540
+ const emptyMultipartBody = `--${boundary}--\r\n`;
1541
+
1542
+ console.log(` → Descargando SET desde pfeInternet/downloadFile (RUT ${rutNum}-${dv})...`);
1543
+
1544
+ const r = await makeReq(
1545
+ `https://www4.sii.cl/pfeInternet/downloadFile?re=${rutNum}&dve=${dv}`,
1546
+ {
1547
+ method: 'POST',
1548
+ body: emptyMultipartBody,
1549
+ headers: {
1550
+ 'Content-Type': `multipart/form-data; boundary=${boundary}`,
1551
+ 'Referer': 'https://www4.sii.cl/pfeInternet/',
1552
+ 'Origin': 'https://www4.sii.cl',
1553
+ },
1554
+ cookies,
1555
+ }
1556
+ );
1557
+
1558
+ // Guardar respuesta raw para debugging
1559
+ fs.writeFileSync(path.join(debugDir, `pfe-download-${Date.now()}.xml`), r.body, 'utf8');
1560
+
1561
+ if (r.status === 200 && (
1562
+ r.body.includes('<EnvioDTE') ||
1563
+ r.body.includes('<SetDTE') ||
1564
+ r.body.includes('<?xml')
1565
+ )) {
1566
+ console.log(` ✓ SET descargado correctamente (${r.body.length} bytes)`);
1567
+ return { success: true, xml: r.body };
1568
+ }
1569
+
1570
+ const errMsg = `pfeInternet/downloadFile respondió HTTP ${r.status} sin XML válido`;
1571
+ console.log(` ✗ ${errMsg}`);
1572
+ fs.writeFileSync(path.join(debugDir, `pfe-download-error-${Date.now()}.html`), r.body, 'utf8');
1573
+ return { success: false, error: errMsg };
1574
+ } catch (err) {
1575
+ return { success: false, error: err.message };
1576
+ }
1577
+ }
1578
+
1579
+ /**
1580
+ * Sube las 3 respuestas de intercambio a www4.sii.cl/pfeInternet usando Puppeteer.
1581
+ * El portal GWT requiere que el JavaScript inicialice la sesión antes de aceptar uploads.
1582
+ * Con Puppeteer el browser real ejecuta el JS del portal y los uploads quedan registrados.
1583
+ * @private
1584
+ */
1585
+ async _subirRespuestasPfeInternet({ recepcionXml, aprobacionXml, recibosXml, debugDir }) {
1586
+ const puppeteer = require('puppeteer');
1587
+ const os = require('os');
1588
+
1589
+ // Guardar XMLs en archivos temporales para que Puppeteer pueda subirlos
1590
+ const tmpDir = debugDir || path.join(os.tmpdir(), 'pfe-intercambio');
1591
+ fs.mkdirSync(tmpDir, { recursive: true });
1592
+ // Los labels deben coincidir con el texto del portal GWT (Archivo N: ...)
1593
+ const archivos = [
1594
+ { label: 'Respuesta de Intercambio', filename: 'respuesta-recepcion-envio.xml', content: recepcionXml, uploadN: 1 },
1595
+ { label: 'Recibo de Mercaderias', filename: 'envio-recibos.xml', content: recibosXml, uploadN: 2 },
1596
+ { label: 'Resultado Aprobaci\u00f3n Comercial de Documento', filename: 'respuesta-aprobacion-comercial.xml', content: aprobacionXml, uploadN: 3 },
1597
+ ];
1598
+ for (const a of archivos) {
1599
+ fs.writeFileSync(path.join(tmpDir, a.filename), a.content, 'utf8');
1600
+ }
1601
+
1602
+ // Obtener cookies de sesión SII (reutiliza caché en memoria/disco)
1603
+ const cookieJar = await this._obtenerCookiesSII();
1604
+
1605
+ // Convertir cookieJar a formato Puppeteer para dominio .sii.cl
1606
+ const puppeteerCookies = Object.entries(cookieJar).map(([name, value]) => ({
1607
+ name, value, domain: '.sii.cl', path: '/', httpOnly: false, secure: true,
1608
+ }));
1609
+
1610
+ let browser;
1611
+ try {
1612
+ browser = await puppeteer.launch({
1613
+ headless: true,
1614
+ ignoreHTTPSErrors: true,
1615
+ args: ['--no-sandbox', '--disable-setuid-sandbox', '--ignore-certificate-errors'],
1616
+ });
1617
+
1618
+ const page = await browser.newPage();
1619
+ await page.setCookie(...puppeteerCookies);
1620
+
1621
+ // Navegar al portal pfeInternet
1622
+ console.log(' → Cargando portal pfeInternet...');
1623
+ await page.goto('https://www4.sii.cl/pfeInternet/', {
1624
+ waitUntil: 'networkidle2',
1625
+ timeout: 60000,
1626
+ });
1627
+
1628
+ // Dar 3 segundos extra para que GWT renderice el menú inicial
1629
+ await new Promise(r => setTimeout(r, 3000));
1630
+
1631
+ // Hacer click en el enlace "Subir archivos XML de respuesta de Intercambio"
1632
+ // El href es javascript:openForm('opt-ingresoEmpresaUp') — necesita click real para GWT
1633
+ console.log(' → Clickeando "Subir archivos XML de respuesta de Intercambio"...');
1634
+ const linkClicked = await page.click('a[href*="ingresoEmpresaUp"]').then(() => true).catch(() => false);
1635
+ if (!linkClicked) {
1636
+ // Fallback: evaluar click con dispatchEvent
1637
+ await page.evaluate(() => {
1638
+ const link = document.querySelector('a[href*="ingresoEmpresaUp"]');
1639
+ if (link) link.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
1640
+ });
1641
+ }
1642
+
1643
+ // Esperar a que GWT complete el RPC y re-renderice la vista de upload
1644
+ await page.waitForNetworkIdle({ timeout: 15000, idleTime: 1000 }).catch(() => {});
1645
+
1646
+ // === PASO INTERMEDIO: ingresar RUT y confirmar empresa ===
1647
+ // GWT muestra "Ingrese el RUT de la empresa" antes de mostrar el formulario de upload
1648
+ const rutInput = await page.$('input.gwt-TextBox[maxlength="10"]');
1649
+ if (rutInput) {
1650
+ const [rutNum, dv] = this.config.emisor.rut.split('-');
1651
+ const rutConDv = `${rutNum}-${dv}`;
1652
+ console.log(` → Ingresando RUT empresa: ${rutConDv}`);
1653
+ await rutInput.click({ clickCount: 3 }); // seleccionar todo
1654
+ await rutInput.type(rutConDv);
1655
+
1656
+ // Click en "Confirmar Empresa"
1657
+ const confirmBtn = await page.evaluateHandle(() => {
1658
+ return Array.from(document.querySelectorAll('button.gwt-Button'))
1659
+ .find(b => b.textContent.trim() === 'Confirmar Empresa');
1660
+ });
1661
+ if (confirmBtn) {
1662
+ await confirmBtn.asElement().click();
1663
+ console.log(' → Click "Confirmar Empresa", esperando formulario de upload...');
1664
+ await page.waitForNetworkIdle({ timeout: 15000, idleTime: 1000 }).catch(() => {});
1665
+ }
1666
+ }
1667
+
1668
+ // Esperar que GWT renderice el formulario con los 3 inputs de upload
1669
+ const inputFound = await page.waitForSelector('input[name="uploadFormElement"]', { timeout: 30000 }).catch(() => null);
1670
+ if (!inputFound) {
1671
+ if (debugDir) {
1672
+ await page.screenshot({ path: path.join(debugDir, 'pfeInternet-error.png'), fullPage: true }).catch(() => {});
1673
+ fs.writeFileSync(path.join(debugDir, 'pfeInternet-error.html'), await page.content().catch(() => ''), 'utf8');
1674
+ }
1675
+ const pageText = await page.$eval('body', el => el.textContent).catch(() => '');
1676
+ if (pageText.includes('DOCUMENTOS IMPRESOS') || pageText.includes('fue cargado exitosamente')) {
1677
+ throw new Error('PASO_YA_COMPLETADO');
1678
+ }
1679
+ throw new Error('pfeInternet no mostró formulario de upload tras openForm — ver pfeInternet-error.png/.html');
1680
+ }
1681
+ console.log(' → Formulario de upload listo');
1682
+
1683
+ // ── DEBUG: screenshot del formulario con los inputs listos ──
1684
+ if (debugDir) {
1685
+ await page.screenshot({ path: path.join(debugDir, 'pfeInternet-form-listo.png'), fullPage: true }).catch(() => {});
1686
+ fs.writeFileSync(path.join(debugDir, 'pfeInternet-form-listo.html'), await page.content().catch(() => ''), 'utf8');
1687
+ }
1688
+
1689
+ // Subir cada archivo en secuencia.
1690
+ // GWT mantiene solo los inputs de archivos pendientes: "procesado con exito anteriormente"
1691
+ // reemplaza al input. Así que iteramos solo los archivos pendientes en orden.
1692
+ for (const archivo of archivos) {
1693
+ const filePath = path.join(tmpDir, archivo.filename);
1694
+
1695
+ // Asegurarse de que no haya diálogo abierto del upload anterior
1696
+ await page.waitForFunction(
1697
+ () => !document.querySelector('.gwt-DialogBox'),
1698
+ { timeout: 10000 }
1699
+ ).catch(() => {});
1700
+
1701
+ // Verificar si este archivo ya fue procesado (GWT reemplaza el input con texto)
1702
+ // Estructura DOM: <td class="filter-label">Archivo N: Label</td> en una <tr>
1703
+ // La siguiente <tr> tiene <td class="filter-widget"> con el input O el texto "procesado"
1704
+ const yaProcessado = await page.evaluate((labelText) => {
1705
+ const allTds = Array.from(document.querySelectorAll('td.filter-label'));
1706
+ const labelTd = allTds.find(el => el.textContent.includes(labelText));
1707
+ if (!labelTd) return false;
1708
+ // Subir al <tr> padre y tomar el siguiente <tr>
1709
+ const tr = labelTd.closest('tr');
1710
+ if (!tr) return false;
1711
+ const nextTr = tr.nextElementSibling;
1712
+ if (!nextTr) return false;
1713
+ return nextTr.textContent.includes('procesado con exito anteriormente');
1714
+ }, archivo.label);
1715
+
1716
+ if (yaProcessado) {
1717
+ console.log(` → ${archivo.filename}: ya procesado anteriormente, saltando...`);
1718
+ continue;
1719
+ }
1720
+
1721
+ console.log(` → Subiendo ${archivo.filename}...`);
1722
+
1723
+ // Cada archivo tiene su propio form con action uploadFile1/2/3
1724
+ // Usamos el selector específico para no confundir entre los 3 inputs que pueden
1725
+ // estar presentes simultáneamente en el DOM
1726
+ const formSel = `form[action*="uploadFile${archivo.uploadN}"]`;
1727
+ await page.waitForSelector(`${formSel} input[name="uploadFormElement"]`, { timeout: 15000 });
1728
+
1729
+ const input = await page.$(`${formSel} input[name="uploadFormElement"]`);
1730
+ if (!input) throw new Error(`No se encontró input uploadFile${archivo.uploadN}`);
1731
+ await input.uploadFile(filePath);
1732
+
1733
+ const submitBtn = await page.$(`${formSel} button.button-little`);
1734
+ if (!submitBtn) throw new Error(`No se encontró botón Subir para uploadFile${archivo.uploadN}`);
1735
+ await submitBtn.click();
1736
+
1737
+ // Esperar el diálogo GWT de confirmación
1738
+ await page.waitForSelector('.gwt-DialogBox .msgeDialogBox', { timeout: 30000 });
1739
+ const msgText = await page.$eval('.gwt-DialogBox .msgeDialogBox', el => el.textContent.trim());
1740
+ console.log(` ✓ ${msgText}`);
1741
+
1742
+ if (debugDir) {
1743
+ fs.writeFileSync(
1744
+ path.join(debugDir, `upload-resp-${archivo.filename}.txt`),
1745
+ `Puppeteer: ${msgText}`, 'utf8'
1746
+ );
1747
+ }
1748
+
1749
+ if (msgText.toLowerCase().includes('error') || msgText.toLowerCase().includes('rechaz')) {
1750
+ throw new Error(`Error en archivo ${archivo.filename}: ${msgText}`);
1751
+ }
1752
+
1753
+ // Cerrar el diálogo usando el botón "Cerrar" dentro del gwt-DialogBox
1754
+ await page.evaluate(() => {
1755
+ const dlg = document.querySelector('.gwt-DialogBox');
1756
+ if (!dlg) return;
1757
+ const btn = Array.from(dlg.querySelectorAll('button')).find(
1758
+ b => b.textContent.trim() === 'Cerrar'
1759
+ );
1760
+ if (btn) btn.click();
1761
+ });
1762
+
1763
+ // Esperar que el diálogo desaparezca antes del siguiente archivo
1764
+ await page.waitForFunction(
1765
+ () => !document.querySelector('.gwt-DialogBox'),
1766
+ { timeout: 10000 }
1767
+ ).catch(() => {});
1768
+
1769
+ // Esperar que GWT termine de actualizar el estado (RPC post-upload)
1770
+ await page.waitForNetworkIdle({ timeout: 10000, idleTime: 500 }).catch(() => {});
1771
+ }
1772
+
1773
+ return { success: true, resultado: 'Los 3 archivos subidos y registrados correctamente' };
1774
+
1775
+ } catch (err) {
1776
+ return { success: false, error: err.message };
1777
+ } finally {
1778
+ if (browser) await browser.close();
1779
+ }
1780
+ }
1781
+
1782
+ // ═══════════════════════════════════════════════════════════════
1783
+ // FASE 8: MUESTRAS IMPRESAS — subir PDFs a pe_avance5
1784
+ // ═══════════════════════════════════════════════════════════════
1785
+
1786
+ /**
1787
+ * Consulta el estado actual del portal pdfdteInternet sin subir nada.
1788
+ * Útil para saltarse la generación de PDFs si ya están enviados.
1789
+ * @returns {Promise<{estado: string|null, error?: string}>}
1790
+ */
1791
+ async verificarEstadoPortalMuestras() {
1792
+ const puppeteer = require('puppeteer');
1793
+ const cookieJar = await this._obtenerCookiesSII();
1794
+ const puppeteerCookies = Object.entries(cookieJar).map(([name, value]) => ({
1795
+ name, value, domain: '.sii.cl', path: '/', httpOnly: false, secure: true,
1796
+ }));
1797
+ const [rutNum, dvChar] = this.config.emisor.rut.split('-');
1798
+ let browser;
1799
+ try {
1800
+ browser = await puppeteer.launch({
1801
+ headless: true,
1802
+ ignoreHTTPSErrors: true,
1803
+ args: ['--no-sandbox', '--disable-setuid-sandbox', '--ignore-certificate-errors'],
1804
+ });
1805
+ const page = await browser.newPage();
1806
+ await page.setCookie(...puppeteerCookies);
1807
+ await page.goto('https://www4.sii.cl/pdfdteInternet/', { waitUntil: 'networkidle2', timeout: 60000 });
1808
+ await new Promise(r => setTimeout(r, 3000));
1809
+
1810
+ const rutInputs = await page.$$('input[name="rut"]');
1811
+ const dvInputs = await page.$$('input[name="dv"]');
1812
+ if (!rutInputs.length) return { estado: null, error: 'sin campos RUT (¿sesión expirada?)' };
1813
+
1814
+ // Solo RUT empresa → "Rut": el portal ya muestra "Estado de la Revisión" en el DOM
1815
+ await rutInputs[0].click({ clickCount: 3 }); await rutInputs[0].type(rutNum);
1816
+ await dvInputs[0].click({ clickCount: 3 }); await dvInputs[0].type(dvChar);
1817
+ await page.evaluate((t) => {
1818
+ const btn = Array.from(document.querySelectorAll('button.x-btn-text'))
1819
+ .find(b => b.textContent.trim() === t && !b.disabled && b.getAttribute('aria-disabled') !== 'true');
1820
+ if (btn) btn.click();
1821
+ }, 'Rut');
1822
+
1823
+ // Descartar diálogo "ya existe revisión" si aparece
1824
+ await new Promise(r => setTimeout(r, 1500));
1825
+ await page.evaluate(() => {
1826
+ const si = Array.from(document.querySelectorAll('button.x-btn-text'))
1827
+ .find(b => /^s[ií]$/i.test(b.textContent.trim()));
1828
+ if (si) si.click();
1829
+ }).catch(() => {});
1830
+
1831
+ // Esperar hasta 8s a que aparezca el estado en el DOM
1832
+ await page.waitForFunction(() => {
1833
+ const t = (document.body.textContent || '').toUpperCase();
1834
+ return t.includes('ESTADO DE LA REVISI') ||
1835
+ t.includes('POR REVISAR') || t.includes('APROBADO') ||
1836
+ t.includes('EN REVISI') || t.includes('RECHAZADO');
1837
+ }, { timeout: 8000, polling: 500 }).catch(() => {});
1838
+
1839
+ const estado = await page.evaluate(() => {
1840
+ const t = (document.body.textContent || '').toUpperCase();
1841
+ if (t.includes('APROBADO')) return 'APROBADO';
1842
+ if (t.includes('POR REVISAR')) return 'POR REVISAR';
1843
+ if (t.includes('EN REVISI')) return 'EN REVISIÓN';
1844
+ if (t.includes('RECHAZADO')) return 'RECHAZADO';
1845
+ if (t.includes('ENVIADO AL SII')) return 'ENVIADO AL SII';
1846
+ return null;
1847
+ }).catch(() => null);
1848
+
1849
+ return { estado };
1850
+ } catch (err) {
1851
+ return { estado: null, error: err.message };
1852
+ } finally {
1853
+ if (browser) await browser.close().catch(() => {});
1854
+ }
1855
+ }
1856
+
1857
+ /**
1858
+ * Sube los PDFs de muestras impresas al portal pe_avance5 via Puppeteer.
1859
+ * @param {Object} opts
1860
+ * @param {string} opts.pdfDir - Directorio con los PDFs generados
1861
+ * @returns {Promise<Object>} { success, error? }
1862
+ */
1863
+ async ejecutarFase8Muestras({ pdfDir }) {
1864
+ const _collectPdfs = (dir) => {
1865
+ if (!fs.existsSync(dir)) return [];
1866
+ return fs.readdirSync(dir).flatMap(f => {
1867
+ const full = path.join(dir, f);
1868
+ return fs.statSync(full).isDirectory() ? _collectPdfs(full) : f.endsWith('.pdf') ? [full] : [];
1869
+ });
1870
+ };
1871
+ const pdfPaths = _collectPdfs(pdfDir);
1872
+
1873
+ if (!pdfPaths.length) throw new Error(`No se encontraron PDFs en: ${pdfDir}`);
1874
+
1875
+ console.log('\n' + '═'.repeat(60));
1876
+ console.log(`📄 FASE 8: MUESTRAS IMPRESAS (${pdfPaths.length} PDFs)`);
1877
+ console.log('═'.repeat(60));
1878
+
1879
+ return this._subirMuestrasImpresasPortal({ pdfPaths, debugDir: pdfDir });
1880
+ }
1881
+
1882
+ /**
1883
+ * Sube PDFs al portal https://www4.sii.cl/pdfdteInternet/ via Puppeteer.
1884
+ * El portal usa ExtJS 3 — botones buscados por texto, no por ID dinámico.
1885
+ * @private
1886
+ */
1887
+ async _subirMuestrasImpresasPortal({ pdfPaths, debugDir }) {
1888
+ const puppeteer = require('puppeteer');
1889
+
1890
+ // Reutiliza sesión SII en memoria/disco (misma sesión que intercambio u otros pasos)
1891
+ const cookieJar = await this._obtenerCookiesSII();
1892
+ const puppeteerCookies = Object.entries(cookieJar).map(([name, value]) => ({
1893
+ name, value, domain: '.sii.cl', path: '/', httpOnly: false, secure: true,
1894
+ }));
1895
+
1896
+ const [rutNum, dvChar] = this.config.emisor.rut.split('-');
1897
+
1898
+ // Helper: click botón ExtJS por texto
1899
+ const clickBoton = (page, texto) => page.evaluate((t) => {
1900
+ const btn = Array.from(document.querySelectorAll('button.x-btn-text'))
1901
+ .find(b => b.textContent.trim() === t && !b.disabled && b.getAttribute('aria-disabled') !== 'true');
1902
+ if (btn) { btn.click(); return true; }
1903
+ return false;
1904
+ }, texto);
1905
+
1906
+ let browser;
1907
+ try {
1908
+ browser = await puppeteer.launch({
1909
+ headless: true,
1910
+ ignoreHTTPSErrors: true,
1911
+ args: ['--no-sandbox', '--disable-setuid-sandbox', '--ignore-certificate-errors'],
1912
+ });
1913
+ const page = await browser.newPage();
1914
+ await page.setCookie(...puppeteerCookies);
1915
+
1916
+ // Navegar directamente a www4.sii.cl/pdfdteInternet/ con las cookies de sesión SII
1917
+ console.log(' → Cargando portal pdfdteInternet...');
1918
+ await page.goto('https://www4.sii.cl/pdfdteInternet/', {
1919
+ waitUntil: 'networkidle2', timeout: 60000,
1920
+ });
1921
+ await new Promise(r => setTimeout(r, 3000));
1922
+ if (debugDir) await page.screenshot({ path: path.join(debugDir, 'pdfte-01-loaded.png'), fullPage: true }).catch(() => {});
1923
+
1924
+ // Paso 1: RUT Empresa
1925
+ const rutInputs = await page.$$('input[name="rut"]');
1926
+ const dvInputs = await page.$$('input[name="dv"]');
1927
+ if (!rutInputs.length) {
1928
+ if (debugDir) {
1929
+ await page.screenshot({ path: path.join(debugDir, 'pdfte-error-no-rut.png'), fullPage: true }).catch(() => {});
1930
+ fs.writeFileSync(path.join(debugDir, 'pdfte-error.html'), await page.content(), 'utf8');
1931
+ }
1932
+ throw new Error('pdfdteInternet: no se encontraron campos de RUT (¿sesión expirada?)');
1933
+ }
1934
+ console.log(` → Ingresando RUT empresa: ${rutNum}-${dvChar}`);
1935
+ await rutInputs[0].click({ clickCount: 3 }); await rutInputs[0].type(rutNum);
1936
+ await dvInputs[0].click({ clickCount: 3 }); await dvInputs[0].type(dvChar);
1937
+ await clickBoton(page, 'Rut');
1938
+ await new Promise(r => setTimeout(r, 2500));
1939
+ if (debugDir) await page.screenshot({ path: path.join(debugDir, 'pdfte-02-after-rut.png'), fullPage: true }).catch(() => {});
1940
+
1941
+ // Paso 2: Diálogo "ya existe revisión" → click "Sí"
1942
+ const hayDialog = await page.evaluate(() => {
1943
+ const dlg = document.querySelector('.x-window');
1944
+ return !!(dlg && dlg.offsetParent !== null);
1945
+ });
1946
+ if (hayDialog) {
1947
+ console.log(' → Diálogo de revisión existente → haciendo click en "Sí"');
1948
+ const clicked = await page.evaluate(() => {
1949
+ const si = Array.from(document.querySelectorAll('button.x-btn-text'))
1950
+ .find(b => /^s[ií]$/i.test(b.textContent.trim()));
1951
+ if (si) { si.click(); return true; }
1952
+ return false;
1953
+ });
1954
+ if (!clicked) await page.evaluate(() => { const b = document.querySelector('.x-window button'); if (b) b.click(); });
1955
+ await new Promise(r => setTimeout(r, 2500));
1956
+ }
1957
+
1958
+ // Paso 3: RUT Proveedor (mismo RUT empresa)
1959
+ await page.waitForFunction(() => {
1960
+ const ins = document.querySelectorAll('input[name="rut"]');
1961
+ return ins.length >= 2 && !ins[1].disabled;
1962
+ }, { timeout: 15000 }).catch(() => {});
1963
+
1964
+ const rutNow = await page.$$('input[name="rut"]');
1965
+ const dvNow = await page.$$('input[name="dv"]');
1966
+ const pRut = rutNow.length >= 2 ? rutNow[1] : rutNow[0];
1967
+ const pDv = dvNow.length >= 2 ? dvNow[1] : dvNow[0];
1968
+ console.log(` → Ingresando RUT proveedor: ${rutNum}-${dvChar}`);
1969
+ await pRut.click({ clickCount: 3 }); await pRut.type(rutNum);
1970
+ await pDv.click({ clickCount: 3 }); await pDv.type(dvChar);
1971
+ await clickBoton(page, 'Consultar');
1972
+ await new Promise(r => setTimeout(r, 2500));
1973
+ if (debugDir) await page.screenshot({ path: path.join(debugDir, 'pdfte-03-after-consultar.png'), fullPage: true }).catch(() => {});
1974
+
1975
+ // ── Re-ejecución: detectar estado terminal antes de proceder ──
1976
+ const _estadoYaSubido = await page.evaluate(() => {
1977
+ const t = (document.body.textContent || '').toUpperCase();
1978
+ if (t.includes('APROBADO')) return 'APROBADO';
1979
+ if (t.includes('POR REVISAR')) return 'POR REVISAR';
1980
+ if (t.includes('EN REVISI')) return 'EN REVISIÓN';
1981
+ if (t.includes('RECHAZADO')) return 'RECHAZADO';
1982
+ if (t.includes('ENVIADO AL SII')) return 'ENVIADO AL SII';
1983
+ return null;
1984
+ }).catch(() => null);
1985
+ if (_estadoYaSubido) {
1986
+ console.log(` ✅ Portal ya muestra estado "${_estadoYaSubido}" — muestras subidas previamente. Proceso completado.`);
1987
+ return { success: true, alreadyCompleted: true, estado: _estadoYaSubido };
1988
+ }
1989
+
1990
+ // Paso 4: "Crear" → habilita el input de archivo
1991
+ console.log(' → Click "Crear"...');
1992
+ await clickBoton(page, 'Crear');
1993
+ await new Promise(r => setTimeout(r, 2500));
1994
+ if (debugDir) await page.screenshot({ path: path.join(debugDir, 'pdfte-04-after-crear.png'), fullPage: true }).catch(() => {});
1995
+
1996
+ // Paso 5: Verificar que el input de archivo existe antes de empezar
1997
+ const inputCheck = await page.waitForSelector('input.gwt-FileUpload', { timeout: 30000 }).catch(() => null);
1998
+ if (!inputCheck) {
1999
+ if (debugDir) {
2000
+ await page.screenshot({ path: path.join(debugDir, 'pdfte-error-no-fileinput.png'), fullPage: true }).catch(() => {});
2001
+ fs.writeFileSync(path.join(debugDir, 'pdfte-error.html'), await page.content(), 'utf8');
2002
+ }
2003
+ throw new Error('pdfdteInternet: no apareció el input de archivo tras "Crear"');
2004
+ }
2005
+
2006
+ // El portal sólo acepta un PDF a la vez (input sin atributo "multiple").
2007
+ // Tras el submit GWT no recrea el input dentro de la misma carga de página.
2008
+ // Solución: re-navegar al portal antes de cada archivo ≥ 2 (misma revisión,
2009
+ // misma sesión con cookies); el diálogo "ya existe revisión → Sí" la abre.
2010
+ const navegarAlFormulario = async () => {
2011
+ await page.goto('https://www4.sii.cl/pdfdteInternet/', { waitUntil: 'networkidle2', timeout: 60000 });
2012
+ await new Promise(r => setTimeout(r, 2000));
2013
+ const _ruts = await page.$$('input[name="rut"]');
2014
+ const _dvs = await page.$$('input[name="dv"]');
2015
+ if (!_ruts.length) throw new Error('pdfdteInternet: sin campos RUT en re-navegación (¿sesión expirada?)');
2016
+ await _ruts[0].click({ clickCount: 3 }); await _ruts[0].type(rutNum);
2017
+ await _dvs[0].click({ clickCount: 3 }); await _dvs[0].type(dvChar);
2018
+ await clickBoton(page, 'Rut');
2019
+ await new Promise(r => setTimeout(r, 2000));
2020
+ // Diálogo "ya existe revisión" → click "Sí" para abrirla
2021
+ const _dlg = await page.evaluate(() => { const d = document.querySelector('.x-window'); return !!(d && d.offsetParent !== null); });
2022
+ if (_dlg) {
2023
+ const _ok = await page.evaluate(() => {
2024
+ const si = Array.from(document.querySelectorAll('button.x-btn-text')).find(b => /^s[ií]$/i.test(b.textContent.trim()));
2025
+ if (si) { si.click(); return true; }
2026
+ return false;
2027
+ });
2028
+ if (!_ok) await page.evaluate(() => { const b = document.querySelector('.x-window button'); if (b) b.click(); });
2029
+ await new Promise(r => setTimeout(r, 2000));
2030
+ }
2031
+ // RUT proveedor (mismo que empresa)
2032
+ await page.waitForFunction(() => { const ins = document.querySelectorAll('input[name="rut"]'); return ins.length >= 2 && !ins[1].disabled; }, { timeout: 10000 }).catch(() => {});
2033
+ const _rutsN = await page.$$('input[name="rut"]');
2034
+ const _dvsN = await page.$$('input[name="dv"]');
2035
+ const _pRut = _rutsN.length >= 2 ? _rutsN[1] : _rutsN[0];
2036
+ const _pDv = _dvsN.length >= 2 ? _dvsN[1] : _dvsN[0];
2037
+ await _pRut.click({ clickCount: 3 }); await _pRut.type(rutNum);
2038
+ await _pDv.click({ clickCount: 3 }); await _pDv.type(dvChar);
2039
+ await clickBoton(page, 'Consultar');
2040
+ await new Promise(r => setTimeout(r, 2500));
2041
+ // Si aún no hay formulario de subida (primera vez), crear revisión
2042
+ const _hayInp = await page.$('input.gwt-FileUpload').catch(() => null);
2043
+ if (!_hayInp) { await clickBoton(page, 'Crear'); await new Promise(r => setTimeout(r, 2500)); }
2044
+ };
2045
+
2046
+ // Cargar todos los PDFs como base64 en Node.js y soltarlos en el drop zone de GWT
2047
+ // de una sola vez via DataTransfer. GWT los procesa en secuencia internamente:
2048
+ // drop → por cada file: submit form al iframe → respuesta → leeImpresoById → tick verde
2049
+ // Esto evita la re-navegación entre archivos y garantiza que la validación
2050
+ // (Timbre/CAF/TED) ocurra antes de salir de la página.
2051
+ console.log(` → Cargando ${pdfPaths.length} PDFs para drop en el portal...`);
2052
+ const _fileDataList = pdfPaths.map(p => ({
2053
+ name: path.basename(p),
2054
+ b64: fs.readFileSync(p).toString('base64'),
2055
+ }));
2056
+
2057
+ if (debugDir) await page.screenshot({ path: path.join(debugDir, 'pdfte-04b-antes-drop.png'), fullPage: true }).catch(() => {});
2058
+
2059
+ console.log(` → Ejecutando drop de ${pdfPaths.length} PDFs sobre el portal...`);
2060
+ const _dropped = await page.evaluate((files) => {
2061
+ const dt = new DataTransfer();
2062
+ for (const f of files) {
2063
+ const bin = atob(f.b64);
2064
+ const arr = new Uint8Array(bin.length);
2065
+ for (let i = 0; i < bin.length; i++) arr[i] = bin.charCodeAt(i);
2066
+ dt.items.add(new File([arr], f.name, { type: 'application/pdf' }));
2067
+ }
2068
+ const dz = document.querySelector('.dropFilesLabel');
2069
+ if (!dz) return 0;
2070
+ dz.dispatchEvent(new DragEvent('dragenter', { dataTransfer: dt, bubbles: true, cancelable: true }));
2071
+ dz.dispatchEvent(new DragEvent('dragover', { dataTransfer: dt, bubbles: true, cancelable: true }));
2072
+ dz.dispatchEvent(new DragEvent('drop', { dataTransfer: dt, bubbles: true, cancelable: true }));
2073
+ return dt.files.length;
2074
+ }, _fileDataList);
2075
+
2076
+ if (_dropped === 0) throw new Error('pdfdteInternet: drop zone no encontrado (.dropFilesLabel)');
2077
+ console.log(` → Drop ejecutado (${_dropped} archivos). Esperando procesamiento...`);
2078
+
2079
+ // ── Fase 1: esperar hasta 45s por primera señal de progreso o estado terminal ──
2080
+ // Si el portal ya está en "POR REVISAR" (re-ejecución), lo detectamos aquí inmediatamente.
2081
+ // Si el drop inició normalmente, "Procesados 1" aparece en pocos segundos.
2082
+ await page.waitForFunction(() => {
2083
+ for (const el of document.querySelectorAll('.x-progress-text')) {
2084
+ const m = el.textContent.match(/Procesados\s+(\d+)/);
2085
+ if (m && +m[1] > 0) return true;
2086
+ }
2087
+ const t = (document.body.textContent || '').toUpperCase();
2088
+ if (t.includes('APROBADO') || t.includes('POR REVISAR') || t.includes('EN REVISI') || t.includes('RECHAZADO')) return true;
2089
+ return false;
2090
+ }, { timeout: 45000, polling: 1000 }).catch(() => {});
2091
+
2092
+ // Leer estado real tras la fase 1
2093
+ const _fase1 = await page.evaluate(() => {
2094
+ let procesados = 0;
2095
+ for (const el of document.querySelectorAll('.x-progress-text')) {
2096
+ const m = el.textContent.match(/Procesados\s+(\d+)/);
2097
+ if (m) { procesados = +m[1]; break; }
2098
+ }
2099
+ const t = (document.body.textContent || '').toUpperCase();
2100
+ let estado = null;
2101
+ if (t.includes('APROBADO')) estado = 'APROBADO';
2102
+ else if (t.includes('POR REVISAR')) estado = 'POR REVISAR';
2103
+ else if (t.includes('EN REVISI')) estado = 'EN REVISIÓN';
2104
+ else if (t.includes('RECHAZADO')) estado = 'RECHAZADO';
2105
+ return { procesados, estado };
2106
+ }).catch(() => ({ procesados: 0, estado: null }));
2107
+
2108
+ if (_fase1.estado) {
2109
+ console.log(` ✅ Portal en estado "${_fase1.estado}" — muestras ya procesadas previamente.`);
2110
+ return { success: true, alreadyCompleted: true, estado: _fase1.estado };
2111
+ }
2112
+
2113
+ if (_fase1.procesados === 0) {
2114
+ // Sin progreso y sin estado terminal: el portal puede no estar procesando
2115
+ console.warn(' ⚠ Sin progreso en 45s y sin estado terminal. Continuando al paso siguiente...');
2116
+ } else {
2117
+ // ── Fase 2: progreso iniciado — esperar al total ──
2118
+ await page.waitForFunction((total) => {
2119
+ for (const el of document.querySelectorAll('.x-progress-text')) {
2120
+ const m = el.textContent.match(/Procesados\s+(\d+)/);
2121
+ if (m && +m[1] >= total) return true;
2122
+ }
2123
+ return false;
2124
+ }, { timeout: pdfPaths.length * 15000, polling: 1000 }, pdfPaths.length).catch(async () => {
2125
+ const procesados = await page.evaluate(() => {
2126
+ for (const el of document.querySelectorAll('.x-progress-text')) {
2127
+ const m = el.textContent.match(/Procesados\s+(\d+)/);
2128
+ if (m) return +m[1];
2129
+ }
2130
+ return 0;
2131
+ }).catch(() => 0);
2132
+ console.warn(` ⚠ Timeout: solo se procesaron ${procesados}/${pdfPaths.length} antes del timeout`);
2133
+ });
2134
+ }
2135
+
2136
+ // Esperar que todos los requests de leeImpresoById (validación) terminen
2137
+ await page.waitForNetworkIdle({ timeout: 60000, idleTime: 2000 }).catch(() => {});
2138
+ await new Promise(r => setTimeout(r, 2000));
2139
+
2140
+ if (debugDir) await page.screenshot({ path: path.join(debugDir, 'pdfte-05-archivos-listos.png'), fullPage: true }).catch(() => {});
2141
+
2142
+ // Paso 6: re-navegar al estado limpio y Enviar al SII
2143
+ console.log(' → Re-navegando para "Enviar al SII"...');
2144
+ await navegarAlFormulario();
2145
+
2146
+ // Esperar a que el botón esté habilitado (aria-disabled="false")
2147
+ await page.waitForFunction(() => {
2148
+ const btn = Array.from(document.querySelectorAll('button.x-btn-text'))
2149
+ .find(b => b.textContent.trim() === 'Enviar al SII' && b.getAttribute('aria-disabled') !== 'true');
2150
+ return !!btn;
2151
+ }, { timeout: 15000 }).catch(() => {});
2152
+
2153
+ if (debugDir) await page.screenshot({ path: path.join(debugDir, 'pdfte-05b-antes-enviar.png'), fullPage: true }).catch(() => {});
2154
+
2155
+ console.log(' → Click "Enviar al SII"...');
2156
+ const enviado = await clickBoton(page, 'Enviar al SII');
2157
+ if (!enviado) throw new Error('pdfdteInternet: botón "Enviar al SII" no disponible o deshabilitado');
2158
+
2159
+ await page.waitForNetworkIdle({ timeout: 30000, idleTime: 1000 }).catch(() => {});
2160
+ await new Promise(r => setTimeout(r, 3000));
2161
+ if (debugDir) {
2162
+ await page.screenshot({ path: path.join(debugDir, 'pdfte-06-enviado.png'), fullPage: true }).catch(() => {});
2163
+ fs.writeFileSync(path.join(debugDir, 'pdfte-06-enviado.html'), await page.content(), 'utf8');
2164
+ }
2165
+
2166
+ const pageText = await page.$eval('body', el => el.textContent).catch(() => '');
2167
+ const exitoso = /revision.*creada|solicitud.*enviada|documentos.*enviados|fue.*enviado|[eé]xito/i.test(pageText);
2168
+ console.log(` → Resultado: ${exitoso ? '✅ enviado correctamente' : '⚠️ sin confirmación explícita'}`);
2169
+ return { success: exitoso, pageText: pageText.substring(0, 800) };
2170
+
2171
+ } catch (err) {
2172
+ return { success: false, error: err.message };
2173
+ } finally {
2174
+ if (browser) await browser.close().catch(() => {});
2175
+ }
2176
+ }
2177
+
2178
+ // ═══════════════════════════════════════════════════════════════
2179
+ // BOLETA ELECTRÓNICA — automatización portal certBolElectDteInternet
2180
+ // ═══════════════════════════════════════════════════════════════
2181
+
2182
+ /**
2183
+ * Descarga el Set de Pruebas de Boleta Electrónica desde el portal SII.
2184
+ * Flujo real portal:
2185
+ * 1. Ingresa RUT empresa → click "Confirmar Empresa"
2186
+ * 2. Marca checkbox "SET DE BOLETA ELECTRÓNICA AFECTA"
2187
+ * 3. Rellena email proveedor
2188
+ * 4. Click "Bajar Nuevo Set" → descarga archivo .txt
2189
+ * @param {Object} opts
2190
+ * @param {string} [opts.setPath] - Ruta donde guardar el set
2191
+ * @param {string} [opts.correoSet='sii.certificacion@devlas.cl'] - Correo proveedor para el set
2192
+ * @returns {Promise<{success: boolean, setText?: string, error?: string}>}
2193
+ */
2194
+ async obtenerSetBoletaPortal({ setPath, correoSet = 'sii.certificacion@devlas.cl' } = {}) {
2195
+ const puppeteer = require('puppeteer');
2196
+ const cookieJar = await this._obtenerCookiesSII();
2197
+ const puppeteerCookies = Object.entries(cookieJar).map(([name, value]) => ({
2198
+ name, value, domain: '.sii.cl', path: '/', httpOnly: false, secure: true,
2199
+ }));
2200
+ const [rutNum, dvChar] = this.config.emisor.rut.split('-');
2201
+
2202
+ let browser;
2203
+ try {
2204
+ browser = await puppeteer.launch({
2205
+ headless: true,
2206
+ ignoreHTTPSErrors: true,
2207
+ args: ['--no-sandbox', '--disable-setuid-sandbox', '--ignore-certificate-errors'],
2208
+ });
2209
+ const page = await browser.newPage();
2210
+ await page.setCookie(...puppeteerCookies);
2211
+ page.on('dialog', async dlg => { console.log(` → [dialog SET=1] ${dlg.message()}`); await dlg.accept(); });
2212
+
2213
+ console.log(' → Cargando portal certBolElectDteInternet (SET=1)...');
2214
+ await page.goto('https://www4.sii.cl/certBolElectDteInternet/?SET=1', {
2215
+ waitUntil: 'networkidle2', timeout: 60000,
2216
+ });
2217
+ await new Promise(r => setTimeout(r, 2000));
2218
+
2219
+ // Paso 1: RUT empresa → "Confirmar Empresa"
2220
+ const rutInput = await page.$('input[maxlength="8"]');
2221
+ const dvInput = await page.$('input[maxlength="1"]');
2222
+ if (!rutInput) throw new Error('certBolElectDteInternet/?SET=1: no se encontró campo RUT');
2223
+ console.log(` → Ingresando RUT empresa: ${rutNum}-${dvChar}`);
2224
+ await rutInput.click({ clickCount: 3 }); await rutInput.type(rutNum);
2225
+ await dvInput.click({ clickCount: 3 }); await dvInput.type(dvChar);
2226
+ await page.evaluate(() => {
2227
+ const btn = Array.from(document.querySelectorAll('button'))
2228
+ .find(b => /confirmar/i.test(b.textContent));
2229
+ if (btn) btn.click();
2230
+ });
2231
+ console.log(' → Click "Confirmar Empresa"');
2232
+
2233
+ // Esperar checkboxes — GWT dispara ~8-10 POST /facade en paralelo
2234
+ await page.waitForNetworkIdle({ idleTime: 800, timeout: 40000 }).catch(() => {});
2235
+ await page.waitForFunction(() => {
2236
+ return document.querySelector('input[type="checkbox"]') !== null;
2237
+ }, { timeout: 40000, polling: 500 }).catch(() => {});
2238
+ await new Promise(r => setTimeout(r, 500));
2239
+
2240
+ // Paso 2: Marcar todos los checkboxes
2241
+ const nCbs = await page.evaluate(() => {
2242
+ const cbs = Array.from(document.querySelectorAll('input[type="checkbox"]'));
2243
+ cbs.forEach(cb => { if (!cb.checked) cb.click(); });
2244
+ return cbs.length;
2245
+ });
2246
+ console.log(` → ${nCbs} checkbox(es) marcados`);
2247
+ await new Promise(r => setTimeout(r, 300));
2248
+
2249
+ // Paso 3: Rellenar correo proveedor
2250
+ // El campo de email es el input visible que NO tiene maxlength pequeño
2251
+ const allInputs = await page.$$('input[type="text"].form-control');
2252
+ let emailInput = null;
2253
+ for (const inp of allInputs) {
2254
+ const ml = await page.evaluate(el => el.maxLength, inp);
2255
+ const visible = await page.evaluate(el => el.offsetParent !== null, inp);
2256
+ if (visible && (ml <= 0 || ml > 8)) { emailInput = inp; break; }
2257
+ }
2258
+ if (emailInput) {
2259
+ await emailInput.click({ clickCount: 3 });
2260
+ await emailInput.type(correoSet);
2261
+ console.log(` → Correo proveedor: ${correoSet}`);
2262
+ } else {
2263
+ console.log(' ⚠️ No se encontró campo de correo — continuando sin él');
2264
+ }
2265
+
2266
+ // Paso 4: Click "Bajar Nuevo Set" — esperar POST /facade (GWT RPC) y luego
2267
+ // construir la URL de DownloadFileServlet directamente con los parámetros conocidos.
2268
+ // La descarga real es un GET:
2269
+ // DownloadFileServlet?rutEmpresa=X&dvEmpresa=X&rutRepre=X&dvRepre=X&mailProvSw=X
2270
+ // donde rutRepre/dvRepre vienen de las cookies NETSCAPE_LIVEWIRE.rut / .dv
2271
+
2272
+ const rutRepreNum = cookieJar['NETSCAPE_LIVEWIRE.rut'] || cookieJar['RUT_NS'] || '';
2273
+ const dvRepreChar = cookieJar['NETSCAPE_LIVEWIRE.dv'] || cookieJar['DV_NS'] || '';
2274
+ if (!rutRepreNum) throw new Error('No se pudo obtener rutRepre de las cookies SII (NETSCAPE_LIVEWIRE.rut)');
2275
+
2276
+ // Registrar listener de facade ANTES de hacer click
2277
+ const facadePromise = page.waitForResponse(
2278
+ resp => resp.url().includes('/certBolElectDteInternet/facade'),
2279
+ { timeout: 20000 }
2280
+ ).catch(() => null);
2281
+
2282
+ console.log(' → Click "Bajar Nuevo Set" — esperando GWT facade...');
2283
+ await page.evaluate(() => {
2284
+ const btn = Array.from(document.querySelectorAll('button'))
2285
+ .find(b => /bajar/i.test(b.textContent));
2286
+ if (btn) btn.click();
2287
+ });
2288
+
2289
+ // Esperar que el facade GWT procese la solicitud
2290
+ await facadePromise;
2291
+ await new Promise(r => setTimeout(r, 1000));
2292
+
2293
+ // Construir URL de descarga e ir directo con https.get + cookies
2294
+ const https = require('https');
2295
+ const cookieStr = Object.entries(cookieJar).map(([k, v]) => `${k}=${v}`).join('; ');
2296
+ const downloadUrl = `https://www4.sii.cl/certBolElectDteInternet/DownloadFileServlet` +
2297
+ `?rutEmpresa=${rutNum}&dvEmpresa=${dvChar}` +
2298
+ `&rutRepre=${rutRepreNum}&dvRepre=${dvRepreChar}` +
2299
+ `&mailProvSw=${encodeURIComponent(correoSet)}`;
2300
+
2301
+ console.log(` → Descargando set directamente: DownloadFileServlet?rutEmpresa=${rutNum}&dvEmpresa=${dvChar}&rutRepre=${rutRepreNum}&dvRepre=${dvRepreChar}&mailProvSw=${correoSet}`);
2302
+
2303
+ const setText = await new Promise((resolve, reject) => {
2304
+ const req = https.get(downloadUrl, {
2305
+ headers: {
2306
+ 'Cookie': cookieStr,
2307
+ 'Referer': 'https://www4.sii.cl/certBolElectDteInternet/?SET=1',
2308
+ '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',
2309
+ 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
2310
+ },
2311
+ rejectUnauthorized: false,
2312
+ }, (res) => {
2313
+ const chunks = [];
2314
+ res.on('data', chunk => chunks.push(chunk));
2315
+ res.on('end', () => {
2316
+ const body = Buffer.concat(chunks).toString('utf-8');
2317
+ resolve(body);
2318
+ });
2319
+ });
2320
+ req.on('error', reject);
2321
+ req.setTimeout(30000, () => { req.destroy(); reject(new Error('Timeout descargando DownloadFileServlet')); });
2322
+ });
2323
+
2324
+ if (!setText || setText.trim().length < 10) {
2325
+ if (this.config.debugDir) {
2326
+ fs.mkdirSync(this.config.debugDir, { recursive: true });
2327
+ fs.writeFileSync(path.join(this.config.debugDir, 'boleta-set-debug.txt'), setText || '', 'utf-8');
2328
+ }
2329
+ throw new Error(`DownloadFileServlet devolvió contenido vacío (${setText?.length ?? 0} chars). Verificar cookies.`);
2330
+ }
2331
+
2332
+ if (setPath) {
2333
+ const nodePath = require('path');
2334
+ fs.mkdirSync(nodePath.dirname(setPath), { recursive: true });
2335
+ fs.writeFileSync(setPath, setText, 'utf-8');
2336
+ console.log(` ✓ Set guardado en: ${setPath}`);
2337
+ }
2338
+
2339
+ console.log(` ✓ Set de pruebas obtenido (${setText.length} chars)`);
2340
+ return { success: true, setText };
2341
+ } catch (err) {
2342
+ return { success: false, error: err.message };
2343
+ } finally {
2344
+ if (browser) await browser.close().catch(() => {});
2345
+ }
2346
+ }
2347
+
2348
+ /**
2349
+ * Solicita la validación del set de pruebas de Boleta Electrónica al SII (?SET=2).
2350
+ * Flujo real portal:
2351
+ * 1. Ingresa RUT empresa → click "Confirmar Empresa"
2352
+ * 2. Ingresa el TrackId del EnvioBOLETA en "Identificador de Envio"
2353
+ * 3. Click "Solicitar validación"
2354
+ * El SII procesa el set de forma asíncrona y notifica por correo (SOK/SRH).
2355
+ * @param {Object} opts
2356
+ * @param {string} opts.trackId - TrackId del EnvioBOLETA
2357
+ * @returns {Promise<{success: boolean, respuesta?: string, error?: string}>}
2358
+ */
2359
+ async solicitarValidacionBoletaPortal({ trackId } = {}) {
2360
+ if (!trackId) throw new Error('solicitarValidacionBoletaPortal: trackId es obligatorio');
2361
+ const puppeteer = require('puppeteer');
2362
+ const cookieJar = await this._obtenerCookiesSII();
2363
+ const puppeteerCookies = Object.entries(cookieJar).map(([name, value]) => ({
2364
+ name, value, domain: '.sii.cl', path: '/', httpOnly: false, secure: true,
2365
+ }));
2366
+ const [rutNum, dvChar] = this.config.emisor.rut.split('-');
2367
+
2368
+ let browser;
2369
+ try {
2370
+ browser = await puppeteer.launch({
2371
+ headless: true,
2372
+ ignoreHTTPSErrors: true,
2373
+ args: ['--no-sandbox', '--disable-setuid-sandbox', '--ignore-certificate-errors'],
2374
+ });
2375
+ const page = await browser.newPage();
2376
+ await page.setCookie(...puppeteerCookies);
2377
+ page.on('dialog', async dlg => { console.log(` → [dialog SET=2] ${dlg.message()}`); await dlg.accept(); });
2378
+
2379
+ console.log(' → Cargando portal certBolElectDteInternet (SET=2)...');
2380
+ await page.goto('https://www4.sii.cl/certBolElectDteInternet/?SET=2', {
2381
+ waitUntil: 'networkidle2', timeout: 60000,
2382
+ });
2383
+ await new Promise(r => setTimeout(r, 2000));
2384
+
2385
+ // Paso 1: RUT empresa → "Confirmar Empresa"
2386
+ const rutInput = await page.$('input[maxlength="8"]');
2387
+ const dvInput = await page.$('input[maxlength="1"]');
2388
+ if (!rutInput) throw new Error('certBolElectDteInternet/?SET=2: no se encontró campo RUT');
2389
+ console.log(` → Ingresando RUT empresa: ${rutNum}-${dvChar}`);
2390
+ await rutInput.click({ clickCount: 3 }); await rutInput.type(rutNum);
2391
+ await dvInput.click({ clickCount: 3 }); await dvInput.type(dvChar);
2392
+ await page.evaluate(() => {
2393
+ const btn = Array.from(document.querySelectorAll('button'))
2394
+ .find(b => /confirmar/i.test(b.textContent));
2395
+ if (btn) btn.click();
2396
+ });
2397
+ console.log(' → Click "Confirmar Empresa"');
2398
+
2399
+ // Esperar que aparezca el campo "Identificador de Envio" — GWT dispara ~8-10 POST /facade en paralelo
2400
+ await page.waitForNetworkIdle({ idleTime: 800, timeout: 40000 }).catch(() => {});
2401
+ await page.waitForFunction(() => {
2402
+ return document.querySelector('input[maxlength="15"]') !== null;
2403
+ }, { timeout: 40000, polling: 500 }).catch(() => {});
2404
+ await new Promise(r => setTimeout(r, 500));
2405
+
2406
+ // Paso 2: Ingresar TrackId
2407
+ const trackInput = await page.$('input[maxlength="15"]');
2408
+ if (!trackInput) throw new Error('No se encontró campo "Identificador de Envio" en certBolElectDteInternet/?SET=2');
2409
+ console.log(` → Ingresando TrackId: ${trackId}`);
2410
+ await trackInput.click({ clickCount: 3 });
2411
+ await trackInput.type(String(trackId));
2412
+
2413
+ // Paso 3: Click "Solicitar validación"
2414
+ await page.evaluate(() => {
2415
+ const btn = Array.from(document.querySelectorAll('button'))
2416
+ .find(b => /solicitar/i.test(b.textContent));
2417
+ if (btn) btn.click();
2418
+ });
2419
+ console.log(' → Click "Solicitar validación" — esperando respuesta...');
2420
+
2421
+ // Esperar respuesta del portal (puede ser confirmación o error)
2422
+ await page.waitForFunction(() => {
2423
+ const t = (document.body.textContent || '').toUpperCase();
2424
+ return t.includes('ENVI') || t.includes('CORREO') || t.includes('ERROR') ||
2425
+ t.includes('VALIDACI') || t.includes('SOLICITUD');
2426
+ }, { timeout: 15000, polling: 500 }).catch(() => {});
2427
+
2428
+ const respuesta = await page.evaluate(() => (document.body.innerText || '').trim().substring(0, 500));
2429
+ console.log(` ✓ Validación solicitada. Respuesta: ${respuesta.substring(0, 120)}`);
2430
+
2431
+ return { success: true, respuesta };
2432
+ } catch (err) {
2433
+ return { success: false, error: err.message };
2434
+ } finally {
2435
+ if (browser) await browser.close().catch(() => {});
2436
+ }
2437
+ }
2438
+
2439
+ /**
2440
+ * Completa la declaración de cumplimiento de Boleta Electrónica en el portal SII.
2441
+ * Marca los checkboxes de requisitos y rellena el formulario de proveedor.
2442
+ * @param {Object} opts
2443
+ * @param {string} [opts.linkConsulta='www.sii.cl']
2444
+ * @param {string} [opts.rutProveedor='78206276-K']
2445
+ * @param {string} [opts.nombreProveedor='DEVLAS SPA']
2446
+ * @param {string} [opts.correoProveedor='certificacion.sii@devlas.cl']
2447
+ * @returns {Promise<{success: boolean, mensaje?: string, error?: string}>}
2448
+ */
2449
+ async completarDeclaracionBoletaPortal({
2450
+ linkConsulta = 'www.sii.cl',
2451
+ rutProveedor = '78206276-K',
2452
+ nombreProveedor = 'DEVLAS SPA',
2453
+ correoProveedor = 'certificacion.sii@devlas.cl',
2454
+ } = {}) {
2455
+ const puppeteer = require('puppeteer');
2456
+ const cookieJar = await this._obtenerCookiesSII();
2457
+
2458
+ // RUT empresa desde config (ej: "78206276-K")
2459
+ const rutEmpresaRaw = (this.config.emisor?.rut || '').replace(/\./g, '');
2460
+ const [rutEmpNum, rutEmpDvRaw = 'K'] = rutEmpresaRaw.split('-');
2461
+ const rutEmpDv = rutEmpDvRaw.toUpperCase();
2462
+
2463
+ const puppeteerCookies = Object.entries(cookieJar).map(([name, value]) => ({
2464
+ name, value, domain: '.sii.cl', path: '/', httpOnly: false, secure: true,
2465
+ }));
2466
+
2467
+ let browser;
2468
+ try {
2469
+ browser = await puppeteer.launch({
2470
+ headless: true,
2471
+ ignoreHTTPSErrors: true,
2472
+ args: ['--no-sandbox', '--disable-setuid-sandbox', '--ignore-certificate-errors'],
2473
+ });
2474
+ const page = await browser.newPage();
2475
+ await page.setCookie(...puppeteerCookies);
2476
+
2477
+ // Capturar alerts/confirms de GWT — sin handler quedan bloqueados
2478
+ let dialogMsg = null;
2479
+ page.on('dialog', async dlg => {
2480
+ dialogMsg = dlg.message();
2481
+ console.log(` → [dialog] ${dialogMsg}`);
2482
+ await dlg.accept();
2483
+ });
2484
+
2485
+ // ── PASOS 1+2: Confirmar Empresa → esperar formulario (con retry) ──
2486
+ // El portal GWT a veces responde con error transitorio ("empresa no autorizada")
2487
+ // que se resuelve recargando la página y reintentando.
2488
+ const MAX_INTENTOS = 3;
2489
+ let checkboxOk = false;
2490
+ let lastDialogMsg = null;
2491
+ for (let intento = 1; intento <= MAX_INTENTOS; intento++) {
2492
+ dialogMsg = null; // resetear entre intentos
2493
+
2494
+ console.log(` → Navegando a certBolElectDteInternet (declaración) [intento ${intento}/${MAX_INTENTOS}]...`);
2495
+ await page.goto('https://www4.sii.cl/certBolElectDteInternet/', {
2496
+ waitUntil: 'networkidle2', timeout: 60000,
2497
+ });
2498
+ await new Promise(r => setTimeout(r, 2500));
2499
+
2500
+ // GWT requiere eventos de teclado reales — NO funciona con .value = ...
2501
+ const rutInput = await page.$('input[maxlength="8"]');
2502
+ const dvInput = await page.$('input[maxlength="1"]');
2503
+ if (!rutInput) throw new Error('certBolElectDteInternet/: no se encontró campo RUT empresa');
2504
+ console.log(` → Ingresando RUT empresa: ${rutEmpresaRaw}`);
2505
+ await rutInput.click({ clickCount: 3 }); await rutInput.type(rutEmpNum);
2506
+ await dvInput.click({ clickCount: 3 }); await dvInput.type(rutEmpDv);
2507
+
2508
+ await page.evaluate(() => {
2509
+ const btn = Array.from(document.querySelectorAll('button'))
2510
+ .find(b => /confirmar empresa/i.test(b.textContent));
2511
+ if (btn) btn.click();
2512
+ });
2513
+ console.log(' → Click "Confirmar Empresa"...');
2514
+
2515
+ // GWT dispara ~8-10 POST /facade EN PARALELO al confirmar.
2516
+ // Esperar red inactiva y luego DOM con checkboxes.
2517
+ await page.waitForNetworkIdle({ idleTime: 800, timeout: 40000 }).catch(() => {});
2518
+ await page.waitForFunction(() => {
2519
+ return document.querySelector('input[type="checkbox"]') !== null;
2520
+ }, { timeout: 40000, polling: 500 }).catch(() => {});
2521
+
2522
+ if (dialogMsg) {
2523
+ // Portal lanzó alert — puede ser transitorio. Guardar y reintentar.
2524
+ lastDialogMsg = dialogMsg;
2525
+ console.log(` ⚠️ Portal respondió con alerta en intento ${intento}: ${dialogMsg.substring(0, 100)}`);
2526
+ if (intento < MAX_INTENTOS) {
2527
+ console.log(' → Recargando y reintentando en 3s...');
2528
+ await new Promise(r => setTimeout(r, 3000));
2529
+ continue;
2530
+ }
2531
+ // Agotados los intentos con alerta — el SII puede requerir esperar SOK
2532
+ return { success: false, pendingSok: true, error: lastDialogMsg };
2533
+ }
2534
+
2535
+ if (await page.$('input[type="checkbox"]')) {
2536
+ checkboxOk = true;
2537
+ break;
2538
+ }
2539
+
2540
+ console.log(` ⚠️ Formulario no cargó en intento ${intento}${intento < MAX_INTENTOS ? ` — reintentando...` : ''}`);
2541
+ await new Promise(r => setTimeout(r, 2000));
2542
+ }
2543
+
2544
+ if (!checkboxOk) {
2545
+ throw new Error('Formulario de declaración no cargó tras confirmar empresa (3 intentos)');
2546
+ }
2547
+
2548
+ const totalCbs = await page.evaluate(() => {
2549
+ const todos = Array.from(document.querySelectorAll('input[type="checkbox"]'));
2550
+ todos.forEach(cb => { if (!cb.checked) cb.click(); });
2551
+ return todos.length;
2552
+ });
2553
+ console.log(` → ${totalCbs} checkbox(es) marcados`);
2554
+ await new Promise(r => setTimeout(r, 500));
2555
+
2556
+ // ── PASO 3: Rellenar campos proveedor software ────────────────
2557
+ // GWT requiere page.type() real — NOT funciona con .value + dispatchEvent
2558
+ const fillByLabel = async (labelFragment, value) => {
2559
+ const handle = await page.evaluateHandle((frag) => {
2560
+ for (const td of document.querySelectorAll('td')) {
2561
+ if (!td.textContent.includes(frag)) continue;
2562
+ const next = td.nextElementSibling;
2563
+ if (!next) continue;
2564
+ const inp = next.querySelector('input[type="text"]');
2565
+ if (inp) return inp;
2566
+ }
2567
+ return null;
2568
+ }, labelFragment);
2569
+ const elem = handle && await handle.asElement();
2570
+ if (!elem) return false;
2571
+ await elem.click({ clickCount: 3 });
2572
+ await elem.type(value);
2573
+ return true;
2574
+ };
2575
+
2576
+ // Link de Consulta (maxlength=100)
2577
+ const linkOk = await fillByLabel('Link de Consulta', linkConsulta);
2578
+ if (!linkOk) {
2579
+ const inp = await page.$('input[type="text"][maxlength="100"]');
2580
+ if (inp) { await inp.click({ clickCount: 3 }); await inp.type(linkConsulta); }
2581
+ }
2582
+
2583
+ // RUT Proveedor — dos inputs (num + DV) en la fila "Rut Proveedor"
2584
+ const [rutProvNum, rutProvDvRaw] = rutProveedor.replace(/\./g, '').split('-');
2585
+ const rutProvDv = (rutProvDvRaw || 'K').toUpperCase();
2586
+ const rutProvH = await page.evaluateHandle(() => {
2587
+ for (const td of document.querySelectorAll('td')) {
2588
+ if (!td.textContent.includes('Rut Proveedor')) continue;
2589
+ const next = td.nextElementSibling;
2590
+ if (next) { const ins = next.querySelectorAll('input[type="text"]'); if (ins[0]) return ins[0]; }
2591
+ }
2592
+ return null;
2593
+ });
2594
+ const dvProvH = await page.evaluateHandle(() => {
2595
+ for (const td of document.querySelectorAll('td')) {
2596
+ if (!td.textContent.includes('Rut Proveedor')) continue;
2597
+ const next = td.nextElementSibling;
2598
+ if (next) { const ins = next.querySelectorAll('input[type="text"]'); if (ins[1]) return ins[1]; }
2599
+ }
2600
+ return null;
2601
+ });
2602
+ if (rutProvH && await rutProvH.asElement()) { const e = rutProvH.asElement(); await e.click({ clickCount: 3 }); await e.type(rutProvNum); }
2603
+ if (dvProvH && await dvProvH.asElement()) { const e = dvProvH.asElement(); await e.click({ clickCount: 3 }); await e.type(rutProvDv); }
2604
+
2605
+ // Nombre Proveedor
2606
+ await fillByLabel('Nombre Proveedor', nombreProveedor);
2607
+
2608
+ // Correo Proveedor Software
2609
+ await fillByLabel('Correo electrónico Proveedor', correoProveedor);
2610
+
2611
+ // Captura pre-submit
2612
+ if (this.config.debugDir) {
2613
+ await page.screenshot({ path: path.join(this.config.debugDir, 'boleta-declaracion-pre-submit.png'), fullPage: true }).catch(() => {});
2614
+ }
2615
+
2616
+ // ── PASO 4: Click "Grabar Declaración" ───────────────────────
2617
+ const submitOk = await page.evaluate(() => {
2618
+ const btn = Array.from(document.querySelectorAll('button'))
2619
+ .find(b => /grabar declaraci/i.test(b.textContent));
2620
+ if (btn) { btn.click(); return true; }
2621
+ return false;
2622
+ });
2623
+ if (!submitOk) throw new Error('No se encontró botón "Grabar Declaración" en certBolElectDteInternet/');
2624
+ console.log(' → Click "Grabar Declaración"...');
2625
+
2626
+ // Esperar confirmación del SII
2627
+ await page.waitForFunction(() => {
2628
+ const t = (document.body.textContent || '').toUpperCase();
2629
+ return t.includes('GRABADA') || t.includes('GRABADO') || t.includes('COMPLETADA') ||
2630
+ t.includes('EXITOSA') || t.includes('GUARDADA') || t.includes('REGISTRADA');
2631
+ }, { timeout: 20000, polling: 1000 }).catch(() => {});
2632
+
2633
+ const msgFinal = await page.evaluate(() => (document.body.textContent || '').trim().substring(0, 300));
2634
+ console.log(` ✓ Declaración completada. Respuesta: ${msgFinal.substring(0, 150)}`);
2635
+
2636
+ if (this.config.debugDir) {
2637
+ await page.screenshot({ path: path.join(this.config.debugDir, 'boleta-declaracion-post-submit.png'), fullPage: true }).catch(() => {});
2638
+ }
2639
+
2640
+ return { success: true, mensaje: msgFinal };
2641
+ } catch (err) {
2642
+ return { success: false, error: err.message };
2643
+ } finally {
2644
+ if (browser) await browser.close().catch(() => {});
2645
+ }
2646
+ }
2647
+
2648
+ // ═══════════════════════════════════════════════════════════════
2649
+ // Helpers privados
2650
+ // ═══════════════════════════════════════════════════════════════
2651
+
2652
+ _getFechaHoy() {
2653
+ const now = new Date();
2654
+ return `${String(now.getDate()).padStart(2, '0')}-${String(now.getMonth() + 1).padStart(2, '0')}-${now.getFullYear()}`;
2655
+ }
2656
+ }
2657
+
2658
+ module.exports = CertRunner;