Add project scaffolding and database schema with tests

Initialize Express project with all dependencies (better-sqlite3,
fast-xml-parser, chokidar, xlsx, multer, vitest). Create folder
structure, entry point, and SQLite database service with schema for
facturas, conceptos, catalogo_intercambios, and configuracion tables.
All 4 database tests pass.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
consultoria-as
2026-02-17 06:24:09 +00:00
commit 27efded33a
8 changed files with 4847 additions and 0 deletions

3
.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
node_modules/
data/
*.db

View File

@@ -0,0 +1,129 @@
# Portal de Facturas y Etiquetas - Refaccionaria
## Contexto
Cliente con refaccionaria que descarga diariamente sus facturas CFDI (XML) para análisis financiero. Necesita un portal web para:
1. Visualizar las facturas y sus artículos
2. Buscar artículos por nombre, número de parte o intercambio
3. Imprimir etiquetas con impresora Zebra ZT410 conectada por Ethernet
## Arquitectura
```
Carpeta XMLs ──> Servidor Node.js (Express + API REST) ──> Navegadores (PCs red local)
CSV Catálogo ──> + SQLite local ──> Zebra ZT410 (TCP:9100)
```
- **Backend**: Node.js + Express
- **Base de datos**: SQLite (sin servidor adicional)
- **Frontend**: HTML/CSS/JS servido por Express
- **Impresión**: ZPL directo por TCP al puerto 9100 de la Zebra
- **Usuarios**: Varias PCs en red local
## Modelo de Datos
### Tabla: facturas
| Campo | Tipo | Descripción |
|---------------|----------|--------------------------------|
| id | INTEGER | PK autoincrement |
| uuid | TEXT | UUID del timbre fiscal (CFDI) |
| rfc_emisor | TEXT | RFC del proveedor |
| nombre_emisor | TEXT | Nombre del proveedor |
| fecha | TEXT | Fecha de emisión |
| subtotal | REAL | Subtotal de la factura |
| total | REAL | Total de la factura |
| xml_path | TEXT | Ruta al archivo XML original |
| created_at | TEXT | Fecha de importación |
### Tabla: conceptos
| Campo | Tipo | Descripción |
|--------------------|----------|------------------------------------------|
| id | INTEGER | PK autoincrement |
| factura_id | INTEGER | FK → facturas |
| clave_prod_serv | TEXT | Clave SAT del producto |
| no_identificacion | TEXT | Número de parte del proveedor |
| descripcion | TEXT | Nombre/descripción del artículo |
| cantidad | REAL | Cantidad facturada |
| valor_unitario | REAL | Precio unitario |
| importe | REAL | Importe total del concepto |
| intercambio | TEXT | Número de parte alterno (del catálogo) |
### Tabla: catalogo_intercambios
| Campo | Tipo | Descripción |
|----------------------|----------|------------------------------------|
| id | INTEGER | PK autoincrement |
| no_parte_proveedor | TEXT | Número de parte del proveedor |
| no_parte_intercambio | TEXT | Número de parte alterno/equivalente|
## Pantallas del Portal
### 1. Dashboard / Lista de Facturas
- Tabla con: Fecha, UUID, Emisor (proveedor), Total, # artículos
- Filtros por: fecha, proveedor
- Búsqueda por UUID o proveedor
- Click en factura → detalle
### 2. Detalle de Factura
- Datos generales: emisor, fecha, UUID, subtotal, total
- Tabla de conceptos con checkboxes para selección
- Columnas: descripción, no. parte, cantidad, precio unitario, importe, intercambio
- Botón "Imprimir etiquetas" con selector de cantidad por artículo
### 3. Búsqueda Global de Artículos
- Buscar por nombre, número de parte o número de intercambio
- Resultados: descripción, no. parte, intercambio, proveedor, precio, fecha última compra
- Selección directa para impresión de etiquetas
### 4. Gestión de Catálogo de Intercambios
- Importar CSV/Excel con equivalencias
- Ver, buscar, editar intercambios existentes
- Mapear manualmente intercambios faltantes
### 5. Configuración
- IP de la impresora Zebra
- Ruta de la carpeta de XMLs
- Tamaño y diseño de etiqueta (configurable, default 2"x1")
## Etiqueta ZPL
Contenido de la etiqueta:
- **Línea 1**: Nombre del artículo (descripción)
- **Línea 2**: Número de intercambio
- **Línea 3**: Código de barras Code 128 (número de parte)
Tamaño configurable (default 2"x1"). Ejemplo ZPL:
```zpl
^XA
^FO50,30^A0N,28,28^FD BALATA CERAMICA DELANTERA^FS
^FO50,70^A0N,22,22^FD Int: 7890-ABC^FS
^FO50,110^BY2^BCN,60,Y,N,N^FD>:D1234^FS
^XZ
```
## Flujo de Importación de XMLs
1. Servidor vigila carpeta de XMLs (escaneo periódico o chokidar)
2. Detecta XMLs nuevos → parsea estructura CFDI
3. Extrae UUID, emisor, conceptos
4. Guarda en SQLite
5. Cruza `no_identificacion` con catálogo de intercambios
## Impresión
1. Usuario selecciona artículos → click "Imprimir etiquetas"
2. Frontend envía petición al backend con artículos y cantidad
3. Backend genera ZPL para cada etiqueta
4. Backend envía ZPL por TCP a IP_ZEBRA:9100
5. Zebra imprime
## Stack Técnico
- **Runtime**: Node.js
- **Framework**: Express
- **DB**: better-sqlite3
- **XML Parser**: fast-xml-parser
- **File Watcher**: chokidar
- **Excel/CSV**: xlsx (SheetJS) para importar catálogo
- **TCP**: net (módulo nativo de Node.js) para enviar ZPL
- **Frontend**: HTML + CSS + JavaScript vanilla (o framework ligero)

File diff suppressed because it is too large Load Diff

3019
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

28
package.json Normal file
View File

@@ -0,0 +1,28 @@
{
"name": "estrada",
"version": "1.0.0",
"main": "index.js",
"directories": {
"doc": "docs"
},
"scripts": {
"start": "node src/index.js",
"test": "vitest run",
"test:watch": "vitest"
},
"keywords": [],
"author": "",
"license": "ISC",
"description": "",
"dependencies": {
"better-sqlite3": "^12.6.2",
"chokidar": "^5.0.0",
"express": "^5.2.1",
"fast-xml-parser": "^5.3.6",
"multer": "^2.0.2",
"xlsx": "^0.18.5"
},
"devDependencies": {
"vitest": "^4.0.18"
}
}

14
src/index.js Normal file
View File

@@ -0,0 +1,14 @@
const express = require('express');
const path = require('path');
const app = express();
const PORT = process.env.PORT || 3000;
app.use(express.json());
app.use(express.static(path.join(__dirname, 'public')));
app.listen(PORT, '0.0.0.0', () => {
console.log(`Portal running at http://0.0.0.0:${PORT}`);
});
module.exports = app;

63
src/services/database.js Normal file
View File

@@ -0,0 +1,63 @@
const Database = require('better-sqlite3');
function createDatabase(dbPath) {
const db = new Database(dbPath);
db.pragma('journal_mode = WAL');
db.pragma('foreign_keys = ON');
db.exec(`
CREATE TABLE IF NOT EXISTS facturas (
id INTEGER PRIMARY KEY AUTOINCREMENT,
uuid TEXT UNIQUE NOT NULL,
rfc_emisor TEXT,
nombre_emisor TEXT,
fecha TEXT,
subtotal REAL,
total REAL,
xml_path TEXT,
created_at TEXT DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS conceptos (
id INTEGER PRIMARY KEY AUTOINCREMENT,
factura_id INTEGER NOT NULL,
clave_prod_serv TEXT,
no_identificacion TEXT,
descripcion TEXT,
cantidad REAL,
valor_unitario REAL,
importe REAL,
intercambio TEXT,
FOREIGN KEY (factura_id) REFERENCES facturas(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS catalogo_intercambios (
id INTEGER PRIMARY KEY AUTOINCREMENT,
no_parte_proveedor TEXT NOT NULL,
no_parte_intercambio TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS configuracion (
clave TEXT PRIMARY KEY,
valor TEXT
);
CREATE INDEX IF NOT EXISTS idx_conceptos_factura ON conceptos(factura_id);
CREATE INDEX IF NOT EXISTS idx_conceptos_no_id ON conceptos(no_identificacion);
CREATE INDEX IF NOT EXISTS idx_catalogo_parte ON catalogo_intercambios(no_parte_proveedor);
`);
const insertConfig = db.prepare(
'INSERT OR IGNORE INTO configuracion (clave, valor) VALUES (?, ?)'
);
insertConfig.run('zebra_ip', '192.168.1.100');
insertConfig.run('zebra_port', '9100');
insertConfig.run('xml_folder', './data/xmls');
insertConfig.run('label_width', '2');
insertConfig.run('label_height', '1');
return db;
}
module.exports = { createDatabase };

66
tests/database.test.js Normal file
View File

@@ -0,0 +1,66 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { createDatabase } from '../src/services/database.js';
let db;
beforeEach(() => {
db = createDatabase(':memory:');
});
describe('Database', () => {
it('should create all tables', () => {
const tables = db.prepare(
"SELECT name FROM sqlite_master WHERE type='table' ORDER BY name"
).all().map(r => r.name);
expect(tables).toContain('facturas');
expect(tables).toContain('conceptos');
expect(tables).toContain('catalogo_intercambios');
expect(tables).toContain('configuracion');
});
it('should insert and retrieve a factura', () => {
const result = db.prepare(`
INSERT INTO facturas (uuid, rfc_emisor, nombre_emisor, fecha, subtotal, total, xml_path)
VALUES (?, ?, ?, ?, ?, ?, ?)
`).run('ABC-123', 'RFC123', 'Proveedor Test', '2026-01-15', 100.0, 116.0, '/path/to.xml');
expect(result.changes).toBe(1);
const factura = db.prepare('SELECT * FROM facturas WHERE uuid = ?').get('ABC-123');
expect(factura.nombre_emisor).toBe('Proveedor Test');
expect(factura.total).toBe(116.0);
});
it('should insert and retrieve conceptos linked to a factura', () => {
db.prepare(`
INSERT INTO facturas (uuid, rfc_emisor, nombre_emisor, fecha, subtotal, total, xml_path)
VALUES (?, ?, ?, ?, ?, ?, ?)
`).run('ABC-123', 'RFC123', 'Proveedor', '2026-01-15', 100, 116, '/path.xml');
const factura = db.prepare('SELECT id FROM facturas WHERE uuid = ?').get('ABC-123');
db.prepare(`
INSERT INTO conceptos (factura_id, clave_prod_serv, no_identificacion, descripcion, cantidad, valor_unitario, importe)
VALUES (?, ?, ?, ?, ?, ?, ?)
`).run(factura.id, '25101503', 'BK-001', 'Balata ceramica delantera', 4, 250.0, 1000.0);
const conceptos = db.prepare('SELECT * FROM conceptos WHERE factura_id = ?').all(factura.id);
expect(conceptos).toHaveLength(1);
expect(conceptos[0].descripcion).toBe('Balata ceramica delantera');
});
it('should enforce unique uuid on facturas', () => {
db.prepare(`
INSERT INTO facturas (uuid, rfc_emisor, nombre_emisor, fecha, subtotal, total, xml_path)
VALUES (?, ?, ?, ?, ?, ?, ?)
`).run('ABC-123', 'RFC123', 'Proveedor', '2026-01-15', 100, 116, '/path.xml');
expect(() => {
db.prepare(`
INSERT INTO facturas (uuid, rfc_emisor, nombre_emisor, fecha, subtotal, total, xml_path)
VALUES (?, ?, ?, ?, ?, ?, ?)
`).run('ABC-123', 'RFC456', 'Otro', '2026-01-16', 200, 232, '/other.xml');
}).toThrow();
});
});