@devlas/dte-sii 2.9.2 → 2.9.4
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/CafSolicitor.js +151 -29
- package/DTE.js +23 -4
- package/EnviadorSII.js +28 -11
- package/Envio.js +12 -8
- package/FolioService.js +263 -94
- package/SiiSession.js +106 -6
- package/package.json +1 -1
package/CafSolicitor.js
CHANGED
|
@@ -17,6 +17,15 @@ const path = require('path');
|
|
|
17
17
|
const SiiSession = require('./SiiSession');
|
|
18
18
|
const { splitRut } = require('./utils/rut');
|
|
19
19
|
|
|
20
|
+
/**
|
|
21
|
+
* Registro global de sesiones SII (singleton por ambiente+rut).
|
|
22
|
+
* Permite reutilizar la misma sesión HTTP entre múltiples llamadas a CafSolicitor
|
|
23
|
+
* sin abrir una nueva sesión en el portal del SII cada vez.
|
|
24
|
+
* Clave: `${ambiente}::${rutEmisor}`
|
|
25
|
+
* @type {Map<string, SiiSession>}
|
|
26
|
+
*/
|
|
27
|
+
const _sessionRegistry = new Map();
|
|
28
|
+
|
|
20
29
|
/**
|
|
21
30
|
* Clase para solicitar CAFs al SII
|
|
22
31
|
*/
|
|
@@ -28,7 +37,6 @@ class CafSolicitor {
|
|
|
28
37
|
* @param {string} options.pfxPath - Ruta absoluta al certificado PFX
|
|
29
38
|
* @param {string} options.pfxPassword - Contraseña del certificado
|
|
30
39
|
* @param {string} [options.baseDir] - Directorio base para guardar archivos
|
|
31
|
-
* @param {string} [options.sessionPath] - Ruta al archivo de sesión compartida
|
|
32
40
|
* @param {string} [options.runStamp] - Timestamp de la ejecución
|
|
33
41
|
*/
|
|
34
42
|
constructor(options = {}) {
|
|
@@ -48,22 +56,22 @@ class CafSolicitor {
|
|
|
48
56
|
this.ambiente = options.ambiente.toLowerCase();
|
|
49
57
|
this.rutEmisor = options.rutEmisor;
|
|
50
58
|
this.baseDir = options.baseDir || path.resolve(__dirname, '..', '..');
|
|
51
|
-
this.sessionPath = options.sessionPath || null;
|
|
52
59
|
this.runStamp = options.runStamp || new Date().toISOString().replace(/[:.]/g, '-');
|
|
53
60
|
|
|
54
|
-
//
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
61
|
+
// Reutilizar sesión SII global si ya existe para este ambiente+rut.
|
|
62
|
+
// Esto evita abrir múltiples sesiones en el portal del SII.
|
|
63
|
+
const sessionKey = `${this.ambiente}::${this.rutEmisor}`;
|
|
64
|
+
if (_sessionRegistry.has(sessionKey)) {
|
|
65
|
+
this.session = _sessionRegistry.get(sessionKey);
|
|
66
|
+
console.log('[CafSolicitor] ♻️ Reutilizando sesión SII en memoria');
|
|
67
|
+
} else {
|
|
68
|
+
this.session = new SiiSession({
|
|
69
|
+
ambiente: this.ambiente,
|
|
70
|
+
pfxPath: options.pfxPath,
|
|
71
|
+
pfxPassword: options.pfxPassword,
|
|
72
|
+
});
|
|
73
|
+
_sessionRegistry.set(sessionKey, this.session);
|
|
74
|
+
console.log('[CafSolicitor] 🔑 Nueva sesión SII registrada para', sessionKey);
|
|
67
75
|
}
|
|
68
76
|
}
|
|
69
77
|
|
|
@@ -193,16 +201,73 @@ class CafSolicitor {
|
|
|
193
201
|
// Verificar si obtuvimos el CAF
|
|
194
202
|
if (response.body && response.body.includes('<AUTORIZACION')) {
|
|
195
203
|
const cafPath = this._saveCafOrganized(response.body, tipoDte);
|
|
196
|
-
return { success: true, cafPath, xml: response.body };
|
|
204
|
+
return { success: true, cafPath, xml: response.body, maxAutor: this._lastMaxAutor ?? cantidad };
|
|
197
205
|
}
|
|
198
206
|
|
|
199
207
|
if (response.body && response.body.includes('Autenticaci')) {
|
|
200
208
|
return { success: false, error: 'El SII devolvió página de autenticación' };
|
|
201
209
|
}
|
|
202
210
|
|
|
203
|
-
// Detectar error de
|
|
211
|
+
// Detectar error de MAX_AUTOR: "La cantidad de documentos a timbrar debe ser menor o igual al máximo autorizado"
|
|
212
|
+
// Puede ocurrir cuando el MAX_AUTOR efectivo del SII es menor al mostrado en el formulario
|
|
213
|
+
// (ej: ya hay un timbraje del mismo día que consume parte del cupo diario).
|
|
214
|
+
// Solución: reintentar con cantidad=1 para garantizar obtener al menos 1 folio.
|
|
204
215
|
if (response.body && response.body.includes('menor o igual al m')) {
|
|
205
|
-
|
|
216
|
+
if (cantidad > 1) {
|
|
217
|
+
console.warn(`[CafSolicitor] MAX_AUTOR excedido para ${cantidad} folios — reintentando con 1 folio...`);
|
|
218
|
+
// No limpiar cookieJar: el error es del formulario, no de la sesión.
|
|
219
|
+
// Reutilizar la sesión activa evita abrir una nueva y acumular sesiones en el SII.
|
|
220
|
+
this.runStamp = new Date().toISOString().replace(/[:.]/g, '-');
|
|
221
|
+
return this.solicitar({ tipoDte, cantidad: 1 });
|
|
222
|
+
}
|
|
223
|
+
return { success: false, error: 'Cantidad de folios excede el máximo que SII autoriza por solicitud para este tipo de documento (MAX_AUTOR). Verifica el estado de timbraje de tu empresa en el portal SII.' };
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// Detectar rango ya autorizado — el CAF existe en SII pero no fue capturado
|
|
227
|
+
// (ocurre cuando una solicitud previa tuvo éxito en SII pero falló en la red al devolver el XML,
|
|
228
|
+
// o cuando of_genera_archivo devolvió DTE-OFGA y el SII registró el rango igualmente)
|
|
229
|
+
const finalBody = response.body || '';
|
|
230
|
+
if (finalBody.includes('ya fue autorizado el rango desde') || finalBody.includes('ya fue autorizado')) {
|
|
231
|
+
const m = finalBody.match(/ya fue autorizado el rango desde\s+(\d+)\s+hasta\s+(\d+)/i);
|
|
232
|
+
const desde = m ? parseInt(m[1]) : null;
|
|
233
|
+
const hasta = m ? parseInt(m[2]) : null;
|
|
234
|
+
|
|
235
|
+
// Intentar recuperar el XML directamente enviando los datos del rango a of_genera_archivo.
|
|
236
|
+
// El SII tiene el CAF autorizado en su sistema; si enviamos los campos correctos
|
|
237
|
+
// puede devolver el XML aunque la sesión de timbraje original ya terminó.
|
|
238
|
+
if (desde != null && hasta != null) {
|
|
239
|
+
console.warn(`[CafSolicitor] Rango ${desde}-${hasta} ya autorizado — intentando recuperar XML desde of_genera_archivo...`);
|
|
240
|
+
const today = new Date().toISOString().split('T')[0]; // YYYY-MM-DD
|
|
241
|
+
const recoveryFields = {
|
|
242
|
+
RUT_EMP: rut,
|
|
243
|
+
DV_EMP: dv,
|
|
244
|
+
COD_DOCTO: String(tipoDte),
|
|
245
|
+
FOLIO_INI: String(desde),
|
|
246
|
+
FOLIO_FIN: String(hasta),
|
|
247
|
+
FECHA: today,
|
|
248
|
+
ACEPTAR: 'AQUI',
|
|
249
|
+
};
|
|
250
|
+
try {
|
|
251
|
+
const recoveryResponse = await this.session.submitForm('/cvc_cgi/dte/of_genera_archivo', recoveryFields);
|
|
252
|
+
const recoveryBody = recoveryResponse.body || '';
|
|
253
|
+
this._saveDebug(debugDir, 'recovery.xml', recoveryBody);
|
|
254
|
+
if (recoveryBody.includes('<AUTORIZACION')) {
|
|
255
|
+
console.log(`[CafSolicitor] ✅ CAF recuperado exitosamente para rango ${desde}-${hasta}`);
|
|
256
|
+
const cafPath = this._saveCafOrganized(recoveryBody, tipoDte);
|
|
257
|
+
return { success: true, cafPath, xml: recoveryBody, maxAutor: hasta - desde + 1 };
|
|
258
|
+
}
|
|
259
|
+
console.warn(`[CafSolicitor] Recuperación de rango ${desde}-${hasta} no devolvió XML (${recoveryBody.substring(0, 80).replace(/\s+/g, ' ')})`);
|
|
260
|
+
} catch (recoveryErr) {
|
|
261
|
+
console.warn(`[CafSolicitor] Error al intentar recuperar rango: ${recoveryErr.message}`);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
const rangoStr = desde != null && hasta != null ? ` ${desde}-${hasta}` : '';
|
|
266
|
+
return {
|
|
267
|
+
success: false,
|
|
268
|
+
rangoYaAutorizado: desde != null && hasta != null ? { folioDesde: desde, folioHasta: hasta } : null,
|
|
269
|
+
error: `SII: Ya existe CAF autorizado para el rango${rangoStr}. El XML fue aprobado por SII en una solicitud previa pero no se pudo recuperar automáticamente. Descárgalo manualmente desde el portal SII (Factura Electrónica → Solicitud de Timbraje).`,
|
|
270
|
+
};
|
|
206
271
|
}
|
|
207
272
|
|
|
208
273
|
return { success: false, error: 'No se obtuvo CAF en la respuesta' };
|
|
@@ -238,15 +303,16 @@ class CafSolicitor {
|
|
|
238
303
|
// Selección de tipo de documento
|
|
239
304
|
if (currentHtml.includes('COD_DOCTO')) {
|
|
240
305
|
const selectInputs = SiiSession.extractInputValues(currentHtml);
|
|
241
|
-
//
|
|
242
|
-
|
|
243
|
-
|
|
306
|
+
// NO enviar CANT_DOCTOS aquí — el browser tampoco lo envía en este paso.
|
|
307
|
+
// Si se envía un número, el SII no incluye MAX_AUTOR ni CONTROL="S" en la
|
|
308
|
+
// respuesta, lo que causa rechazo silencioso en of_genera_folio más adelante.
|
|
309
|
+
// El SII fija CANT_DOCTOS automáticamente según MAX_AUTOR al responder.
|
|
244
310
|
const selectFields = {
|
|
245
311
|
...selectInputs,
|
|
246
312
|
RUT_EMP: rut,
|
|
247
313
|
DV_EMP: dv,
|
|
248
314
|
COD_DOCTO: tipoDte,
|
|
249
|
-
CANT_DOCTOS:
|
|
315
|
+
CANT_DOCTOS: '',
|
|
250
316
|
};
|
|
251
317
|
|
|
252
318
|
response = await this.session.submitForm('/cvc_cgi/dte/of_solicita_folios_dcto', selectFields);
|
|
@@ -270,16 +336,19 @@ class CafSolicitor {
|
|
|
270
336
|
|
|
271
337
|
const formAction3 = SiiSession.extractFormAction(currentHtml) || '/cvc_cgi/dte/of_confirma_folio';
|
|
272
338
|
const inputs3 = SiiSession.extractInputValues(currentHtml);
|
|
273
|
-
|
|
274
|
-
//
|
|
275
|
-
|
|
276
|
-
const
|
|
339
|
+
|
|
340
|
+
// CANT_DOCTOS debe enviarse con un valor <= MAX_AUTOR.
|
|
341
|
+
// Si se omite o excede MAX_AUTOR, el SII rechaza devolviendo la página de inicio (rechazo silencioso).
|
|
342
|
+
const maxAutor = parseInt(inputs3.MAX_AUTOR || String(cantidad), 10);
|
|
343
|
+
const cantReal = Math.min(cantidad, maxAutor);
|
|
344
|
+
this._lastMaxAutor = maxAutor; // guardado para retornarlo desde solicitar()
|
|
345
|
+
|
|
277
346
|
const step3Fields = {
|
|
278
347
|
...inputs3,
|
|
279
348
|
RUT_EMP: rut,
|
|
280
349
|
DV_EMP: dv,
|
|
281
350
|
COD_DOCTO: tipoDte,
|
|
282
|
-
CANT_DOCTOS:
|
|
351
|
+
CANT_DOCTOS: String(cantReal),
|
|
283
352
|
ACEPTAR: 'Solicitar Numeración',
|
|
284
353
|
};
|
|
285
354
|
|
|
@@ -351,9 +420,20 @@ class CafSolicitor {
|
|
|
351
420
|
currentHtml = response.body || '';
|
|
352
421
|
this._saveDebug(debugDir, 'genera.html', currentHtml);
|
|
353
422
|
|
|
423
|
+
// El SII rechaza el request si ese rango ya fue autorizado en una solicitud previa
|
|
424
|
+
// (ocurre en reintentos cuando la primera solicitud llegó a SII pero falló la red al devolver el XML)
|
|
425
|
+
if (currentHtml.includes('ya fue autorizado')) {
|
|
426
|
+
const m = currentHtml.match(/ya fue autorizado el rango desde\s+(\d+)\s+hasta\s+(\d+)/i);
|
|
427
|
+
console.warn(`[CafSolicitor] of_genera_folio: rango ya autorizado${m ? ' ' + m[1] + '-' + m[2] : ''} — el XML existe en SII pero no se obtuvo`);
|
|
428
|
+
return response;
|
|
429
|
+
}
|
|
430
|
+
|
|
354
431
|
// Paso final: of_genera_archivo
|
|
355
432
|
if (!currentHtml.includes('<AUTORIZACION') && currentHtml.includes('of_genera_archivo')) {
|
|
356
|
-
|
|
433
|
+
// Guardar los inputs del genera.html para poder reintentar of_genera_archivo si falla con DTE-OFGA
|
|
434
|
+
const generaInputs = SiiSession.extractInputValues(currentHtml);
|
|
435
|
+
const generaFormAction = SiiSession.extractFormAction(currentHtml) || '/cvc_cgi/dte/of_genera_archivo';
|
|
436
|
+
response = await this._processGeneraArchivo(response, debugDir, generaInputs, generaFormAction);
|
|
357
437
|
}
|
|
358
438
|
|
|
359
439
|
return response;
|
|
@@ -362,8 +442,12 @@ class CafSolicitor {
|
|
|
362
442
|
/**
|
|
363
443
|
* Procesa generación de archivo CAF
|
|
364
444
|
* @private
|
|
445
|
+
* @param {Object} response - Respuesta previa
|
|
446
|
+
* @param {string} debugDir - Directorio de debug
|
|
447
|
+
* @param {Object} [retryInputs] - Inputs de genera.html para retry si falla con DTE-OFGA
|
|
448
|
+
* @param {string} [retryFormAction] - URL de of_genera_archivo para retry
|
|
365
449
|
*/
|
|
366
|
-
async _processGeneraArchivo(response, debugDir) {
|
|
450
|
+
async _processGeneraArchivo(response, debugDir, retryInputs, retryFormAction) {
|
|
367
451
|
let currentHtml = response.body || '';
|
|
368
452
|
|
|
369
453
|
const formAction = SiiSession.extractFormAction(currentHtml) || '/cvc_cgi/dte/of_genera_archivo';
|
|
@@ -378,6 +462,32 @@ class CafSolicitor {
|
|
|
378
462
|
currentHtml = response.body || '';
|
|
379
463
|
this._saveDebug(debugDir, 'archivo.xml', currentHtml);
|
|
380
464
|
|
|
465
|
+
// El SII a veces devuelve error interno DTE-OFGA (servidor sobrecargado o fallo transitorio).
|
|
466
|
+
// En ese caso el rango ya fue registrado en SII pero el XML no se generó.
|
|
467
|
+
// Reintentar el mismo POST hasta 3 veces con delay de 3s antes de rendirse.
|
|
468
|
+
if (!currentHtml.includes('<AUTORIZACION') && currentHtml.includes('DTE-OFGA')) {
|
|
469
|
+
for (let attempt = 1; attempt <= 3; attempt++) {
|
|
470
|
+
const delaySecs = attempt * 3;
|
|
471
|
+
console.warn(`[CafSolicitor] Error DTE-OFGA en of_genera_archivo — reintentando en ${delaySecs}s (intento ${attempt}/3)...`);
|
|
472
|
+
await new Promise(r => setTimeout(r, delaySecs * 1000));
|
|
473
|
+
// Reintentar con los inputs originales del genera.html si están disponibles,
|
|
474
|
+
// ya que los del archivo.xml (página de error) no tienen los campos necesarios.
|
|
475
|
+
const retryFields = retryInputs ? { ...retryInputs, ACEPTAR: 'AQUI' } : fields;
|
|
476
|
+
const retryAction = retryFormAction || formAction;
|
|
477
|
+
response = await this.session.submitForm(retryAction, retryFields);
|
|
478
|
+
currentHtml = response.body || '';
|
|
479
|
+
this._saveDebug(debugDir, `archivo-retry${attempt}.xml`, currentHtml);
|
|
480
|
+
if (currentHtml.includes('<AUTORIZACION')) {
|
|
481
|
+
console.log(`[CafSolicitor] ✅ CAF obtenido en retry ${attempt} de of_genera_archivo`);
|
|
482
|
+
return response;
|
|
483
|
+
}
|
|
484
|
+
if (!currentHtml.includes('DTE-OFGA')) {
|
|
485
|
+
// Error diferente (ya fue autorizado, etc.) — salir del loop
|
|
486
|
+
break;
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
|
|
381
491
|
// A veces hay un paso extra
|
|
382
492
|
if (!currentHtml.includes('<AUTORIZACION') && currentHtml.includes('of_genera_archivo')) {
|
|
383
493
|
const formAction2 = SiiSession.extractFormAction(currentHtml) || '/cvc_cgi/dte/of_genera_archivo';
|
|
@@ -394,6 +504,18 @@ class CafSolicitor {
|
|
|
394
504
|
|
|
395
505
|
return response;
|
|
396
506
|
}
|
|
507
|
+
|
|
508
|
+
/**
|
|
509
|
+
* Cierra todas las sesiones SII en caché haciendo logout en el portal.
|
|
510
|
+
* Llamar durante el shutdown del proceso para no dejar sesiones huérfanas
|
|
511
|
+
* que acumulen el límite de sesiones concurrentes del SII.
|
|
512
|
+
*/
|
|
513
|
+
static async closeAllSessions() {
|
|
514
|
+
for (const [key, session] of _sessionRegistry) {
|
|
515
|
+
try { await session.logout() } catch (_e) { /* ignorar errores de red al apagar */ }
|
|
516
|
+
_sessionRegistry.delete(key)
|
|
517
|
+
}
|
|
518
|
+
}
|
|
397
519
|
}
|
|
398
520
|
|
|
399
521
|
module.exports = CafSolicitor;
|
package/DTE.js
CHANGED
|
@@ -71,9 +71,14 @@ class DTE {
|
|
|
71
71
|
|
|
72
72
|
_convertirDatosSimplificados(d) {
|
|
73
73
|
const esExenta = d.tipo === 41;
|
|
74
|
+
const esBoleta = TIPOS_BOLETA.includes(d.tipo);
|
|
74
75
|
const precioConIva = d.precioConIva === true;
|
|
75
|
-
|
|
76
|
-
|
|
76
|
+
// Boletas afectas: reportar precios al consumidor (bruto con IVA) en <Detalle>.
|
|
77
|
+
// El SII valida MntTotal = suma(MontoItem), por lo que MontoItem debe ser precio con IVA.
|
|
78
|
+
// Para facturas: convertir a neto (precio sin IVA) como de costumbre.
|
|
79
|
+
const convertirANeto = precioConIva && !esBoleta;
|
|
80
|
+
const { detalle, mntNeto, mntExento } = this._procesarItems(d.items, esExenta, convertirANeto);
|
|
81
|
+
const totales = this._calcularTotales(mntNeto, mntExento, esExenta, esBoleta, esBoleta && precioConIva);
|
|
77
82
|
|
|
78
83
|
const resultado = {
|
|
79
84
|
Encabezado: {
|
|
@@ -145,14 +150,28 @@ class DTE {
|
|
|
145
150
|
|
|
146
151
|
// PrcItem = precio neto unitario (o precio con IVA si se usa precioConIva:true).
|
|
147
152
|
// IVA = mntNeto * TasaIVA.
|
|
148
|
-
_calcularTotales(mntNeto, mntExento, esExenta) {
|
|
153
|
+
_calcularTotales(mntNeto, mntExento, esExenta, esBoleta = false, esBruto = false) {
|
|
154
|
+
// Boleta afecta con precios al consumidor (brutos): mntNeto contiene el total bruto (con IVA).
|
|
155
|
+
// MntNeto y IVA se derivan del bruto; MntTotal = bruto + exento = suma(MontoItem).
|
|
156
|
+
if (esBoleta && esBruto && !esExenta && mntNeto > 0) {
|
|
157
|
+
const bruto = mntNeto;
|
|
158
|
+
const neto = Math.round(bruto / (1 + TASA_IVA / 100));
|
|
159
|
+
const iva = bruto - neto;
|
|
160
|
+
const mntTotal = bruto + mntExento;
|
|
161
|
+
const totales = { MntNeto: neto };
|
|
162
|
+
if (mntExento > 0) totales.MntExe = mntExento;
|
|
163
|
+
totales.IVA = iva;
|
|
164
|
+
totales.MntTotal = mntTotal;
|
|
165
|
+
return totales;
|
|
166
|
+
}
|
|
167
|
+
|
|
149
168
|
const neto = esExenta ? 0 : mntNeto;
|
|
150
169
|
const iva = esExenta ? 0 : Math.round(neto * TASA_IVA / 100);
|
|
151
170
|
const mntTotal = neto + iva + mntExento;
|
|
152
171
|
|
|
153
172
|
const totales = {};
|
|
154
173
|
if (neto > 0) totales.MntNeto = neto;
|
|
155
|
-
if (neto > 0) totales.TasaIVA = TASA_IVA; // requerido en facturas
|
|
174
|
+
if (neto > 0 && !esBoleta) totales.TasaIVA = TASA_IVA; // requerido en facturas, NO en boletas (EnvioBOLETA_v11.xsd no lo admite)
|
|
156
175
|
if (mntExento > 0) totales.MntExe = mntExento;
|
|
157
176
|
if (neto > 0) totales.IVA = iva;
|
|
158
177
|
totales.MntTotal = mntTotal;
|
package/EnviadorSII.js
CHANGED
|
@@ -437,7 +437,7 @@ class EnviadorSII {
|
|
|
437
437
|
formData.append('rutCompany', rutCompany.toString());
|
|
438
438
|
formData.append('dvCompany', dvCompany.toUpperCase());
|
|
439
439
|
|
|
440
|
-
const xmlBuffer = Buffer.from(xml, '
|
|
440
|
+
const xmlBuffer = Buffer.from(xml, 'latin1');
|
|
441
441
|
formData.append('archivo', xmlBuffer, {
|
|
442
442
|
filename: 'EnvioBOLETA.xml',
|
|
443
443
|
contentType: 'application/xml',
|
|
@@ -703,13 +703,20 @@ class EnviadorSII {
|
|
|
703
703
|
else if (estado === 'RCH') mensaje = `[ERR] Rechazado: ${json.glosa || json.descripcion || 'Sin detalle'}`;
|
|
704
704
|
else mensaje = `Estado: ${estado}`;
|
|
705
705
|
|
|
706
|
+
const esExitoso = ['EPR', 'RPR', 'RLV'].includes(estado);
|
|
707
|
+
const esRechazado = ['RPT', 'RFR', 'VOF', 'RCT', 'RCH', 'RSC'].includes(estado);
|
|
708
|
+
const esIntermedio = !esExitoso && !esRechazado;
|
|
709
|
+
|
|
706
710
|
return {
|
|
707
711
|
ok: true,
|
|
708
|
-
|
|
709
|
-
|
|
712
|
+
...json,
|
|
713
|
+
trackId,
|
|
714
|
+
estado,
|
|
710
715
|
descripcion: json.descripcion || json.glosa,
|
|
711
716
|
mensaje,
|
|
712
|
-
|
|
717
|
+
esExitoso,
|
|
718
|
+
esRechazado,
|
|
719
|
+
esIntermedio,
|
|
713
720
|
};
|
|
714
721
|
} catch (e) {
|
|
715
722
|
return {
|
|
@@ -1343,24 +1350,34 @@ class EnviadorSII {
|
|
|
1343
1350
|
99: 'Error desconocido',
|
|
1344
1351
|
};
|
|
1345
1352
|
|
|
1346
|
-
// STATUS 7 =
|
|
1347
|
-
// EXCEPCIÓN: CHR-00001 en <DETAIL> = error de charset, NO es duplicado.
|
|
1353
|
+
// STATUS 7 = puede ser duplicado real, o error de schema/charset (NO duplicado).
|
|
1348
1354
|
if (status === 7) {
|
|
1349
1355
|
const detailMatch = responseText.match(/<ERROR>([^<]*)<\/ERROR>/i);
|
|
1350
1356
|
const detailMsg = detailMatch ? detailMatch[1] : '';
|
|
1357
|
+
|
|
1358
|
+
// CHR-00001: error de charset en el XML (encoding incorrecto)
|
|
1351
1359
|
if (detailMsg.includes('CHR-00001')) {
|
|
1352
1360
|
return {
|
|
1353
|
-
ok: false,
|
|
1354
|
-
status,
|
|
1361
|
+
ok: false, status,
|
|
1355
1362
|
error: `Error de charset en XML enviado (${detailMsg}). Verifique encoding="ISO-8859-1" en la declaración XML.`,
|
|
1356
|
-
respuesta: responseText,
|
|
1357
|
-
|
|
1363
|
+
respuesta: responseText, duplicado: false,
|
|
1364
|
+
};
|
|
1365
|
+
}
|
|
1366
|
+
|
|
1367
|
+
// SCH-00001: XML no cumple el esquema (ej: campo undefined, fecha inválida, esquema incorrecto)
|
|
1368
|
+
if (detailMsg.includes('SCH-00001')) {
|
|
1369
|
+
return {
|
|
1370
|
+
ok: false, status,
|
|
1371
|
+
error: `XML rechazado por SII: esquema inválido (${detailMsg}). El XML contiene campos malformados o la referencia de esquema no es reconocida.`,
|
|
1372
|
+
respuesta: responseText, duplicado: false,
|
|
1358
1373
|
};
|
|
1359
1374
|
}
|
|
1375
|
+
|
|
1376
|
+
// Duplicado real: SII ya recibió este SetDTE ID
|
|
1360
1377
|
if (trackId) {
|
|
1361
1378
|
return { ok: true, status, trackId, archivo: fileName, mensaje: `[DUPLICADO] TrackID recuperado: ${trackId}`, respuesta: responseText };
|
|
1362
1379
|
}
|
|
1363
|
-
return { ok: false, status, error: errorMessages[7]
|
|
1380
|
+
return { ok: false, status, error: `${errorMessages[7]}. Detail: ${detailMsg || 'sin detalle'}`, respuesta: responseText, duplicado: true };
|
|
1364
1381
|
}
|
|
1365
1382
|
|
|
1366
1383
|
return {
|
package/Envio.js
CHANGED
|
@@ -62,17 +62,21 @@ class EnvioBase {
|
|
|
62
62
|
}
|
|
63
63
|
|
|
64
64
|
/**
|
|
65
|
-
* Generar SetId con formato específico
|
|
65
|
+
* Generar SetId con formato específico.
|
|
66
|
+
* @param {string} prefix
|
|
67
|
+
* @param {string} [suffix] - Sufijo opcional (ej: ID de sobre) para garantizar unicidad entre sobres del mismo segundo.
|
|
66
68
|
*/
|
|
67
|
-
_generateSetId(prefix) {
|
|
69
|
+
_generateSetId(prefix, suffix) {
|
|
68
70
|
const now = new Date();
|
|
69
|
-
const dd
|
|
70
|
-
const mm
|
|
71
|
+
const dd = String(now.getDate()).padStart(2, '0');
|
|
72
|
+
const mm = String(now.getMonth() + 1).padStart(2, '0');
|
|
71
73
|
const yyyy = now.getFullYear();
|
|
72
|
-
const hh
|
|
73
|
-
const min
|
|
74
|
-
const ss
|
|
75
|
-
|
|
74
|
+
const hh = String(now.getHours()).padStart(2, '0');
|
|
75
|
+
const min = String(now.getMinutes()).padStart(2, '0');
|
|
76
|
+
const ss = String(now.getSeconds()).padStart(2, '0');
|
|
77
|
+
const ms = String(now.getMilliseconds()).padStart(3, '0');
|
|
78
|
+
const base = `${prefix}_${dd}_${mm}_${yyyy}_${hh}_${min}_${ss}_${ms}`;
|
|
79
|
+
return suffix ? `${base}_${suffix}` : base;
|
|
76
80
|
}
|
|
77
81
|
|
|
78
82
|
/**
|
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.
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
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.
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
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
|
-
*
|
|
335
|
-
*
|
|
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
|
|
339
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
354
|
-
|
|
366
|
+
for (let pasada = 0; pasada < maxPasadas; pasada++) {
|
|
367
|
+
const consulta = await this.consultarFolios({ tipoDte });
|
|
355
368
|
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
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
|
-
|
|
379
|
-
const
|
|
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
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
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
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
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
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
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
|
-
|
|
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
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
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
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
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
|
-
|
|
432
|
-
|
|
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
|
-
|
|
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/SiiSession.js
CHANGED
|
@@ -264,6 +264,76 @@ class SiiSession {
|
|
|
264
264
|
return { success: true, response: redirected.response };
|
|
265
265
|
}
|
|
266
266
|
|
|
267
|
+
/**
|
|
268
|
+
* Cierra la sesión activa en el SII para liberar el cupo de sesiones.
|
|
269
|
+
* Es seguro llamar aunque no haya sesión activa.
|
|
270
|
+
*/
|
|
271
|
+
async logout() {
|
|
272
|
+
if (!this.cookieJar) return;
|
|
273
|
+
// El SII usa estos endpoints según el tipo de autenticación.
|
|
274
|
+
// Intentamos ambos; ignoramos errores.
|
|
275
|
+
const logoutUrls = [
|
|
276
|
+
`https://${this.baseHost}/cgi_AUT2000/autLogout.cgi`,
|
|
277
|
+
'https://www.sii.cl/AUT2000/autLogout.cgi',
|
|
278
|
+
'https://herculesr.sii.cl/cgi_AUT2000/autLogout.cgi',
|
|
279
|
+
];
|
|
280
|
+
for (const url of logoutUrls) {
|
|
281
|
+
try {
|
|
282
|
+
await this.request(url, { method: 'GET' });
|
|
283
|
+
} catch (_) {}
|
|
284
|
+
}
|
|
285
|
+
this.cookieJar = '';
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* Detecta la página de "demasiadas sesiones" y, si el SII ofrece un form
|
|
290
|
+
* para cerrar las sesiones anteriores, lo envía automáticamente.
|
|
291
|
+
* Devuelve true si se envió el form de cierre (hay que reintentar la sesión).
|
|
292
|
+
* @private
|
|
293
|
+
*/
|
|
294
|
+
async _tryForceCloseSessions(body) {
|
|
295
|
+
if (!body || !body.includes('superado el m')) return false;
|
|
296
|
+
|
|
297
|
+
// Guardar HTML para diagnóstico
|
|
298
|
+
try {
|
|
299
|
+
const fs = require('fs');
|
|
300
|
+
const path = require('path');
|
|
301
|
+
const dbgDir = path.resolve(__dirname, '..', 'devlas-cloud-api-node', 'debug', 'sii-sessions');
|
|
302
|
+
fs.mkdirSync(dbgDir, { recursive: true });
|
|
303
|
+
fs.writeFileSync(path.join(dbgDir, `demasiadas-sesiones-${Date.now()}.html`), body, 'utf-8');
|
|
304
|
+
} catch (_) {}
|
|
305
|
+
|
|
306
|
+
// El SII a veces incluye un form o link para cerrar sesiones anteriores
|
|
307
|
+
// Buscamos: action con "CierraAnt", "cerrar", "logout" o "Anular"
|
|
308
|
+
const formActionMatch = body.match(/<form[^>]+action=["']([^"']*(?:CierraAnt|cerrar|Logout|anular)[^"']*)/i);
|
|
309
|
+
if (formActionMatch) {
|
|
310
|
+
const closeUrl = formActionMatch[1];
|
|
311
|
+
const inputs = SiiSession.extractInputValues(body);
|
|
312
|
+
console.warn(`[SiiSession] Detectadas demasiadas sesiones — cerrando sesiones anteriores en ${closeUrl}...`);
|
|
313
|
+
try {
|
|
314
|
+
await this.submitForm(closeUrl, { ...inputs, ACEPTAR: 'Aceptar' });
|
|
315
|
+
return true;
|
|
316
|
+
} catch (e) {
|
|
317
|
+
console.warn(`[SiiSession] No se pudo cerrar sesiones anteriores: ${e.message}`);
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// Alternativa: link directo (href) a CierraAnt o similar
|
|
322
|
+
const linkMatch = body.match(/href=["']([^"']*CierraAnt[^"']*)/i);
|
|
323
|
+
if (linkMatch) {
|
|
324
|
+
const closeUrl = new URL(linkMatch[1], `https://${this.baseHost}`).toString();
|
|
325
|
+
console.warn(`[SiiSession] Cerrando sesiones anteriores via GET ${closeUrl}...`);
|
|
326
|
+
try {
|
|
327
|
+
await this.request(closeUrl, { method: 'GET' });
|
|
328
|
+
return true;
|
|
329
|
+
} catch (e) {
|
|
330
|
+
console.warn(`[SiiSession] No se pudo cerrar sesiones anteriores: ${e.message}`);
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
return false;
|
|
335
|
+
}
|
|
336
|
+
|
|
267
337
|
/**
|
|
268
338
|
* Asegura una sesión autenticada para acceder a una página
|
|
269
339
|
* @param {string} targetPath - Ruta del recurso
|
|
@@ -278,9 +348,25 @@ class SiiSession {
|
|
|
278
348
|
|
|
279
349
|
// Detectar bloqueo por demasiadas sesiones
|
|
280
350
|
if (response.body && response.body.includes('superado el m')) {
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
351
|
+
// Intentar cerrar sesiones anteriores automáticamente si el SII lo ofrece
|
|
352
|
+
const closed = await this._tryForceCloseSessions(response.body);
|
|
353
|
+
if (closed) {
|
|
354
|
+
// Reintentar la sesión después de cerrar las anteriores
|
|
355
|
+
console.warn('[SiiSession] Sesiones anteriores cerradas — reintentando autenticación...');
|
|
356
|
+
response = await this.request(targetUrl, { method: 'GET' });
|
|
357
|
+
const redirected2 = await this.followRedirects(response);
|
|
358
|
+
response = redirected2.response;
|
|
359
|
+
// Si sigue con demasiadas sesiones, lanzar error
|
|
360
|
+
if (response.body && response.body.includes('superado el m')) {
|
|
361
|
+
const errorMsg = 'SII: Demasiadas sesiones abiertas. Espera ~30 min o cierra sesión manualmente en el portal SII.';
|
|
362
|
+
console.error(`\n[ERR] ${errorMsg}\n`);
|
|
363
|
+
throw new Error(errorMsg);
|
|
364
|
+
}
|
|
365
|
+
} else {
|
|
366
|
+
const errorMsg = 'SII: Demasiadas sesiones abiertas. Espera ~30 min o cierra sesión manualmente en el portal SII.';
|
|
367
|
+
console.error(`\n[ERR] ${errorMsg}\n`);
|
|
368
|
+
throw new Error(errorMsg);
|
|
369
|
+
}
|
|
284
370
|
}
|
|
285
371
|
|
|
286
372
|
// Si requiere autenticación
|
|
@@ -291,9 +377,23 @@ class SiiSession {
|
|
|
291
377
|
|
|
292
378
|
// Verificar si el login resultó en bloqueo por sesiones
|
|
293
379
|
if (response.body && response.body.includes('superado el m')) {
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
380
|
+
// Intentar cerrar sesiones anteriores automáticamente
|
|
381
|
+
const closed = await this._tryForceCloseSessions(response.body);
|
|
382
|
+
if (closed) {
|
|
383
|
+
console.warn('[SiiSession] Sesiones anteriores cerradas post-login — reintentando...');
|
|
384
|
+
const retry = await this.request(targetUrl, { method: 'GET' });
|
|
385
|
+
const redirected3 = await this.followRedirects(retry);
|
|
386
|
+
response = redirected3.response;
|
|
387
|
+
if (response.body && response.body.includes('superado el m')) {
|
|
388
|
+
const errorMsg = 'SII: Demasiadas sesiones abiertas. Espera ~30 min o cierra sesión manualmente en el portal SII.';
|
|
389
|
+
console.error(`\n[ERR] ${errorMsg}\n`);
|
|
390
|
+
throw new Error(errorMsg);
|
|
391
|
+
}
|
|
392
|
+
} else {
|
|
393
|
+
const errorMsg = 'SII: Demasiadas sesiones abiertas. Espera ~30 min o cierra sesión manualmente en el portal SII.';
|
|
394
|
+
console.error(`\n[ERR] ${errorMsg}\n`);
|
|
395
|
+
throw new Error(errorMsg);
|
|
396
|
+
}
|
|
297
397
|
}
|
|
298
398
|
}
|
|
299
399
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@devlas/dte-sii",
|
|
3
|
-
"version": "2.9.
|
|
3
|
+
"version": "2.9.4",
|
|
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",
|