@devlas/dte-sii 2.5.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.
Files changed (60) hide show
  1. package/BoletaService.js +109 -0
  2. package/CAF.js +173 -0
  3. package/CafSolicitor.js +380 -0
  4. package/Certificado.js +123 -0
  5. package/ConsumoFolio.js +376 -0
  6. package/DTE.js +399 -0
  7. package/EnviadorSII.js +1304 -0
  8. package/Envio.js +196 -0
  9. package/FolioRegistry.js +553 -0
  10. package/FolioService.js +703 -0
  11. package/LICENSE +27 -0
  12. package/LibroBase.js +134 -0
  13. package/LibroCompraVenta.js +205 -0
  14. package/LibroGuia.js +225 -0
  15. package/README.md +239 -0
  16. package/Signer.js +94 -0
  17. package/SiiCertificacion.js +1189 -0
  18. package/SiiPortalAuth.js +460 -0
  19. package/SiiSession.js +499 -0
  20. package/cert/BoletaCert.js +731 -0
  21. package/cert/CertFolioHelper.js +185 -0
  22. package/cert/CertRunner.js +2658 -0
  23. package/cert/ConfigLoader.js +133 -0
  24. package/cert/IntercambioCert.js +429 -0
  25. package/cert/LibroCompras.js +359 -0
  26. package/cert/LibroGuias.js +171 -0
  27. package/cert/LibroVentas.js +153 -0
  28. package/cert/MuestrasImpresas.js +676 -0
  29. package/cert/SetBase.js +321 -0
  30. package/cert/SetBasico.js +413 -0
  31. package/cert/SetCompra.js +472 -0
  32. package/cert/SetExenta.js +490 -0
  33. package/cert/SetGuia.js +283 -0
  34. package/cert/SetParser.js +1184 -0
  35. package/cert/SetsProvider.js +499 -0
  36. package/cert/Simulacion.js +521 -0
  37. package/cert/comunaOficina.js +460 -0
  38. package/cert/index.js +124 -0
  39. package/cert/types.js +330 -0
  40. package/dte-sii.d.ts +458 -0
  41. package/index.js +428 -0
  42. package/package.json +48 -0
  43. package/utils/c14n.js +275 -0
  44. package/utils/calculo.js +396 -0
  45. package/utils/config.js +276 -0
  46. package/utils/constants.js +302 -0
  47. package/utils/emisor.js +174 -0
  48. package/utils/endpoints.js +225 -0
  49. package/utils/error.js +235 -0
  50. package/utils/index.js +339 -0
  51. package/utils/logger.js +239 -0
  52. package/utils/pfx.js +203 -0
  53. package/utils/receptor.js +218 -0
  54. package/utils/referencia.js +169 -0
  55. package/utils/resolucion.js +119 -0
  56. package/utils/rut.js +169 -0
  57. package/utils/sanitize.js +124 -0
  58. package/utils/tokenCache.js +214 -0
  59. package/utils/xml.js +358 -0
  60. package/utils.js +4 -0
@@ -0,0 +1,703 @@
1
+ // Copyright (c) 2026 Devlas SpA — https://devlas.cl
2
+ // Licencia MIT. Ver archivo LICENSE para mas detalles.
3
+ /**
4
+ * FolioService.js - Servicio de gestión de folios
5
+ *
6
+ * Servicio principal para obtener, consultar y anular folios del SII.
7
+ * Integra SiiSession para la comunicación y FolioRegistry para el control local.
8
+ *
9
+ * @module FolioService
10
+ */
11
+
12
+ const fs = require('fs');
13
+ const path = require('path');
14
+ const SiiSession = require('./SiiSession');
15
+ const FolioRegistry = require('./FolioRegistry');
16
+ const CAF = require('./CAF');
17
+ const CafSolicitor = require('./CafSolicitor');
18
+
19
+ /**
20
+ * Clase para gestión integral de folios
21
+ */
22
+ class FolioService {
23
+ /**
24
+ * @param {Object} options - Opciones de configuración
25
+ * @param {string} options.ambiente - 'certificacion' o 'produccion' (OBLIGATORIO)
26
+ * @param {string} options.rutEmisor - RUT del emisor (OBLIGATORIO)
27
+ * @param {Object} [options.certificado] - Instancia de Certificado
28
+ * @param {string} [options.pfxPath] - Ruta al archivo PFX
29
+ * @param {string} [options.pfxPassword] - Contraseña del PFX
30
+ * @param {string} [options.baseDir] - Directorio base del proyecto
31
+ * @param {string} [options.cafDir] - Directorio de CAFs
32
+ * @param {string} [options.debugDir] - Directorio de debug
33
+ * @param {string} [options.registryPath] - Ruta al registro de folios
34
+ * @param {string} [options.solicitarScript] - Script para solicitar CAFs
35
+ * @param {number} [options.retries=2] - Número de reintentos para solicitar CAF
36
+ * @param {number} [options.retryDelayMs=1500] - Delay entre reintentos en ms
37
+ * @param {boolean} [options.useRegistry=true] - Usar registro de folios
38
+ * @param {boolean} [options.singleRequest=false] - Solicitar CAFs uno a uno
39
+ */
40
+ constructor(options = {}) {
41
+ // Validar parámetros obligatorios (multi-tenant: nunca usar defaults de .env)
42
+ if (!options.ambiente) {
43
+ throw new Error('FolioService: options.ambiente es obligatorio');
44
+ }
45
+ if (!options.rutEmisor) {
46
+ throw new Error('FolioService: options.rutEmisor es obligatorio');
47
+ }
48
+ if (!['certificacion', 'produccion'].includes(options.ambiente)) {
49
+ throw new Error(`FolioService: ambiente inválido "${options.ambiente}", debe ser 'certificacion' o 'produccion'`);
50
+ }
51
+
52
+ this.ambiente = options.ambiente;
53
+ this.rutEmisor = options.rutEmisor;
54
+ this.baseDir = options.baseDir || path.resolve(__dirname, '..', '..');
55
+
56
+ // Directorios
57
+ this.cafDir = options.cafDir || path.join(this.baseDir, 'debug', 'auto-caf');
58
+ this.debugDir = options.debugDir || path.join(this.baseDir, 'debug');
59
+
60
+ // Sesión SII
61
+ this.session = new SiiSession({
62
+ ambiente: this.ambiente,
63
+ certificado: options.certificado,
64
+ pfxPath: options.pfxPath,
65
+ pfxPassword: options.pfxPassword,
66
+ });
67
+
68
+ // Cargar sesión compartida si está disponible
69
+ this.sessionPath = options.sessionPath || process.env.SII_SESSION_PATH;
70
+ if (this.sessionPath) {
71
+ const loaded = this.session.loadSession(this.sessionPath);
72
+ if (loaded) {
73
+ console.log('[FolioService] ✓ Usando sesión compartida');
74
+ }
75
+ }
76
+
77
+ // Registro de folios
78
+ this.registry = new FolioRegistry({
79
+ registryPath: options.registryPath,
80
+ baseDir: this.baseDir,
81
+ });
82
+
83
+ // Solicitador de CAF interno (migrado de test-caf-solicitar.js)
84
+ this.cafSolicitor = null;
85
+ if (options.pfxPath && options.pfxPassword) {
86
+ this.cafSolicitor = new CafSolicitor({
87
+ ambiente: this.ambiente,
88
+ rutEmisor: this.rutEmisor,
89
+ pfxPath: options.pfxPath,
90
+ pfxPassword: options.pfxPassword,
91
+ baseDir: this.baseDir,
92
+ sessionPath: this.sessionPath,
93
+ });
94
+ }
95
+
96
+ // Configuración (parámetros explícitos, sin process.env para multi-tenant)
97
+ this.config = {
98
+ retries: Number(options.retries ?? 2),
99
+ retryDelayMs: Number(options.retryDelayMs ?? 1500),
100
+ useRegistry: options.useRegistry !== false,
101
+ singleRequest: options.singleRequest === true,
102
+ };
103
+ }
104
+
105
+ /**
106
+ * Busca el CAF más reciente para un tipo de DTE
107
+ * Busca recursivamente en cafDir y también en debug/caf/{ambiente}/{rut}/{tipo}/
108
+ * @param {number} tipoDte - Tipo de DTE
109
+ * @returns {string|null} - Ruta al CAF o null
110
+ */
111
+ findLatestCaf(tipoDte) {
112
+ const matches = [];
113
+
114
+ // Helper para buscar recursivamente
115
+ const searchRecursive = (dir, depth = 0) => {
116
+ if (!fs.existsSync(dir) || depth > 5) return;
117
+
118
+ try {
119
+ const entries = fs.readdirSync(dir, { withFileTypes: true });
120
+ for (const entry of entries) {
121
+ const fullPath = path.join(dir, entry.name);
122
+ if (entry.isDirectory()) {
123
+ searchRecursive(fullPath, depth + 1);
124
+ } else if (entry.isFile() && entry.name.endsWith('.xml')) {
125
+ try {
126
+ const xml = fs.readFileSync(fullPath, 'utf8');
127
+ const tdMatch = xml.match(/<TD>(\d+)<\/TD>/i);
128
+ if (tdMatch && Number(tdMatch[1]) === Number(tipoDte)) {
129
+ const stat = fs.statSync(fullPath);
130
+ matches.push({ filePath: fullPath, mtime: stat.mtimeMs });
131
+ }
132
+ } catch (_) {
133
+ // ignore
134
+ }
135
+ }
136
+ }
137
+ } catch (_) {
138
+ // ignore
139
+ }
140
+ };
141
+
142
+ // Buscar en cafDir (auto-caf)
143
+ searchRecursive(this.cafDir);
144
+
145
+ // También buscar en el directorio de CAFs canónicos
146
+ const canonicalDir = path.join(this.debugDir, 'caf', this.ambiente, this.rutEmisor, String(tipoDte));
147
+ searchRecursive(canonicalDir);
148
+
149
+ if (!matches.length) return null;
150
+ matches.sort((a, b) => b.mtime - a.mtime);
151
+ return matches[0].filePath;
152
+ }
153
+
154
+ /**
155
+ * Solicita un nuevo CAF al SII
156
+ * @param {Object} params - Parámetros
157
+ * @returns {Promise<boolean>} - true si fue exitoso
158
+ */
159
+ async solicitarCaf({ tipoDte, cantidad = 1 }) {
160
+ if (!this.cafSolicitor) {
161
+ throw new Error('FolioService: CafSolicitor no inicializado (se requiere pfxPath y pfxPassword)');
162
+ }
163
+
164
+ let attempt = 0;
165
+
166
+ while (attempt <= this.config.retries) {
167
+ try {
168
+ const result = await this.cafSolicitor.solicitar({ tipoDte, cantidad });
169
+
170
+ if (result.success) {
171
+ return true;
172
+ }
173
+
174
+ console.log(`[FolioService] Intento ${attempt + 1} fallido: ${result.error}`);
175
+ } catch (err) {
176
+ console.log(`[FolioService] Error en intento ${attempt + 1}: ${err.message}`);
177
+ }
178
+
179
+ if (attempt >= this.config.retries) {
180
+ throw new Error(`Fallo auto-CAF para tipo ${tipoDte}`);
181
+ }
182
+
183
+ await this._sleep(this.config.retryDelayMs);
184
+ attempt += 1;
185
+ }
186
+
187
+ return false;
188
+ }
189
+
190
+ /**
191
+ * Sleep asíncrono
192
+ * @private
193
+ */
194
+ _sleep(ms) {
195
+ return new Promise(resolve => setTimeout(resolve, ms));
196
+ }
197
+
198
+ /**
199
+ * Solicita CAF con fallback a cantidad menor
200
+ * @param {Object} params - Parámetros
201
+ * @returns {Promise<string|null>} - Ruta al CAF obtenido
202
+ */
203
+ async solicitarCafConFallback({ tipoDte, cantidad, previousPath = null }) {
204
+ try {
205
+ await this.solicitarCaf({ tipoDte, cantidad });
206
+ } catch (error) {
207
+ // Continuar con fallback
208
+ }
209
+
210
+ let resolvedPath = this.findLatestCaf(tipoDte);
211
+
212
+ if (this.config.singleRequest) {
213
+ return resolvedPath;
214
+ }
215
+
216
+ // Si no hay CAF nuevo, intentar anular folios pendientes y pedir de nuevo
217
+ if (!resolvedPath || resolvedPath === previousPath) {
218
+ try {
219
+ await this.anularFolios({ tipoDte });
220
+ await this.solicitarCaf({ tipoDte, cantidad: 1 });
221
+ resolvedPath = this.findLatestCaf(tipoDte);
222
+ } catch (_) {
223
+ // ignore
224
+ }
225
+ }
226
+
227
+ // Fallback a cantidad 1
228
+ const shouldFallback = (!resolvedPath || resolvedPath === previousPath) && Number(cantidad) > 1;
229
+ if (shouldFallback) {
230
+ try {
231
+ await this.solicitarCaf({ tipoDte, cantidad: 1 });
232
+ resolvedPath = this.findLatestCaf(tipoDte);
233
+ } catch (_) {
234
+ // ignore
235
+ }
236
+ }
237
+
238
+ return resolvedPath;
239
+ }
240
+
241
+ /**
242
+ * Consulta el estado de folios en el SII
243
+ * @param {Object} params - Parámetros
244
+ * @returns {Promise<Object>}
245
+ */
246
+ async consultarFolios({ tipoDte }) {
247
+ const { rut, dv } = SiiSession.parseRut(this.rutEmisor);
248
+ const debugStamp = new Date().toISOString().replace(/[:.]/g, '-');
249
+ const debugDir = path.join(this.debugDir, 'auto-caf', 'anulacion', debugStamp);
250
+ fs.mkdirSync(debugDir, { recursive: true });
251
+
252
+ // Asegurar sesión
253
+ this.session.reset();
254
+ const page = await this.session.ensureSession('/cvc_cgi/dte/af_anular1');
255
+
256
+ if (!page.body || !page.body.includes('ANULACION DE FOLIOS')) {
257
+ fs.writeFileSync(path.join(debugDir, 'error.html'), page.body || '', 'utf8');
258
+ throw new Error('No se pudo acceder a la página de anulación.');
259
+ }
260
+
261
+ // Consultar folios
262
+ const hiddenInputs = SiiSession.extractInputValues(page.body);
263
+ const fields = {
264
+ ...hiddenInputs,
265
+ RUT_EMP: rut,
266
+ DV_EMP: dv,
267
+ COD_DOCTO: String(tipoDte),
268
+ ACEPTAR: 'Consultar',
269
+ };
270
+
271
+ const consulta = await this.session.submitForm(
272
+ '/cvc_cgi/dte/af_anular2',
273
+ fields,
274
+ `https://${this.session.getBaseHost()}/cvc_cgi/dte/af_anular1`
275
+ );
276
+
277
+ let currentHtml = consulta.body || '';
278
+ let info = this._parseAnulacionTable(currentHtml);
279
+ let action = SiiSession.extractFormActionByName(currentHtml, 'frm') || '/cvc_cgi/dte/af_anular2';
280
+ let hiddenInputsConsulta = SiiSession.extractInputValues(currentHtml);
281
+
282
+ // Paginar resultados
283
+ let nextButton = this._findNextButton(currentHtml);
284
+ let safety = 0;
285
+
286
+ while (nextButton && safety < 20) {
287
+ const formInputs = SiiSession.extractFormInputsByName(currentHtml, 'frm');
288
+ const nextFields = { ...formInputs };
289
+
290
+ if (Number.isFinite(nextButton.page)) {
291
+ nextFields.PAGINA = String(nextButton.page);
292
+ }
293
+
294
+ const nextRes = await this.session.submitForm(
295
+ action,
296
+ nextFields,
297
+ `https://${this.session.getBaseHost()}/cvc_cgi/dte/af_anular2`
298
+ );
299
+
300
+ currentHtml = nextRes.body || '';
301
+
302
+ try {
303
+ const pageStamp = nextButton.page ? `page-${nextButton.page}` : `page-${safety + 2}`;
304
+ fs.writeFileSync(path.join(debugDir, `${pageStamp}.html`), currentHtml, 'utf8');
305
+ } catch (_) {
306
+ // ignore
307
+ }
308
+
309
+ const nextInfo = this._parseAnulacionTable(currentHtml);
310
+ info = {
311
+ ranges: this._mergeRanges(info.ranges, nextInfo.ranges),
312
+ ultimoFolioFinal: Math.max(info.ultimoFolioFinal || 0, nextInfo.ultimoFolioFinal || 0) || null,
313
+ };
314
+
315
+ action = SiiSession.extractFormActionByName(currentHtml, 'frm') || action;
316
+ hiddenInputsConsulta = SiiSession.extractInputValues(currentHtml);
317
+ nextButton = this._findNextButton(currentHtml);
318
+ safety += 1;
319
+ }
320
+
321
+ return {
322
+ ok: true,
323
+ tipoDte,
324
+ baseHost: this.session.getBaseHost(),
325
+ html: consulta.body || '',
326
+ action,
327
+ hiddenInputs: hiddenInputsConsulta,
328
+ ...info,
329
+ };
330
+ }
331
+
332
+ /**
333
+ * Anula folios en el SII
334
+ * @param {Object} params - Parámetros
335
+ * @returns {Promise<Object>}
336
+ */
337
+ async anularFolios({ tipoDte, folioDesde = null, folioHasta = null, motivo = 'Folios no utilizados' }) {
338
+ const consulta = await this.consultarFolios({ tipoDte });
339
+ const anulados = [];
340
+ const rechazados = [];
341
+
342
+ // Calcular total de folios a anular para mostrar progreso
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;
352
+
353
+ const anularFolioIndividual = async (folio) => {
354
+ let currentHtml = consulta.html;
355
+
356
+ const range = consulta.ranges.find((r) => folio >= r.folioDesde && folio <= r.folioHasta);
357
+
358
+ if (range && range.formFields && Object.keys(range.formFields).length) {
359
+ const selectRes = await this.session.submitForm(
360
+ range.formAction || consulta.action,
361
+ range.formFields,
362
+ `https://${this.session.getBaseHost()}/cvc_cgi/dte/af_anular1`
363
+ );
364
+ currentHtml = selectRes.body || currentHtml;
365
+ } else if (range && range.selection) {
366
+ const selectFields = {
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;
376
+ }
377
+
378
+ const action = SiiSession.extractFormAction(currentHtml) || consulta.action;
379
+ const fields = SiiSession.extractInputValues(currentHtml);
380
+ this._setFolioFields(fields, folio, folio);
381
+ this._setMotivoField(fields, motivo);
382
+
383
+ const resultRes = await this.session.submitForm(
384
+ action,
385
+ fields,
386
+ `https://${this.session.getBaseHost()}/cvc_cgi/dte/af_anular2`
387
+ );
388
+
389
+ // Guardar resultado para debug
390
+ try {
391
+ const debugDir = path.join(this.debugDir, 'auto-caf', 'anulacion', 'resultados');
392
+ fs.mkdirSync(debugDir, { recursive: true });
393
+ const stamp = new Date().toISOString().replace(/[:.]/g, '-');
394
+ fs.writeFileSync(path.join(debugDir, `sii-anulacion-result-${folio}-${stamp}.html`), resultRes.body || '', 'utf8');
395
+ } catch (_) {
396
+ // ignore
397
+ }
398
+
399
+ // Log de progreso
400
+ foliosAnulados += 1;
401
+ const pct = Math.round((foliosAnulados / totalFolios) * 100);
402
+ process.stdout.write(`\r Anulando folio ${folio} (${foliosAnulados}/${totalFolios} - ${pct}%)`);
403
+
404
+ return { folio, ...this._parseAnulacionResult(resultRes.body || '') };
405
+ };
406
+
407
+ // Anular rango específico o todos
408
+ if (Number.isFinite(folioDesde) && Number.isFinite(folioHasta)) {
409
+ for (let folio = folioDesde; folio <= folioHasta; folio += 1) {
410
+ const result = await anularFolioIndividual(folio);
411
+ if (result.ok) {
412
+ anulados.push(result);
413
+ } else if (result.ok === false) {
414
+ rechazados.push(result);
415
+ }
416
+ }
417
+ } else {
418
+ // Anular todos los rangos
419
+ for (const range of consulta.ranges) {
420
+ for (let folio = range.folioDesde; folio <= range.folioHasta; folio += 1) {
421
+ const result = await anularFolioIndividual(folio);
422
+ if (result.ok) {
423
+ anulados.push(result);
424
+ } else if (result.ok === false) {
425
+ rechazados.push(result);
426
+ }
427
+ }
428
+ }
429
+ }
430
+
431
+ // Nueva línea después del progreso
432
+ if (totalFolios > 0) {
433
+ console.log('');
434
+ }
435
+
436
+ return { ok: true, anulados, rechazados };
437
+ }
438
+
439
+ /**
440
+ * Resuelve la ruta de un CAF, solicitando uno nuevo si es necesario
441
+ * @param {Object} params - Parámetros
442
+ * @returns {Promise<string|null>}
443
+ */
444
+ async resolveCafPath({ tipoDte, cafPath = null, autoCaf = true, requiredCount = 1 }) {
445
+ let resolvedPath = cafPath;
446
+
447
+ // Si no existe, buscar o solicitar
448
+ if (autoCaf && (!resolvedPath || !fs.existsSync(resolvedPath))) {
449
+ resolvedPath = await this.solicitarCafConFallback({
450
+ tipoDte,
451
+ cantidad: requiredCount,
452
+ });
453
+ }
454
+
455
+ // Validar si hay folios suficientes
456
+ if (autoCaf && resolvedPath && fs.existsSync(resolvedPath)) {
457
+ const cafXml = fs.readFileSync(resolvedPath, 'utf8');
458
+ const caf = new CAF(cafXml);
459
+ const cafFingerprint = FolioRegistry.createCafFingerprint(cafXml);
460
+
461
+ // Verificar con consulta al SII si está disponible
462
+ if (this.config.useRegistry) {
463
+ try {
464
+ const siiInfo = await this.consultarFolios({ tipoDte: caf.getTipoDTE() });
465
+ if (siiInfo && Number.isFinite(Number(siiInfo.ultimoFolioFinal))) {
466
+ const last = Number(siiInfo.ultimoFolioFinal);
467
+ if (Number(caf.getFolioHasta()) <= last) {
468
+ resolvedPath = await this.solicitarCafConFallback({
469
+ tipoDte,
470
+ cantidad: requiredCount,
471
+ previousPath: resolvedPath,
472
+ });
473
+ }
474
+ }
475
+ } catch (_) {
476
+ // Continuar sin validación SII
477
+ }
478
+ }
479
+
480
+ // Verificar folios restantes en registro local
481
+ const remaining = this.registry.getRemainingFolios({
482
+ rutEmisor: this.rutEmisor,
483
+ tipoDte: caf.getTipoDTE(),
484
+ folioDesde: caf.getFolioDesde(),
485
+ folioHasta: caf.getFolioHasta(),
486
+ ambiente: this.ambiente,
487
+ cafFingerprint,
488
+ });
489
+
490
+ if (remaining < requiredCount) {
491
+ resolvedPath = await this.solicitarCafConFallback({
492
+ tipoDte,
493
+ cantidad: requiredCount,
494
+ previousPath: resolvedPath,
495
+ });
496
+ }
497
+ }
498
+
499
+ return resolvedPath;
500
+ }
501
+
502
+ /**
503
+ * Reserva el siguiente folio disponible
504
+ * @param {Object} params - Parámetros del CAF
505
+ * @returns {number}
506
+ */
507
+ reserveNextFolio({ tipoDte, folioDesde, folioHasta, cafFingerprint }) {
508
+ return this.registry.reserveNextFolio({
509
+ rutEmisor: this.rutEmisor,
510
+ tipoDte,
511
+ folioDesde,
512
+ folioHasta,
513
+ ambiente: this.ambiente,
514
+ cafFingerprint,
515
+ });
516
+ }
517
+
518
+ /**
519
+ * Marca un folio como enviado
520
+ * @param {Object} params - Parámetros
521
+ */
522
+ markFolioSent(params) {
523
+ this.registry.markFolioSent({
524
+ rutEmisor: this.rutEmisor,
525
+ ambiente: this.ambiente,
526
+ ...params,
527
+ });
528
+ }
529
+
530
+ /**
531
+ * Libera folios del registro
532
+ * @param {Object} params - Parámetros
533
+ */
534
+ releaseFolios(params) {
535
+ this.registry.releaseFolios({
536
+ rutEmisor: this.rutEmisor,
537
+ ambiente: this.ambiente,
538
+ ...params,
539
+ });
540
+ }
541
+
542
+ // ============================================
543
+ // MÉTODOS PRIVADOS DE PARSING
544
+ // ============================================
545
+
546
+ /**
547
+ * @private
548
+ */
549
+ _parseAnulacionTable(html) {
550
+ const rows = html.match(/<tr[^>]*>[\s\S]*?<\/tr>/gi) || [];
551
+ const ranges = [];
552
+
553
+ rows.forEach((row) => {
554
+ const cols = row.match(/<td[^>]*>[\s\S]*?<\/td>/gi) || [];
555
+ if (cols.length < 4) return;
556
+
557
+ const selectionTag = SiiSession.extractInputTags(row).find((tag) => {
558
+ const name = String(tag.name || '');
559
+ const value = String(tag.value || '');
560
+ return /selecc/i.test(name) || /selecc/i.test(value);
561
+ });
562
+
563
+ const formFields = SiiSession.extractFormInputsByName(row);
564
+ const formAction = SiiSession.extractFormAction(row);
565
+
566
+ const values = cols.map((c) => SiiSession.stripHtml(c));
567
+ const folioDesde = SiiSession.parseIntFromText(values[2]);
568
+ const folioHasta = SiiSession.parseIntFromText(values[3]);
569
+ const cantidad = SiiSession.parseIntFromText(values[1]);
570
+ const fecha = values[0] || null;
571
+ const efectuadoPor = values[4] || null;
572
+
573
+ if (!Number.isFinite(folioDesde) || !Number.isFinite(folioHasta)) return;
574
+
575
+ ranges.push({
576
+ fecha,
577
+ cantidad: Number.isFinite(cantidad) ? cantidad : null,
578
+ folioDesde,
579
+ folioHasta,
580
+ efectuadoPor,
581
+ selection: selectionTag && selectionTag.name ? { name: selectionTag.name, value: selectionTag.value || '' } : null,
582
+ formFields,
583
+ formAction,
584
+ });
585
+ });
586
+
587
+ const ultimoFolioFinal = ranges.reduce((acc, r) => (r.folioHasta > acc ? r.folioHasta : acc), 0);
588
+ return { ranges, ultimoFolioFinal: ultimoFolioFinal || null };
589
+ }
590
+
591
+ /**
592
+ * @private
593
+ */
594
+ _findNextButton(html) {
595
+ const inputs = SiiSession.extractInputTags(html);
596
+ const next = inputs.find((tag) => {
597
+ const value = String(tag.value || '').toLowerCase();
598
+ const name = String(tag.name || '').toLowerCase();
599
+ return value.includes('ver siguiente') || value.includes('siguiente') || name.includes('siguiente') || name.includes('next');
600
+ });
601
+
602
+ if (!next || !next.name) return null;
603
+
604
+ let page = null;
605
+ if (next.onClick) {
606
+ const match = String(next.onClick).match(/cambiapag\((\d+)\)/i);
607
+ if (match) page = Number(match[1]);
608
+ }
609
+
610
+ return { name: next.name, value: next.value || '', page };
611
+ }
612
+
613
+ /**
614
+ * @private
615
+ */
616
+ _mergeRanges(base, extra) {
617
+ const out = [...base];
618
+ extra.forEach((range) => {
619
+ if (!out.some((r) => r.folioDesde === range.folioDesde && r.folioHasta === range.folioHasta)) {
620
+ out.push(range);
621
+ }
622
+ });
623
+ out.sort((a, b) => b.folioHasta - a.folioHasta);
624
+ return out;
625
+ }
626
+
627
+ /**
628
+ * @private
629
+ */
630
+ _setFolioFields(fields, folioDesde, folioHasta) {
631
+ let setDesde = false;
632
+ let setHasta = false;
633
+
634
+ Object.keys(fields).forEach((key) => {
635
+ if (/folio.*(ini|desde)/i.test(key)) {
636
+ fields[key] = String(folioDesde);
637
+ setDesde = true;
638
+ }
639
+ if (/folio.*(fin|hasta)/i.test(key)) {
640
+ fields[key] = String(folioHasta);
641
+ setHasta = true;
642
+ }
643
+ });
644
+
645
+ if (!setDesde) fields.FOLIO_INICIAL = String(folioDesde);
646
+ if (!setHasta) fields.FOLIO_FINAL = String(folioHasta);
647
+ }
648
+
649
+ /**
650
+ * @private
651
+ */
652
+ _setMotivoField(fields, motivo) {
653
+ let set = false;
654
+
655
+ Object.keys(fields).forEach((key) => {
656
+ if (/motivo|glosa|razon|coment/i.test(key)) {
657
+ fields[key] = motivo;
658
+ set = true;
659
+ }
660
+ });
661
+
662
+ if (!set) fields.MOTIVO = motivo;
663
+ }
664
+
665
+ /**
666
+ * @private
667
+ */
668
+ _parseAnulacionResult(html) {
669
+ const text = String(html || '').toLowerCase();
670
+
671
+ if (text.includes('recepcionad')) {
672
+ return { ok: false, reason: 'recepcionado' };
673
+ }
674
+
675
+ if (
676
+ text.includes('ya fue anulado') ||
677
+ text.includes('anulado anteriormente') ||
678
+ (text.includes('anulad') && text.includes('ya'))
679
+ ) {
680
+ return { ok: false, reason: 'ya-anulado' };
681
+ }
682
+
683
+ if (
684
+ text.includes('ha autorizado la anulaci') ||
685
+ text.includes('ha autorizado la anulacion') ||
686
+ text.includes('solicitud anulacion de folios')
687
+ ) {
688
+ return { ok: true, reason: 'anulado' };
689
+ }
690
+
691
+ if (text.includes('anulaci') && (text.includes('exit') || text.includes('realiz') || text.includes('correct'))) {
692
+ return { ok: true, reason: 'anulado' };
693
+ }
694
+
695
+ if (text.includes('anulaci') && text.includes('no')) {
696
+ return { ok: false, reason: 'rechazado' };
697
+ }
698
+
699
+ return { ok: null, reason: 'desconocido' };
700
+ }
701
+ }
702
+
703
+ module.exports = FolioService;