@devlas/dte-sii 2.12.23 → 2.12.24

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
@@ -311,7 +311,11 @@ class FolioService {
311
311
  `[FolioService] Tipo ${tipoDte}: ${result.errorCode} — anulando folios sin utilizar y reintentando...`
312
312
  );
313
313
  try {
314
- const anul = await this.anularFolios({ tipoDte });
314
+ // Acotado: acá interesan los folios viejos (son los que inflan
315
+ // FOLIOS_DISP), pero sin barrer el historial entero — anular de a
316
+ // cientos satura al SII y suma al contador de "anulados últimos 6
317
+ // meses", que también juega en contra del cupo.
318
+ const anul = await this.anularFolios({ tipoDte, maxRangos: 20 });
315
319
 
316
320
  // Desglosar los motivos reales del rechazo: no todos son "ya recepcionado"
317
321
  // (folio consumido en un DTE enviado). También caen acá los folios ya
@@ -500,7 +504,53 @@ class FolioService {
500
504
  * @param {string} [params.motivo]
501
505
  * @returns {Promise<{ok:boolean, anulados:Array, rechazados:Array, totalAnulados:number, totalRechazados:number}>}
502
506
  */
503
- async anularFolios({ tipoDte, folioDesde = null, folioHasta = null, motivo = 'Folios no utilizados', maxRangos = 50 }) {
507
+ /**
508
+ * @param {number} [params.soloUltimosDias] - Si se indica, solo se anulan los
509
+ * rangos timbrados dentro de esos días. Sirve para limpiar los sobrantes de
510
+ * una corrida reciente sin tocar el historial de la empresa: sin este filtro
511
+ * la función barre TODOS los rangos sin utilizar, lo que en un RUT con
512
+ * volumen real significa cientos de anulaciones contra el SII — y, peor, el
513
+ * propio SII cuenta los folios anulados de los últimos 6 meses en contra del
514
+ * cupo de timbraje. Verificado 2026-07-22: una limpieza sin acotar anuló 296
515
+ * folios de un RUT antes de detenerla.
516
+ */
517
+ /**
518
+ * Ruta del registro de rangos ya anulados, por RUT y tipo de DTE.
519
+ *
520
+ * El SII sigue listando un folio anulado como "sin utilizar" —anulado es, en
521
+ * efecto, sin usar— así que `consultarFolios` lo devuelve para siempre y cada
522
+ * limpieza vuelve a intentarlo. Sin memoria entre corridas eso son
523
+ * round-trips al SII que solo pueden fallar, y peor: van comiendo el cupo de
524
+ * `maxRangos`, hasta dejar fuera a los sobrantes que sí hay que anular.
525
+ */
526
+ _anuladosPath(tipoDte) {
527
+ const rutLimpio = String(this.rutEmisor || '').replace(/[^0-9kK]/g, '');
528
+ return path.join(this.debugDir, `folios-anulados-${rutLimpio}-${tipoDte}.json`);
529
+ }
530
+
531
+ /** Set de claves "desde-hasta" que el SII ya reportó como anuladas. */
532
+ _cargarAnulados(tipoDte) {
533
+ try {
534
+ const raw = fs.readFileSync(this._anuladosPath(tipoDte), 'utf8');
535
+ const arr = JSON.parse(raw);
536
+ return new Set(Array.isArray(arr) ? arr : []);
537
+ } catch (_) {
538
+ // Sin registro previo (o ilegible): se parte de cero. Nunca es fatal —
539
+ // como mucho se repite el intento una vez más.
540
+ return new Set();
541
+ }
542
+ }
543
+
544
+ _guardarAnulados(tipoDte, set) {
545
+ try {
546
+ fs.mkdirSync(this.debugDir, { recursive: true });
547
+ fs.writeFileSync(this._anuladosPath(tipoDte), JSON.stringify([...set]), 'utf8');
548
+ } catch (err) {
549
+ console.warn(`[FolioService] No se pudo persistir registro de anulados: ${err.message}`);
550
+ }
551
+ }
552
+
553
+ async anularFolios({ tipoDte, folioDesde = null, folioHasta = null, motivo = 'Folios no utilizados', maxRangos = 50, soloUltimosDias = null }) {
504
554
  const debugStampA = new Date().toISOString().replace(/[:.]/g, '-');
505
555
  const debugDirA = path.join(this.debugDir, 'auto-caf', 'anulacion', debugStampA);
506
556
  fs.mkdirSync(debugDirA, { recursive: true });
@@ -509,18 +559,38 @@ class FolioService {
509
559
  const anulados = [];
510
560
  const rechazados = [];
511
561
  const vistos = new Set(); // claves "folioDesde-folioHasta" ya procesadas en esta ejecución
562
+ // Rangos que el SII ya reportó como anulados en corridas anteriores: se
563
+ // saltan de entrada para no gastar el cupo de `maxRangos` en reintentos
564
+ // que solo pueden volver a fallar. Ver `_anuladosPath`.
565
+ const yaAnulados = this._cargarAnulados(tipoDte);
566
+ const yaAnuladosInicial = yaAnulados.size;
512
567
 
513
568
  const maxPasadas = 4;
514
569
 
515
570
  for (let pasada = 0; pasada < maxPasadas; pasada++) {
516
571
  const consulta = await this.consultarFolios({ tipoDte });
517
572
 
573
+ // Corte por antigüedad: `fecha` viene como DD-MM-AAAA en cada rango.
574
+ // Un rango sin fecha parseable se descarta cuando el filtro está activo —
575
+ // ante la duda, no anular.
576
+ const limiteMs = Number.isFinite(soloUltimosDias)
577
+ ? Date.now() - soloUltimosDias * 24 * 60 * 60 * 1000
578
+ : null;
579
+
518
580
  // Filtrar rangos dentro del rango solicitado y que no hayamos intentado aún
519
581
  let rangos = consulta.ranges.filter(r => {
520
582
  if (Number.isFinite(folioDesde) && Number.isFinite(folioHasta)) {
521
583
  if (r.folioDesde > folioHasta || r.folioHasta < folioDesde) return false;
522
584
  }
523
- return !vistos.has(`${r.folioDesde}-${r.folioHasta}`);
585
+ if (limiteMs !== null) {
586
+ const { dia, mes, ano } = this._parseFecha(r.fecha);
587
+ if (!dia || !mes || !ano) return false;
588
+ const t = new Date(`${ano}-${mes}-${dia}T00:00:00`).getTime();
589
+ if (!Number.isFinite(t) || t < limiteMs) return false;
590
+ }
591
+ const clave = `${r.folioDesde}-${r.folioHasta}`;
592
+ if (yaAnulados.has(clave)) return false;
593
+ return !vistos.has(clave);
524
594
  });
525
595
 
526
596
  // Limitar a los más recientes para evitar operaciones masivas
@@ -574,6 +644,8 @@ class FolioService {
574
644
  body3.includes('efectuado anteriormente') ||
575
645
  /anulad[oa]\s+anteriormente/i.test(body3)) {
576
646
  rechazados.push({ folioDesde: iniA, folioHasta: finA, count, reason: 'ya-anulado' });
647
+ // Se recuerda para que la próxima corrida no lo reintente.
648
+ yaAnulados.add(`${iniA}-${finA}`);
577
649
  console.warn(`[FolioService] ✗ Rango ${iniA}-${finA}: ya anulado (af_anular3)`);
578
650
  try { fs.writeFileSync(path.join(debugDirA, `rango-${iniA}-${finA}-af_anular3-error.html`), body3, 'utf8'); } catch (_) {}
579
651
  continue;
@@ -603,6 +675,11 @@ class FolioService {
603
675
  bodyBulk.includes('SOLICITUD ANULACION DE FOLIOS');
604
676
  if (exitoBulk) {
605
677
  anulados.push({ folioDesde: iniA, folioHasta: finA, count });
678
+ // Un rango recién anulado sigue apareciendo en `consultarFolios` como
679
+ // "sin utilizar": si no se recuerda acá, la próxima corrida lo
680
+ // reintenta y el SII contesta "ya anulado". Ésta es la fuente
681
+ // principal de los rangos zombis.
682
+ yaAnulados.add(`${iniA}-${finA}`);
606
683
  try { fs.writeFileSync(path.join(debugDirA, `rango-${iniA}-${finA}-af_anular-ok.html`), bodyBulk, 'utf8'); } catch (_) {}
607
684
  console.log(`[FolioService] ✓ Rango ${iniA}-${finA} (${count} folios) anulado en bulk`);
608
685
  continue;
@@ -643,6 +720,7 @@ class FolioService {
643
720
  const ok = bs.includes('ha autorizado la anulaci') || bs.includes('SOLICITUD ANULACION DE FOLIOS');
644
721
  if (ok) {
645
722
  anulados.push({ folioDesde: folio, folioHasta: folio, count: 1 });
723
+ yaAnulados.add(`${folio}-${folio}`);
646
724
  } else {
647
725
  const razon = this._parseAnulacionResult(bs).reason || 'error';
648
726
  rechazados.push({ folioDesde: folio, folioHasta: folio, count: 1, reason: razon });
@@ -655,9 +733,16 @@ class FolioService {
655
733
  if (pasada < maxPasadas - 1) await this._sleep(1500);
656
734
  }
657
735
 
736
+ if (yaAnulados.size !== yaAnuladosInicial) {
737
+ this._guardarAnulados(tipoDte, yaAnulados);
738
+ }
739
+
658
740
  const totalAnulados = anulados.reduce((s, r) => s + r.count, 0);
659
741
  const totalRechazados = rechazados.reduce((s, r) => s + r.count, 0);
660
- console.log(`[FolioService] Completado: ${totalAnulados} anulados, ${totalRechazados} rechazados`);
742
+ console.log(
743
+ `[FolioService] Completado: ${totalAnulados} anulados, ${totalRechazados} rechazados` +
744
+ (yaAnulados.size ? ` (${yaAnulados.size} rango(s) en memoria, se omiten en la próxima corrida)` : '')
745
+ );
661
746
 
662
747
  return { ok: true, anulados, rechazados, totalAnulados, totalRechazados };
663
748
  }
@@ -1553,7 +1553,20 @@ class SiiCertificacion {
1553
1553
 
1554
1554
  if (algunoRechazado) {
1555
1555
  emitProgress(STEPS.SETS_REJECTED);
1556
- return { success: false, error: 'Sets rechazados', estados: estadosRelevantes };
1556
+ // El detalle va en el error, no solo en `estados`: el llamador suele
1557
+ // interpolar `error` en el mensaje que ve el usuario, y un literal
1558
+ // "Sets rechazados" rendía el inútil "Sets rechazados: Sets rechazados"
1559
+ // sin decir cuál set ni con qué estado. Se nombran solo los rechazados
1560
+ // para no ahogar el dato entre los que sí pasaron.
1561
+ const detalle = Object.values(estadosRelevantes)
1562
+ .filter(e => e.esRechazado)
1563
+ .map(e => `${e.nombre}: ${e.estado}`)
1564
+ .join('; ');
1565
+ return {
1566
+ success: false,
1567
+ error: detalle || 'el SII rechazó uno o más sets',
1568
+ estados: estadosRelevantes,
1569
+ };
1557
1570
  }
1558
1571
  }
1559
1572
 
@@ -262,7 +262,19 @@ class CertRunner {
262
262
  // usados se rechazan sin efecto.
263
263
  for (const tipoDte of Object.keys(cafRequired)) {
264
264
  try {
265
- const limpieza = await this.folioService.anularFolios({ tipoDte: Number(tipoDte) });
265
+ // ACOTADA a propósito. Solo interesan los folios que ESTA certificación
266
+ // timbró y dejó sin usar, que son de hoy. Sin el corte por antigüedad la
267
+ // limpieza barre el historial completo de la empresa: en un RUT con
268
+ // volumen real eso son cientos de anulaciones contra el SII y, peor, el
269
+ // SII cuenta los folios anulados de los últimos 6 meses EN CONTRA del
270
+ // cupo de timbraje — la limpieza terminaría provocando el bloqueo que
271
+ // intenta evitar. Verificado 2026-07-22: sin acotar anuló 296 folios de
272
+ // un RUT (rango 1851–2909) antes de detenerla a mano.
273
+ const limpieza = await this.folioService.anularFolios({
274
+ tipoDte: Number(tipoDte),
275
+ soloUltimosDias: 1,
276
+ maxRangos: 10,
277
+ });
266
278
  if (limpieza.totalAnulados > 0) {
267
279
  console.log(
268
280
  ` Tipo ${tipoDte}: ${limpieza.totalAnulados} folio(s) sin usar de intentos previos anulados`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@devlas/dte-sii",
3
- "version": "2.12.23",
3
+ "version": "2.12.24",
4
4
  "description": "Facturación y boletas electrónicas para el SII de Chile. Genera, timbra, firma y envía DTEs, libros electrónicos y automatiza la certificación.",
5
5
  "main": "index.js",
6
6
  "types": "dte-sii.d.ts",