@devlas/dte-sii 2.8.3 → 2.9.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/FolioService.js CHANGED
@@ -249,10 +249,14 @@ class FolioService {
249
249
  const debugDir = path.join(this.debugDir, 'auto-caf', 'anulacion', debugStamp);
250
250
  fs.mkdirSync(debugDir, { recursive: true });
251
251
 
252
- // Asegurar sesión
253
- this.session.reset();
252
+ // Asegurar sesión — sin reset() para reutilizar cookieJar cargado del caché
254
253
  const page = await this.session.ensureSession('/cvc_cgi/dte/af_anular1');
255
254
 
255
+ // Persistir sesión para reutilizar en llamadas posteriores (evita abrir sesiones SII extra)
256
+ if (this.sessionPath) {
257
+ try { this.session.saveSession(this.sessionPath); } catch (_) {}
258
+ }
259
+
256
260
  if (!page.body || !page.body.includes('ANULACION DE FOLIOS')) {
257
261
  fs.writeFileSync(path.join(debugDir, 'error.html'), page.body || '', 'utf8');
258
262
  throw new Error('No se pudo acceder a la página de anulación.');
@@ -264,17 +268,21 @@ class FolioService {
264
268
  ...hiddenInputs,
265
269
  RUT_EMP: rut,
266
270
  DV_EMP: dv,
271
+ PAGINA: '1',
267
272
  COD_DOCTO: String(tipoDte),
268
273
  ACEPTAR: 'Consultar',
269
274
  };
270
275
 
271
- const consulta = await this.session.submitForm(
272
- '/cvc_cgi/dte/af_anular2',
273
- fields,
274
- `https://${this.session.getBaseHost()}/cvc_cgi/dte/af_anular1`
276
+ const consulta = await this._withRetry('af_anular2', () =>
277
+ this.session.submitForm(
278
+ '/cvc_cgi/dte/af_anular2',
279
+ fields,
280
+ `https://${this.session.getBaseHost()}/cvc_cgi/dte/af_anular1`
281
+ )
275
282
  );
276
283
 
277
284
  let currentHtml = consulta.body || '';
285
+ try { fs.writeFileSync(path.join(debugDir, 'page-1.html'), currentHtml, 'utf8'); } catch (_) {}
278
286
  let info = this._parseAnulacionTable(currentHtml);
279
287
  let action = SiiSession.extractFormActionByName(currentHtml, 'frm') || '/cvc_cgi/dte/af_anular2';
280
288
  let hiddenInputsConsulta = SiiSession.extractInputValues(currentHtml);
@@ -291,10 +299,12 @@ class FolioService {
291
299
  nextFields.PAGINA = String(nextButton.page);
292
300
  }
293
301
 
294
- const nextRes = await this.session.submitForm(
295
- action,
296
- nextFields,
297
- `https://${this.session.getBaseHost()}/cvc_cgi/dte/af_anular2`
302
+ const nextRes = await this._withRetry(`af_anular2 pág ${nextButton.page || safety + 2}`, () =>
303
+ this.session.submitForm(
304
+ action,
305
+ nextFields,
306
+ `https://${this.session.getBaseHost()}/cvc_cgi/dte/af_anular2`
307
+ )
298
308
  );
299
309
 
300
310
  currentHtml = nextRes.body || '';
@@ -318,6 +328,7 @@ class FolioService {
318
328
  safety += 1;
319
329
  }
320
330
 
331
+ console.log(`[FolioService] consultarFolios tipoDte=${tipoDte}: ${info.ranges.length} rango(s) encontrado(s)`);
321
332
  return {
322
333
  ok: true,
323
334
  tipoDte,
@@ -330,110 +341,179 @@ class FolioService {
330
341
  }
331
342
 
332
343
  /**
333
- * Anula folios en el SII
334
- * @param {Object} params - Parámetros
335
- * @returns {Promise<Object>}
344
+ * Anula folios en el SII usando el flujo bulk: af_anular3 → af_anular.
345
+ * Un request por rango CAF en lugar de un request por folio individual.
346
+ *
347
+ * @param {Object} params
348
+ * @param {number} params.tipoDte
349
+ * @param {number|null} [params.folioDesde] - Si se omite, anula todos los rangos pendientes
350
+ * @param {number|null} [params.folioHasta]
351
+ * @param {string} [params.motivo]
352
+ * @returns {Promise<{ok:boolean, anulados:Array, rechazados:Array, totalAnulados:number, totalRechazados:number}>}
336
353
  */
337
354
  async anularFolios({ tipoDte, folioDesde = null, folioHasta = null, motivo = 'Folios no utilizados' }) {
338
- const consulta = await this.consultarFolios({ tipoDte });
339
- const anulados = [];
355
+ const debugStampA = new Date().toISOString().replace(/[:.]/g, '-');
356
+ const debugDirA = path.join(this.debugDir, 'auto-caf', 'anulacion', debugStampA);
357
+ fs.mkdirSync(debugDirA, { recursive: true });
358
+
359
+ const { rut, dv } = SiiSession.parseRut(this.rutEmisor);
360
+ const anulados = [];
340
361
  const rechazados = [];
362
+ const vistos = new Set(); // claves "folioDesde-folioHasta" ya procesadas en esta ejecución
341
363
 
342
- // Calcular total de folios a anular para mostrar progreso
343
- let totalFolios = 0;
344
- if (Number.isFinite(folioDesde) && Number.isFinite(folioHasta)) {
345
- totalFolios = folioHasta - folioDesde + 1;
346
- } else {
347
- for (const range of consulta.ranges) {
348
- totalFolios += (range.folioHasta - range.folioDesde + 1);
349
- }
350
- }
351
- let foliosAnulados = 0;
364
+ const maxPasadas = 4;
352
365
 
353
- const anularFolioIndividual = async (folio) => {
354
- let currentHtml = consulta.html;
366
+ for (let pasada = 0; pasada < maxPasadas; pasada++) {
367
+ const consulta = await this.consultarFolios({ tipoDte });
355
368
 
356
- const range = consulta.ranges.find((r) => folio >= r.folioDesde && folio <= r.folioHasta);
357
-
358
- if (range && range.formFields && Object.keys(range.formFields).length) {
359
- const selectRes = await this.session.submitForm(
360
- range.formAction || consulta.action,
361
- range.formFields,
362
- `https://${this.session.getBaseHost()}/cvc_cgi/dte/af_anular1`
363
- );
364
- currentHtml = selectRes.body || currentHtml;
365
- } else if (range && range.selection) {
366
- const selectFields = {
367
- ...consulta.hiddenInputs,
368
- [range.selection.name]: range.selection.value || '',
369
- };
370
- const selectRes = await this.session.submitForm(
371
- consulta.action,
372
- selectFields,
373
- `https://${this.session.getBaseHost()}/cvc_cgi/dte/af_anular1`
374
- );
375
- currentHtml = selectRes.body || currentHtml;
369
+ // Filtrar rangos dentro del rango solicitado y que no hayamos intentado aún
370
+ let rangos = consulta.ranges.filter(r => {
371
+ if (Number.isFinite(folioDesde) && Number.isFinite(folioHasta)) {
372
+ if (r.folioDesde > folioHasta || r.folioHasta < folioDesde) return false;
373
+ }
374
+ return !vistos.has(`${r.folioDesde}-${r.folioHasta}`);
375
+ });
376
+
377
+ if (rangos.length === 0) {
378
+ console.log(`[FolioService] Pasada ${pasada + 1}: sin rangos nuevos, finalizando`);
379
+ break;
376
380
  }
377
381
 
378
- const action = SiiSession.extractFormAction(currentHtml) || consulta.action;
379
- const fields = SiiSession.extractInputValues(currentHtml);
380
- this._setFolioFields(fields, folio, folio);
381
- this._setMotivoField(fields, motivo);
382
+ console.log(`[FolioService] Pasada ${pasada + 1}: ${rangos.length} rango(s) a procesar`);
383
+ const host = this.session.getBaseHost();
382
384
 
383
- const resultRes = await this.session.submitForm(
384
- action,
385
- fields,
386
- `https://${this.session.getBaseHost()}/cvc_cgi/dte/af_anular2`
387
- );
385
+ for (const range of rangos) {
386
+ vistos.add(`${range.folioDesde}-${range.folioHasta}`);
387
+ const iniA = folioDesde != null ? Math.max(range.folioDesde, folioDesde) : range.folioDesde;
388
+ const finA = folioHasta != null ? Math.min(range.folioHasta, folioHasta) : range.folioHasta;
389
+ const count = finA - iniA + 1;
390
+ const { dia, mes, ano } = this._parseFecha(range.fecha);
388
391
 
389
- // Guardar resultado para debug
390
- try {
391
- const debugDir = path.join(this.debugDir, 'auto-caf', 'anulacion', 'resultados');
392
- fs.mkdirSync(debugDir, { recursive: true });
393
- const stamp = new Date().toISOString().replace(/[:.]/g, '-');
394
- fs.writeFileSync(path.join(debugDir, `sii-anulacion-result-${folio}-${stamp}.html`), resultRes.body || '', 'utf8');
395
- } catch (_) {
396
- // ignore
397
- }
392
+ // Paso 1: af_anular3 — obtener el formulario con campos ocultos del SII
393
+ let body3;
394
+ try {
395
+ const r3 = await this._withRetry(`af_anular3 rango ${iniA}-${finA}`, () =>
396
+ this.session.submitForm(
397
+ '/cvc_cgi/dte/af_anular3',
398
+ {
399
+ RUT_EMP: rut, DV_EMP: dv,
400
+ DIA: dia, MES: mes, ANO: ano,
401
+ COD_DOCTO: String(tipoDte),
402
+ FOLIO_INI: String(range.folioDesde),
403
+ FOLIO_FIN: String(range.folioHasta),
404
+ CANT_DOCTOS: String(range.cantidad ?? (range.folioHasta - range.folioDesde + 1)),
405
+ },
406
+ `https://${host}/cvc_cgi/dte/af_anular2`
407
+ )
408
+ );
409
+ body3 = r3.body || '';
410
+ } catch (err) {
411
+ console.error(`[FolioService] af_anular3 falló rango ${iniA}-${finA}: ${err.message}`);
412
+ rechazados.push({ folioDesde: iniA, folioHasta: finA, count, reason: 'error-red' });
413
+ continue;
414
+ }
398
415
 
399
- // Log de progreso
400
- foliosAnulados += 1;
401
- const pct = Math.round((foliosAnulados / totalFolios) * 100);
402
- process.stdout.write(`\r Anulando folio ${folio} (${foliosAnulados}/${totalFolios} - ${pct}%)`);
416
+ if (body3.includes('ya ha sido efectuado') || body3.includes('ya fue anulado') ||
417
+ body3.includes('efectuado anteriormente')) {
418
+ rechazados.push({ folioDesde: iniA, folioHasta: finA, count, reason: 'ya-anulado' });
419
+ console.warn(`[FolioService] Rango ${iniA}-${finA}: ya anulado (af_anular3)`);
420
+ try { fs.writeFileSync(path.join(debugDirA, `rango-${iniA}-${finA}-af_anular3-error.html`), body3, 'utf8'); } catch (_) {}
421
+ continue;
422
+ }
423
+ try { fs.writeFileSync(path.join(debugDirA, `rango-${iniA}-${finA}-af_anular3.html`), body3, 'utf8'); } catch (_) {}
403
424
 
404
- return { folio, ...this._parseAnulacionResult(resultRes.body || '') };
405
- };
425
+ // Extraer campos ocultos del formulario devuelto por SII
426
+ const formFields = SiiSession.extractInputValues(body3);
427
+ formFields.FOLIO_INI_A = String(iniA);
428
+ formFields.FOLIO_FIN_A = String(finA);
429
+ formFields.MOTIVO = motivo;
406
430
 
407
- // Anular rango específico o todos
408
- if (Number.isFinite(folioDesde) && Number.isFinite(folioHasta)) {
409
- for (let folio = folioDesde; folio <= folioHasta; folio += 1) {
410
- const result = await anularFolioIndividual(folio);
411
- if (result.ok) {
412
- anulados.push(result);
413
- } else if (result.ok === false) {
414
- rechazados.push(result);
431
+ // Paso 2: af_anular intentar anulación bulk del rango completo
432
+ let bodyBulk;
433
+ try {
434
+ const rBulk = await this._withRetry(`af_anular bulk ${iniA}-${finA}`, () =>
435
+ this.session.submitForm('/cvc_cgi/dte/af_anular', formFields, `https://${host}/cvc_cgi/dte/af_anular3`)
436
+ );
437
+ bodyBulk = rBulk.body || '';
438
+ } catch (err) {
439
+ console.error(`[FolioService] af_anular bulk falló rango ${iniA}-${finA}: ${err.message}`);
440
+ rechazados.push({ folioDesde: iniA, folioHasta: finA, count, reason: 'error-red' });
441
+ continue;
415
442
  }
416
- }
417
- } else {
418
- // Anular todos los rangos
419
- for (const range of consulta.ranges) {
420
- for (let folio = range.folioDesde; folio <= range.folioHasta; folio += 1) {
421
- const result = await anularFolioIndividual(folio);
422
- if (result.ok) {
423
- anulados.push(result);
424
- } else if (result.ok === false) {
425
- rechazados.push(result);
443
+
444
+ const exitoBulk = bodyBulk.includes('ha autorizado la anulaci') ||
445
+ bodyBulk.includes('SOLICITUD ANULACION DE FOLIOS');
446
+ if (exitoBulk) {
447
+ anulados.push({ folioDesde: iniA, folioHasta: finA, count });
448
+ try { fs.writeFileSync(path.join(debugDirA, `rango-${iniA}-${finA}-af_anular-ok.html`), bodyBulk, 'utf8'); } catch (_) {}
449
+ console.log(`[FolioService] ✓ Rango ${iniA}-${finA} (${count} folios) anulado en bulk`);
450
+ continue;
451
+ }
452
+
453
+ try { fs.writeFileSync(path.join(debugDirA, `rango-${iniA}-${finA}-af_anular-fail.html`), bodyBulk, 'utf8'); } catch (_) {}
454
+ console.warn(`[FolioService] bulk falló rango ${iniA}-${finA}: ${bodyBulk.slice(0, 300).replace(/\s+/g, ' ')}`);
455
+
456
+ const yaConflicto = bodyBulk.includes('ya ha sido efectuado') ||
457
+ bodyBulk.includes('ya fue anulado') ||
458
+ bodyBulk.includes('efectuado anteriormente') ||
459
+ bodyBulk.includes('recepcionad');
460
+ if (!yaConflicto) {
461
+ const razon = this._parseAnulacionResult(bodyBulk).reason || 'error';
462
+ rechazados.push({ folioDesde: iniA, folioHasta: finA, count, reason: razon });
463
+ console.warn(`[FolioService] ✗ Rango ${iniA}-${finA} rechazado: ${razon}`);
464
+ continue;
465
+ }
466
+
467
+ // Fallback: algunos folios del rango ya fueron anulados/receptados.
468
+ // Reutilizar la sesión de af_anular3 y anular folio a folio.
469
+ console.log(`[FolioService] Rango ${iniA}-${finA}: conflicto bulk, fallback folio-a-folio...`);
470
+ let i = 0;
471
+ for (let folio = iniA; folio <= finA; folio++) {
472
+ i++;
473
+ process.stdout.write(`\r[FolioService] ${iniA}-${finA}: folio ${folio} (${i}/${count}) `);
474
+ const singleFields = { ...formFields, FOLIO_INI_A: String(folio), FOLIO_FIN_A: String(folio) };
475
+ let bs;
476
+ try {
477
+ const rSingle = await this._withRetry(`af_anular folio ${folio}`, () =>
478
+ this.session.submitForm('/cvc_cgi/dte/af_anular', singleFields, `https://${host}/cvc_cgi/dte/af_anular3`)
479
+ );
480
+ bs = rSingle.body || '';
481
+ } catch (err) {
482
+ rechazados.push({ folioDesde: folio, folioHasta: folio, count: 1, reason: 'error-red' });
483
+ continue;
484
+ }
485
+ const ok = bs.includes('ha autorizado la anulaci') || bs.includes('SOLICITUD ANULACION DE FOLIOS');
486
+ if (ok) {
487
+ anulados.push({ folioDesde: folio, folioHasta: folio, count: 1 });
488
+ } else {
489
+ const razon = this._parseAnulacionResult(bs).reason || 'error';
490
+ rechazados.push({ folioDesde: folio, folioHasta: folio, count: 1, reason: razon });
426
491
  }
427
492
  }
493
+ console.log('');
428
494
  }
429
- }
430
495
 
431
- // Nueva línea después del progreso
432
- if (totalFolios > 0) {
433
- console.log('');
496
+ // Pausa breve entre pasadas para no saturar el SII
497
+ if (pasada < maxPasadas - 1) await this._sleep(1500);
434
498
  }
435
499
 
436
- return { ok: true, anulados, rechazados };
500
+ const totalAnulados = anulados.reduce((s, r) => s + r.count, 0);
501
+ const totalRechazados = rechazados.reduce((s, r) => s + r.count, 0);
502
+ console.log(`[FolioService] Completado: ${totalAnulados} anulados, ${totalRechazados} rechazados`);
503
+
504
+ return { ok: true, anulados, rechazados, totalAnulados, totalRechazados };
505
+ }
506
+
507
+ /**
508
+ * Parsea una fecha "DD-MM-YYYY" o "DD/MM/YYYY" en sus componentes.
509
+ * @private
510
+ */
511
+ _parseFecha(fecha) {
512
+ const clean = String(fecha || '').replace(/[\s ]/g, '').trim();
513
+ const m = clean.match(/^(\d{1,2})[-\/](\d{1,2})[-\/](\d{4})$/);
514
+ if (m) return { dia: m[1].padStart(2, '0'), mes: m[2].padStart(2, '0'), ano: m[3] };
515
+ // Si no tiene fecha válida, dejar vacío (el SII puede no requerirla siempre)
516
+ return { dia: '', mes: '', ano: '' };
437
517
  }
438
518
 
439
519
  /**
@@ -547,6 +627,15 @@ class FolioService {
547
627
  * @private
548
628
  */
549
629
  _parseAnulacionTable(html) {
630
+ // Primario: extraer desde formularios (no depende de posición de columnas, más robusto
631
+ // ante tablas anidadas que cortan el regex de filas)
632
+ const formRanges = this._extractRangesFromForms(html);
633
+ if (formRanges.length > 0) {
634
+ const ultimoFolioFinal = formRanges.reduce((acc, r) => r.folioHasta > acc ? r.folioHasta : acc, 0);
635
+ return { ranges: formRanges, ultimoFolioFinal: ultimoFolioFinal || null };
636
+ }
637
+
638
+ // Fallback: parsing por columnas de tabla (backup si no hay forms con FOLIO_INI/FOLIO_FIN)
550
639
  const rows = html.match(/<tr[^>]*>[\s\S]*?<\/tr>/gi) || [];
551
640
  const ranges = [];
552
641
 
@@ -588,6 +677,53 @@ class FolioService {
588
677
  return { ranges, ultimoFolioFinal: ultimoFolioFinal || null };
589
678
  }
590
679
 
680
+ /**
681
+ * Extrae rangos de folios directamente de los <form> de la página af_anular2.
682
+ * Cada fila de la tabla tiene un form con inputs ocultos FOLIO_INI, FOLIO_FIN,
683
+ * CANT_DOCTOS, DIA, MES, ANO. Este método es más confiable que parsear columnas
684
+ * porque no se ve afectado por tablas anidadas ni por variaciones en el orden de columnas.
685
+ * @private
686
+ */
687
+ _extractRangesFromForms(html) {
688
+ const ranges = [];
689
+ const formRegex = /<form[^>]*>[\s\S]*?<\/form>/gi;
690
+ const forms = String(html || '').match(formRegex) || [];
691
+
692
+ forms.forEach(formHtml => {
693
+ const fields = SiiSession.extractInputValues(formHtml);
694
+ const folioDesde = SiiSession.parseIntFromText(fields.FOLIO_INI);
695
+ const folioHasta = SiiSession.parseIntFromText(fields.FOLIO_FIN);
696
+ if (!Number.isFinite(folioDesde) || !Number.isFinite(folioHasta)) return;
697
+
698
+ const cantidadRaw = SiiSession.parseIntFromText(fields.CANT_DOCTOS);
699
+ const cantidad = Number.isFinite(cantidadRaw) ? cantidadRaw : (folioHasta - folioDesde + 1);
700
+ const dia = String(fields.DIA || '').padStart(2, '0');
701
+ const mes = String(fields.MES || '').padStart(2, '0');
702
+ const ano = fields.ANO || '';
703
+ const fecha = (dia !== '00' && mes !== '00' && ano) ? `${dia}-${mes}-${ano}` : null;
704
+
705
+ ranges.push({
706
+ fecha,
707
+ cantidad,
708
+ folioDesde,
709
+ folioHasta,
710
+ efectuadoPor: null,
711
+ selection: null,
712
+ formFields: fields,
713
+ formAction: SiiSession.extractFormAction(formHtml) || '',
714
+ });
715
+ });
716
+
717
+ // Deduplicar por folioDesde+folioHasta (el SII puede repetir el mismo form)
718
+ const seen = new Set();
719
+ return ranges.filter(r => {
720
+ const key = `${r.folioDesde}-${r.folioHasta}`;
721
+ if (seen.has(key)) return false;
722
+ seen.add(key);
723
+ return true;
724
+ });
725
+ }
726
+
591
727
  /**
592
728
  * @private
593
729
  */
@@ -675,6 +811,7 @@ class FolioService {
675
811
  if (
676
812
  text.includes('ya fue anulado') ||
677
813
  text.includes('anulado anteriormente') ||
814
+ text.includes('ya ha sido efectuado anteriormente') ||
678
815
  (text.includes('anulad') && text.includes('ya'))
679
816
  ) {
680
817
  return { ok: false, reason: 'ya-anulado' };
@@ -695,9 +832,41 @@ class FolioService {
695
832
  if (text.includes('anulaci') && text.includes('no')) {
696
833
  return { ok: false, reason: 'rechazado' };
697
834
  }
835
+
836
+ if (text.trim() === 'error 500' || text.includes('error 500') || text.includes('internal server error')) {
837
+ return { ok: false, reason: 'error-sii-500' };
838
+ }
698
839
 
699
840
  return { ok: null, reason: 'desconocido' };
700
841
  }
842
+
843
+ /**
844
+ * Reintenta una función async ante errores de red.
845
+ * No reintenta en errores de lógica (respuestas HTML del SII con error).
846
+ * @private
847
+ */
848
+ async _withRetry(label, fn) {
849
+ const retries = this.config.retries ?? 2;
850
+ const delayMs = this.config.retryDelayMs ?? 1500;
851
+ let lastErr;
852
+ for (let i = 0; i <= retries; i++) {
853
+ try {
854
+ return await fn();
855
+ } catch (err) {
856
+ lastErr = err;
857
+ if (i < retries) {
858
+ console.warn(`[FolioService] ${label}: error "${err.message}", reintentando (${i + 1}/${retries})...`);
859
+ await this._sleep(delayMs);
860
+ }
861
+ }
862
+ }
863
+ throw lastErr;
864
+ }
865
+
866
+ /** @private */
867
+ _sleep(ms) {
868
+ return new Promise(r => setTimeout(r, ms));
869
+ }
701
870
  }
702
871
 
703
872
  module.exports = FolioService;
package/SiiPortalAuth.js CHANGED
@@ -121,7 +121,9 @@ class SiiPortalAuth {
121
121
  if (cookieStr) options.headers['Cookie'] = cookieStr;
122
122
 
123
123
  if (body) {
124
- options.headers['Content-Type'] = 'application/x-www-form-urlencoded';
124
+ if (!options.headers['Content-Type']) {
125
+ options.headers['Content-Type'] = 'application/x-www-form-urlencoded';
126
+ }
125
127
  options.headers['Content-Length'] = Buffer.byteLength(body);
126
128
  }
127
129
 
@@ -493,6 +495,117 @@ if (!fs.existsSync(SESSION_CACHE_PATH)) {
493
495
  fecha_autorizacion: datos['Fecha Autorización'] || null,
494
496
  };
495
497
  }
498
+
499
+ // ─── Consemitidos (www4.sii.cl/consemitidosinternetui) ───────────────────────
500
+
501
+ /**
502
+ * Navega a consemitidosinternetui para obtener el TOKEN de sesión.
503
+ * El TOKEN es el mismo valor que CSESSIONID y va como conversationId en el body.
504
+ * @private
505
+ */
506
+ async _obtenerTokenConsemitidos(cookieJar) {
507
+ await this._request('https://www4.sii.cl/consemitidosinternetui/', { cookieJar });
508
+ const token = cookieJar['TOKEN'] || cookieJar['CSESSIONID'];
509
+ if (!token) {
510
+ throw new Error('SiiPortalAuth: no se pudo obtener TOKEN de sesión de consemitidosinternetui');
511
+ }
512
+ return token;
513
+ }
514
+
515
+ /**
516
+ * Llama a un endpoint JSON de la API consemitidosinternetui.
517
+ * @private
518
+ */
519
+ async _callConsemitidos(method, data, token, cookieJar) {
520
+ const body = JSON.stringify({
521
+ metaData: {
522
+ namespace: `cl.sii.sdi.lob.diii.consemitidos.data.api.interfaces.FacadeService/${method}`,
523
+ conversationId: token,
524
+ transactionId: crypto.randomUUID(),
525
+ page: null,
526
+ },
527
+ data,
528
+ });
529
+ const res = await this._request(
530
+ `https://www4.sii.cl/consemitidosinternetui/services/data/facadeService/${method}`,
531
+ {
532
+ method: 'POST',
533
+ body,
534
+ cookieJar,
535
+ headers: {
536
+ 'Content-Type': 'application/json',
537
+ 'Accept': 'application/json, text/plain, */*',
538
+ 'Origin': 'https://www4.sii.cl',
539
+ 'Referer': 'https://www4.sii.cl/consemitidosinternetui/',
540
+ },
541
+ }
542
+ );
543
+ try {
544
+ return JSON.parse(res.body);
545
+ } catch {
546
+ throw new Error(`SiiPortalAuth: respuesta no-JSON de ${method}: ${res.body.slice(0, 300)}`);
547
+ }
548
+ }
549
+
550
+ /**
551
+ * Obtiene el detalle de DTEs emitidos o recibidos desde www4.sii.cl.
552
+ *
553
+ * @param {string} rut - RUT sin DV (ej: "78206276")
554
+ * @param {string} dv - DV (ej: "K")
555
+ * @param {string} periodo - Período YYYY-MM (ej: "2026-05")
556
+ * @param {number} operacion - 1 = compras / recibidos, 2 = ventas / emitidos
557
+ * @param {Object} [cookieJar] - Sesión ya autenticada (opcional)
558
+ * @returns {Promise<{ resumen: Array, detalles: Array }>}
559
+ */
560
+ async obtenerDetalleDtes(rut, dv, periodo, operacion = 2, cookieJar = null) {
561
+ const jar = cookieJar || await this.autenticar();
562
+ const token = await this._obtenerTokenConsemitidos(jar);
563
+
564
+ // Mapeo de convención interna → convención SII:
565
+ // interno: 1 = compras/recibidos, 2 = ventas/emitidos
566
+ // SII: 1 = emitidos, 2 = recibidos
567
+ const siiOperacion = operacion === 2 ? 1 : 2;
568
+ const esEmitidos = siiOperacion === 1;
569
+
570
+ // 1. Resumen mensual — saber qué tipos de DTE hay en el período
571
+ const resumenResp = await this._callConsemitidos('getResumen', {
572
+ periodo,
573
+ rutContribuyente: rut,
574
+ dvContribuyente: dv,
575
+ operacion: siiOperacion,
576
+ }, token, jar);
577
+
578
+ const resumen = resumenResp.data?.resumenDte ?? [];
579
+ if (resumen.length === 0) return { resumen: [], detalles: [] };
580
+
581
+ // 2. Detalle por cada tipo DTE encontrado en el resumen
582
+ const detallesArr = await Promise.all(
583
+ resumen.map(async (t) => {
584
+ // Para emitidos tipo 33/34, el SII expone un método específico
585
+ const metodo = esEmitidos && (t.tipoDoc === 33 || t.tipoDoc === 34)
586
+ ? 'getDetalleEmitidos3334'
587
+ : 'getDetalleRecibidos';
588
+ const resp = await this._callConsemitidos(metodo, {
589
+ tipoDoc: String(t.tipoDoc),
590
+ rut,
591
+ dv,
592
+ periodo,
593
+ operacion: siiOperacion,
594
+ derrCodigo: String(t.tipoDoc),
595
+ refNCD: '0',
596
+ }, token, jar);
597
+ const items = resp.dataResp?.detalles ?? [];
598
+ // Completar tipoDoc y tipoDocDesc desde el resumen si no vienen en el detalle
599
+ return items.map((d) => ({
600
+ ...d,
601
+ tipoDoc: t.tipoDoc,
602
+ tipoDocDesc: d.descTipoDoc || t.tipoDocDesc,
603
+ }));
604
+ })
605
+ );
606
+
607
+ return { resumen, detalles: detallesArr.flat() };
608
+ }
496
609
  }
497
610
 
498
611
  module.exports = SiiPortalAuth;