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:
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
node_modules/
|
||||
data/
|
||||
*.db
|
||||
129
docs/plans/2026-02-16-portal-facturas-etiquetas-design.md
Normal file
129
docs/plans/2026-02-16-portal-facturas-etiquetas-design.md
Normal 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)
|
||||
1525
docs/plans/2026-02-16-portal-facturas-etiquetas.md
Normal file
1525
docs/plans/2026-02-16-portal-facturas-etiquetas.md
Normal file
File diff suppressed because it is too large
Load Diff
3019
package-lock.json
generated
Normal file
3019
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
28
package.json
Normal file
28
package.json
Normal 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
14
src/index.js
Normal 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
63
src/services/database.js
Normal 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
66
tests/database.test.js
Normal 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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user