@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,676 @@
1
+ // Copyright (c) 2026 Devlas SpA — https://devlas.cl
2
+ // Licencia MIT. Ver archivo LICENSE para mas detalles.
3
+ /**
4
+ * MuestrasImpresas.js
5
+ *
6
+ * Módulo para generar PDFs de muestras impresas según Manual SII v4.0
7
+ * para la etapa "Documentos Impresos" del proceso de certificación.
8
+ *
9
+ * Especificaciones:
10
+ * - Dimensiones: 21.5x11 cm (mín) a 21.5x33 cm (máx)
11
+ * - Borde sin letras: 0.5 cm mínimo
12
+ * - Recuadro tipo DTE: 1.5x5.5 cm (mín) a 4x8 cm (máx), negro/rojo
13
+ * - Timbre PDF417: 2x5 cm (mín) a 4x9 cm (máx)
14
+ * - Timbre a 2 cm mínimo del borde izquierdo
15
+ *
16
+ * @module dte-sii/cert/MuestrasImpresas
17
+ */
18
+
19
+ const fs = require('fs');
20
+ const path = require('path');
21
+ const { XMLParser, XMLBuilder } = require('fast-xml-parser');
22
+ const bwipjs = require('bwip-js');
23
+ const puppeteer = require('puppeteer');
24
+
25
+ // Usar constantes del core
26
+ const {
27
+ NOMBRES_DTE_IMPRESOS,
28
+ NOMBRES_TRASLADO,
29
+ TIPOS_CEDIBLES,
30
+ TIPOS_NO_CEDIBLES,
31
+ DECLARACION_RECIBO,
32
+ } = require('../utils/constants');
33
+ const { normalizeArray } = require('../index');
34
+
35
+ // Sets para lookup rápido
36
+ const CEDIBLE_TIPOS = new Set(TIPOS_CEDIBLES);
37
+ const NO_CEDIBLE_TIPOS = new Set(TIPOS_NO_CEDIBLES);
38
+
39
+ // ═══════════════════════════════════════════════════════════════
40
+ // Helpers
41
+ // ═══════════════════════════════════════════════════════════════
42
+
43
+ const toArray = (value) => (Array.isArray(value) ? value : value ? [value] : []);
44
+
45
+ const safeText = (value) => {
46
+ if (value === undefined || value === null) return '';
47
+ return String(value).trim();
48
+ };
49
+
50
+ const formatMonto = (value) => {
51
+ if (!value) return '0';
52
+ return Number(value).toLocaleString('es-CL');
53
+ };
54
+
55
+ class MuestrasImpresas {
56
+ /**
57
+ * @param {Object} options
58
+ * @param {Object} options.emisor - Datos del emisor { rut, razonSocial, giro, direccion, comuna }
59
+ * @param {string} [options.siiOficina] - Oficina SII (ej: "S.I.I. - SANTIAGO CENTRO")
60
+ * @param {string} [options.resolucion] - Texto resolución (ej: "Res. Ex. SII N° 0 del 2026")
61
+ * @param {string} [options.logoPath] - Ruta al logo PNG (opcional)
62
+ * @param {string} [options.debugDir] - Directorio para guardar PDFs
63
+ */
64
+ constructor({ emisor, siiOficina, resolucion, logoPath, debugDir }) {
65
+ this.emisor = emisor;
66
+ this.siiOficina = siiOficina || 'S.I.I. - SANTIAGO CENTRO';
67
+ this.resolucion = resolucion || `Res. Ex. SII N° 0 del ${new Date().getFullYear()}`;
68
+ this.logoDataUri = this._loadLogo(logoPath);
69
+ this.debugDir = debugDir;
70
+ }
71
+
72
+ /**
73
+ * Carga logo como data URI
74
+ * @private
75
+ */
76
+ _loadLogo(logoPath) {
77
+ if (!logoPath) return '';
78
+ const resolved = path.resolve(logoPath);
79
+ if (!fs.existsSync(resolved)) return '';
80
+ const ext = path.extname(resolved).toLowerCase().replace('.', '') || 'png';
81
+ const data = fs.readFileSync(resolved);
82
+ return `data:image/${ext};base64,${data.toString('base64')}`;
83
+ }
84
+
85
+ /**
86
+ * Parsea un EnvioDTE XML y extrae los documentos
87
+ * @param {string} xml - XML del EnvioDTE (en UTF-8)
88
+ * @returns {Object[]} Array de documentos parseados
89
+ */
90
+ parseEnvioDTE(xml) {
91
+ const parser = new XMLParser({
92
+ ignoreAttributes: false,
93
+ attributeNamePrefix: '@_',
94
+ trimValues: true,
95
+ parseTagValue: true,
96
+ });
97
+
98
+ const data = parser.parse(xml);
99
+ const envio = data?.EnvioDTE || data?.EnvioDTEB || data?.EnvioDTETraslado || data?.EnvioBOLETA || {};
100
+ const setDte = envio?.SetDTE || data?.SetDTE || {};
101
+ const dtes = normalizeArray(setDte?.DTE);
102
+
103
+ const builder = new XMLBuilder({
104
+ ignoreAttributes: false,
105
+ attributeNamePrefix: '@_',
106
+ format: false,
107
+ });
108
+
109
+ // Extraer TEDs directamente del XML original
110
+ // El TED debe extraerse exactamente como está en el XML para preservar la firma
111
+ const extractTedsFromOriginal = (originalXml) => {
112
+ const teds = [];
113
+ const tedRegex = /<TED[^>]*>[\s\S]*?<\/TED>/g;
114
+ let match;
115
+ while ((match = tedRegex.exec(originalXml)) !== null) {
116
+ teds.push(match[0]);
117
+ }
118
+ return teds;
119
+ };
120
+
121
+ // Extraer TEDs del XML como UTF-8 (que es el encoding real del archivo)
122
+ const tedsOriginales = extractTedsFromOriginal(xml);
123
+
124
+ const extractTedValue = (ted, tag) => {
125
+ if (!ted) return '';
126
+ const regex = new RegExp(`<${tag}>([^<]*)</${tag}>`);
127
+ const match = ted.match(regex);
128
+ return match ? match[1] : '';
129
+ };
130
+
131
+ return dtes.map((dte, idx) => {
132
+ const doc = dte?.Documento || dte?.DTE?.Documento || {};
133
+ const encabezado = doc?.Encabezado || {};
134
+ const idDoc = encabezado?.IdDoc || {};
135
+ const emisor = encabezado?.Emisor || {};
136
+ const receptor = encabezado?.Receptor || {};
137
+ const totales = encabezado?.Totales || {};
138
+ const transporte = encabezado?.Transporte || {};
139
+ const detalle = toArray(doc?.Detalle);
140
+ const referencias = toArray(doc?.Referencia);
141
+ const descuentosGlobales = toArray(doc?.DscRcgGlobal);
142
+
143
+ // Obtener TED original del XML (preserva encoding ISO-8859-1 para firma válida)
144
+ // Buscar el TED que corresponda al folio de este documento
145
+ const docFolio = idDoc?.Folio?.toString?.() || '';
146
+ const docTipo = idDoc?.TipoDTE?.toString?.() || '';
147
+
148
+ let tedXml = '';
149
+ // Buscar TED original que coincida con este documento
150
+ for (const ted of tedsOriginales) {
151
+ const tedFolio = extractTedValue(ted, 'F');
152
+ const tedTipo = extractTedValue(ted, 'TD');
153
+ if (tedFolio === docFolio && tedTipo === docTipo) {
154
+ tedXml = ted;
155
+ break;
156
+ }
157
+ }
158
+
159
+ // Fallback: si no encontramos, usar siguiente TED disponible en orden
160
+ if (!tedXml && tedsOriginales.length > idx) {
161
+ tedXml = tedsOriginales[idx];
162
+ }
163
+
164
+ const tipoDteTed = Number(extractTedValue(tedXml, 'TD') || 0);
165
+ const folioTed = extractTedValue(tedXml, 'F');
166
+ const fechaTed = extractTedValue(tedXml, 'FE');
167
+
168
+ const tipoDte = Number(idDoc?.TipoDTE || tipoDteTed || 0);
169
+ const folio = (idDoc?.Folio?.toString?.() ?? '') || folioTed || '';
170
+ const fechaEmision = (idDoc?.FchEmis?.toString?.() ?? '') || fechaTed || '';
171
+ const indTraslado = idDoc?.IndTraslado ? Number(idDoc.IndTraslado) : null;
172
+
173
+ return {
174
+ tipoDte,
175
+ folio,
176
+ fechaEmision,
177
+ indTraslado,
178
+ emisor,
179
+ receptor,
180
+ totales,
181
+ transporte,
182
+ detalle,
183
+ referencias,
184
+ descuentosGlobales,
185
+ tedXml,
186
+ };
187
+ });
188
+ }
189
+
190
+ /**
191
+ * Genera código de barras PDF417 del TED
192
+ * El PDF417 debe contener exactamente los mismos bytes que se usaron para firmar el TED.
193
+ *
194
+ * La firma en CAF.sign() usa forge con 'latin1', lo que toma cada codepoint Unicode
195
+ * del string JavaScript y lo usa directamente como byte. Por ejemplo:
196
+ * - 'ñ' (U+00F1 = 241) → byte 0xF1
197
+ * - 'ó' (U+00F3 = 243) → byte 0xF3
198
+ *
199
+ * El SII espera que el PDF417 contenga estos mismos bytes (ISO-8859-1).
200
+ * Si pasamos el string directamente a bwip-js, internamente lo convierte a UTF-8:
201
+ * - 'ñ' → bytes 0xC3 0xB1 (UTF-8)
202
+ * Esto causa que el SII vea "ñ" en lugar de "ñ" → firma inválida.
203
+ *
204
+ * Solución: Convertir el string a Buffer con encoding 'latin1' para que
205
+ * cada codepoint Unicode se mapee directamente a un byte (codepoint → byte),
206
+ * luego convertir de vuelta a string binary para que bwip-js lo acepte.
207
+ *
208
+ * @param {string} tedXml - XML del TED
209
+ * @returns {Promise<string>} Data URI de la imagen PNG
210
+ */
211
+ async generarPdf417(tedXml) {
212
+ if (!tedXml || !tedXml.includes('<TED')) {
213
+ throw new Error('No se encontró TED para generar PDF417');
214
+ }
215
+
216
+ // Convertir string a Buffer con encoding 'latin1' y luego a string 'binary'
217
+ // Esto asegura que cada codepoint Unicode se convierta a exactamente un byte
218
+ // y que bwip-js reciba un string con esos bytes
219
+ const latin1Buffer = Buffer.from(tedXml, 'latin1');
220
+ const binaryString = latin1Buffer.toString('binary');
221
+
222
+ const buffer = await bwipjs.toBuffer({
223
+ bcid: 'pdf417',
224
+ text: binaryString,
225
+ scale: 3,
226
+ height: 12,
227
+ padding: 6,
228
+ includetext: false,
229
+ // CRÍTICO: Indicar a bwip-js que el texto ya está en formato binario 8-bit
230
+ // Sin esto, bwip-js convierte el string a UTF-8 internamente, lo que
231
+ // causa que ñ (0xF1) se convierta a 0xC3 0xB1 y la firma falle
232
+ binarytext: true,
233
+ });
234
+
235
+ return `data:image/png;base64,${buffer.toString('base64')}`;
236
+ }
237
+
238
+ /**
239
+ * Construye HTML de la muestra impresa según Manual SII
240
+ * @private
241
+ */
242
+ _buildHtml({ doc, esCedible, tedDataUri }) {
243
+ const tipoNombre = NOMBRES_DTE_IMPRESOS[doc.tipoDte] || `DTE ${doc.tipoDte}`;
244
+ const puedeSerCedible = CEDIBLE_TIPOS.has(doc.tipoDte);
245
+ const esNota = NO_CEDIBLE_TIPOS.has(doc.tipoDte);
246
+
247
+ // Guía con traslado interno no tiene cedible
248
+ const esGuiaInterna = doc.tipoDte === 52 && [5, 6].includes(doc.indTraslado);
249
+ const mostrarCedible = esCedible && puedeSerCedible && !esGuiaInterna;
250
+ const cedibleTexto = doc.tipoDte === 52 ? 'CEDIBLE CON SU FACTURA' : 'CEDIBLE';
251
+
252
+ const emisor = doc.emisor || {};
253
+ const receptor = doc.receptor || {};
254
+ const totales = doc.totales || {};
255
+
256
+ // Detalle con descuentos
257
+ const detallesHtml = doc.detalle.map((item, idx) => {
258
+ const descuento = item?.DescuentoMonto || item?.DescuentoPct ?
259
+ `<br><small>Dcto: ${item.DescuentoMonto ? '$' + formatMonto(item.DescuentoMonto) : item.DescuentoPct + '%'}</small>` : '';
260
+ return `
261
+ <tr>
262
+ <td>${idx + 1}</td>
263
+ <td>${safeText(item?.CdgItem?.VlrCodigo || item?.CdgItem || '')}</td>
264
+ <td>${safeText(item?.NmbItem || '')}${descuento}</td>
265
+ <td class="num">${item?.QtyItem ?? ''}</td>
266
+ <td>${safeText(item?.UnmdItem || 'UN')}</td>
267
+ <td class="num">${item?.PrcItem ? '$' + formatMonto(item.PrcItem) : ''}</td>
268
+ <td class="num">${item?.MontoItem ? '$' + formatMonto(item.MontoItem) : ''}</td>
269
+ </tr>
270
+ `;
271
+ }).join('');
272
+
273
+ // Referencias
274
+ const refsHtml = doc.referencias.length
275
+ ? `
276
+ <div class="seccion">
277
+ <div class="seccion-titulo">Referencias a otros documentos</div>
278
+ <table class="refs">
279
+ <thead>
280
+ <tr><th>Tipo Documento</th><th>Folio</th><th>Fecha</th><th>Razón Referencia</th></tr>
281
+ </thead>
282
+ <tbody>
283
+ ${doc.referencias.map((ref) => {
284
+ const tipoRef = ref?.TpoDocRef ? (NOMBRES_DTE_IMPRESOS[ref.TpoDocRef] || `Tipo ${ref.TpoDocRef}`) : '';
285
+ return `
286
+ <tr>
287
+ <td>${safeText(tipoRef)}</td>
288
+ <td>${safeText(ref?.FolioRef || '')}</td>
289
+ <td>${safeText(ref?.FchRef || '')}</td>
290
+ <td>${safeText(ref?.RazonRef || '')}</td>
291
+ </tr>
292
+ `;
293
+ }).join('')}
294
+ </tbody>
295
+ </table>
296
+ </div>
297
+ `
298
+ : '';
299
+
300
+ // Tipo de traslado para guías
301
+ const trasladoHtml = doc.tipoDte === 52 && doc.indTraslado
302
+ ? `<div class="traslado"><strong>Tipo de Traslado:</strong> ${doc.indTraslado} - ${NOMBRES_TRASLADO[doc.indTraslado] || ''}</div>`
303
+ : '';
304
+
305
+ // Descuentos globales
306
+ const dctoGlobalHtml = doc.descuentosGlobales.length
307
+ ? doc.descuentosGlobales.map((dg) => {
308
+ const esDesc = dg?.TpoMov === 'D';
309
+ const label = esDesc ? 'Descuento Global' : 'Recargo Global';
310
+ const valor = dg?.ValorDR ? '$' + formatMonto(dg.ValorDR) : (dg?.PctDR ? dg.PctDR + '%' : '');
311
+ return `<tr><td>${label}</td><td class="num">${valor}</td></tr>`;
312
+ }).join('')
313
+ : '';
314
+
315
+ // Totales según tipo de documento
316
+ let totalesHtml = '';
317
+ const esExenta = doc.tipoDte === 34;
318
+
319
+ if (esExenta) {
320
+ totalesHtml = `
321
+ ${totales?.MntExe ? `<tr><td>Monto Exento</td><td class="num">$${formatMonto(totales.MntExe)}</td></tr>` : ''}
322
+ ${totales?.MntTotal ? `<tr><td><strong>Monto Total</strong></td><td class="num"><strong>$${formatMonto(totales.MntTotal)}</strong></td></tr>` : ''}
323
+ `;
324
+ } else {
325
+ totalesHtml = `
326
+ ${dctoGlobalHtml}
327
+ ${totales?.MntNeto ? `<tr><td>Monto Neto</td><td class="num">$${formatMonto(totales.MntNeto)}</td></tr>` : ''}
328
+ ${totales?.MntExe ? `<tr><td>Monto Exento</td><td class="num">$${formatMonto(totales.MntExe)}</td></tr>` : ''}
329
+ ${totales?.IVA ? `<tr><td>IVA (${totales?.TasaIVA || 19}%)</td><td class="num">$${formatMonto(totales.IVA)}</td></tr>` : ''}
330
+ ${totales?.MntTotal ? `<tr><td><strong>Monto Total</strong></td><td class="num"><strong>$${formatMonto(totales.MntTotal)}</strong></td></tr>` : ''}
331
+ `;
332
+ }
333
+
334
+ // Acuse de recibo (solo en cedible y tipos que aplican)
335
+ const acuseHtml = mostrarCedible && !esNota
336
+ ? `
337
+ <div class="acuse">
338
+ <table class="acuse-tabla">
339
+ <tr>
340
+ <td width="50%">Nombre: _______________________________</td>
341
+ <td width="50%">R.U.T.: _______________________________</td>
342
+ </tr>
343
+ <tr>
344
+ <td>Fecha: _______________________________</td>
345
+ <td>Recinto: _______________________________</td>
346
+ </tr>
347
+ <tr>
348
+ <td colspan="2">Firma: _______________________________</td>
349
+ </tr>
350
+ </table>
351
+ <div class="acuse-leyenda">${DECLARACION_RECIBO}</div>
352
+ </div>
353
+ `
354
+ : '';
355
+
356
+ return `
357
+ <!doctype html>
358
+ <html lang="es">
359
+ <head>
360
+ <meta charset="utf-8" />
361
+ <style>
362
+ @page { size: 215mm 280mm; margin: 10mm; }
363
+ * { box-sizing: border-box; }
364
+ body { font-family: Arial, sans-serif; font-size: 10px; color: #000; margin: 0; padding: 0; line-height: 1.3; }
365
+
366
+ .header { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 10px; }
367
+ .emisor-info { flex: 1; }
368
+ .emisor-info .razon-social { font-size: 12px; font-weight: bold; margin-bottom: 3px; }
369
+ .emisor-info .giro { margin-bottom: 2px; }
370
+ .logo { max-width: 60mm; max-height: 15mm; margin-bottom: 5px; }
371
+
372
+ .recuadro { border: 1.5px solid #C00; padding: 8px 12px; text-align: center; width: 55mm; min-height: 15mm; }
373
+ .recuadro .rut { font-size: 11px; font-weight: bold; color: #C00; }
374
+ .recuadro .tipo { font-size: 10px; font-weight: bold; color: #C00; margin: 4px 0; }
375
+ .recuadro .folio { font-size: 11px; font-weight: bold; color: #C00; }
376
+ .recuadro-sii { font-size: 9px; color: #C00; margin-top: 3px; text-align: center; }
377
+
378
+ .receptor { border: 1px solid #999; padding: 8px; margin: 10px 0; font-size: 9px; }
379
+ .receptor-row { margin: 2px 0; }
380
+ .receptor-label { font-weight: bold; }
381
+
382
+ .fecha-emision { font-size: 10px; margin: 8px 0; }
383
+ .traslado { font-size: 9px; margin: 5px 0; padding: 4px; background: #f5f5f5; }
384
+
385
+ .seccion { margin: 10px 0; }
386
+ .seccion-titulo { font-weight: bold; font-size: 10px; margin-bottom: 5px; border-bottom: 1px solid #000; padding-bottom: 2px; }
387
+
388
+ table { width: 100%; border-collapse: collapse; }
389
+ .detalle th, .detalle td { border: 1px solid #666; padding: 3px 4px; font-size: 9px; }
390
+ .detalle th { background: #e0e0e0; font-weight: bold; }
391
+ .refs th, .refs td { border: 1px solid #999; padding: 2px 4px; font-size: 8px; }
392
+ .refs th { background: #f0f0f0; }
393
+ .num { text-align: right; }
394
+
395
+ .totales-container { display: flex; justify-content: flex-end; margin-top: 10px; }
396
+ .totales { width: 45%; }
397
+ .totales td { padding: 3px 6px; font-size: 10px; border: 1px solid #999; }
398
+
399
+ .acuse { border: 1px solid #000; padding: 8px; margin-top: 15px; font-size: 9px; }
400
+ .acuse-tabla { border: none; }
401
+ .acuse-tabla td { border: none; padding: 4px 0; }
402
+ .acuse-leyenda { margin-top: 8px; font-size: 8px; text-align: justify; border-top: 1px solid #999; padding-top: 5px; }
403
+
404
+ .timbre-container { margin-top: 15px; margin-left: 20mm; display: flex; align-items: flex-start; gap: 10px; }
405
+ .timbre-img { width: 50mm; height: auto; }
406
+ .timbre-text { font-size: 8px; }
407
+ .timbre-text div { margin: 2px 0; }
408
+
409
+ .cedible-marca { font-weight: bold; font-size: 12px; text-align: right; margin-top: 10px; }
410
+ </style>
411
+ </head>
412
+ <body>
413
+ <div class="header">
414
+ <div class="emisor-info">
415
+ ${this.logoDataUri ? `<img class="logo" src="${this.logoDataUri}" />` : ''}
416
+ <div class="razon-social">${safeText(emisor?.RznSoc || emisor?.RznSocEmisor || '')}</div>
417
+ <div class="giro">${safeText(emisor?.GiroEmis || '')}</div>
418
+ <div>Casa Matriz: ${safeText(emisor?.DirOrigen || '')}${emisor?.CmnaOrigen ? ', ' + safeText(emisor.CmnaOrigen) : ''}</div>
419
+ ${emisor?.Sucursal ? `<div>Sucursal: ${safeText(emisor.Sucursal)}</div>` : ''}
420
+ </div>
421
+ <div>
422
+ <div class="recuadro">
423
+ <div class="rut">R.U.T.: ${safeText(emisor?.RUTEmisor || '').replace(/(\d)(?=(\d{3})+(?!\d))/g, '$1.')}</div>
424
+ <div class="tipo">${safeText(tipoNombre)}</div>
425
+ <div class="folio">N° ${safeText(doc.folio)}</div>
426
+ </div>
427
+ <div class="recuadro-sii">${this.siiOficina}</div>
428
+ </div>
429
+ </div>
430
+
431
+ <div class="fecha-emision"><strong>Fecha Emisión:</strong> ${safeText(doc.fechaEmision)}</div>
432
+
433
+ <div class="receptor">
434
+ <div class="receptor-row"><span class="receptor-label">Señor(es):</span> ${safeText(receptor?.RznSocRecep || '')} &nbsp;&nbsp;&nbsp; <span class="receptor-label">RUT:</span> ${safeText(receptor?.RUTRecep || '')}</div>
435
+ <div class="receptor-row"><span class="receptor-label">Dirección:</span> ${safeText(receptor?.DirRecep || '')} &nbsp;&nbsp;&nbsp; <span class="receptor-label">Comuna:</span> ${safeText(receptor?.CmnaRecep || '')}</div>
436
+ <div class="receptor-row"><span class="receptor-label">Giro:</span> ${safeText(receptor?.GiroRecep || '')}</div>
437
+ </div>
438
+
439
+ ${trasladoHtml}
440
+ ${refsHtml}
441
+
442
+ <div class="seccion">
443
+ <div class="seccion-titulo">Detalle</div>
444
+ <table class="detalle">
445
+ <thead>
446
+ <tr>
447
+ <th width="5%">#</th>
448
+ <th width="10%">Código</th>
449
+ <th width="40%">Descripción</th>
450
+ <th width="10%">Cant.</th>
451
+ <th width="8%">Unid.</th>
452
+ <th width="12%">P.Unit.</th>
453
+ <th width="15%">Valor</th>
454
+ </tr>
455
+ </thead>
456
+ <tbody>
457
+ ${detallesHtml}
458
+ </tbody>
459
+ </table>
460
+ </div>
461
+
462
+ <div class="totales-container">
463
+ <table class="totales">
464
+ <tbody>
465
+ ${totalesHtml}
466
+ </tbody>
467
+ </table>
468
+ </div>
469
+
470
+ ${acuseHtml}
471
+
472
+ <div class="timbre-container">
473
+ <img class="timbre-img" src="${tedDataUri}" />
474
+ <div class="timbre-text">
475
+ <div><strong>Timbre Electrónico SII</strong></div>
476
+ <div>${this.resolucion}</div>
477
+ <div>Verifique documento: www.sii.cl</div>
478
+ </div>
479
+ </div>
480
+
481
+ ${mostrarCedible ? `<div class="cedible-marca">${cedibleTexto}</div>` : ''}
482
+ </body>
483
+ </html>
484
+ `;
485
+ }
486
+
487
+ /**
488
+ * Genera PDF desde HTML
489
+ * @private
490
+ */
491
+ async _generarPdf({ html, outputPath, browser }) {
492
+ const page = await browser.newPage();
493
+ await page.setContent(html, { waitUntil: 'networkidle0' });
494
+ await page.pdf({
495
+ path: outputPath,
496
+ printBackground: true,
497
+ width: '215mm',
498
+ height: '280mm',
499
+ margin: { top: '5mm', right: '5mm', bottom: '5mm', left: '5mm' },
500
+ });
501
+ await page.close();
502
+ }
503
+
504
+ /**
505
+ * Genera muestras impresas desde archivos XML
506
+ * @param {Object} options
507
+ * @param {string[]} options.xmlFiles - Array de rutas a archivos XML
508
+ * @param {string} options.outDir - Directorio de salida
509
+ * @param {boolean} [options.generarCedible=false] - Generar también copias cedibles
510
+ * @returns {Promise<Object>} Resultado con estadísticas y archivos generados
511
+ */
512
+ async generarMuestras({ xmlFiles, outDir, generarCedible = false }) {
513
+ fs.mkdirSync(outDir, { recursive: true });
514
+
515
+ // Crear subdirectorios según requisitos del SII
516
+ const pruebasDir = path.join(outDir, 'SET-PRUEBAS');
517
+ const simulacionDir = path.join(outDir, 'SET-SIMULACION');
518
+ fs.mkdirSync(pruebasDir, { recursive: true });
519
+ fs.mkdirSync(simulacionDir, { recursive: true });
520
+
521
+ console.log('\n' + '═'.repeat(60));
522
+ console.log('📄 GENERACIÓN DE MUESTRAS IMPRESAS');
523
+ console.log('═'.repeat(60));
524
+ console.log(` 📂 SET-PRUEBAS: ${pruebasDir}`);
525
+ console.log(` 📂 SET-SIMULACION: ${simulacionDir}`);
526
+
527
+ const browser = await puppeteer.launch({ headless: true });
528
+ const resultado = {
529
+ success: true,
530
+ totalDocs: 0,
531
+ totalPdfs: 0,
532
+ archivos: [],
533
+ errores: [],
534
+ setPruebas: 0,
535
+ setSimulacion: 0,
536
+ };
537
+
538
+ try {
539
+ for (const filePath of xmlFiles) {
540
+ console.log(`\n 📄 Procesando: ${path.basename(filePath)}`);
541
+ const xml = fs.readFileSync(filePath, 'utf8');
542
+
543
+ let documentos;
544
+ try {
545
+ documentos = this.parseEnvioDTE(xml);
546
+ } catch (e) {
547
+ console.log(` ⚠️ Error parseando: ${e.message}`);
548
+ resultado.errores.push({ file: filePath, error: e.message });
549
+ continue;
550
+ }
551
+
552
+ if (!documentos.length) {
553
+ console.log(' ⚠️ Sin documentos');
554
+ continue;
555
+ }
556
+
557
+ // Determinar directorio de salida según archivo fuente
558
+ const sourceFile = path.basename(filePath).toLowerCase();
559
+ const isPruebas = /envio-set-(basico|guia|exenta|compra)\.xml/i.test(sourceFile);
560
+ const targetDir = isPruebas ? pruebasDir : simulacionDir;
561
+ const categoria = isPruebas ? 'PRUEBAS' : 'SIMULACION';
562
+ console.log(` 📁 Categoría: SET-${categoria}`);
563
+
564
+ for (const doc of documentos) {
565
+ resultado.totalDocs++;
566
+
567
+ try {
568
+ const tedDataUri = await this.generarPdf417(doc.tedXml);
569
+
570
+ // Generar ejemplar tributario (sin cedible)
571
+ const html = this._buildHtml({ doc, esCedible: false, tedDataUri });
572
+
573
+ // PDFs organizados en subdirectorios según categoría SII
574
+ const outputName = `muestra_${doc.tipoDte}_${doc.folio}.pdf`;
575
+ const outputPath = path.join(targetDir, outputName);
576
+
577
+ await this._generarPdf({ html, outputPath, browser });
578
+ resultado.totalPdfs++;
579
+ resultado.archivos.push(outputPath);
580
+ if (isPruebas) resultado.setPruebas++;
581
+ else resultado.setSimulacion++;
582
+ console.log(` ✓ ${outputName}`);
583
+
584
+ // Generar copia cedible si corresponde
585
+ if (generarCedible && CEDIBLE_TIPOS.has(doc.tipoDte)) {
586
+ // Guía de traslado interno no tiene cedible
587
+ if (doc.tipoDte === 52 && [5, 6].includes(doc.indTraslado)) {
588
+ console.log(` ⏭️ Guía traslado interno - sin cedible`);
589
+ continue;
590
+ }
591
+
592
+ const htmlCedible = this._buildHtml({ doc, esCedible: true, tedDataUri });
593
+ const outputNameCedible = `muestra_${doc.tipoDte}_${doc.folio}_cedible.pdf`;
594
+ const outputPathCedible = path.join(targetDir, outputNameCedible);
595
+
596
+ await this._generarPdf({ html: htmlCedible, outputPath: outputPathCedible, browser });
597
+ resultado.totalPdfs++;
598
+ resultado.archivos.push(outputPathCedible);
599
+ if (isPruebas) resultado.setPruebas++;
600
+ else resultado.setSimulacion++;
601
+ console.log(` ✓ ${outputNameCedible}`);
602
+ }
603
+
604
+ } catch (e) {
605
+ console.log(` ❌ Error: ${e.message}`);
606
+ resultado.errores.push({ tipo: doc.tipoDte, folio: doc.folio, error: e.message });
607
+ }
608
+ }
609
+ }
610
+ } finally {
611
+ await browser.close();
612
+ }
613
+
614
+ // Resumen
615
+ console.log('\n' + '═'.repeat(60));
616
+ console.log('✅ MUESTRAS IMPRESAS GENERADAS');
617
+ console.log('═'.repeat(60));
618
+ console.log(` 📄 Documentos procesados: ${resultado.totalDocs}`);
619
+ console.log(` 📄 PDFs generados: ${resultado.totalPdfs}`);
620
+ console.log(` 📂 SET-PRUEBAS: ${resultado.setPruebas} PDFs`);
621
+ console.log(` 📂 SET-SIMULACION: ${resultado.setSimulacion} PDFs`);
622
+ console.log(` 📂 Directorio base: ${outDir}`);
623
+
624
+ if (resultado.errores.length > 0) {
625
+ resultado.success = false;
626
+ console.log(` ⚠️ Errores: ${resultado.errores.length}`);
627
+ }
628
+
629
+ return resultado;
630
+ }
631
+
632
+ /**
633
+ * Busca archivos XML recursivamente en una carpeta
634
+ * Filtra solo envíos que contienen DTEs (no respuestas de intercambio)
635
+ * @param {string} inputPath - Ruta a archivo o carpeta
636
+ * @returns {string[]} Array de rutas a archivos XML
637
+ */
638
+ static buscarXmls(inputPath) {
639
+ if (!inputPath) return [];
640
+ const resolved = path.resolve(inputPath);
641
+ if (!fs.existsSync(resolved)) return [];
642
+ const stats = fs.statSync(resolved);
643
+ if (stats.isFile()) return [resolved];
644
+ if (stats.isDirectory()) {
645
+ const files = [];
646
+ const scanDir = (dir) => {
647
+ const items = fs.readdirSync(dir);
648
+ for (const item of items) {
649
+ const fullPath = path.join(dir, item);
650
+ const stat = fs.statSync(fullPath);
651
+ if (stat.isDirectory()) {
652
+ scanDir(fullPath);
653
+ } else if (item.toLowerCase().endsWith('.xml')) {
654
+ const lowerItem = item.toLowerCase();
655
+ // Incluir: envio-set-*, envio-simulacion, EnvioBOLETA
656
+ // Excluir: respuesta-*, envio-recibos (intercambio sin DTEs)
657
+ const isEnvioDTE = (
658
+ lowerItem.includes('envio-set-') ||
659
+ lowerItem.includes('envio-simulacion') ||
660
+ lowerItem.includes('envioboleta') ||
661
+ lowerItem === 'envioboleta.xml'
662
+ );
663
+ if (isEnvioDTE) {
664
+ files.push(fullPath);
665
+ }
666
+ }
667
+ }
668
+ };
669
+ scanDir(resolved);
670
+ return files;
671
+ }
672
+ return [];
673
+ }
674
+ }
675
+
676
+ module.exports = MuestrasImpresas;