@devlas/dte-sii 2.9.8 → 2.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/EnviadorSII.js +100 -64
- package/LICENSE +27 -27
- package/LibroCompraVenta.js +141 -17
- package/LibroGuia.js +36 -25
- package/SiiCertificacion.js +85 -3
- package/SiiPortalAuth.js +85 -19
- package/WsReclamo.js +434 -434
- package/cert/BoletaCert.js +41 -4
- package/cert/CertRunner.js +1123 -1209
- package/cert/LibroCompras.js +3 -2
- package/cert/LibroGuias.js +2 -1
- package/cert/LibroVentas.js +2 -1
- package/cert/MuestrasImpresas.js +831 -131
- package/cert/comunaOficina.js +458 -458
- package/cert/index.js +122 -122
- package/cert/types.js +328 -328
- package/package.json +2 -3
- package/test-muestras.js +180 -0
- package/test-qdetestlibro.js +174 -0
- package/utils/progress.js +4 -0
- package/utils/browser.js +0 -79
package/EnviadorSII.js
CHANGED
|
@@ -12,8 +12,9 @@
|
|
|
12
12
|
* - Consulta de estado de envíos
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
|
-
const https
|
|
16
|
-
const
|
|
15
|
+
const https = require('https');
|
|
16
|
+
const crypto = require('crypto');
|
|
17
|
+
const forge = require('node-forge');
|
|
17
18
|
const FormData = require('form-data');
|
|
18
19
|
const {
|
|
19
20
|
saveEnvioArtifacts,
|
|
@@ -84,6 +85,49 @@ class EnviadorSII {
|
|
|
84
85
|
};
|
|
85
86
|
}
|
|
86
87
|
|
|
88
|
+
// ============================================
|
|
89
|
+
// TLS helper — remplaza fetch() para endpoints SII
|
|
90
|
+
// ============================================
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* POST HTTPS a un endpoint SII con opciones TLS compatibles (rejectUnauthorized:false,
|
|
94
|
+
* TLSv1.2 max, legacy renegotiation). Reemplaza fetch() que usa undici y no acepta
|
|
95
|
+
* estas opciones, causando EPROTO/alert 48 "unknown_ca" en maullin/palena.
|
|
96
|
+
* @param {string} urlStr
|
|
97
|
+
* @param {{ headers?: Record<string,string>, body?: Buffer|string, timeoutMs?: number }} opts
|
|
98
|
+
* @returns {Promise<{ ok: boolean, status: number, text: string }>}
|
|
99
|
+
*/
|
|
100
|
+
_siiPost(urlStr, { headers = {}, body = '', timeoutMs = 30000 } = {}) {
|
|
101
|
+
return new Promise((resolve, reject) => {
|
|
102
|
+
const url = new URL(urlStr);
|
|
103
|
+
const buf = Buffer.isBuffer(body) ? body : Buffer.from(body, 'latin1');
|
|
104
|
+
const opts = {
|
|
105
|
+
hostname: url.hostname,
|
|
106
|
+
port: url.port || 443,
|
|
107
|
+
path: url.pathname + url.search,
|
|
108
|
+
method: 'POST',
|
|
109
|
+
headers: { 'Content-Length': buf.length, ...headers },
|
|
110
|
+
rejectUnauthorized: false,
|
|
111
|
+
maxVersion: 'TLSv1.2',
|
|
112
|
+
secureOptions:
|
|
113
|
+
crypto.constants.SSL_OP_ALLOW_UNSAFE_LEGACY_RENEGOTIATION |
|
|
114
|
+
crypto.constants.SSL_OP_LEGACY_SERVER_CONNECT,
|
|
115
|
+
};
|
|
116
|
+
const req = https.request(opts, (res) => {
|
|
117
|
+
const chunks = [];
|
|
118
|
+
res.on('data', (c) => chunks.push(c));
|
|
119
|
+
res.on('end', () => {
|
|
120
|
+
const text = Buffer.concat(chunks).toString('latin1');
|
|
121
|
+
resolve({ ok: res.statusCode >= 200 && res.statusCode < 300, status: res.statusCode, text });
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
req.on('error', reject);
|
|
125
|
+
req.setTimeout(timeoutMs, () => { req.destroy(); reject(new Error(`_siiPost timeout: ${urlStr}`)); });
|
|
126
|
+
req.write(buf);
|
|
127
|
+
req.end();
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
|
|
87
131
|
// ============================================
|
|
88
132
|
// AUTENTICACIÓN REST (Boletas)
|
|
89
133
|
// ============================================
|
|
@@ -192,24 +236,17 @@ class EnviadorSII {
|
|
|
192
236
|
</soapenv:Envelope>`;
|
|
193
237
|
|
|
194
238
|
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
|
195
|
-
const controller = new AbortController();
|
|
196
|
-
const soapTimeout = setTimeout(() => controller.abort(), getConfigSection('timeout')?.soap || 30000);
|
|
197
239
|
try {
|
|
198
240
|
if (attempt > 1) {
|
|
199
241
|
log.log(` [...] Reintento semilla SOAP ${attempt}/${maxRetries}...`);
|
|
200
242
|
await wait(attempt * 1000);
|
|
201
243
|
}
|
|
202
244
|
|
|
203
|
-
const response = await
|
|
204
|
-
|
|
205
|
-
headers: {
|
|
206
|
-
'Content-Type': 'text/xml; charset=utf-8',
|
|
207
|
-
'SOAPAction': '',
|
|
208
|
-
},
|
|
245
|
+
const response = await this._siiPost(url, {
|
|
246
|
+
headers: { 'Content-Type': 'text/xml; charset=utf-8', 'SOAPAction': '' },
|
|
209
247
|
body: soapEnvelope,
|
|
210
|
-
|
|
248
|
+
timeoutMs: getConfigSection('timeout')?.soap || 30000,
|
|
211
249
|
});
|
|
212
|
-
clearTimeout(soapTimeout);
|
|
213
250
|
|
|
214
251
|
if (!response.ok) {
|
|
215
252
|
if (isRetryableStatus(response.status) && attempt < maxRetries) {
|
|
@@ -219,7 +256,7 @@ class EnviadorSII {
|
|
|
219
256
|
throw siiError(`Error obteniendo semilla SOAP: ${response.status}`, ERROR_CODES.SII_CONNECTION_FAILED);
|
|
220
257
|
}
|
|
221
258
|
|
|
222
|
-
const xml =
|
|
259
|
+
const xml = response.text;
|
|
223
260
|
// Usar utilidad centralizada para decodificar entidades
|
|
224
261
|
const decodedXml = decodeXmlEntities(xml);
|
|
225
262
|
|
|
@@ -236,9 +273,8 @@ class EnviadorSII {
|
|
|
236
273
|
|
|
237
274
|
throw siiError('No se pudo extraer semilla de la respuesta SOAP', ERROR_CODES.SII_INVALID_RESPONSE);
|
|
238
275
|
} catch (error) {
|
|
239
|
-
clearTimeout(soapTimeout);
|
|
240
276
|
if (isRetryableError(error) && attempt < maxRetries) {
|
|
241
|
-
log.log(` [!] Error de conexión semilla SOAP (${error.cause?.code || 'socket'}), reintentando...`);
|
|
277
|
+
log.log(` [!] Error de conexión semilla SOAP (${error.code || error.cause?.code || 'socket'}), reintentando...`);
|
|
242
278
|
continue;
|
|
243
279
|
}
|
|
244
280
|
throw error;
|
|
@@ -269,8 +305,6 @@ class EnviadorSII {
|
|
|
269
305
|
const url = this.urls[this.ambiente].tokenSoap;
|
|
270
306
|
|
|
271
307
|
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
|
272
|
-
const controller = new AbortController();
|
|
273
|
-
const soapTimeout = setTimeout(() => controller.abort(), getConfigSection('timeout')?.soap || 30000);
|
|
274
308
|
try {
|
|
275
309
|
if (attempt > 1) {
|
|
276
310
|
log.log(` [...] Reintento token SOAP ${attempt}/${maxRetries}...`);
|
|
@@ -289,27 +323,21 @@ class EnviadorSII {
|
|
|
289
323
|
</soapenv:Body>
|
|
290
324
|
</soapenv:Envelope>`;
|
|
291
325
|
|
|
292
|
-
const response = await
|
|
293
|
-
|
|
294
|
-
headers: {
|
|
295
|
-
'Content-Type': 'text/xml; charset=utf-8',
|
|
296
|
-
'SOAPAction': '',
|
|
297
|
-
},
|
|
326
|
+
const response = await this._siiPost(url, {
|
|
327
|
+
headers: { 'Content-Type': 'text/xml; charset=utf-8', 'SOAPAction': '' },
|
|
298
328
|
body: soapEnvelope,
|
|
299
|
-
|
|
329
|
+
timeoutMs: getConfigSection('timeout')?.soap || 30000,
|
|
300
330
|
});
|
|
301
|
-
clearTimeout(soapTimeout);
|
|
302
331
|
|
|
303
332
|
if (!response.ok) {
|
|
304
|
-
const errorText = await response.text();
|
|
305
333
|
if (isRetryableStatus(response.status) && attempt < maxRetries) {
|
|
306
334
|
log.log(` [!] Error token SOAP (${response.status}), reintentando...`);
|
|
307
335
|
continue;
|
|
308
336
|
}
|
|
309
|
-
throw siiError(`Error obteniendo token SOAP: ${response.status} - ${
|
|
337
|
+
throw siiError(`Error obteniendo token SOAP: ${response.status} - ${response.text}`, ERROR_CODES.SII_CONNECTION_FAILED);
|
|
310
338
|
}
|
|
311
339
|
|
|
312
|
-
const xml =
|
|
340
|
+
const xml = response.text;
|
|
313
341
|
const decodedXml = xml
|
|
314
342
|
.replace(/</g, '<')
|
|
315
343
|
.replace(/>/g, '>')
|
|
@@ -337,9 +365,8 @@ class EnviadorSII {
|
|
|
337
365
|
|
|
338
366
|
throw siiError('No se pudo obtener token SOAP del SII', ERROR_CODES.SII_INVALID_RESPONSE);
|
|
339
367
|
} catch (error) {
|
|
340
|
-
clearTimeout(soapTimeout);
|
|
341
368
|
if (isRetryableError(error) && attempt < maxRetries) {
|
|
342
|
-
log.log(` [!] Error de conexión token SOAP (${error.cause?.code || 'socket'}), reintentando...`);
|
|
369
|
+
log.log(` [!] Error de conexión token SOAP (${error.code || error.cause?.code || 'socket'}), reintentando...`);
|
|
343
370
|
continue;
|
|
344
371
|
}
|
|
345
372
|
throw error;
|
|
@@ -766,19 +793,14 @@ class EnviadorSII {
|
|
|
766
793
|
log.log('Consultando estado SOAP:', urlQueryEstUp);
|
|
767
794
|
log.log(' TrackID:', trackId, 'RUT:', rutEmisor);
|
|
768
795
|
|
|
769
|
-
const response = await
|
|
770
|
-
|
|
771
|
-
headers: {
|
|
772
|
-
'Content-Type': 'text/xml; charset=utf-8',
|
|
773
|
-
'SOAPAction': '',
|
|
774
|
-
},
|
|
796
|
+
const response = await this._siiPost(urlQueryEstUp, {
|
|
797
|
+
headers: { 'Content-Type': 'text/xml; charset=utf-8', 'SOAPAction': '' },
|
|
775
798
|
body: soapBody,
|
|
799
|
+
timeoutMs: 30000,
|
|
776
800
|
});
|
|
777
|
-
|
|
778
|
-
const text = await response.text();
|
|
779
|
-
|
|
801
|
+
|
|
780
802
|
// Usar decodeXmlEntities centralizado
|
|
781
|
-
const decoded = decodeXmlEntities(text).replace(/
/g, '\n');
|
|
803
|
+
const decoded = decodeXmlEntities(response.text).replace(/
/g, '\n');
|
|
782
804
|
|
|
783
805
|
|
|
784
806
|
|
|
@@ -820,6 +842,10 @@ class EnviadorSII {
|
|
|
820
842
|
mensaje = '[!] Aceptado con Reparos';
|
|
821
843
|
esExitoso = true;
|
|
822
844
|
break;
|
|
845
|
+
case 'LOK':
|
|
846
|
+
mensaje = `[OK] Envio de Libro Aceptado - Cuadrado: ${glosa || ''}`.trim();
|
|
847
|
+
esExitoso = true;
|
|
848
|
+
break;
|
|
823
849
|
case 'REC':
|
|
824
850
|
mensaje = '[...] Envío Recibido - Esperando validación';
|
|
825
851
|
esIntermedio = true;
|
|
@@ -877,6 +903,20 @@ class EnviadorSII {
|
|
|
877
903
|
mensaje = '[...] Envío en Proceso - Validando...';
|
|
878
904
|
esIntermedio = true;
|
|
879
905
|
break;
|
|
906
|
+
case 'LNC':
|
|
907
|
+
mensaje = `[ERR] Tipo de Envio de Libro No Corresponde: ${glosa || 'Período ya tiene LTC'}`;
|
|
908
|
+
esRechazado = true;
|
|
909
|
+
break;
|
|
910
|
+
case 'LRH':
|
|
911
|
+
mensaje = `[ERR] Libro Rechazado con Historial: ${glosa || 'Rechazo con registro'}`;
|
|
912
|
+
esRechazado = true;
|
|
913
|
+
break;
|
|
914
|
+
case 'LSO':
|
|
915
|
+
// Schema de Envio de Libro Correcto: schema validado, SII aún no procesó el contenido.
|
|
916
|
+
// Es un estado INTERMEDIO — el libro no está en LTC todavía.
|
|
917
|
+
mensaje = '[...] Schema de Libro Correcto - Procesando contenido...';
|
|
918
|
+
esIntermedio = true;
|
|
919
|
+
break;
|
|
880
920
|
// Códigos numéricos negativos = errores del servicio de consulta SII (NO rechazo del documento)
|
|
881
921
|
// Según doc SII: son errores del sistema de consulta, el documento puede estar OK
|
|
882
922
|
case '-11':
|
|
@@ -1236,15 +1276,7 @@ class EnviadorSII {
|
|
|
1236
1276
|
const backoffMultiplier = retryConfig?.backoffMultiplier || 1.8;
|
|
1237
1277
|
let lastError = null;
|
|
1238
1278
|
|
|
1239
|
-
// Calcular Content-Length según tipo de body
|
|
1240
|
-
const contentLength = Buffer.isBuffer(body)
|
|
1241
|
-
? body.length.toString()
|
|
1242
|
-
: Buffer.byteLength(body).toString();
|
|
1243
|
-
|
|
1244
1279
|
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
|
1245
|
-
const controller = new AbortController();
|
|
1246
|
-
const timeout = setTimeout(() => controller.abort(), 60000);
|
|
1247
|
-
|
|
1248
1280
|
try {
|
|
1249
1281
|
if (attempt > 1) {
|
|
1250
1282
|
const delay = Math.min(initialDelay * Math.pow(backoffMultiplier, attempt - 2), 15000);
|
|
@@ -1252,28 +1284,23 @@ class EnviadorSII {
|
|
|
1252
1284
|
await new Promise(resolve => setTimeout(resolve, delay));
|
|
1253
1285
|
}
|
|
1254
1286
|
|
|
1255
|
-
const response = await
|
|
1256
|
-
method: 'POST',
|
|
1287
|
+
const response = await this._siiPost(url, {
|
|
1257
1288
|
headers: {
|
|
1258
1289
|
'Cookie': `TOKEN=${this.tokenSoap}`,
|
|
1259
1290
|
'Content-Type': `multipart/form-data; boundary=${boundary}`,
|
|
1260
1291
|
'User-Agent': 'Mozilla/4.0 ( compatible; PROG 1.0; Windows NT)',
|
|
1261
1292
|
'Accept': '*/*',
|
|
1262
1293
|
'Connection': 'keep-alive',
|
|
1263
|
-
'Content-Length': contentLength,
|
|
1264
1294
|
},
|
|
1265
1295
|
body: body,
|
|
1266
|
-
|
|
1296
|
+
timeoutMs: 60000,
|
|
1267
1297
|
});
|
|
1268
|
-
clearTimeout(timeout);
|
|
1269
1298
|
|
|
1270
|
-
const
|
|
1271
|
-
|
|
1272
|
-
const result = this._parsearRespuestaSoap(responseText, response.ok, response.status, tipoEnvio);
|
|
1299
|
+
const result = this._parsearRespuestaSoap(response.text, response.ok, response.status, tipoEnvio);
|
|
1273
1300
|
|
|
1274
1301
|
saveEnvioArtifacts({
|
|
1275
1302
|
xml,
|
|
1276
|
-
responseText,
|
|
1303
|
+
responseText: response.text,
|
|
1277
1304
|
responseOk: response.ok,
|
|
1278
1305
|
responseStatus: response.status,
|
|
1279
1306
|
trackId: result.trackId,
|
|
@@ -1283,27 +1310,26 @@ class EnviadorSII {
|
|
|
1283
1310
|
|
|
1284
1311
|
return result;
|
|
1285
1312
|
} catch (fetchError) {
|
|
1286
|
-
clearTimeout(timeout);
|
|
1287
1313
|
lastError = fetchError;
|
|
1288
|
-
|
|
1314
|
+
|
|
1289
1315
|
// Usar isRetryableError centralizado de utils
|
|
1290
1316
|
if (isRetryableError(fetchError) && attempt < maxRetries) {
|
|
1291
|
-
log.log(` [!] Error de conexión ${tipoEnvio} (${fetchError.cause?.code || 'socket'}), reintentando...`);
|
|
1317
|
+
log.log(` [!] Error de conexión ${tipoEnvio} (${fetchError.code || fetchError.cause?.code || 'socket'}), reintentando...`);
|
|
1292
1318
|
continue;
|
|
1293
1319
|
}
|
|
1294
|
-
|
|
1320
|
+
|
|
1295
1321
|
saveEnvioArtifacts({
|
|
1296
1322
|
xml,
|
|
1297
|
-
responseText: `
|
|
1323
|
+
responseText: `ERROR: ${fetchError.message}`,
|
|
1298
1324
|
responseOk: false,
|
|
1299
1325
|
responseStatus: null,
|
|
1300
1326
|
trackId: null,
|
|
1301
1327
|
ambiente: this.ambiente,
|
|
1302
1328
|
tipoEnvio,
|
|
1303
|
-
error: fetchError
|
|
1329
|
+
error: fetchError,
|
|
1304
1330
|
});
|
|
1305
1331
|
|
|
1306
|
-
log.error('
|
|
1332
|
+
log.error('Error en envío SII:', fetchError.message);
|
|
1307
1333
|
throw fetchError;
|
|
1308
1334
|
}
|
|
1309
1335
|
}
|
|
@@ -1383,6 +1409,16 @@ class EnviadorSII {
|
|
|
1383
1409
|
};
|
|
1384
1410
|
}
|
|
1385
1411
|
|
|
1412
|
+
// cvc-*: error de validación XSD (p.ej. elemento fuera de orden, tipo incorrecto)
|
|
1413
|
+
// El SII puede retornar status 7 para errores XSD sin prefijo SCH-00001
|
|
1414
|
+
if (detailMsg.includes('cvc-')) {
|
|
1415
|
+
return {
|
|
1416
|
+
ok: false, status,
|
|
1417
|
+
error: `XML rechazado por SII: error de validación XSD. Detail: ${detailMsg}`,
|
|
1418
|
+
respuesta: responseText, duplicado: false,
|
|
1419
|
+
};
|
|
1420
|
+
}
|
|
1421
|
+
|
|
1386
1422
|
// Duplicado real: SII ya recibió este SetDTE ID
|
|
1387
1423
|
if (trackId) {
|
|
1388
1424
|
return { ok: true, status, trackId, archivo: fileName, mensaje: `[DUPLICADO] TrackID recuperado: ${trackId}`, respuesta: responseText };
|
package/LICENSE
CHANGED
|
@@ -1,27 +1,27 @@
|
|
|
1
|
-
MIT License
|
|
2
|
-
|
|
3
|
-
Copyright (c) 2026 Devlas SpA — https://devlas.cl
|
|
4
|
-
|
|
5
|
-
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
-
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
-
in the Software without restriction, including without limitation the rights
|
|
8
|
-
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
-
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
-
furnished to do so, subject to the following conditions:
|
|
11
|
-
|
|
12
|
-
The above copyright notice and this permission notice shall be included in all
|
|
13
|
-
copies or substantial portions of the Software.
|
|
14
|
-
|
|
15
|
-
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
-
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
-
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
-
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
-
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
-
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
-
SOFTWARE.
|
|
22
|
-
|
|
23
|
-
---
|
|
24
|
-
|
|
25
|
-
Esta librería implementa el protocolo XML del Servicio de Impuestos Internos (SII)
|
|
26
|
-
de Chile tal como está documentado públicamente por el propio SII.
|
|
27
|
-
Inspirada conceptualmente en LibreDTE de SASCO SpA (https://libredte.cl).
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Devlas SpA — https://devlas.cl
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
22
|
+
|
|
23
|
+
---
|
|
24
|
+
|
|
25
|
+
Esta librería implementa el protocolo XML del Servicio de Impuestos Internos (SII)
|
|
26
|
+
de Chile tal como está documentado públicamente por el propio SII.
|
|
27
|
+
Inspirada conceptualmente en LibreDTE de SASCO SpA (https://libredte.cl).
|
package/LibroCompraVenta.js
CHANGED
|
@@ -13,6 +13,16 @@ const LibroBase = require('./LibroBase');
|
|
|
13
13
|
class LibroCompraVenta extends LibroBase {
|
|
14
14
|
constructor(certificado) {
|
|
15
15
|
super(certificado);
|
|
16
|
+
this.ltcTotales = null;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Establece los totales del envío LTC (MENSUAL/TOTAL) previo para este período.
|
|
21
|
+
* Cuando se genera un AJUSTE, TotalesPeriodo = LTC + delta actual.
|
|
22
|
+
* @param {Array} totalesLtc - Array de resumen del libro TOTAL (misma estructura que setResumen)
|
|
23
|
+
*/
|
|
24
|
+
setLtcTotales(totalesLtc) {
|
|
25
|
+
this.ltcTotales = totalesLtc || null;
|
|
16
26
|
}
|
|
17
27
|
|
|
18
28
|
setCaratula(caratula) {
|
|
@@ -24,10 +34,11 @@ class LibroCompraVenta extends LibroBase {
|
|
|
24
34
|
if (!this.caratula) {
|
|
25
35
|
throw new Error('Debe establecer la carátula antes de generar');
|
|
26
36
|
}
|
|
27
|
-
|
|
37
|
+
|
|
38
|
+
const tipoEnvio = String(this.caratula?.TipoEnvio || '').toUpperCase();
|
|
28
39
|
const caratulaXml = this._renderCaratula();
|
|
29
40
|
const resumenXml = this._renderResumen();
|
|
30
|
-
const detalleXml = this._renderDetalle();
|
|
41
|
+
const detalleXml = this._renderDetalle(tipoEnvio === 'AJUSTE');
|
|
31
42
|
const envioLibroId = this.id;
|
|
32
43
|
const tmstFirma = this._getTmstFirma();
|
|
33
44
|
|
|
@@ -64,13 +75,53 @@ class LibroCompraVenta extends LibroBase {
|
|
|
64
75
|
|
|
65
76
|
_renderResumen() {
|
|
66
77
|
if (!this.resumen.length) return '';
|
|
67
|
-
|
|
78
|
+
|
|
68
79
|
const tipoEnvio = String(this.caratula?.TipoEnvio || '').toUpperCase();
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
80
|
+
|
|
81
|
+
if (tipoEnvio === 'AJUSTE') {
|
|
82
|
+
// AJUSTE: ResumenSegmento (delta actual) + ResumenPeriodo (LTC acumulado + delta)
|
|
83
|
+
const segmentoXml = this._renderResumenSection('ResumenSegmento', 'TotalesSegmento');
|
|
84
|
+
if (this.ltcTotales && this.ltcTotales.length > 0) {
|
|
85
|
+
// TotalesPeriodo = totales LTC previos + delta de este AJUSTE
|
|
86
|
+
const acumulado = this._acumularResumen(this.ltcTotales, this.resumen);
|
|
87
|
+
const savedResumen = this.resumen;
|
|
88
|
+
this.resumen = acumulado;
|
|
89
|
+
const periodoXml = this._renderResumenSection('ResumenPeriodo', 'TotalesPeriodo');
|
|
90
|
+
this.resumen = savedResumen;
|
|
91
|
+
return segmentoXml + periodoXml;
|
|
92
|
+
}
|
|
93
|
+
// Sin LTC guardado: ambas secciones con los mismos datos (ajuste inicial)
|
|
94
|
+
return segmentoXml +
|
|
95
|
+
this._renderResumenSection('ResumenPeriodo', 'TotalesPeriodo');
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const useSegmento = tipoEnvio === 'PARCIAL';
|
|
99
|
+
return this._renderResumenSection(
|
|
100
|
+
useSegmento ? 'ResumenSegmento' : 'ResumenPeriodo',
|
|
101
|
+
useSegmento ? 'TotalesSegmento' : 'TotalesPeriodo'
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
_renderResumenSection(resumenTag, totalesTag) {
|
|
106
|
+
// TotalesPeriodo y TotalesSegmento tienen ordenamientos XSD distintos para FctProp:
|
|
107
|
+
// Periodo: FctProp viene al final, después de TotImpVehiculo
|
|
108
|
+
// Segmento: FctProp viene antes de TotMntTotal, después de TotIVAUsoComun
|
|
109
|
+
// y no incluye TotImpVehiculo en la secuencia post-TotMntTotal
|
|
110
|
+
const isSegmento = totalesTag === 'TotalesSegmento';
|
|
111
|
+
// TotalesSegmento (AJUSTE/PARCIAL): FctProp/TotCredIVAUsoComun no existen en ese tipo XSD.
|
|
112
|
+
// TotalesPeriodo: orden original validado por el SII — FctProp inmediatamente después de TotIVAUsoComun.
|
|
113
|
+
const order = isSegmento ? [
|
|
114
|
+
'TpoDoc', 'TpoImp', 'TotDoc', 'TotAnulado', 'TotOpExe', 'TotMntExe',
|
|
115
|
+
'TotMntNeto', 'TotOpIVARec', 'TotMntIVA', 'TotOpActivoFijo',
|
|
116
|
+
'TotMntActivoFijo', 'TotMntIVAActivoFijo', 'TotIVANoRec',
|
|
117
|
+
'TotOpIVAUsoComun', 'TotIVAUsoComun',
|
|
118
|
+
'TotIVAFueraPlazo', 'TotIVAPropio', 'TotIVATerceros', 'TotLey18211',
|
|
119
|
+
'TotOtrosImp', 'TotImpSinCredito', 'TotOpIVARetTotal', 'TotIVARetTotal',
|
|
120
|
+
'TotOpIVARetParcial', 'TotIVARetParcial', 'TotCredEC', 'TotDepEnvase',
|
|
121
|
+
'TotLiquidaciones', 'TotMntTotal', 'TotOpIVANoRetenido', 'TotIVANoRetenido',
|
|
122
|
+
'TotMntNoFact', 'TotMntPeriodo', 'TotPsjNac', 'TotPsjInt', 'TotTabPuros',
|
|
123
|
+
'TotTabCigarrillos', 'TotTabElaborado',
|
|
124
|
+
] : [
|
|
74
125
|
'TpoDoc', 'TpoImp', 'TotDoc', 'TotAnulado', 'TotOpExe', 'TotMntExe',
|
|
75
126
|
'TotMntNeto', 'TotOpIVARec', 'TotMntIVA', 'TotOpActivoFijo',
|
|
76
127
|
'TotMntActivoFijo', 'TotMntIVAActivoFijo', 'TotIVANoRec',
|
|
@@ -82,7 +133,7 @@ class LibroCompraVenta extends LibroBase {
|
|
|
82
133
|
'TotMntNoFact', 'TotMntPeriodo', 'TotPsjNac', 'TotPsjInt', 'TotTabPuros',
|
|
83
134
|
'TotTabCigarrillos', 'TotTabElaborado', 'TotImpVehiculo',
|
|
84
135
|
];
|
|
85
|
-
|
|
136
|
+
|
|
86
137
|
let xml = `<${resumenTag}>`;
|
|
87
138
|
for (const r of this.resumen) {
|
|
88
139
|
const normalized = { ...r };
|
|
@@ -94,12 +145,12 @@ class LibroCompraVenta extends LibroBase {
|
|
|
94
145
|
) {
|
|
95
146
|
normalized.TotMntExe = 0;
|
|
96
147
|
}
|
|
97
|
-
|
|
148
|
+
|
|
98
149
|
xml += `<${totalesTag}>`;
|
|
99
150
|
order.forEach((key) => {
|
|
100
151
|
const value = normalized[key];
|
|
101
152
|
if (value === undefined || value === null || value === '') return;
|
|
102
|
-
|
|
153
|
+
|
|
103
154
|
if (Array.isArray(value)) {
|
|
104
155
|
value.forEach((item) => {
|
|
105
156
|
if (item && typeof item === 'object') {
|
|
@@ -132,9 +183,77 @@ class LibroCompraVenta extends LibroBase {
|
|
|
132
183
|
return xml;
|
|
133
184
|
}
|
|
134
185
|
|
|
135
|
-
|
|
186
|
+
/**
|
|
187
|
+
* Acumula dos arrays de resumen (base LTC + delta AJUSTE) sumando campos numéricos por TpoDoc.
|
|
188
|
+
* @private
|
|
189
|
+
*/
|
|
190
|
+
_acumularResumen(base, delta) {
|
|
191
|
+
const map = new Map();
|
|
192
|
+
for (const item of base) {
|
|
193
|
+
map.set(item.TpoDoc, { ...item });
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// Key field per array element type — used to match base vs delta items
|
|
197
|
+
const ARRAY_KEY_FIELDS = {
|
|
198
|
+
TotIVANoRec: 'CodIVANoRec',
|
|
199
|
+
TotOtrosImp: 'CodImp',
|
|
200
|
+
};
|
|
201
|
+
// Rate/factor fields — keep existing value (don't sum), set from delta if absent
|
|
202
|
+
const RATE_KEYS = new Set(['TpoDoc', 'TpoImp', 'FctProp']);
|
|
203
|
+
|
|
204
|
+
// Merge two arrays of sub-objects: sum all numeric fields except the key field
|
|
205
|
+
const _mergeArrayField = (baseArr, deltaArr, keyField) => {
|
|
206
|
+
const m = new Map();
|
|
207
|
+
for (const item of (baseArr || [])) m.set(item[keyField], { ...item });
|
|
208
|
+
for (const item of (deltaArr || [])) {
|
|
209
|
+
if (m.has(item[keyField])) {
|
|
210
|
+
const acc = m.get(item[keyField]);
|
|
211
|
+
for (const [k, v] of Object.entries(item)) {
|
|
212
|
+
if (k === keyField) continue;
|
|
213
|
+
const n = Number(v);
|
|
214
|
+
if (!Number.isNaN(n) && v !== '' && v !== null && v !== undefined) {
|
|
215
|
+
acc[k] = (Number(acc[k] || 0)) + n;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
} else {
|
|
219
|
+
m.set(item[keyField], { ...item });
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
return Array.from(m.values());
|
|
223
|
+
};
|
|
224
|
+
|
|
225
|
+
for (const item of delta) {
|
|
226
|
+
if (map.has(item.TpoDoc)) {
|
|
227
|
+
const acc = map.get(item.TpoDoc);
|
|
228
|
+
for (const [key, val] of Object.entries(item)) {
|
|
229
|
+
if (RATE_KEYS.has(key)) {
|
|
230
|
+
// Keep existing value; propagate from delta only if absent in base
|
|
231
|
+
if (acc[key] === undefined || acc[key] === null) acc[key] = val;
|
|
232
|
+
continue;
|
|
233
|
+
}
|
|
234
|
+
if (Array.isArray(val)) {
|
|
235
|
+
const keyField = ARRAY_KEY_FIELDS[key];
|
|
236
|
+
if (keyField) {
|
|
237
|
+
acc[key] = _mergeArrayField(acc[key], val, keyField);
|
|
238
|
+
}
|
|
239
|
+
continue;
|
|
240
|
+
}
|
|
241
|
+
const numVal = Number(val);
|
|
242
|
+
if (!Number.isNaN(numVal) && val !== '' && val !== null && val !== undefined) {
|
|
243
|
+
acc[key] = (Number(acc[key] || 0)) + numVal;
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
} else {
|
|
247
|
+
map.set(item.TpoDoc, { ...item });
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
return Array.from(map.values());
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// isAjuste: cuando true, agrega Operacion=1 (agrega) a cada ítem que no la tenga
|
|
254
|
+
_renderDetalle(isAjuste = false) {
|
|
136
255
|
if (!this.detalle.length) return '';
|
|
137
|
-
|
|
256
|
+
|
|
138
257
|
const order = [
|
|
139
258
|
'TpoDoc', 'Emisor', 'IndFactCompra', 'NroDoc', 'Anulado', 'Operacion',
|
|
140
259
|
'TpoImp', 'TasaImp', 'NumInt', 'IndServicio', 'IndSinCosto', 'FchDoc',
|
|
@@ -146,11 +265,16 @@ class LibroCompraVenta extends LibroBase {
|
|
|
146
265
|
'MntNoFact', 'MntPeriodo', 'PsjNac', 'PsjInt', 'TabPuros', 'TabCigarrillos',
|
|
147
266
|
'TabElaborado', 'ImpVehiculo',
|
|
148
267
|
];
|
|
149
|
-
|
|
268
|
+
|
|
150
269
|
let xml = '';
|
|
151
270
|
for (const d of this.detalle) {
|
|
152
271
|
const normalized = { ...d };
|
|
153
|
-
|
|
272
|
+
|
|
273
|
+
// AJUSTE: Operacion=1 (agrega) por defecto si no está explícito
|
|
274
|
+
if (isAjuste && (normalized.Operacion === undefined || normalized.Operacion === null || normalized.Operacion === '')) {
|
|
275
|
+
normalized.Operacion = 1;
|
|
276
|
+
}
|
|
277
|
+
|
|
154
278
|
// Calcular TasaImp si no existe
|
|
155
279
|
if (
|
|
156
280
|
(normalized.TasaImp === undefined || normalized.TasaImp === null || normalized.TasaImp === '') &&
|
|
@@ -164,12 +288,12 @@ class LibroCompraVenta extends LibroBase {
|
|
|
164
288
|
normalized.TasaImp = tasa.toFixed(2).replace(/\.00$/, '.0');
|
|
165
289
|
}
|
|
166
290
|
}
|
|
167
|
-
|
|
291
|
+
|
|
168
292
|
xml += '<Detalle>';
|
|
169
293
|
order.forEach((key) => {
|
|
170
294
|
const value = normalized[key];
|
|
171
295
|
if (value === undefined || value === null || value === '') return;
|
|
172
|
-
|
|
296
|
+
|
|
173
297
|
if (Array.isArray(value)) {
|
|
174
298
|
value.forEach((item) => {
|
|
175
299
|
if (item && typeof item === 'object') {
|