feat: importar csv y auditar ediciones

This commit is contained in:
mono
2026-10-25 18:20:04 -05:00
parent 8e286d6fec
commit 01b5ee64cc
23 changed files with 483 additions and 19 deletions

View File

@@ -0,0 +1,14 @@
export type EntidadAuditada = 'PRODUCTOR' | 'PRODUCTO' | 'VENTA';
export interface Auditoria {
id: string;
entidad: EntidadAuditada;
registroId: string;
operacion: 'CREAR' | 'EDITAR';
modificadoPor: string;
motivo: string;
valoresAnteriores?: string;
valoresNuevos: string;
fecha: Date;
createdAt: Date;
}

View File

@@ -6,3 +6,4 @@ export * from './Gasto';
export * from './Ajuste';
export * from './CuadreCaja';
export * from './JornadaToldo';
export * from './Auditoria';

View File

@@ -1,5 +1,5 @@
import Dexie, { type Table } from 'dexie';
import type { Productor, Producto, MovimientoInventario, Venta, VentaLinea, Gasto, Ajuste, CuadreCaja, JornadaToldo } from '@toldo/core';
import type { Productor, Producto, MovimientoInventario, Venta, VentaLinea, Gasto, Ajuste, CuadreCaja, JornadaToldo, Auditoria } from '@toldo/core';
export class ToldoDB extends Dexie {
productores!: Table<Productor, string>;
@@ -11,6 +11,7 @@ export class ToldoDB extends Dexie {
ajustes!: Table<Ajuste, string>;
cuadres!: Table<CuadreCaja, string>;
jornadas!: Table<JornadaToldo, string>;
auditorias!: Table<Auditoria, string>;
constructor() {
super('ToldoConjuntoDB');
@@ -35,6 +36,18 @@ export class ToldoDB extends Dexie {
cuadres: 'id, fecha',
jornadas: 'id, fecha, estado'
});
this.version(3).stores({
productores: 'id, nombre, activo',
productos: 'id, productorId, nombrePresentacion, [productorId+nombrePresentacion]',
movimientosInventario: 'id, fecha, tipo, productoId',
ventas: 'id, fecha',
ventaLineas: 'id, ventaId, productoId, formaPago',
gastos: 'id, fecha',
ajustes: 'id, fecha, productoId',
cuadres: 'id, fecha',
jornadas: 'id, fecha, estado',
auditorias: 'id, entidad, registroId, fecha'
});
}
}

View File

@@ -0,0 +1,29 @@
import { db } from '@toldo/data';
import type { Auditoria, EntidadAuditada } from '@toldo/core';
export interface DatosEdicion {
modificadoPor: string;
motivo: string;
}
export function validarEdicion(datos: DatosEdicion) {
if (!datos.modificadoPor.trim()) throw new Error('Indica quién realiza el cambio.');
if (!datos.motivo.trim()) throw new Error('Indica el motivo del cambio.');
}
export class AuditoriaService {
async registrar(entidad: EntidadAuditada, registroId: string, anteriores: unknown | undefined, nuevos: unknown, datos: DatosEdicion) {
validarEdicion(datos);
const auditoria: Auditoria = {
id: crypto.randomUUID(), entidad, registroId, operacion: anteriores ? 'EDITAR' : 'CREAR',
modificadoPor: datos.modificadoPor.trim(), motivo: datos.motivo.trim(),
valoresAnteriores: anteriores ? JSON.stringify(anteriores) : undefined,
valoresNuevos: JSON.stringify(nuevos), fecha: new Date(), createdAt: new Date(),
};
return db.auditorias.add(auditoria);
}
async listar(entidad?: EntidadAuditada) {
return entidad ? db.auditorias.where('entidad').equals(entidad).reverse().sortBy('fecha') : db.auditorias.orderBy('fecha').reverse().toArray();
}
}

View File

@@ -0,0 +1,37 @@
import { db } from '@toldo/data';
import type { Producto, Productor, Venta, VentaLinea } from '@toldo/core';
import { validarEdicion, type DatosEdicion } from './AuditoriaService';
export class EdicionService {
async editarProductor(productor: Productor, anterior: Productor, datos: DatosEdicion) {
validarEdicion(datos);
return db.transaction('rw', db.productores, db.auditorias, async () => {
await db.productores.put(productor);
await db.auditorias.add({ id: crypto.randomUUID(), entidad: 'PRODUCTOR', registroId: productor.id, operacion: 'EDITAR', ...datos, modificadoPor: datos.modificadoPor.trim(), motivo: datos.motivo.trim(), valoresAnteriores: JSON.stringify(anterior), valoresNuevos: JSON.stringify(productor), fecha: new Date(), createdAt: new Date() } as any);
});
}
async editarProducto(producto: Producto, anterior: Producto, datos: DatosEdicion) {
validarEdicion(datos);
return db.transaction('rw', db.productos, db.auditorias, async () => {
await db.productos.put(producto);
await db.auditorias.add({ id: crypto.randomUUID(), entidad: 'PRODUCTO', registroId: producto.id, operacion: 'EDITAR', ...datos, modificadoPor: datos.modificadoPor.trim(), motivo: datos.motivo.trim(), valoresAnteriores: JSON.stringify(anterior), valoresNuevos: JSON.stringify(producto), fecha: new Date(), createdAt: new Date() } as any);
});
}
async editarVenta(venta: Venta, anterior: Venta, lineasAnteriores: VentaLinea[], datos: DatosEdicion) {
validarEdicion(datos);
return db.transaction('rw', db.ventas, db.ventaLineas, db.movimientosInventario, db.auditorias, async () => {
const lineasNuevas = venta.lineas;
for (const anteriorLinea of lineasAnteriores) {
const nueva = lineasNuevas.find((linea) => linea.id === anteriorLinea.id);
if (!nueva) continue;
const diferencia = nueva.cantidad - anteriorLinea.cantidad;
if (diferencia) await db.movimientosInventario.add({ id: crypto.randomUUID(), fecha: new Date(), tipo: 'VENTA', productoId: nueva.productoId, cantidad: -diferencia, referenciaId: venta.id, motivo: `Corrección de venta: ${datos.motivo}`, createdAt: new Date() } as any);
await db.ventaLineas.put(nueva);
}
await db.ventas.put(venta);
await db.auditorias.add({ id: crypto.randomUUID(), entidad: 'VENTA', registroId: venta.id, operacion: 'EDITAR', ...datos, modificadoPor: datos.modificadoPor.trim(), motivo: datos.motivo.trim(), valoresAnteriores: JSON.stringify({ venta: anterior, lineas: lineasAnteriores }), valoresNuevos: JSON.stringify({ venta, lineas: lineasNuevas }), fecha: new Date(), createdAt: new Date() } as any);
});
}
}

View File

@@ -1,4 +1,4 @@
import type { Productor, Producto, MovimientoInventario, Venta, VentaLinea, Gasto, Ajuste, CuadreCaja, JornadaToldo } from '@toldo/core';
import type { Productor, Producto, MovimientoInventario, Venta, VentaLinea, Gasto, Ajuste, CuadreCaja, JornadaToldo, Auditoria } from '@toldo/core';
export interface ExportData {
schemaVersion: number;
@@ -14,6 +14,7 @@ export interface ExportData {
ajustes: Ajuste[];
cuadres: CuadreCaja[];
jornadas?: JornadaToldo[];
auditorias?: Auditoria[];
};
}
@@ -52,6 +53,6 @@ export class ExportImportService {
throw new Error('Versión de esquema incompatible. Actualice la aplicación.');
}
// Simple pass-through for v1; migrations would go here
return { ...(data as ExportData), data: { ...(data as ExportData).data, jornadas: (data as ExportData).data.jornadas ?? [] } };
return { ...(data as ExportData), data: { ...(data as ExportData).data, jornadas: (data as ExportData).data.jornadas ?? [], auditorias: (data as ExportData).data.auditorias ?? [] } };
}
}

View File

@@ -6,3 +6,6 @@ export * from './cuadre/CuadreService';
export * from './reportes/ReportesService';
export * from './export-import/ExportImportService';
export * from './JornadaService';
export * from './auditoria/AuditoriaService';
export * from './auditoria/EdicionService';
export * from './inventario/ImportInventarioCsvService';

View File

@@ -0,0 +1,63 @@
import { db } from '@toldo/data';
import type { Producto, Productor } from '@toldo/core';
export interface ResultadoImportacionCsv {
creados: number;
reutilizados: number;
movimientos: number;
omitidos: number;
errores: Array<{ linea: number; mensaje: string }>;
}
export function normalizarNombre(valor: string) {
return valor.normalize('NFD').replace(/[\u0300-\u036f]/g, '').replace(/^\uFEFF/, '').trim().replace(/\s+/g, ' ').toLocaleLowerCase();
}
function separar(linea: string) {
const separador = (linea.match(/;/g)?.length ?? 0) > (linea.match(/,/g)?.length ?? 0) ? ';' : ',';
return linea.split(separador).map((valor) => valor.trim().replace(/^"|"$/g, ''));
}
export class ImportInventarioCsvService {
async importar(texto: string, fecha = new Date()): Promise<ResultadoImportacionCsv> {
const filas = texto.replace(/^\uFEFF/, '').split(/\r?\n/).filter((fila) => fila.trim());
const resultado: ResultadoImportacionCsv = { creados: 0, reutilizados: 0, movimientos: 0, omitidos: 0, errores: [] };
if (!filas.length) throw new Error('El CSV está vacío.');
const encabezados = separar(filas[0]).map(normalizarNombre);
const requeridos = ['nombre de producto', 'nombre productor', 'precio unitario', 'cantidad'];
const indices = requeridos.map((nombre) => encabezados.indexOf(normalizarNombre(nombre)));
if (indices.slice(0, 3).some((indice) => indice < 0)) throw new Error('El CSV debe incluir: Nombre de producto, Nombre Productor y precio unitario.');
await db.transaction('rw', db.productores, db.productos, db.movimientosInventario, async () => {
const productores = await db.productores.toArray();
const productos = await db.productos.toArray();
for (let i = 1; i < filas.length; i++) {
const linea = i + 1;
const columnas = separar(filas[i]);
const nombreProducto = columnas[indices[0]]?.trim();
const nombreProductor = columnas[indices[1]]?.trim();
const precio = Number((columnas[indices[2]] ?? '').replace(',', '.'));
const cantidadTexto = indices[3] >= 0 ? columnas[indices[3]]?.trim() : '';
const cantidad = cantidadTexto ? Number(cantidadTexto.replace(',', '.')) : 0;
if (!nombreProducto || !nombreProductor) { resultado.errores.push({ linea, mensaje: 'Falta nombre de producto o productor.' }); continue; }
if (!Number.isFinite(precio) || precio < 0) { resultado.errores.push({ linea, mensaje: 'El precio unitario debe ser numérico y no negativo.' }); continue; }
if (!Number.isFinite(cantidad) || cantidad < 0) { resultado.errores.push({ linea, mensaje: 'La cantidad debe ser cero, vacía o positiva.' }); continue; }
let productor = productores.find((item) => normalizarNombre(item.nombre) === normalizarNombre(nombreProductor));
if (!productor) {
productor = { id: crypto.randomUUID(), nombre: nombreProductor, activo: true, createdAt: new Date() } as Productor;
productores.push(productor); await db.productores.add(productor); resultado.creados++;
} else resultado.reutilizados++;
let producto = productos.find((item) => item.productorId === productor!.id && normalizarNombre(item.nombrePresentacion) === normalizarNombre(nombreProducto));
if (!producto) {
producto = { id: crypto.randomUUID(), productorId: productor.id, nombrePresentacion: nombreProducto, precioUnitario: precio, activo: true, createdAt: new Date() } as Producto;
productos.push(producto); await db.productos.add(producto); resultado.creados++;
} else { resultado.reutilizados++; }
if (cantidad > 0) {
await db.movimientosInventario.add({ id: crypto.randomUUID(), fecha, tipo: 'INGRESO_INICIAL', productoId: producto.id, cantidad, createdAt: new Date() } as any);
resultado.movimientos++;
} else resultado.omitidos++;
}
});
return resultado;
}
}