@devlas/dte-sii 2.8.3 → 2.9.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CafSolicitor.js +151 -21
- package/DTE.js +44 -15
- package/EnviadorSII.js +28 -11
- package/Envio.js +12 -8
- package/FolioService.js +263 -94
- package/SiiPortalAuth.js +114 -1
- package/SiiSession.js +106 -6
- package/WsReclamo.js +434 -0
- package/index.js +8 -0
- package/package.json +1 -1
- package/utils/endpoints.js +87 -0
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/SiiPortalAuth.js
CHANGED
|
@@ -121,7 +121,9 @@ class SiiPortalAuth {
|
|
|
121
121
|
if (cookieStr) options.headers['Cookie'] = cookieStr;
|
|
122
122
|
|
|
123
123
|
if (body) {
|
|
124
|
-
options.headers['Content-Type']
|
|
124
|
+
if (!options.headers['Content-Type']) {
|
|
125
|
+
options.headers['Content-Type'] = 'application/x-www-form-urlencoded';
|
|
126
|
+
}
|
|
125
127
|
options.headers['Content-Length'] = Buffer.byteLength(body);
|
|
126
128
|
}
|
|
127
129
|
|
|
@@ -493,6 +495,117 @@ if (!fs.existsSync(SESSION_CACHE_PATH)) {
|
|
|
493
495
|
fecha_autorizacion: datos['Fecha Autorización'] || null,
|
|
494
496
|
};
|
|
495
497
|
}
|
|
498
|
+
|
|
499
|
+
// ─── Consemitidos (www4.sii.cl/consemitidosinternetui) ───────────────────────
|
|
500
|
+
|
|
501
|
+
/**
|
|
502
|
+
* Navega a consemitidosinternetui para obtener el TOKEN de sesión.
|
|
503
|
+
* El TOKEN es el mismo valor que CSESSIONID y va como conversationId en el body.
|
|
504
|
+
* @private
|
|
505
|
+
*/
|
|
506
|
+
async _obtenerTokenConsemitidos(cookieJar) {
|
|
507
|
+
await this._request('https://www4.sii.cl/consemitidosinternetui/', { cookieJar });
|
|
508
|
+
const token = cookieJar['TOKEN'] || cookieJar['CSESSIONID'];
|
|
509
|
+
if (!token) {
|
|
510
|
+
throw new Error('SiiPortalAuth: no se pudo obtener TOKEN de sesión de consemitidosinternetui');
|
|
511
|
+
}
|
|
512
|
+
return token;
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
/**
|
|
516
|
+
* Llama a un endpoint JSON de la API consemitidosinternetui.
|
|
517
|
+
* @private
|
|
518
|
+
*/
|
|
519
|
+
async _callConsemitidos(method, data, token, cookieJar) {
|
|
520
|
+
const body = JSON.stringify({
|
|
521
|
+
metaData: {
|
|
522
|
+
namespace: `cl.sii.sdi.lob.diii.consemitidos.data.api.interfaces.FacadeService/${method}`,
|
|
523
|
+
conversationId: token,
|
|
524
|
+
transactionId: crypto.randomUUID(),
|
|
525
|
+
page: null,
|
|
526
|
+
},
|
|
527
|
+
data,
|
|
528
|
+
});
|
|
529
|
+
const res = await this._request(
|
|
530
|
+
`https://www4.sii.cl/consemitidosinternetui/services/data/facadeService/${method}`,
|
|
531
|
+
{
|
|
532
|
+
method: 'POST',
|
|
533
|
+
body,
|
|
534
|
+
cookieJar,
|
|
535
|
+
headers: {
|
|
536
|
+
'Content-Type': 'application/json',
|
|
537
|
+
'Accept': 'application/json, text/plain, */*',
|
|
538
|
+
'Origin': 'https://www4.sii.cl',
|
|
539
|
+
'Referer': 'https://www4.sii.cl/consemitidosinternetui/',
|
|
540
|
+
},
|
|
541
|
+
}
|
|
542
|
+
);
|
|
543
|
+
try {
|
|
544
|
+
return JSON.parse(res.body);
|
|
545
|
+
} catch {
|
|
546
|
+
throw new Error(`SiiPortalAuth: respuesta no-JSON de ${method}: ${res.body.slice(0, 300)}`);
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
/**
|
|
551
|
+
* Obtiene el detalle de DTEs emitidos o recibidos desde www4.sii.cl.
|
|
552
|
+
*
|
|
553
|
+
* @param {string} rut - RUT sin DV (ej: "78206276")
|
|
554
|
+
* @param {string} dv - DV (ej: "K")
|
|
555
|
+
* @param {string} periodo - Período YYYY-MM (ej: "2026-05")
|
|
556
|
+
* @param {number} operacion - 1 = compras / recibidos, 2 = ventas / emitidos
|
|
557
|
+
* @param {Object} [cookieJar] - Sesión ya autenticada (opcional)
|
|
558
|
+
* @returns {Promise<{ resumen: Array, detalles: Array }>}
|
|
559
|
+
*/
|
|
560
|
+
async obtenerDetalleDtes(rut, dv, periodo, operacion = 2, cookieJar = null) {
|
|
561
|
+
const jar = cookieJar || await this.autenticar();
|
|
562
|
+
const token = await this._obtenerTokenConsemitidos(jar);
|
|
563
|
+
|
|
564
|
+
// Mapeo de convención interna → convención SII:
|
|
565
|
+
// interno: 1 = compras/recibidos, 2 = ventas/emitidos
|
|
566
|
+
// SII: 1 = emitidos, 2 = recibidos
|
|
567
|
+
const siiOperacion = operacion === 2 ? 1 : 2;
|
|
568
|
+
const esEmitidos = siiOperacion === 1;
|
|
569
|
+
|
|
570
|
+
// 1. Resumen mensual — saber qué tipos de DTE hay en el período
|
|
571
|
+
const resumenResp = await this._callConsemitidos('getResumen', {
|
|
572
|
+
periodo,
|
|
573
|
+
rutContribuyente: rut,
|
|
574
|
+
dvContribuyente: dv,
|
|
575
|
+
operacion: siiOperacion,
|
|
576
|
+
}, token, jar);
|
|
577
|
+
|
|
578
|
+
const resumen = resumenResp.data?.resumenDte ?? [];
|
|
579
|
+
if (resumen.length === 0) return { resumen: [], detalles: [] };
|
|
580
|
+
|
|
581
|
+
// 2. Detalle por cada tipo DTE encontrado en el resumen
|
|
582
|
+
const detallesArr = await Promise.all(
|
|
583
|
+
resumen.map(async (t) => {
|
|
584
|
+
// Para emitidos tipo 33/34, el SII expone un método específico
|
|
585
|
+
const metodo = esEmitidos && (t.tipoDoc === 33 || t.tipoDoc === 34)
|
|
586
|
+
? 'getDetalleEmitidos3334'
|
|
587
|
+
: 'getDetalleRecibidos';
|
|
588
|
+
const resp = await this._callConsemitidos(metodo, {
|
|
589
|
+
tipoDoc: String(t.tipoDoc),
|
|
590
|
+
rut,
|
|
591
|
+
dv,
|
|
592
|
+
periodo,
|
|
593
|
+
operacion: siiOperacion,
|
|
594
|
+
derrCodigo: String(t.tipoDoc),
|
|
595
|
+
refNCD: '0',
|
|
596
|
+
}, token, jar);
|
|
597
|
+
const items = resp.dataResp?.detalles ?? [];
|
|
598
|
+
// Completar tipoDoc y tipoDocDesc desde el resumen si no vienen en el detalle
|
|
599
|
+
return items.map((d) => ({
|
|
600
|
+
...d,
|
|
601
|
+
tipoDoc: t.tipoDoc,
|
|
602
|
+
tipoDocDesc: d.descTipoDoc || t.tipoDocDesc,
|
|
603
|
+
}));
|
|
604
|
+
})
|
|
605
|
+
);
|
|
606
|
+
|
|
607
|
+
return { resumen, detalles: detallesArr.flat() };
|
|
608
|
+
}
|
|
496
609
|
}
|
|
497
610
|
|
|
498
611
|
module.exports = SiiPortalAuth;
|