@devlas/dte-sii 2.17.0 → 2.18.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
@@ -362,7 +362,7 @@ class FolioService {
362
362
  *
363
363
  * @returns {Promise<{ ok: boolean, cafPath?: string, motivo?: string, disponibles?: number }>}
364
364
  */
365
- async reobtenerCaf({ tipoDte, cantidad }) {
365
+ async reobtenerCaf({ tipoDte, cantidad, yaEmitido = null }) {
366
366
  if (!this.cafSolicitor) {
367
367
  return { ok: false, motivo: 'CafSolicitor no inicializado' };
368
368
  }
@@ -375,11 +375,30 @@ class FolioService {
375
375
  return { ok: false, motivo: err.message };
376
376
  }
377
377
 
378
- const usables = rangos.filter(r => !r.anulado);
379
- const anulados = rangos.length - usables.length;
378
+ // ⚠️ El listado del portal NO dice si un folio ya se emitió.
379
+ //
380
+ // `anulado` es lo único que marca, y eso deja pasar los folios que ya viajaron al SII
381
+ // dentro de un envío. Emitir de nuevo con ellos hace que el SII rechace cada documento
382
+ // con `(DTE-3-101) Folio para este Tipo de documento ya fue recibido en el SII`.
383
+ //
384
+ // Pasó de verdad (24/08/2026): la corrida descartó los CAF en disco por tener folios
385
+ // ya emitidos y, tres líneas después, la reobtención trajo esos mismos folios del
386
+ // portal y los usó. Los 7 documentos del tipo 61 fueron rechazados.
387
+ //
388
+ // Por eso el llamador puede pasar `yaEmitido`: es el mismo registro con el que descarta
389
+ // los CAF de disco (`CertRunner._rangoYaConsumido`), aplicado también acá. Sin ese dato
390
+ // la reobtención no puede saberlo, porque el SII no lo publica.
391
+ const emitidos = yaEmitido
392
+ ? rangos.filter(r => yaEmitido({ folioDesde: r.folioDesde, folioHasta: r.folioHasta }))
393
+ : [];
394
+ const emitidosSet = new Set(emitidos);
395
+ const usables = rangos.filter(r => !r.anulado && !emitidosSet.has(r));
396
+ const anulados = rangos.filter(r => r.anulado).length;
380
397
  console.log(
381
398
  `[FolioService] Tipo ${tipoDte}: ${rangos.length} rango(s) reobtenible(s), ` +
382
- `${usables.length} usable(s)${anulados ? `, ${anulados} anulado(s) descartado(s)` : ''}`
399
+ `${usables.length} usable(s)` +
400
+ `${anulados ? `, ${anulados} anulado(s) descartado(s)` : ''}` +
401
+ `${emitidos.length ? `, ${emitidos.length} ya emitido(s) descartado(s)` : ''}`
383
402
  );
384
403
 
385
404
  const total = usables.reduce((n, r) => n + r.cantidad, 0);
@@ -809,20 +809,34 @@ class CertRunner {
809
809
  // devuelve sin gastar cupo. Por eso va acá: después de reusar lo que hay en disco y
810
810
  // antes de `solicitarCafExacto`, que pide folios nuevos y, si falla, anula.
811
811
  //
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.
812
+ // ── Cuándo reobtener: solo si NO se pueden pedir folios nuevos ──────────
818
813
  //
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.
814
+ // La reobtención es el último recurso antes de anular, no un atajo para ahorrar
815
+ // cupo, porque tiene un riesgo que pedir folios nuevos no tiene: el listado del
816
+ // portal **no marca los folios ya emitidos**, y emitir de nuevo con uno hace que el
817
+ // SII rechace el documento con `(DTE-3-101) Folio ... ya fue recibido en el SII`.
818
+ //
819
+ // El filtro `yaEmitido` de abajo tapa lo que sabemos, pero no alcanza solo: el
820
+ // registro local puede estar incompleto (corridas viejas, disco efímero). Medido el
821
+ // 24/08/2026: de los folios reobtenidos del tipo 61, los rangos 1-3 y 10 sí estaban
822
+ // registrados, pero el 4-6 no, y el SII también lo tenía. Los 7 documentos del envío
823
+ // fueron rechazados.
824
+ //
825
+ // Por eso vuelve a exigirse que el cupo NO alcance: con timbraje bloqueado (el SII no
826
+ // publica MAX_AUTOR y queda `null`) o con un tope racionado por debajo de lo que hace
827
+ // falta. Con cupo holgado —el caso de esa corrida, MAX_AUTOR=19 para 7 folios— se
828
+ // piden folios nuevos, que es la vía sin riesgo.
822
829
  const topeTipo = this._topeConsultado?.[tipoDte];
823
- if (topeTipo?.bloqueado || (topeTipo?.foliosDisp ?? 0) > 0) {
830
+ const cupoAlcanza = topeTipo?.sinTope === true
831
+ || (topeTipo?.maxAutor != null && topeTipo.maxAutor >= Number(cantidad));
832
+ if (topeTipo?.bloqueado || (!cupoAlcanza && (topeTipo?.foliosDisp ?? 0) > 0)) {
824
833
  const reob = await this.folioService.reobtenerCaf({
825
834
  tipoDte: Number(tipoDte), cantidad: Number(cantidad),
835
+ // El portal lista folios ya emitidos sin marcarlos: el único que lo sabe es este
836
+ // registro, el mismo con el que se descartan los CAF de disco unas líneas arriba.
837
+ // Sin pasarlo, la reobtención devuelve folios ya recibidos por el SII y todo el
838
+ // envío se rechaza documento por documento.
839
+ yaEmitido: (r) => this._rangoYaConsumido(Number(tipoDte), r.folioDesde, r.folioHasta),
826
840
  });
827
841
  if (reob.ok) {
828
842
  // Puede ser más de uno: el SII entrega los folios reobtenidos de a uno y cada
@@ -1117,6 +1131,7 @@ class CertRunner {
1117
1131
  await sleep(intervalo);
1118
1132
  } else {
1119
1133
  console.log(` [ERR] Contenido rechazado por SII (ENVIO CON ERRORES O REPAROS): ${(result.nombresConError || []).join(', ')}`);
1134
+ await this._diagnosticarEnviosConError(sets, result.nombresConError, debugPrefix);
1120
1135
  break;
1121
1136
  }
1122
1137
  } else if (result.allRejected) {
@@ -1142,6 +1157,144 @@ class CertRunner {
1142
1157
  return lastResult;
1143
1158
  }
1144
1159
 
1160
+ /**
1161
+ * Cuando el portal dice "ENVIO CON ERRORES O REPAROS", pregunta POR QUÉ.
1162
+ *
1163
+ * El portal de certificación solo da el titular: nombra el set y dice que tiene errores
1164
+ * o reparos, sin un solo dato del documento culpable. El detalle vive en la consulta de
1165
+ * estado del envío, que se hace con el TrackId — y ese TrackId lo tenemos en la mano
1166
+ * desde que se subió el set.
1167
+ *
1168
+ * Hasta acá nadie preguntaba. Caso real (24/08/2026): una corrida quedó trabada con
1169
+ * "SET BASICO, SET GUIA DE DESPACHO" con errores, y en las 105 respuestas HTTP de la
1170
+ * etapa los TrackIds de esos sets aparecían solo en el `DTEUpload` que los creó y en el
1171
+ * formulario que los declaró. Ni el comercio ni nosotros teníamos forma de saber qué
1172
+ * corregir; la corrida siguió a libros, avance y simulación arrastrando el problema.
1173
+ *
1174
+ * ⚠️ Se consulta por SOAP (`QueryEstUp.jws`), NO por el REST de `consultarEstado()`: ese
1175
+ * apunta a `apicert.sii.cl/recursos/v1/boleta.electronica.envio/`, que es el servicio de
1176
+ * BOLETAS. Pasarle el TrackId de un set de facturas devuelve un resultado que no
1177
+ * corresponde, y el diagnóstico saldría equivocado justo cuando más se necesita.
1178
+ *
1179
+ * La respuesta cruda queda en el debug dir y también en la captura HTTP de la librería.
1180
+ *
1181
+ * Nunca es fatal: es diagnóstico. Si la consulta falla, se deja constancia y se sigue.
1182
+ *
1183
+ * @private
1184
+ * @param {Object} sets - El mismo mapa que se declaró: { setBasico: { trackId }, ... }
1185
+ * @param {string[]} nombresConError - Nombres tal como los escribe el portal
1186
+ * @param {string} debugPrefix - Prefijo de los archivos de debug de esta declaración
1187
+ */
1188
+ async _diagnosticarEnviosConError(sets, nombresConError, debugPrefix) {
1189
+ // Espejo de la tabla `patterns` de SiiCertificacion.declararAvance: ahí se traduce de
1190
+ // fila del portal a clave, acá de vuelta a la clave para encontrar el TrackId.
1191
+ const NOMBRE_A_CLAVE = {
1192
+ 'SET BASICO': 'setBasico',
1193
+ 'SET GUIA DE DESPACHO': 'setGuiaDespacho',
1194
+ 'SET FACTURA EXENTA': 'setFacturaExenta',
1195
+ 'SET CASO GENERAL FACTURA COMPRA': 'setFacturaCompra',
1196
+ 'SET DE SIMULACION': 'setSimulacion',
1197
+ 'LIBRO DE VENTAS': 'libroVentas',
1198
+ 'LIBRO DE COMPRAS': 'libroCompras',
1199
+ 'LIBRO DE COMPRAS PARA EXENTOS': 'libroComprasExentos',
1200
+ 'LIBRO DE GUIAS': 'libroGuias',
1201
+ };
1202
+
1203
+ const nombres = nombresConError || [];
1204
+ if (!nombres.length) return;
1205
+
1206
+ console.log('\n──────────────────────────────────────────────────────────');
1207
+ console.log(' DIAGNÓSTICO: consultando al SII por qué rechazó');
1208
+ console.log('──────────────────────────────────────────────────────────');
1209
+
1210
+ let enviador = null;
1211
+ try {
1212
+ enviador = new EnviadorSII(this.certificado, this.ambiente);
1213
+ } catch (err) {
1214
+ console.warn(` [!] No se pudo crear el consultor de estado: ${err.message}`);
1215
+ return;
1216
+ }
1217
+
1218
+ const rut = this.config.emisor.rut;
1219
+
1220
+ // Se consultan TODOS los envíos declarados, no solo los marcados. El contraste es la
1221
+ // mitad del diagnóstico: si el que falló dice RPR y los demás EPR, el problema es de
1222
+ // ese documento; si todos vuelven RCT o RFR, es de la carátula o de la firma y el
1223
+ // portal solo alcanzó a marcar los que ya procesó.
1224
+ const conError = new Set(nombres.map(n => NOMBRE_A_CLAVE[n]).filter(Boolean));
1225
+ const claves = [...new Set([...conError, ...Object.keys(sets || {})])];
1226
+
1227
+ for (const clave of claves) {
1228
+ const nombre = Object.keys(NOMBRE_A_CLAVE).find(n => NOMBRE_A_CLAVE[n] === clave) || clave;
1229
+ const marca = conError.has(clave) ? '[CON ERRORES]' : '[sin marca en el portal]';
1230
+ const trackId = sets?.[clave]?.trackId;
1231
+
1232
+ if (!trackId) {
1233
+ console.log(` [?] ${nombre} ${marca}: sin TrackId a mano — no se puede consultar el detalle`);
1234
+ continue;
1235
+ }
1236
+
1237
+ console.log(`\n ${nombre} ${marca} — TrackId ${trackId}`);
1238
+
1239
+ try {
1240
+ const res = await enviador.consultarEstadoSoap(trackId, rut);
1241
+
1242
+ // Cruda y completa: el resumen de abajo es para leer en el momento, el archivo es
1243
+ // para poder revisar después qué dijo exactamente el SII.
1244
+ try {
1245
+ fs.writeFileSync(
1246
+ path.join(this.debugDir, `${debugPrefix}-estado-${clave}-${trackId}.xml`),
1247
+ res?.xmlRaw || res?.respuesta || JSON.stringify(res, null, 2),
1248
+ 'utf8'
1249
+ );
1250
+ } catch { /* el debug no puede tumbar el diagnóstico */ }
1251
+
1252
+ if (!res || res.ok === false) {
1253
+ console.log(` sin estado: ${res?.error || 'sin detalle'}`);
1254
+ continue;
1255
+ }
1256
+
1257
+ console.log(` estado=${res.estado ?? '?'} ${res.mensaje || ''}`.trimEnd());
1258
+ if (res.glosa) console.log(` glosa: ${res.glosa}`);
1259
+ this._imprimirDetalleEstado(res.xmlRaw);
1260
+ } catch (err) {
1261
+ console.log(` falló la consulta de estado: ${err.message}`);
1262
+ }
1263
+ }
1264
+ console.log('──────────────────────────────────────────────────────────\n');
1265
+ }
1266
+
1267
+ /**
1268
+ * Saca a la luz el detalle por documento que trae el XML de estado.
1269
+ *
1270
+ * `consultarEstadoSoap` parsea solo ESTADO, GLOSA y NUM_ATENCION, que es lo que necesita
1271
+ * para decidir si seguir esperando. El detalle de los reparos viene en el mismo XML y se
1272
+ * perdía: son las líneas que nombran el documento culpable.
1273
+ *
1274
+ * No se asume una estructura fija — el SII varía los nombres según el tipo de envío — así
1275
+ * que se imprimen los campos que aparezcan y el XML completo queda en el archivo.
1276
+ * @private
1277
+ */
1278
+ _imprimirDetalleEstado(xml) {
1279
+ if (!xml) return;
1280
+
1281
+ // Cualquier bloque que hable de reparos, rechazos o detalle por documento.
1282
+ const bloques = xml.match(/<(DETALLE_REP|REPARO|RECHAZO|DETALLE)[\s\S]{0,600}?<\/\1>/gi) || [];
1283
+ for (const b of bloques.slice(0, 20)) {
1284
+ const campos = [...b.matchAll(/<([A-Z_]+)>([^<]+)<\/\1>/gi)]
1285
+ .map(m => `${m[1]}=${m[2].trim()}`)
1286
+ .filter(t => !/^DETALLE(_REP)?=/.test(t));
1287
+ if (campos.length) console.log(` · ${campos.join(' | ')}`);
1288
+ }
1289
+ if (bloques.length > 20) console.log(` · (+${bloques.length - 20} más — ver el XML en el debug)`);
1290
+
1291
+ // Contadores del envío: cuántos documentos aceptó, cuántos objetó. Aunque no haya
1292
+ // bloques de detalle, esto ya dice si el problema es de uno o de todos.
1293
+ const contadores = [...xml.matchAll(/<(NUM_DOC[A-Z_]*|ACEPTADOS|RECHAZADOS|REPAROS)>([^<]+)<\/\1>/gi)]
1294
+ .map(m => `${m[1]}=${m[2].trim()}`);
1295
+ if (contadores.length) console.log(` · ${contadores.join(' | ')}`);
1296
+ }
1297
+
1145
1298
  /**
1146
1299
  * Declara avance de los sets ejecutados con reintentos automáticos
1147
1300
  * @param {Object} [resultadosExt] - Resultados externos (usa this.resultados si no se pasa)
package/dte-sii.d.ts CHANGED
@@ -652,7 +652,16 @@ export class FolioService {
652
652
  * el portal reporta como anulados (los documentos emitidos con esos folios se
653
653
  * rechazan).
654
654
  */
655
- reobtenerCaf(options: { tipoDte: number; cantidad: number }): Promise<ReobtenerCafResult | null>;
655
+ reobtenerCaf(options: {
656
+ tipoDte: number;
657
+ cantidad: number;
658
+ /**
659
+ * Descarta los rangos que ya se emitieron. El listado del portal solo marca los
660
+ * ANULADOS, así que sin esto la reobtención devuelve folios que el SII ya recibió y
661
+ * cada documento vuelve rechazado con `DTE-3-101`.
662
+ */
663
+ yaEmitido?: (rango: { folioDesde: number; folioHasta: number }) => boolean;
664
+ }): Promise<ReobtenerCafResult | null>;
656
665
  /** El CAF más reciente de un tipo para este RUT y ambiente, o null. */
657
666
  findLatestCaf(tipoDte: number): string | null;
658
667
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@devlas/dte-sii",
3
- "version": "2.17.0",
3
+ "version": "2.18.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",