@devlas/dte-sii 2.16.0 → 2.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/FolioService.js CHANGED
@@ -142,6 +142,24 @@ class FolioService {
142
142
  * @returns {string|null} - Ruta al CAF o null
143
143
  */
144
144
  findLatestCaf(tipoDte) {
145
+ return this.listarCafs(tipoDte)[0] ?? null;
146
+ }
147
+
148
+ /**
149
+ * Todos los CAF de un tipo que hay en disco para este RUT y ambiente, del más
150
+ * reciente al más viejo.
151
+ *
152
+ * `findLatestCaf` devuelve solo el primero, que alcanza mientras un CAF cubra
153
+ * la cantidad completa. Cuando el SII raciona el timbraje eso deja de valer: un
154
+ * pedido de 4 folios puede haber quedado repartido en un CAF de 3 y otro de 1, y
155
+ * mirando solo el último se concluye "no alcanza" y se vuelven a pedir folios
156
+ * nuevos, dejando los anteriores timbrados y sin usar (que es justo lo que el SII
157
+ * cuenta en contra del cupo).
158
+ *
159
+ * @param {number} tipoDte
160
+ * @returns {string[]} rutas ordenadas por mtime descendente
161
+ */
162
+ listarCafs(tipoDte) {
145
163
  const matches = [];
146
164
 
147
165
  // Helper para buscar recursivamente
@@ -195,9 +213,8 @@ class FolioService {
195
213
  const canonicalDir = path.join(this.debugDir, 'caf', this.ambiente, this.rutEmisor, String(tipoDte));
196
214
  searchRecursive(canonicalDir);
197
215
 
198
- if (!matches.length) return null;
199
216
  matches.sort((a, b) => b.mtime - a.mtime);
200
- return matches[0].filePath;
217
+ return matches.map(m => m.filePath);
201
218
  }
202
219
 
203
220
  /**
@@ -456,7 +473,16 @@ class FolioService {
456
473
  result.errorCode === 'MAX_AUTOR_INSUFICIENTE' ||
457
474
  result.errorCode === 'TIMBRAJE_BLOQUEADO';
458
475
 
459
- if (!result.success && recuperable && permitirAnular) {
476
+ // ⚠️ `!== 0` estricto. FOLIOS_DISP es el total de folios timbrados y sin utilizar,
477
+ // que es exactamente el conjunto anulable (el SII solo anula folios "que no han sido
478
+ // recepcionados"). Con 0 la anulación no puede prosperar y solo gasta requests: el
479
+ // 19/08/2026 una corrida intentó anular 4 folios ya emitidos, uno por uno, para
480
+ // terminar en `SIN_FOLIOS_ANULABLES` y un mensaje que culpaba a folios inexistentes.
481
+ // Con el timbraje BLOQUEADO el SII no publica el campo y queda `null`, y ese caso sí
482
+ // necesita intentarlo.
483
+ const hayQueAnular = result.foliosDisp !== 0;
484
+
485
+ if (!result.success && recuperable && permitirAnular && hayQueAnular) {
460
486
  console.warn(
461
487
  `[FolioService] Tipo ${tipoDte}: ${result.errorCode} — anulando folios sin utilizar y reintentando...`
462
488
  );
@@ -541,6 +567,96 @@ class FolioService {
541
567
  return { ok: true, cafPath: result.cafPath, otorgados, ...base };
542
568
  }
543
569
 
570
+ /**
571
+ * Cubre `cantidad` folios en varios timbrajes cuando el SII no autoriza tantos de
572
+ * una vez.
573
+ *
574
+ * `solicitarCafExacto` exige que UN CAF cubra todo y aborta si `MAX_AUTOR` no da.
575
+ * Eso es correcto para no agravar el racionamiento cuando hay folios sin usar
576
+ * (anular/reobtener sí destraba ese caso), pero deja sin salida el caso contrario:
577
+ * `FOLIOS_DISP=0` y `MAX_AUTOR` chico porque el SII raciona por historial. Ahí no
578
+ * hay nada que anular ni que reobtener, y la única vía es pedir de a tandas.
579
+ *
580
+ * Medido el 19/08/2026 (RUT 76543210-K, maullin): el set de simulación necesita 4
581
+ * folios de tipo 33, el SII autorizaba 3 con FOLIOS_DISP=0, y el tope siguió en 3
582
+ * durante 24 h. Esperar no lo mueve.
583
+ *
584
+ * Los sets aceptan varios CAF por tipo (`SetBase._tomarFolio` recorre la lista y
585
+ * salta al siguiente cuando se agota un rango), así que juntar tandas es
586
+ * transparente para quien genera los documentos.
587
+ *
588
+ * ⚠️ Cada tanda sube `FOLIOS_DISP` y puede bajar el `MAX_AUTOR` de la siguiente. Por
589
+ * eso se corta apenas una tanda no aporta folios nuevos, en vez de insistir: cada
590
+ * pedido inútil deja folios timbrados que después juegan en contra.
591
+ *
592
+ * @param {Object} params
593
+ * @param {number} params.tipoDte
594
+ * @param {number} params.cantidad - folios a cubrir en total
595
+ * @param {number} [params.maxTandas=4] - tope duro de viajes al portal
596
+ * @param {Object} [params.topeInicial] - resultado de `consultarTope` ya en mano, para
597
+ * no repetir el sondeo de la primera tanda (cuesta ~3 requests al portal).
598
+ * @returns {Promise<{ ok: boolean, cafPaths: string[], otorgados: number,
599
+ * maxAutor: number|null, foliosDisp: number|null, errorCode?: string, error?: string }>}
600
+ */
601
+ async solicitarCafPorTandas({ tipoDte, cantidad, maxTandas = 4, topeInicial = null }) {
602
+ if (!this.cafSolicitor) {
603
+ throw new Error('FolioService: CafSolicitor no inicializado (se requiere pfxPath y pfxPassword)');
604
+ }
605
+
606
+ const objetivo = Number(cantidad);
607
+ const cafPaths = [];
608
+ let cubiertos = 0;
609
+ let tope = null;
610
+
611
+ for (let tanda = 1; tanda <= maxTandas && cubiertos < objetivo; tanda++) {
612
+ tope = (tanda === 1 && topeInicial) ? topeInicial : await this.consultarTope({ tipoDte });
613
+ const faltan = objetivo - cubiertos;
614
+
615
+ // `sinTope` = el SII no publica MAX_AUTOR, o sea no está racionando este tipo:
616
+ // se pide todo lo que falta de una.
617
+ const pedir = tope.sinTope ? faltan : Math.min(tope.maxAutor ?? 0, faltan);
618
+ if (pedir <= 0) {
619
+ console.warn(
620
+ `[FolioService] Tipo ${tipoDte}: tanda ${tanda} sin cupo ` +
621
+ `(MAX_AUTOR=${tope.maxAutor}, FOLIOS_DISP=${tope.foliosDisp}) — se corta con ${cubiertos}/${objetivo}`
622
+ );
623
+ break;
624
+ }
625
+
626
+ console.log(
627
+ `[FolioService] Tipo ${tipoDte}: tanda ${tanda} pide ${pedir} folio(s) ` +
628
+ `(faltan ${faltan}; MAX_AUTOR=${tope.maxAutor ?? 'sin tope'}, FOLIOS_DISP=${tope.foliosDisp ?? 'sin dato'})`
629
+ );
630
+
631
+ const res = await this.cafSolicitor.solicitar({ tipoDte, cantidad: pedir, minCantidad: 1 });
632
+ const otorgados = res.success ? this._contarFoliosCaf(res.cafPath) : 0;
633
+ if (!otorgados) {
634
+ console.warn(
635
+ `[FolioService] Tipo ${tipoDte}: tanda ${tanda} no entregó folios ` +
636
+ `(${res.errorCode || 'sin código'}: ${res.error || 'sin detalle'}) — se corta`
637
+ );
638
+ break;
639
+ }
640
+
641
+ cafPaths.push(res.cafPath);
642
+ cubiertos += otorgados;
643
+ console.log(`[FolioService] Tipo ${tipoDte}: tanda ${tanda} entregó ${otorgados} folio(s) — ${cubiertos}/${objetivo}`);
644
+ }
645
+
646
+ const base = { maxAutor: tope?.maxAutor ?? null, foliosDisp: tope?.foliosDisp ?? null };
647
+ if (cubiertos < objetivo) {
648
+ return {
649
+ ok: false, cafPaths, otorgados: cubiertos, ...base,
650
+ errorCode: 'TOPE_SII_INSUFICIENTE',
651
+ error:
652
+ `El SII autorizó ${cubiertos} folio(s) de los ${objetivo} que necesita el tipo ${tipoDte}, ` +
653
+ `repartidos en ${cafPaths.length} timbraje(s). No hay folios sin utilizar que anular ni recuperar ` +
654
+ `(FOLIOS_DISP=${base.foliosDisp}): el tope lo fija el SII según el historial de timbraje y emisión.`,
655
+ };
656
+ }
657
+ return { ok: true, cafPaths, otorgados: cubiertos, ...base };
658
+ }
659
+
544
660
  /**
545
661
  * Consulta el estado de folios en el SII
546
662
  * @param {Object} params - Parámetros
@@ -492,51 +492,116 @@ class CertRunner {
492
492
 
493
493
  _cafReusable(tipoDte, cantidad) {
494
494
  try {
495
- const cafPath = this.folioService.findLatestCaf(tipoDte);
496
- if (!cafPath || !fs.existsSync(cafPath)) return null;
497
- // Ya se emitió con él: reusarlo repetiría folios y el SII rechaza el envío entero.
498
- if (fs.existsSync(`${cafPath}.usado`)) {
499
- console.log(` Tipo ${tipoDte}: el CAF previo ya se usó para emitir se pedirá uno nuevo`);
500
- return null;
501
- }
502
- const rango = this._rangoDelCaf(cafPath);
503
- if (rango && this._rangoYaConsumido(tipoDte, rango.desde, rango.hasta)) {
504
- console.log(` Tipo ${tipoDte}: los folios ${rango.desde}-${rango.hasta} ya se emitieron `
505
- + '(otra copia del mismo CAF) se pedirá uno nuevo');
506
- return null;
507
- }
495
+ // ⚠️ Se recorren TODOS los CAF del tipo, no solo el último.
496
+ //
497
+ // Cuando el SII raciona el timbraje, un pedido de 4 folios puede haber quedado
498
+ // repartido en un CAF de 3 y otro de 1 (ver FolioService.solicitarCafPorTandas).
499
+ // Mirando solo el último se concluía "no alcanza" y se pedían folios nuevos,
500
+ // dejando los anteriores timbrados y sin usar — que es exactamente lo que el SII
501
+ // cuenta en contra del cupo, o sea que el reintento empeoraba el problema que
502
+ // intentaba resolver.
503
+ // `listarCafs` es de 2.17.0. El fallback cubre a un `folioService` inyectado con la
504
+ // forma vieja: sin él, la excepción caería en el catch de abajo y el resultado sería
505
+ // "no hay CAF reusable", que dispara un timbraje nuevo teniendo folios en mano.
506
+ const rutas = typeof this.folioService.listarCafs === 'function'
507
+ ? this.folioService.listarCafs(tipoDte)
508
+ : [this.folioService.findLatestCaf(tipoDte)].filter(Boolean);
509
+ if (!rutas.length) return null;
508
510
 
509
511
  // Require local, igual que en _construirCafObjects (línea ~2110): `../index` cierra
510
512
  // el ciclo con este archivo, así que importarlo arriba deja CAF sin definir.
511
513
  const { CAF } = require('../index');
512
- const caf = new CAF(fs.readFileSync(cafPath, 'utf8'));
513
-
514
- // Doble chequeo del RUT, aunque findLatestCaf ya filtre. Firmar un timbre con el CAF
515
- // de otro contribuyente hace que el SII rechace el envío ENTERO con `RFR - Rechazado
516
- // por Error en Firma`, y el síntoma aparece etapas más adelante (la declaración de
517
- // simulación se traba porque el envío tiene rechazos). Un fallo así de caro no puede
518
- // depender de que una función de otro archivo siga filtrando bien.
519
- const rutCaf = String(caf.da?.RE ?? '').replace(/\./g, '').trim().toUpperCase();
520
514
  const rutMio = String(this.config.emisor.rut).replace(/\./g, '').trim().toUpperCase();
521
- if (rutCaf !== rutMio) {
522
- console.warn(
523
- `[CertRunner] CAF previo del tipo ${tipoDte} DESCARTADO: es del RUT ${rutCaf}, ` +
524
- `no del emisor ${rutMio} (${cafPath})`
515
+
516
+ const candidatos = [];
517
+ const rangosVistos = new Set();
518
+ const descartes = [];
519
+
520
+ for (const cafPath of rutas) {
521
+ if (!fs.existsSync(cafPath)) continue;
522
+ // Ya se emitió con él: reusarlo repetiría folios y el SII rechaza el envío entero.
523
+ if (fs.existsSync(`${cafPath}.usado`)) {
524
+ descartes.push('ya se usó para emitir');
525
+ continue;
526
+ }
527
+
528
+ const rango = this._rangoDelCaf(cafPath);
529
+ if (rango && this._rangoYaConsumido(tipoDte, rango.desde, rango.hasta)) {
530
+ descartes.push(`folios ${rango.desde}-${rango.hasta} ya emitidos (otra copia del mismo CAF)`);
531
+ continue;
532
+ }
533
+
534
+ const caf = new CAF(fs.readFileSync(cafPath, 'utf8'));
535
+
536
+ // Doble chequeo del RUT, aunque listarCafs ya filtre. Firmar un timbre con el CAF
537
+ // de otro contribuyente hace que el SII rechace el envío ENTERO con `RFR - Rechazado
538
+ // por Error en Firma`, y el síntoma aparece etapas más adelante (la declaración de
539
+ // simulación se traba porque el envío tiene rechazos). Un fallo así de caro no puede
540
+ // depender de que una función de otro archivo siga filtrando bien.
541
+ const rutCaf = String(caf.da?.RE ?? '').replace(/\./g, '').trim().toUpperCase();
542
+ if (rutCaf !== rutMio) {
543
+ console.warn(
544
+ `[CertRunner] CAF previo del tipo ${tipoDte} DESCARTADO: es del RUT ${rutCaf}, ` +
545
+ `no del emisor ${rutMio} (${cafPath})`
546
+ );
547
+ continue;
548
+ }
549
+
550
+ const desde = caf.getFolioDesde();
551
+ const hasta = caf.getFolioHasta();
552
+ if (!Number.isFinite(desde) || !Number.isFinite(hasta)) continue;
553
+
554
+ // El MISMO CAF vive en dos árboles con el mismo contenido (ver _foliosUsadosPath),
555
+ // así que sin deduplicar por rango se contarían dos veces los mismos folios y el
556
+ // set se quedaría sin numeración a mitad de camino.
557
+ const clave = `${desde}-${hasta}`;
558
+ if (rangosVistos.has(clave)) continue;
559
+ rangosVistos.add(clave);
560
+
561
+ candidatos.push({ path: cafPath, desde, hasta });
562
+ }
563
+
564
+ if (!candidatos.length) {
565
+ const motivos = [...new Set(descartes)].join('; ');
566
+ console.log(
567
+ ` Tipo ${tipoDte}: sin CAF previo reusable${motivos ? ` (${motivos})` : ''} — se pedirá uno nuevo`
525
568
  );
526
569
  return null;
527
570
  }
528
571
 
529
- const desde = caf.getFolioDesde();
530
- const hasta = caf.getFolioHasta();
531
- if (!Number.isFinite(desde) || !Number.isFinite(hasta)) return null;
532
-
533
- // Cuántos folios de ese rango quedan libres según el contador de ESTA corrida.
534
- // Si el helper ya consumió parte del rango, lo que queda es lo que sirve.
572
+ // Descuento del contador de ESTA corrida: si el helper ya reservó folios de este
573
+ // tipo, no vuelven a estar disponibles. Va sobre el total y no por CAF porque el
574
+ // contador es por tipo, no por rango.
535
575
  const yaUsados = this.folioHelper.usedFolios.get(tipoDte)?.size ?? 0;
536
- const disponibles = (hasta - desde + 1) - yaUsados;
537
- if (disponibles < cantidad) return null;
576
+ const disponibles = candidatos.reduce((n, c) => n + (c.hasta - c.desde + 1), 0) - yaUsados;
577
+ if (disponibles <= 0) return null;
538
578
 
539
- return { path: cafPath, desde, hasta };
579
+ // Se juntan desde el folio más bajo, y solo los que hagan falta.
580
+ //
581
+ // Los dos criterios importan. Del folio más bajo porque los documentos quedan
582
+ // numerados como si vinieran de un rango contiguo, y porque gastar primero lo más
583
+ // viejo es lo que baja FOLIOS_DISP más rápido. Y solo los necesarios porque
584
+ // `_marcarCafsConsumidos` marca como usados TODOS los CAF que reciba: incluir uno
585
+ // que el plan no llega a tocar quema esos folios sin haber emitido nada con ellos.
586
+ candidatos.sort((a, b) => a.desde - b.desde);
587
+ const elegidos = [];
588
+ let acumulado = 0;
589
+ for (const c of candidatos) {
590
+ if (acumulado >= cantidad) break;
591
+ elegidos.push(c);
592
+ acumulado += (c.hasta - c.desde + 1);
593
+ }
594
+
595
+ return {
596
+ path: elegidos[0].path,
597
+ paths: elegidos.map(e => e.path),
598
+ desde: elegidos[0].desde,
599
+ hasta: elegidos[elegidos.length - 1].hasta,
600
+ /** Folios libres que aportan los CAF elegidos. */
601
+ total: Math.min(acumulado, disponibles),
602
+ /** false = hay folios en disco, pero no alcanzan para el plan. */
603
+ alcanza: disponibles >= cantidad,
604
+ };
540
605
  } catch (err) {
541
606
  // Un CAF ilegible no puede tumbar la corrida: se pide uno nuevo y listo.
542
607
  console.warn(`[CertRunner] No se pudo evaluar el CAF previo del tipo ${tipoDte}: ${err.message}`);
@@ -621,6 +686,36 @@ class CertRunner {
621
686
  );
622
687
  continue;
623
688
  }
689
+
690
+ // ── Cupo corto pero nada que limpiar ────────────────────────────────────
691
+ //
692
+ // FOLIOS_DISP es, textual en el formulario del SII, "considerando el timbraje
693
+ // histórico de documentos y de los documentos emitidos y/o anulados, la empresa
694
+ // posee folios disponibles o sin utilizar, por un total de N". O sea:
695
+ // timbrados - emitidos - anulados. Y anulable es exactamente ese conjunto: la
696
+ // página de anulación aclara que solo acepta "folios que no han sido
697
+ // recepcionados por el SII".
698
+ //
699
+ // Con N=0 la limpieza no puede anular nada, pero igual se intentaba, porque los
700
+ // candidatos NO salen de ahí: `consultarFolios` los saca del listado de
701
+ // TIMBRAJES (af_anular2), que lista los rangos autorizados sin decir si se usaron.
702
+ // Medido el 19/08/2026 (RUT 76543210-K): MAX_AUTOR=3 para 4 folios con
703
+ // FOLIOS_DISP=0, y la corrida gastó 43 de sus 70 requests intentando anular
704
+ // folios ya emitidos, uno por uno, para terminar con "0 anulados" y un error que
705
+ // además decía lo contrario de lo que pasaba ("el SII tiene bloqueado el
706
+ // timbraje", cuando el SII estaba autorizando 3).
707
+ //
708
+ // ⚠️ `=== 0` estricto, nunca `!foliosDisp`: con el timbraje BLOQUEADO el SII no
709
+ // publica el campo y queda `null` (ver FolioService.consultarTope), y ese caso
710
+ // sí necesita la limpieza, que es lo único que puede destrabarlo.
711
+ if (tope.foliosDisp === 0) {
712
+ console.log(
713
+ ` Tipo ${tipoDte}: sin limpieza previa — MAX_AUTOR=${tope.maxAutor} para ` +
714
+ `${necesarios} folio(s), pero FOLIOS_DISP=0: no hay folios sin utilizar que ` +
715
+ 'anular ni recuperar. El tope lo fija el SII por historial, se pedirá en tandas.'
716
+ );
717
+ continue;
718
+ }
624
719
  // Un solo mensaje según el motivo: con el timbraje bloqueado el SII no publica
625
720
  // MAX_AUTOR ni FOLIOS_DISP, así que el texto de racionamiento saldría con `null`
626
721
  // en los dos números y confundiría a quien lea el log.
@@ -691,10 +786,13 @@ class CertRunner {
691
786
  // Reusarlos es seguro: los sets se envían recién cuando TODOS los CAF están en mano,
692
787
  // así que si el intento anterior falló, esos folios nunca se emitieron.
693
788
  const previo = this._cafReusable(Number(tipoDte), Number(cantidad));
694
- if (previo) {
695
- cafs[tipoDte] = previo.path;
789
+ if (previo?.alcanza) {
790
+ cafs[tipoDte] = previo.paths.length > 1 ? previo.paths : previo.path;
696
791
  emitProgress(STEPS.CAF_OK, { tipo: Number(tipoDte) });
697
- console.log(` ✓ CAF tipo ${tipoDte} reusado del intento anterior (folios ${previo.desde}-${previo.hasta})`);
792
+ console.log(
793
+ ` ✓ CAF tipo ${tipoDte} reusado del intento anterior (folios ${previo.desde}-${previo.hasta}` +
794
+ `${previo.paths.length > 1 ? `, ${previo.paths.length} CAF` : ''})`
795
+ );
698
796
  continue;
699
797
  }
700
798
 
@@ -711,10 +809,18 @@ class CertRunner {
711
809
  // devuelve sin gastar cupo. Por eso va acá: después de reusar lo que hay en disco y
712
810
  // antes de `solicitarCafExacto`, que pide folios nuevos y, si falla, anula.
713
811
  //
714
- // Solo se intenta con el timbraje bloqueado. Con cupo disponible pedir es más simple
715
- // y no cuesta nada; la reobtención implica un request por rango para descartar los
716
- // anulados, que el listado del SII no distingue.
717
- if (this._topeConsultado?.[tipoDte]?.bloqueado) {
812
+ // Se intenta cuando hay folios timbrados y sin utilizar, que es el pool sobre el que
813
+ // trabaja: `FOLIOS_DISP > 0`, o el timbraje bloqueado (ahí el SII no publica el campo
814
+ // y queda `null`, así que hay que intentar a ciegas). Con FOLIOS_DISP=0 todos los
815
+ // folios timbrados ya se emitieron y reobtener devolvería folios consumidos:
816
+ // `listarReobtenibles` filtra los anulados pero no distingue usados, así que emitir
817
+ // con ellos haría que el SII rechace el envío entero por folio repetido.
818
+ //
819
+ // Antes el gate era solo `bloqueado`, y con cupo corto sin bloqueo se saltaba
820
+ // derecho a pedir folios nuevos teniendo folios ya autorizados a mano. Pedir gasta
821
+ // cupo; reobtener no.
822
+ const topeTipo = this._topeConsultado?.[tipoDte];
823
+ if (topeTipo?.bloqueado || (topeTipo?.foliosDisp ?? 0) > 0) {
718
824
  const reob = await this.folioService.reobtenerCaf({
719
825
  tipoDte: Number(tipoDte), cantidad: Number(cantidad),
720
826
  });
@@ -733,32 +839,82 @@ class CertRunner {
733
839
  console.warn(`[CertRunner] Tipo ${tipoDte}: reobtención no sirvió — ${reob.motivo}`);
734
840
  }
735
841
 
736
- // solicitarCafExacto (no ConFallback): la simulación genera un plan fijo de
737
- // documentos y muere si falta un folio, así que un CAF corto no sirve. El
738
- // método aborta antes de emitir si el SII no autoriza lo suficiente, anula
739
- // folios sin utilizar para destrabar el tope, y reintenta.
740
- const res = await this.folioService.solicitarCafExacto({
741
- tipoDte: Number(tipoDte),
742
- cantidad: Number(cantidad),
743
- });
842
+ // ── Cómo pedir: de una o en tandas ──────────────────────────────────────
843
+ //
844
+ // `solicitarCafExacto` exige que UN CAF cubra la cantidad y, si no, anula folios
845
+ // sin utilizar y reintenta. Eso es lo correcto cuando el cupo está corto POR esos
846
+ // folios (FOLIOS_DISP > 0) y cuando el timbraje está bloqueado.
847
+ //
848
+ // Con FOLIOS_DISP=0 no aplica: no hay nada que anular ni que reobtener, el tope lo
849
+ // fija el SII por historial y no se mueve esperando (medido: 24 h con el mismo
850
+ // MAX_AUTOR=3 para 4 folios, RUT 76543210-K). Ahí la única vía es juntar el rango
851
+ // en varios timbrajes, que los sets soportan de fábrica porque `_tomarFolio` acepta
852
+ // una lista de CAF y salta al siguiente cuando se agota un rango.
853
+ const racionadoPorHistorial =
854
+ topeTipo?.foliosDisp === 0 &&
855
+ topeTipo?.maxAutor != null &&
856
+ topeTipo.maxAutor < Number(cantidad);
857
+
858
+ // Folios que ya están en disco pero no alcanzaban solos. En el camino normal se
859
+ // ignoran (se pide un rango que cubra todo y estos quedan para después), pero acá
860
+ // son la mitad del asunto: si el intento anterior alcanzó a timbrar 3 de 4, pedir
861
+ // los 4 de nuevo desperdicia esos 3 y encima sube FOLIOS_DISP, que es lo que aprieta
862
+ // el tope. Se cuentan como cubiertos y al SII se le pide solo la diferencia.
863
+ const parciales = racionadoPorHistorial && previo && !previo.alcanza ? previo : null;
864
+ const faltante = Number(cantidad) - (parciales?.total ?? 0);
865
+ if (parciales) {
866
+ console.log(
867
+ ` Tipo ${tipoDte}: ${parciales.total} folio(s) ya timbrados en disco ` +
868
+ `(${parciales.desde}-${parciales.hasta}) — se pedirán ${faltante} al SII`
869
+ );
870
+ }
871
+
872
+ const res = racionadoPorHistorial
873
+ ? await this.folioService.solicitarCafPorTandas({
874
+ tipoDte: Number(tipoDte),
875
+ cantidad: faltante,
876
+ topeInicial: topeTipo,
877
+ })
878
+ : await this.folioService.solicitarCafExacto({
879
+ tipoDte: Number(tipoDte),
880
+ cantidad: Number(cantidad),
881
+ });
744
882
 
745
883
  if (!res.ok) {
746
884
  const detalle = [
747
885
  res.maxAutor != null ? `MAX_AUTOR=${res.maxAutor}` : null,
748
886
  res.foliosDisp != null ? `FOLIOS_DISP=${res.foliosDisp}` : null,
749
887
  ].filter(Boolean).join(', ');
888
+ // Dos causas con remedios opuestos. Antes las dos salían con el mismo texto, y en
889
+ // el caso de abajo el mensaje mandaba a anular folios que no existen: el 19/08/2026
890
+ // un comercio recibió "el SII tiene bloqueado el timbraje" mientras el SII le
891
+ // estaba autorizando 3 folios y no tenía ni uno sin utilizar.
892
+ const remedio = res.foliosDisp === 0
893
+ ? 'No hay folios sin utilizar que anular ni recuperar: el tope lo fija el SII ' +
894
+ 'según el historial de timbraje y emisión de este tipo de documento.'
895
+ : 'El SII limita el timbraje cuando hay folios ya autorizados sin utilizar; ' +
896
+ 'emite o anula documentos electrónicos de este tipo y reintenta.';
897
+ const cubiertos = res.otorgados + (parciales?.total ?? 0);
750
898
  throw new Error(
751
- `Folios insuficientes para tipo ${tipoDte}: se requieren ${cantidad} y el SII ` +
752
- `otorgó ${res.otorgados}${detalle ? ` (${detalle})` : ''}. ` +
753
- `El SII limita el timbraje cuando hay folios ya autorizados sin utilizar; ` +
754
- `emite o anula documentos electrónicos de este tipo y reintenta. ` +
899
+ `Folios insuficientes para tipo ${tipoDte}: se requieren ${cantidad} y hay ` +
900
+ `${cubiertos}${detalle ? ` (${detalle})` : ''}. ${remedio} ` +
755
901
  `[${res.errorCode}] ${res.error || ''}`.trim()
756
902
  );
757
903
  }
758
904
 
759
- cafs[tipoDte] = res.cafPath;
905
+ // `solicitarCafPorTandas` devuelve varios; `solicitarCafExacto`, uno solo. Los
906
+ // parciales van primero: el set numera de menor a mayor y son los folios más viejos,
907
+ // así que gastarlos antes es también lo que baja FOLIOS_DISP más rápido.
908
+ const rutas = [
909
+ ...(parciales?.paths ?? []),
910
+ ...(res.cafPaths ?? (res.cafPath ? [res.cafPath] : [])),
911
+ ];
912
+ cafs[tipoDte] = rutas.length > 1 ? rutas : rutas[0];
760
913
  emitProgress(STEPS.CAF_OK, { tipo: Number(tipoDte) });
761
- console.log(` ✓ CAF tipo ${tipoDte} (${res.otorgados} folios)`);
914
+ console.log(
915
+ ` ✓ CAF tipo ${tipoDte} (${res.otorgados + (parciales?.total ?? 0)} folios` +
916
+ `${rutas.length > 1 ? ` en ${rutas.length} CAF` : ''})`
917
+ );
762
918
  }
763
919
 
764
920
  return cafs;
@@ -2395,12 +2551,17 @@ class CertRunner {
2395
2551
  // Solicitar CAFs frescos
2396
2552
  const cafs = await this.solicitarCafs(cafRequired);
2397
2553
 
2398
- // Cargar objetos CAF
2554
+ // Cargar objetos CAF. Un tipo puede traer VARIOS CAF cuando el SII racionó el
2555
+ // timbraje y el rango se juntó en tandas: `Simulacion._tomarFolio` recorre la lista.
2399
2556
  const cafObjects = {};
2400
2557
  const { CAF } = require('../index');
2401
- for (const [tipo, cafPath] of Object.entries(cafs)) {
2402
- const cafXml = fs.readFileSync(cafPath, 'utf8');
2403
- cafObjects[tipo] = new CAF(cafXml);
2558
+ for (const [tipo, ref] of Object.entries(cafs)) {
2559
+ const rutas = Array.isArray(ref) ? ref : [ref];
2560
+ const objetos = rutas.map(p => new CAF(fs.readFileSync(p, 'utf8')))
2561
+ // De menor a mayor folio: los documentos del plan quedan numerados como si
2562
+ // vinieran de un rango contiguo.
2563
+ .sort((a, b) => a.getFolioDesde() - b.getFolioDesde());
2564
+ cafObjects[tipo] = objetos.length > 1 ? objetos : objetos[0];
2404
2565
  }
2405
2566
 
2406
2567
  // Crear simulación
@@ -38,10 +38,49 @@ class Simulacion {
38
38
  };
39
39
  }
40
40
 
41
+ /**
42
+ * Reserva el siguiente folio de un tipo, aceptando UNO o VARIOS CAF.
43
+ *
44
+ * El SII raciona el timbraje: cuando el cupo por solicitud es menor a lo que pide el
45
+ * plan, los folios llegan repartidos en varios CAF (ver
46
+ * `FolioService.solicitarCafPorTandas`). Cada CAF firma con su propia llave, así que
47
+ * hay que devolver también CUÁL se usó, para timbrar el documento con ese y no con el
48
+ * primero de la lista.
49
+ *
50
+ * Los sets ya funcionaban así (`SetBase._tomarFolio`); la simulación no, porque recibía
51
+ * un objeto CAF por tipo. Ese era el único punto que impedía repartir un rango.
52
+ *
53
+ * @private
54
+ * @returns {{ caf: Object, folio: number }}
55
+ */
56
+ _tomarFolio(cafRef, tipoDte, folioHelper) {
57
+ const lista = (Array.isArray(cafRef) ? cafRef : [cafRef]).filter(Boolean);
58
+ if (!lista.length) {
59
+ throw new Error(`No hay CAF para tipo ${tipoDte}`);
60
+ }
61
+
62
+ let ultimoError = null;
63
+ for (const caf of lista) {
64
+ try {
65
+ const folio = folioHelper.reserveNextFolio({
66
+ tipoDte,
67
+ folioDesde: caf.getFolioDesde(),
68
+ folioHasta: caf.getFolioHasta(),
69
+ });
70
+ return { caf, folio };
71
+ } catch (err) {
72
+ // Rango agotado: se pasa al CAF siguiente. Cualquier otro error sí es fatal.
73
+ if (!/No hay más folios disponibles/.test(err.message)) throw err;
74
+ ultimoError = err;
75
+ }
76
+ }
77
+ throw ultimoError;
78
+ }
79
+
41
80
  /**
42
81
  * Genera el EnvioDTE de simulación a partir de las estructuras
43
82
  * @param {Object} estructuras - Estructuras del set de pruebas
44
- * @param {Object} cafs - { tipoDte: CAF } pre-cargados
83
+ * @param {Object} cafs - { tipoDte: CAF | CAF[] } pre-cargados
45
84
  * @param {Object} folioHelper - Helper para gestionar folios
46
85
  * @param {Object} [options] - Opciones
47
86
  * @param {string} [options.fechaEmision] - Fecha de emisión (default: hoy)
@@ -61,17 +100,7 @@ class Simulacion {
61
100
 
62
101
  for (const doc of plan) {
63
102
  const tipoDte = Number(doc.tipoDte || 33);
64
- const caf = cafs[tipoDte];
65
- if (!caf) {
66
- throw new Error(`No hay CAF para tipo ${tipoDte}`);
67
- }
68
-
69
- // Obtener folio desde el CAF
70
- const folio = folioHelper.reserveNextFolio({
71
- tipoDte,
72
- folioDesde: caf.getFolioDesde(),
73
- folioHasta: caf.getFolioHasta(),
74
- });
103
+ const { caf, folio } = this._tomarFolio(cafs[tipoDte], tipoDte, folioHelper);
75
104
  const base = doc.referenciaCaso ? docRefs[doc.referenciaCaso] : null;
76
105
 
77
106
  // Resolver items
package/dte-sii.d.ts CHANGED
@@ -653,6 +653,44 @@ export class FolioService {
653
653
  * rechazan).
654
654
  */
655
655
  reobtenerCaf(options: { tipoDte: number; cantidad: number }): Promise<ReobtenerCafResult | null>;
656
+ /** El CAF más reciente de un tipo para este RUT y ambiente, o null. */
657
+ findLatestCaf(tipoDte: number): string | null;
658
+ /**
659
+ * Todos los CAF de un tipo en disco, del más reciente al más viejo.
660
+ *
661
+ * Hace falta cuando el SII raciona el timbraje y un rango queda repartido en
662
+ * varios CAF: mirando solo el último se concluye "no alcanza" y se piden folios
663
+ * nuevos, dejando los anteriores timbrados y sin usar.
664
+ */
665
+ listarCafs(tipoDte: number): string[];
666
+ /**
667
+ * Cubre `cantidad` folios en varios timbrajes cuando el SII no autoriza tantos de
668
+ * una vez.
669
+ *
670
+ * Es para el caso `FOLIOS_DISP = 0` con `MAX_AUTOR` corto: no hay folios sin usar
671
+ * que anular ni recuperar, el tope lo fija el SII por historial y no se mueve
672
+ * esperando. Se corta apenas una tanda no aporta folios nuevos, porque cada
673
+ * timbraje parcial sube `FOLIOS_DISP` y puede bajar el tope siguiente.
674
+ */
675
+ solicitarCafPorTandas(options: {
676
+ tipoDte: number;
677
+ cantidad: number;
678
+ maxTandas?: number;
679
+ topeInicial?: FolioTope | null;
680
+ }): Promise<SolicitarCafTandasResult>;
681
+ }
682
+
683
+ export interface SolicitarCafTandasResult {
684
+ ok: boolean;
685
+ /** CAF obtenidos, uno por tanda. Los sets y la simulación aceptan la lista completa. */
686
+ cafPaths: string[];
687
+ /** Folios cubiertos entre todas las tandas. */
688
+ otorgados: number;
689
+ maxAutor: number | null;
690
+ foliosDisp: number | null;
691
+ /** `TOPE_SII_INSUFICIENTE` cuando el SII no llegó a cubrir la cantidad pedida. */
692
+ errorCode?: string;
693
+ error?: string;
656
694
  }
657
695
 
658
696
  export interface FolioRegistryOptions {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@devlas/dte-sii",
3
- "version": "2.16.0",
3
+ "version": "2.17.0",
4
4
  "description": "Facturación y boletas electrónicas para el SII de Chile. Genera, timbra, firma y envía DTEs, libros electrónicos y automatiza la certificación.",
5
5
  "main": "index.js",
6
6
  "types": "dte-sii.d.ts",