Add CFDI XML parser and invoice import services with tests
Implement Task 3 (CFDI Parser) and Task 4 (Invoice Import) following TDD: - CFDI parser extracts UUID, emisor, fecha, totals, and conceptos from CFDI 4.0 XML - Import service inserts parsed invoice data into SQLite with duplicate detection and cross-references catalogo_intercambios for part number lookups - All 12 tests pass (5 parser + 3 import + 4 existing database tests) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
49
src/services/cfdi-parser.js
Normal file
49
src/services/cfdi-parser.js
Normal file
@@ -0,0 +1,49 @@
|
||||
const fs = require('fs');
|
||||
const { XMLParser } = require('fast-xml-parser');
|
||||
|
||||
const parser = new XMLParser({
|
||||
ignoreAttributes: false,
|
||||
attributeNamePrefix: '@_',
|
||||
isArray: (name) => {
|
||||
return name === 'cfdi:Concepto';
|
||||
},
|
||||
});
|
||||
|
||||
function parseCfdiFile(filePath) {
|
||||
const xml = fs.readFileSync(filePath, 'utf-8');
|
||||
return parseCfdiXml(xml);
|
||||
}
|
||||
|
||||
function parseCfdiXml(xml) {
|
||||
const parsed = parser.parse(xml);
|
||||
const comprobante = parsed['cfdi:Comprobante'];
|
||||
|
||||
const emisor = comprobante['cfdi:Emisor'];
|
||||
const conceptosNode = comprobante['cfdi:Conceptos']['cfdi:Concepto'];
|
||||
const complemento = comprobante['cfdi:Complemento'];
|
||||
|
||||
const timbre = complemento['tfd:TimbreFiscalDigital'];
|
||||
const uuid = timbre['@_UUID'];
|
||||
|
||||
const conceptosList = Array.isArray(conceptosNode) ? conceptosNode : [conceptosNode];
|
||||
const conceptos = conceptosList.map((c) => ({
|
||||
claveProdServ: c['@_ClaveProdServ'],
|
||||
noIdentificacion: c['@_NoIdentificacion'] || '',
|
||||
descripcion: c['@_Descripcion'],
|
||||
cantidad: parseFloat(c['@_Cantidad']),
|
||||
valorUnitario: parseFloat(c['@_ValorUnitario']),
|
||||
importe: parseFloat(c['@_Importe']),
|
||||
}));
|
||||
|
||||
return {
|
||||
uuid,
|
||||
rfcEmisor: emisor['@_Rfc'],
|
||||
nombreEmisor: emisor['@_Nombre'],
|
||||
fecha: comprobante['@_Fecha'],
|
||||
subtotal: parseFloat(comprobante['@_SubTotal']),
|
||||
total: parseFloat(comprobante['@_Total']),
|
||||
conceptos,
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { parseCfdiFile, parseCfdiXml };
|
||||
65
src/services/import-service.js
Normal file
65
src/services/import-service.js
Normal file
@@ -0,0 +1,65 @@
|
||||
const { parseCfdiFile } = require('./cfdi-parser');
|
||||
|
||||
function importXmlFile(db, filePath) {
|
||||
let parsed;
|
||||
try {
|
||||
parsed = parseCfdiFile(filePath);
|
||||
} catch (err) {
|
||||
return { success: false, reason: 'parse_error', error: err.message };
|
||||
}
|
||||
|
||||
const existing = db.prepare('SELECT id FROM facturas WHERE uuid = ?').get(parsed.uuid);
|
||||
if (existing) {
|
||||
return { success: false, reason: 'duplicate', uuid: parsed.uuid };
|
||||
}
|
||||
|
||||
const insertFactura = db.prepare(`
|
||||
INSERT INTO facturas (uuid, rfc_emisor, nombre_emisor, fecha, subtotal, total, xml_path)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
`);
|
||||
|
||||
const insertConcepto = db.prepare(`
|
||||
INSERT INTO conceptos (factura_id, clave_prod_serv, no_identificacion, descripcion, cantidad, valor_unitario, importe, intercambio)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`);
|
||||
|
||||
const getIntercambio = db.prepare(
|
||||
'SELECT no_parte_intercambio FROM catalogo_intercambios WHERE no_parte_proveedor = ?'
|
||||
);
|
||||
|
||||
const transaction = db.transaction(() => {
|
||||
const { lastInsertRowid } = insertFactura.run(
|
||||
parsed.uuid,
|
||||
parsed.rfcEmisor,
|
||||
parsed.nombreEmisor,
|
||||
parsed.fecha,
|
||||
parsed.subtotal,
|
||||
parsed.total,
|
||||
filePath
|
||||
);
|
||||
|
||||
for (const concepto of parsed.conceptos) {
|
||||
const intercambioRow = getIntercambio.get(concepto.noIdentificacion);
|
||||
const intercambio = intercambioRow ? intercambioRow.no_parte_intercambio : null;
|
||||
|
||||
insertConcepto.run(
|
||||
lastInsertRowid,
|
||||
concepto.claveProdServ,
|
||||
concepto.noIdentificacion,
|
||||
concepto.descripcion,
|
||||
concepto.cantidad,
|
||||
concepto.valorUnitario,
|
||||
concepto.importe,
|
||||
intercambio
|
||||
);
|
||||
}
|
||||
|
||||
return lastInsertRowid;
|
||||
});
|
||||
|
||||
transaction();
|
||||
|
||||
return { success: true, uuid: parsed.uuid };
|
||||
}
|
||||
|
||||
module.exports = { importXmlFile };
|
||||
69
tests/cfdi-parser.test.js
Normal file
69
tests/cfdi-parser.test.js
Normal file
@@ -0,0 +1,69 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { parseCfdiFile } from '../src/services/cfdi-parser.js';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
describe('CFDI Parser', () => {
|
||||
const fixturePath = path.join(__dirname, 'fixtures', 'sample-cfdi.xml');
|
||||
|
||||
it('should extract UUID from TimbreFiscalDigital', () => {
|
||||
const result = parseCfdiFile(fixturePath);
|
||||
expect(result.uuid).toBe('6F8A29C3-1234-4567-8901-ABCDEF123456');
|
||||
});
|
||||
|
||||
it('should extract emisor data', () => {
|
||||
const result = parseCfdiFile(fixturePath);
|
||||
expect(result.rfcEmisor).toBe('AAA010101AAA');
|
||||
expect(result.nombreEmisor).toBe('Refacciones del Norte SA');
|
||||
});
|
||||
|
||||
it('should extract fecha, subtotal, total', () => {
|
||||
const result = parseCfdiFile(fixturePath);
|
||||
expect(result.fecha).toBe('2026-01-15T10:30:00');
|
||||
expect(result.subtotal).toBe(1500.0);
|
||||
expect(result.total).toBe(1740.0);
|
||||
});
|
||||
|
||||
it('should extract all conceptos', () => {
|
||||
const result = parseCfdiFile(fixturePath);
|
||||
expect(result.conceptos).toHaveLength(2);
|
||||
|
||||
expect(result.conceptos[0]).toEqual({
|
||||
claveProdServ: '25101503',
|
||||
noIdentificacion: 'BK-001',
|
||||
descripcion: 'Balata ceramica delantera',
|
||||
cantidad: 4,
|
||||
valorUnitario: 250.0,
|
||||
importe: 1000.0,
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle single concepto (not array)', () => {
|
||||
const singleConceptXml = `<?xml version="1.0" encoding="utf-8"?>
|
||||
<cfdi:Comprobante xmlns:cfdi="http://www.sat.gob.mx/cfd/4"
|
||||
xmlns:tfd="http://www.sat.gob.mx/TimbreFiscalDigital"
|
||||
Version="4.0" Fecha="2026-02-01T08:00:00" SubTotal="100.00" Total="116.00">
|
||||
<cfdi:Emisor Rfc="BBB020202BBB" Nombre="Proveedor Unico" />
|
||||
<cfdi:Conceptos>
|
||||
<cfdi:Concepto ClaveProdServ="12345678" NoIdentificacion="X-1"
|
||||
Descripcion="Articulo unico" Cantidad="1"
|
||||
ValorUnitario="100.00" Importe="100.00" />
|
||||
</cfdi:Conceptos>
|
||||
<cfdi:Complemento>
|
||||
<tfd:TimbreFiscalDigital UUID="SINGLE-UUID-TEST" />
|
||||
</cfdi:Complemento>
|
||||
</cfdi:Comprobante>`;
|
||||
|
||||
const tmpPath = path.join(__dirname, 'fixtures', 'single-concept.xml');
|
||||
fs.writeFileSync(tmpPath, singleConceptXml);
|
||||
|
||||
const result = parseCfdiFile(tmpPath);
|
||||
expect(result.conceptos).toHaveLength(1);
|
||||
expect(result.conceptos[0].descripcion).toBe('Articulo unico');
|
||||
|
||||
fs.unlinkSync(tmpPath);
|
||||
});
|
||||
});
|
||||
20
tests/fixtures/sample-cfdi.xml
vendored
Normal file
20
tests/fixtures/sample-cfdi.xml
vendored
Normal file
@@ -0,0 +1,20 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<cfdi:Comprobante xmlns:cfdi="http://www.sat.gob.mx/cfd/4"
|
||||
xmlns:tfd="http://www.sat.gob.mx/TimbreFiscalDigital"
|
||||
Version="4.0"
|
||||
Fecha="2026-01-15T10:30:00"
|
||||
SubTotal="1500.00"
|
||||
Total="1740.00">
|
||||
<cfdi:Emisor Rfc="AAA010101AAA" Nombre="Refacciones del Norte SA" />
|
||||
<cfdi:Conceptos>
|
||||
<cfdi:Concepto ClaveProdServ="25101503" NoIdentificacion="BK-001"
|
||||
Descripcion="Balata ceramica delantera" Cantidad="4"
|
||||
ValorUnitario="250.00" Importe="1000.00" />
|
||||
<cfdi:Concepto ClaveProdServ="25101504" NoIdentificacion="FT-200"
|
||||
Descripcion="Filtro de aceite" Cantidad="10"
|
||||
ValorUnitario="50.00" Importe="500.00" />
|
||||
</cfdi:Conceptos>
|
||||
<cfdi:Complemento>
|
||||
<tfd:TimbreFiscalDigital UUID="6F8A29C3-1234-4567-8901-ABCDEF123456" />
|
||||
</cfdi:Complemento>
|
||||
</cfdi:Comprobante>
|
||||
55
tests/import-service.test.js
Normal file
55
tests/import-service.test.js
Normal file
@@ -0,0 +1,55 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
describe('Import Service', () => {
|
||||
let db;
|
||||
|
||||
beforeEach(async () => {
|
||||
const { createDatabase } = await import('../src/services/database.js');
|
||||
db = createDatabase(':memory:');
|
||||
});
|
||||
|
||||
it('should import a CFDI XML into the database', async () => {
|
||||
const { importXmlFile } = await import('../src/services/import-service.js');
|
||||
const fixturePath = path.join(__dirname, 'fixtures', 'sample-cfdi.xml');
|
||||
const result = importXmlFile(db, fixturePath);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.uuid).toBe('6F8A29C3-1234-4567-8901-ABCDEF123456');
|
||||
|
||||
const factura = db.prepare('SELECT * FROM facturas WHERE uuid = ?').get(result.uuid);
|
||||
expect(factura).toBeTruthy();
|
||||
expect(factura.nombre_emisor).toBe('Refacciones del Norte SA');
|
||||
|
||||
const conceptos = db.prepare('SELECT * FROM conceptos WHERE factura_id = ?').all(factura.id);
|
||||
expect(conceptos).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('should skip duplicate UUIDs', async () => {
|
||||
const { importXmlFile } = await import('../src/services/import-service.js');
|
||||
const fixturePath = path.join(__dirname, 'fixtures', 'sample-cfdi.xml');
|
||||
importXmlFile(db, fixturePath);
|
||||
const result = importXmlFile(db, fixturePath);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.reason).toBe('duplicate');
|
||||
});
|
||||
|
||||
it('should cross-reference intercambios from catalogo', async () => {
|
||||
const { importXmlFile } = await import('../src/services/import-service.js');
|
||||
db.prepare(
|
||||
'INSERT INTO catalogo_intercambios (no_parte_proveedor, no_parte_intercambio) VALUES (?, ?)'
|
||||
).run('BK-001', 'INT-9999');
|
||||
|
||||
const fixturePath = path.join(__dirname, 'fixtures', 'sample-cfdi.xml');
|
||||
importXmlFile(db, fixturePath);
|
||||
|
||||
const concepto = db.prepare(
|
||||
"SELECT * FROM conceptos WHERE no_identificacion = 'BK-001'"
|
||||
).get();
|
||||
expect(concepto.intercambio).toBe('INT-9999');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user