feat: importar csv y auditar ediciones
This commit is contained in:
14
apps/toldo-app/src/__tests__/csv-import.test.ts
Normal file
14
apps/toldo-app/src/__tests__/csv-import.test.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { normalizarNombre, validarEdicion } from '@toldo/services';
|
||||
|
||||
describe('importación de inventario CSV', () => {
|
||||
it('compara nombres sin tildes, mayúsculas ni espacios extra', () => {
|
||||
expect(normalizarNombre(' La Familia González ')).toBe('la familia gonzalez');
|
||||
expect(normalizarNombre('CAFÉ 250 GR')).toBe('cafe 250 gr');
|
||||
});
|
||||
|
||||
it('exige actor y motivo para editar', () => {
|
||||
expect(() => validarEdicion({ modificadoPor: '', motivo: 'Corrección' })).toThrow('quién');
|
||||
expect(() => validarEdicion({ modificadoPor: 'Ana', motivo: '' })).toThrow('motivo');
|
||||
});
|
||||
});
|
||||
@@ -15,7 +15,16 @@
|
||||
</v-navigation-drawer>
|
||||
|
||||
<v-main>
|
||||
<v-container fluid>
|
||||
<v-container fluid class="pa-4 pa-md-6">
|
||||
<div class="d-flex flex-wrap align-center justify-space-between mb-3 ga-2">
|
||||
<v-breadcrumbs :items="breadcrumbs" class="pa-0" aria-label="Migas de pan">
|
||||
<template #prepend><v-icon icon="mdi-home-outline" size="small" /></template>
|
||||
</v-breadcrumbs>
|
||||
<div class="d-flex ga-2">
|
||||
<v-btn variant="text" size="small" prepend-icon="mdi-arrow-left" data-testid="back-button" @click="goBack">Atrás</v-btn>
|
||||
<v-btn to="/" variant="text" size="small" prepend-icon="mdi-home">Inicio</v-btn>
|
||||
</div>
|
||||
</div>
|
||||
<router-view />
|
||||
</v-container>
|
||||
</v-main>
|
||||
@@ -23,13 +32,16 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, onUnmounted, ref } from 'vue';
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue';
|
||||
import { JornadaService } from '@toldo/services';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
|
||||
const drawer = ref(true);
|
||||
const isMobile = ref(false);
|
||||
const jornada = ref<any>(null);
|
||||
const jornadaService = new JornadaService();
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const navItems = [
|
||||
{ to: '/jornada', icon: 'mdi-calendar-check', title: 'Jornada' },
|
||||
{ to: '/inventario', icon: 'mdi-package-variant', title: 'Inventario' },
|
||||
@@ -40,6 +52,8 @@ const navItems = [
|
||||
{ to: '/reportes', icon: 'mdi-chart-bar', title: 'Reportes' },
|
||||
{ to: '/exportar', icon: 'mdi-export', title: 'Exportar / importar' },
|
||||
];
|
||||
const breadcrumbs = computed(() => [{ title: 'Inicio', disabled: false, to: '/' }, ...(route.path === '/' ? [] : [{ title: String(route.meta.title ?? 'Sección'), disabled: true }])]);
|
||||
function goBack() { if (window.history.state?.back) router.back(); else router.push('/'); }
|
||||
function updateViewport() { isMobile.value = window.innerWidth < 960; if (isMobile.value) drawer.value = false; }
|
||||
function closeOnMobile() { if (isMobile.value) drawer.value = false; }
|
||||
onMounted(async () => { updateViewport(); window.addEventListener('resize', updateViewport); jornada.value = await jornadaService.getJornadaAbierta(); });
|
||||
|
||||
@@ -17,15 +17,15 @@ const router = createRouter({
|
||||
path: '/',
|
||||
component: Layout,
|
||||
children: [
|
||||
{ path: '', component: Home },
|
||||
{ path: 'jornada', component: Jornada },
|
||||
{ path: 'inventario', component: Inventario },
|
||||
{ path: 'ventas', component: Ventas },
|
||||
{ path: 'gastos', component: Gastos },
|
||||
{ path: 'cuadre', component: Cuadre },
|
||||
{ path: 'reportes', component: Reportes },
|
||||
{ path: 'ajustes', component: Ajustes },
|
||||
{ path: 'exportar', component: Exportar },
|
||||
{ path: '', component: Home, meta: { title: 'Inicio' } },
|
||||
{ path: 'jornada', component: Jornada, meta: { title: 'Jornada' } },
|
||||
{ path: 'inventario', component: Inventario, meta: { title: 'Inventario' } },
|
||||
{ path: 'ventas', component: Ventas, meta: { title: 'Ventas POS' } },
|
||||
{ path: 'gastos', component: Gastos, meta: { title: 'Gastos' } },
|
||||
{ path: 'cuadre', component: Cuadre, meta: { title: 'Cierre de caja' } },
|
||||
{ path: 'reportes', component: Reportes, meta: { title: 'Reportes' } },
|
||||
{ path: 'ajustes', component: Ajustes, meta: { title: 'Ajustes' } },
|
||||
{ path: 'exportar', component: Exportar, meta: { title: 'Exportar / importar' } },
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
<input v-model="motivo" placeholder="Motivo del ajuste (OBLIGATORIO)" />
|
||||
<button @click="ajustar">Realizar Ajuste</button>
|
||||
</section>
|
||||
<v-card class="mt-4"><v-card-title>Historial de cambios</v-card-title><v-card-text><v-list v-if="auditorias.length"><v-list-item v-for="a in auditorias" :key="a.id" :title="`${a.entidad} · ${a.operacion}`" :subtitle="`${a.motivo} · ${a.modificadoPor} · ${new Date(a.fecha).toLocaleString()}`" /></v-list><div v-else class="text-body-2 text-medium-emphasis">Aún no hay cambios auditados.</div></v-card-text></v-card>
|
||||
<section>
|
||||
<h3>Ajustes del día</h3>
|
||||
<ul>
|
||||
@@ -24,7 +25,7 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { db } from '@toldo/data';
|
||||
import { AjustesService, InventarioService } from '@toldo/services';
|
||||
import { AjustesService, InventarioService, AuditoriaService } from '@toldo/services';
|
||||
import { validateAjusteMotivo } from '@toldo/core';
|
||||
|
||||
const ajustesService = new AjustesService();
|
||||
@@ -34,12 +35,15 @@ const productoSel = ref('');
|
||||
const cantidadNueva = ref(0);
|
||||
const motivo = ref('');
|
||||
const ajustes = ref<any[]>([]);
|
||||
const auditorias = ref<any[]>([]);
|
||||
const auditoriaService = new AuditoriaService();
|
||||
const mensaje = ref('');
|
||||
const tipoMensaje = ref<'success' | 'error'>('success');
|
||||
|
||||
async function cargar() {
|
||||
productos.value = await db.productos.toArray();
|
||||
ajustes.value = await ajustesService.getAjustesDelDia(new Date());
|
||||
auditorias.value = await auditoriaService.listar();
|
||||
}
|
||||
|
||||
async function ajustar() {
|
||||
|
||||
@@ -37,6 +37,7 @@ async function exportar() {
|
||||
ajustes: await db.ajustes.toArray(),
|
||||
cuadres: await db.cuadres.toArray(),
|
||||
jornadas: await db.jornadas.toArray(),
|
||||
auditorias: await db.auditorias.toArray(),
|
||||
};
|
||||
const exp = exportService.exportAll(data);
|
||||
const blob = new Blob([JSON.stringify(exp, null, 2)], { type: 'application/json' });
|
||||
@@ -63,6 +64,7 @@ async function importar() {
|
||||
if (imp.data.ajustes.length) await db.ajustes.bulkPut(imp.data.ajustes);
|
||||
if (imp.data.cuadres.length) await db.cuadres.bulkPut(imp.data.cuadres);
|
||||
if (imp.data.jornadas?.length) await db.jornadas.bulkPut(imp.data.jornadas);
|
||||
if (imp.data.auditorias?.length) await db.auditorias.bulkPut(imp.data.auditorias);
|
||||
tipoMensaje.value = 'success'; mensaje.value = 'Datos importados correctamente.';
|
||||
} catch (e: any) {
|
||||
tipoMensaje.value = 'error'; mensaje.value = e.message;
|
||||
|
||||
@@ -2,6 +2,10 @@
|
||||
<div>
|
||||
<h1 class="text-h4 mb-4">Inventario y Productos</h1>
|
||||
<v-alert v-if="mensaje" :type="tipoMensaje" variant="tonal" class="mb-4">{{ mensaje }}</v-alert>
|
||||
<v-card class="mb-4" variant="tonal" color="info">
|
||||
<v-card-title><v-icon icon="mdi-file-delimited" class="mr-2" />Importar inventario inicial</v-card-title>
|
||||
<v-card-text><div class="text-body-2 mb-3">CSV con: Nombre de producto, Nombre Productor, precio unitario y cantidad.</div><v-file-input v-model="csvFile" accept=".csv,text/csv" label="Seleccionar CSV" density="compact" prepend-icon="mdi-paperclip" @update:model-value="previsualizarCsv" /><v-alert v-if="previewRows.length" variant="outlined" density="compact" class="mb-3"><div class="text-caption font-weight-bold mb-1">Previsualización</div><div v-for="(row, index) in previewRows" :key="index" class="text-caption text-truncate">{{ row }}</div></v-alert><v-btn :disabled="!csvFile || !previewRows.length" color="info" @click="importarCsv" prepend-icon="mdi-upload">Confirmar importación</v-btn></v-card-text>
|
||||
</v-card>
|
||||
|
||||
<v-row>
|
||||
<v-col cols="12" md="6">
|
||||
@@ -17,7 +21,7 @@
|
||||
</v-col>
|
||||
</v-row>
|
||||
<v-list>
|
||||
<v-list-item v-for="p in productores" :key="p.id" :title="p.nombre"></v-list-item>
|
||||
<v-list-item v-for="p in productores" :key="p.id" :title="p.nombre"><template #append><v-btn icon="mdi-pencil" variant="text" size="small" :aria-label="`Editar ${p.nombre}`" @click="editarProductor(p)" /></template></v-list-item>
|
||||
</v-list>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
@@ -32,7 +36,7 @@
|
||||
<v-text-field v-model.number="precioProd" label="Precio unitario" type="number" density="compact" class="mb-2"></v-text-field>
|
||||
<v-btn @click="crearProducto" color="primary" block>Agregar Producto</v-btn>
|
||||
<v-list class="mt-3">
|
||||
<v-list-item v-for="prod in productos" :key="prod.id" :title="prod.nombrePresentacion" :subtitle="'$' + prod.precioUnitario"></v-list-item>
|
||||
<v-list-item v-for="prod in productos" :key="prod.id" :title="prod.nombrePresentacion" :subtitle="'$' + prod.precioUnitario"><template #append><v-btn icon="mdi-pencil" variant="text" size="small" :aria-label="`Editar ${prod.nombrePresentacion}`" @click="editarProducto(prod)" /></template></v-list-item>
|
||||
</v-list>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
@@ -63,13 +67,14 @@
|
||||
</v-card>
|
||||
</v-col>
|
||||
</v-row>
|
||||
<v-dialog v-model="dialogoEdicion" max-width="520"><v-card><v-card-title>Editar {{ entidadEdicion === 'PRODUCTOR' ? 'productor' : 'producto' }}</v-card-title><v-card-text><v-text-field v-if="entidadEdicion === 'PRODUCTOR'" v-model="edicionProductor.nombre" label="Nombre" /><template v-else><v-text-field v-model="edicionProducto.nombrePresentacion" label="Nombre / presentación" /><v-text-field v-model.number="edicionProducto.precioUnitario" type="number" label="Precio unitario" /></template><v-divider class="my-3" /><v-text-field v-model="actor" label="Quién realiza el cambio" /><v-textarea v-model="motivo" label="Motivo del cambio" rows="2" /></v-card-text><v-card-actions><v-spacer /><v-btn variant="text" @click="dialogoEdicion = false">Cancelar</v-btn><v-btn color="primary" @click="guardarEdicion">Guardar cambio</v-btn></v-card-actions></v-card></v-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { db } from '@toldo/data';
|
||||
import { InventarioService } from '@toldo/services';
|
||||
import { InventarioService, ImportInventarioCsvService, EdicionService } from '@toldo/services';
|
||||
|
||||
const invService = new InventarioService();
|
||||
const productores = ref<any[]>([]);
|
||||
@@ -85,7 +90,37 @@ const cantInv2 = ref(0);
|
||||
const recibidoPor = ref('');
|
||||
const mensaje = ref('');
|
||||
const tipoMensaje = ref<'success' | 'error'>('success');
|
||||
const csvFile = ref<File | File[] | null>(null);
|
||||
const previewRows = ref<string[]>([]);
|
||||
const csvService = new ImportInventarioCsvService();
|
||||
const edicionService = new EdicionService();
|
||||
const dialogoEdicion = ref(false);
|
||||
const entidadEdicion = ref<'PRODUCTOR' | 'PRODUCTO'>('PRODUCTOR');
|
||||
const edicionProductor = ref<any>({});
|
||||
const edicionProducto = ref<any>({});
|
||||
const originalEdicion = ref<any>({});
|
||||
const actor = ref('');
|
||||
const motivo = ref('');
|
||||
function avisar(texto: string, tipo: 'success' | 'error' = 'error') { mensaje.value = texto; tipoMensaje.value = tipo; }
|
||||
function editarProductor(productor: any) { entidadEdicion.value = 'PRODUCTOR'; originalEdicion.value = { ...productor }; edicionProductor.value = { ...productor }; actor.value = ''; motivo.value = ''; dialogoEdicion.value = true; }
|
||||
function editarProducto(producto: any) { entidadEdicion.value = 'PRODUCTO'; originalEdicion.value = { ...producto }; edicionProducto.value = { ...producto }; actor.value = ''; motivo.value = ''; dialogoEdicion.value = true; }
|
||||
async function guardarEdicion() {
|
||||
try {
|
||||
if (entidadEdicion.value === 'PRODUCTOR') await edicionService.editarProductor(edicionProductor.value, originalEdicion.value, { modificadoPor: actor.value, motivo: motivo.value });
|
||||
else await edicionService.editarProducto(edicionProducto.value, originalEdicion.value, { modificadoPor: actor.value, motivo: motivo.value });
|
||||
dialogoEdicion.value = false; await cargar(); avisar('Cambio guardado y auditado.', 'success');
|
||||
} catch (error: any) { avisar(error.message); }
|
||||
}
|
||||
async function importarCsv() {
|
||||
const archivo = Array.isArray(csvFile.value) ? csvFile.value[0] : csvFile.value;
|
||||
if (!archivo) return;
|
||||
try { const resultado = await csvService.importar(await archivo.text()); csvFile.value = null; await cargar(); avisar(`Importación finalizada: ${resultado.creados} creados, ${resultado.movimientos} movimientos, ${resultado.errores.length} errores.`, resultado.errores.length ? 'error' : 'success'); }
|
||||
catch (error: any) { avisar(error.message); }
|
||||
}
|
||||
async function previsualizarCsv() {
|
||||
const archivo = Array.isArray(csvFile.value) ? csvFile.value[0] : csvFile.value;
|
||||
previewRows.value = archivo ? (await archivo.text()).split(/\r?\n/).filter((linea) => linea.trim()).slice(0, 6) : [];
|
||||
}
|
||||
|
||||
async function cargar() {
|
||||
productores.value = await db.productores.toArray();
|
||||
|
||||
@@ -52,13 +52,15 @@
|
||||
</v-card>
|
||||
</v-col>
|
||||
</v-row>
|
||||
<v-card class="mt-4"><v-card-title>Ventas recientes</v-card-title><v-card-text><v-list v-if="ventas.length"><v-list-item v-for="venta in ventas" :key="venta.id" :title="`Venta $${venta.total.toLocaleString()}`" :subtitle="`${new Date(venta.fecha).toLocaleString()} · ${venta.lineas.length} productos`"><template #append><v-btn variant="tonal" size="small" prepend-icon="mdi-pencil" @click="editarVenta(venta)">Editar</v-btn></template></v-list-item></v-list><v-alert v-else type="info" variant="tonal">Las ventas registradas durante el día aparecerán aquí.</v-alert></v-card-text></v-card>
|
||||
<v-dialog v-model="dialogoVenta" max-width="560"><v-card><v-card-title>Editar venta</v-card-title><v-card-text><v-list><v-list-item v-for="linea in ventaEditada.lineas" :key="linea.id" :title="getProdNombre(linea.productoId)"><template #append><v-text-field v-model.number="linea.cantidad" type="number" label="Cantidad" density="compact" hide-details style="max-width: 130px" /></template></v-list-item></v-list><v-divider class="my-3" /><v-text-field v-model="actorVenta" label="Quién realiza el cambio" /><v-textarea v-model="motivoVenta" label="Motivo del cambio" rows="2" /></v-card-text><v-card-actions><v-spacer /><v-btn variant="text" @click="dialogoVenta = false">Cancelar</v-btn><v-btn color="primary" @click="guardarVenta">Guardar cambio</v-btn></v-card-actions></v-card></v-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue';
|
||||
import { db } from '@toldo/data';
|
||||
import { VentasService, JornadaService } from '@toldo/services';
|
||||
import { VentasService, JornadaService, EdicionService } from '@toldo/services';
|
||||
|
||||
const ventasService = new VentasService();
|
||||
const jornadaService = new JornadaService();
|
||||
@@ -68,10 +70,20 @@ const formaPago = ref('EFECTIVO');
|
||||
const jornadaAbierta = ref<any>(null);
|
||||
const error = ref('');
|
||||
const exito = ref('');
|
||||
const ventas = ref<any[]>([]);
|
||||
const dialogoVenta = ref(false);
|
||||
const ventaEditada = ref<any>({ lineas: [] });
|
||||
const ventaOriginal = ref<any>(null);
|
||||
const lineasOriginales = ref<any[]>([]);
|
||||
const actorVenta = ref('');
|
||||
const motivoVenta = ref('');
|
||||
const edicionService = new EdicionService();
|
||||
|
||||
async function cargar() {
|
||||
productos.value = await db.productos.toArray();
|
||||
jornadaAbierta.value = await jornadaService.getJornadaAbierta();
|
||||
const ventasDelDia = await ventasService.getVentasDelDia(new Date());
|
||||
ventas.value = await Promise.all(ventasDelDia.map(async (venta) => ({ ...venta, lineas: await ventasService.getLineasPorVenta(venta.id) })));
|
||||
}
|
||||
|
||||
function getProdNombre(id: string) {
|
||||
@@ -105,6 +117,17 @@ function eliminarLinea(i: number) {
|
||||
lineas.value.splice(i, 1);
|
||||
}
|
||||
|
||||
function editarVenta(venta: any) { ventaOriginal.value = JSON.parse(JSON.stringify(venta)); lineasOriginales.value = JSON.parse(JSON.stringify(venta.lineas)); ventaEditada.value = JSON.parse(JSON.stringify(venta)); actorVenta.value = ''; motivoVenta.value = ''; dialogoVenta.value = true; }
|
||||
async function guardarVenta() {
|
||||
try {
|
||||
const lineas = ventaEditada.value.lineas.map((linea: any) => ({ ...linea, cantidad: Number(linea.cantidad), subtotal: Number(linea.cantidad) * linea.precioUnitario }));
|
||||
if (lineas.some((linea: any) => linea.cantidad <= 0)) throw new Error('Las cantidades deben ser mayores que cero.');
|
||||
const venta = { ...ventaEditada.value, lineas, subtotal: lineas.reduce((total: number, linea: any) => total + linea.subtotal, 0), total: lineas.reduce((total: number, linea: any) => total + linea.subtotal, 0) };
|
||||
await edicionService.editarVenta(venta, ventaOriginal.value, lineasOriginales.value, { modificadoPor: actorVenta.value, motivo: motivoVenta.value });
|
||||
dialogoVenta.value = false; await cargar(); exito.value = 'Venta modificada y auditada.';
|
||||
} catch (e: any) { error.value = e.message; }
|
||||
}
|
||||
|
||||
const total = computed(() => lineas.value.reduce((acc, l) => acc + l.subtotal, 0));
|
||||
|
||||
async function registrarVenta() {
|
||||
|
||||
@@ -14,3 +14,18 @@ test('guía la configuración cuando el catálogo está vacío', async ({ page }
|
||||
await page.getByRole('link', { name: 'Ir a inventario' }).click();
|
||||
await expect(page).toHaveURL(/inventario/);
|
||||
});
|
||||
|
||||
test('permite volver a inicio desde Jornada e Inventario', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
if ((await page.viewportSize())!.width < 960) await page.getByTestId('navigation-toggle').click();
|
||||
await page.getByRole('link', { name: 'Jornada', exact: true }).click();
|
||||
await expect(page.getByText('Jornada del Toldo')).toBeVisible();
|
||||
await expect(page.getByLabel('Migas de pan')).toContainText('Inicio');
|
||||
await page.getByRole('link', { name: 'Inicio' }).last().click();
|
||||
await expect(page.getByText('Panel de control')).toBeVisible();
|
||||
if ((await page.viewportSize())!.width < 960) await page.getByTestId('navigation-toggle').click();
|
||||
await page.getByRole('link', { name: 'Inventario', exact: true }).click();
|
||||
await expect(page.getByText('Inventario y Productos')).toBeVisible();
|
||||
await page.getByTestId('back-button').click();
|
||||
await expect(page.getByText('Panel de control')).toBeVisible();
|
||||
});
|
||||
|
||||
2
openspec/changes/add-navigation-csv-audit/.openspec.yaml
Normal file
2
openspec/changes/add-navigation-csv-audit/.openspec.yaml
Normal file
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-10-25
|
||||
41
openspec/changes/add-navigation-csv-audit/design.md
Normal file
41
openspec/changes/add-navigation-csv-audit/design.md
Normal file
@@ -0,0 +1,41 @@
|
||||
# Design
|
||||
|
||||
## Context
|
||||
|
||||
La app usa Vue Router, Vuetify y Dexie offline-first. El Layout actual sólo ofrece el drawer; las vistas no comparten contexto de ruta ni controles de historial. Productores, productos y ventas se guardan directamente desde las vistas y no existe una bitácora general de cambios.
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:**
|
||||
|
||||
- Hacer evidente cómo volver a Inicio o a la vista anterior en cualquier tamaño de pantalla.
|
||||
- Procesar CSV localmente, sin backend, con errores por fila y resumen de importación.
|
||||
- Mantener registros originales y almacenar auditorías inmutables de las ediciones.
|
||||
- Reutilizar diálogos y tablas Vuetify para editar sin perder el contexto.
|
||||
|
||||
**Non-Goals:**
|
||||
|
||||
- Control de usuarios, autenticación o sincronización remota.
|
||||
- Edición destructiva sin confirmación.
|
||||
- Interpretar formatos de hoja de cálculo distintos de CSV UTF-8/compatible con separador coma o punto y coma.
|
||||
|
||||
## Decisions
|
||||
|
||||
- **Navegación en Layout:** usar `router-link`, `router.back()` y `router.push('/')` en un encabezado compartido. Se elige un encabezado sobre botones duplicados en vistas para garantizar consistencia.
|
||||
- **Importación CSV en servicio:** parsear encabezados con normalización Unicode NFD, validar cada fila y ejecutar una transacción Dexie. Se rechazan filas inválidas sin abortar las válidas y se entrega un resumen.
|
||||
- **Auditoría append-only:** agregar una tabla `auditorias` con entidad, registro, operación, actor, motivo, fecha y snapshots JSON. Las ediciones actualizan el registro y agregan la auditoría en una misma transacción.
|
||||
- **Ventas editables como corrección controlada:** conservar el id y registrar diferencias de líneas/inventario; exigir motivo y actor antes de persistir.
|
||||
- **Compatibilidad de datos:** nueva versión Dexie agrega la tabla sin borrar datos existentes; exportación incluirá auditorías y podrá importar archivos previos sin auditorías como lista vacía.
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
- [CSV ambiguo] Separador o codificación inesperada puede producir columnas incorrectas -> detectar separador, normalizar BOM y mostrar errores por fila antes de confirmar.
|
||||
- [Edición de ventas] Puede alterar stock y reportes históricos -> registrar snapshots y movimientos compensatorios en transacción.
|
||||
- [Historial atrás] Puede salir de la app si no existe historial -> fallback explícito a Inicio.
|
||||
- [Datos grandes] Auditar snapshots incrementa almacenamiento local -> aceptar el costo por trazabilidad de la POC y exportarlo junto con los datos.
|
||||
|
||||
## Migration Plan
|
||||
|
||||
1. Migrar Dexie agregando `auditorias`.
|
||||
2. Mantener importación de exportaciones antiguas con auditorías vacías.
|
||||
3. Validar navegación, importación y edición en web; después ejecutar build y APK.
|
||||
35
openspec/changes/add-navigation-csv-audit/proposal.md
Normal file
35
openspec/changes/add-navigation-csv-audit/proposal.md
Normal file
@@ -0,0 +1,35 @@
|
||||
# Proposal
|
||||
|
||||
## Why
|
||||
|
||||
La aplicación permite abrir módulos, pero el usuario puede quedar atrapado en una vista porque no tiene una ruta visible de regreso, historial atrás ni migas de pan. Además, el catálogo inicial debe poder cargarse rápidamente desde una hoja CSV y los cambios a datos operativos deben conservar trazabilidad para proteger la confianza en los reportes.
|
||||
|
||||
## What Changes
|
||||
|
||||
- Añadir navegación global consistente con Inicio, Atrás, migas de pan y menú móvil/desktop.
|
||||
- Cubrir con Playwright la navegación entre Inicio, Jornada, Inventario y regreso atrás.
|
||||
- Importar inventario inicial desde CSV con columnas `Nombre de producto`, `Nombre Productor`, `precio unitario` y `cantidad`.
|
||||
- Comparar nombres ignorando mayúsculas, tildes y espacios redundantes; crear productores y productos faltantes.
|
||||
- Permitir editar productores, productos y ventas desde la UI.
|
||||
- Registrar auditoría de cada edición con persona, fecha, motivo, valores anteriores y nuevos.
|
||||
- Mejorar formularios, tablas, diálogos y mensajes usando Vuetify.
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
|
||||
- `navigation`: navegación global y orientación contextual en todas las vistas.
|
||||
- `inventory-csv-import`: carga e importación validada de inventario inicial en CSV.
|
||||
- `auditable-edits`: edición de productores, productos y ventas con trazabilidad.
|
||||
|
||||
### Modified Capabilities
|
||||
|
||||
- `toldo-poc`: amplía persistencia, inventario inicial y experiencia de uso sin conexión.
|
||||
|
||||
## Impact
|
||||
|
||||
- `apps/toldo-app`: Layout, router, vistas, diálogos, tablas y pruebas Playwright.
|
||||
- `packages/core`: entidad de auditoría y reglas de normalización/validación.
|
||||
- `packages/data`: tabla Dexie y migración de auditorías.
|
||||
- `packages/services`: importador CSV y servicios de edición auditada.
|
||||
- No requiere backend ni cambia el formato de exportación de forma incompatible.
|
||||
@@ -0,0 +1,31 @@
|
||||
# Spec Delta
|
||||
|
||||
## Purpose
|
||||
|
||||
Garantiza que cambios posteriores al registro original de productores, productos y ventas sean explicables y aparezcan en la información exportada y reportada.
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Editar registros operativos
|
||||
The system SHALL allow editing producers, products, and sales from the application UI.
|
||||
|
||||
#### Scenario: Editar producto
|
||||
- **WHEN** user edits a product and confirms with actor and reason
|
||||
- **THEN** the product stores the new values
|
||||
- **AND** the edit appears in the audit history
|
||||
|
||||
### Requirement: Auditoría obligatoria
|
||||
The system SHALL require actor and non-empty reason for every edit and SHALL store before and after values with timestamp.
|
||||
|
||||
#### Scenario: Edición sin motivo
|
||||
- **WHEN** user attempts to save an edit without actor or reason
|
||||
- **THEN** the edit is not persisted
|
||||
- **AND** an inline validation message identifies the missing data
|
||||
|
||||
### Requirement: Corrección de venta trazable
|
||||
The system SHALL update inventory consistently when an existing sale is edited and SHALL preserve the original and corrected sale snapshots.
|
||||
|
||||
#### Scenario: Cambiar cantidad vendida
|
||||
- **WHEN** user changes a sale line quantity with a valid audit reason
|
||||
- **THEN** compensating inventory movement is recorded
|
||||
- **AND** the audit entry identifies the original and new sale values
|
||||
@@ -0,0 +1,33 @@
|
||||
# Spec Delta
|
||||
|
||||
## Purpose
|
||||
|
||||
Permite cargar rápidamente el inventario inicial desde archivos CSV locales, creando sólo las entidades que no existen y evitando duplicados por diferencias de formato en los nombres.
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Importar inventario CSV
|
||||
The system SHALL import CSV rows with product name, producer name, unit price, and optional quantity columns.
|
||||
|
||||
#### Scenario: Importación válida
|
||||
- **GIVEN** a CSV with the required headers
|
||||
- **WHEN** user previews and confirms the import
|
||||
- **THEN** missing producers and products are created
|
||||
- **AND** a positive quantity creates an initial inventory movement
|
||||
- **AND** empty or zero quantity creates no movement
|
||||
|
||||
### Requirement: Comparación normalizada
|
||||
The system SHALL match producer and product names ignoring case, accents, BOM, and redundant surrounding whitespace.
|
||||
|
||||
#### Scenario: Evitar duplicado
|
||||
- **GIVEN** an existing producer `La Familia González`
|
||||
- **WHEN** CSV contains ` la familia gonzalez `
|
||||
- **THEN** the row uses the existing producer instead of creating a duplicate
|
||||
|
||||
### Requirement: Errores de importación
|
||||
The system SHALL report invalid rows without persisting invalid records and SHALL show a summary of created, reused, skipped, and failed rows.
|
||||
|
||||
#### Scenario: Precio inválido
|
||||
- **WHEN** a row has a non-numeric or negative price
|
||||
- **THEN** that row is rejected with its line number and reason
|
||||
- **AND** valid rows remain importable
|
||||
@@ -0,0 +1,28 @@
|
||||
# Spec Delta
|
||||
|
||||
## Purpose
|
||||
|
||||
Permite que una persona siempre entienda dónde está y pueda volver a Inicio o al paso anterior sin depender del botón físico del dispositivo.
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Navegación global visible
|
||||
The system SHALL show a shared navigation context with a home action, back action, and breadcrumbs on every application view.
|
||||
|
||||
#### Scenario: Volver desde Jornada
|
||||
- **WHEN** user navigates from Inicio to Jornada
|
||||
- **THEN** the view shows the breadcrumb `Inicio > Jornada`
|
||||
- **AND** selecting Inicio returns to the dashboard
|
||||
- **AND** selecting Atrás returns to the previous route
|
||||
|
||||
#### Scenario: Fallback de Atrás
|
||||
- **GIVEN** the user opened a deep link without router history
|
||||
- **WHEN** user selects Atrás
|
||||
- **THEN** the system navigates to Inicio instead of leaving the application
|
||||
|
||||
### Requirement: Navegación responsive
|
||||
The system SHALL expose the same destinations through a visible desktop menu and an accessible mobile menu.
|
||||
|
||||
#### Scenario: Menú móvil
|
||||
- **WHEN** viewport width is narrow
|
||||
- **THEN** user can open the menu, choose Inventario, close it, and return to Inicio
|
||||
26
openspec/changes/add-navigation-csv-audit/tasks.md
Normal file
26
openspec/changes/add-navigation-csv-audit/tasks.md
Normal file
@@ -0,0 +1,26 @@
|
||||
# Tasks
|
||||
|
||||
## 1. Navegación y orientación
|
||||
|
||||
- [x] 1.1 Añadir encabezado compartido con Inicio, Atrás, breadcrumbs y fallback seguro; verificar navegación manual en todas las rutas
|
||||
- [x] 1.2 Mejorar menú desktop/móvil con destinos accesibles y estado activo; verificar con Playwright en viewport móvil
|
||||
- [x] 1.3 Añadir pruebas Playwright para Inicio -> Jornada -> Inicio, Atrás, Inicio -> Inventario -> Atrás y menú móvil
|
||||
|
||||
## 2. Importación CSV
|
||||
|
||||
- [x] 2.1 Implementar normalización Unicode de nombres, detección de separador y validación de encabezados; cubrir con tests unitarios
|
||||
- [x] 2.2 Implementar servicio transaccional de importación con resumen por fila y movimientos iniciales; cubrir cantidades vacías/0 y errores
|
||||
- [x] 2.3 Añadir versión Dexie para auditorías y soportar importación/exportación compatible con datos existentes
|
||||
- [x] 2.4 Incorporar flujo Vuetify de selección, previsualización, confirmación y resultado de CSV en Inventario
|
||||
|
||||
## 3. Edición auditable
|
||||
|
||||
- [x] 3.1 Crear entidad/repositorio/servicio de auditoría append-only y exigir actor/motivo; verificar snapshots
|
||||
- [x] 3.2 Añadir edición de productores y productos con diálogos Vuetify y confirmación auditada
|
||||
- [x] 3.3 Añadir edición de ventas con actualización compensatoria de inventario y auditoría
|
||||
- [x] 3.4 Mostrar historial de cambios y exportarlo; cubrir edición sin motivo y edición válida con tests
|
||||
|
||||
## 4. Verificación y entrega
|
||||
|
||||
- [x] 4.1 Ejecutar tests unitarios, `vue-tsc` y Playwright; verificar navegación e importación en web
|
||||
- [x] 4.2 Generar build web, sincronizar Capacitor y generar APK debug; verificar artefacto
|
||||
14
packages/core/src/entities/Auditoria.ts
Normal file
14
packages/core/src/entities/Auditoria.ts
Normal 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;
|
||||
}
|
||||
@@ -6,3 +6,4 @@ export * from './Gasto';
|
||||
export * from './Ajuste';
|
||||
export * from './CuadreCaja';
|
||||
export * from './JornadaToldo';
|
||||
export * from './Auditoria';
|
||||
|
||||
@@ -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'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
29
packages/services/src/auditoria/AuditoriaService.ts
Normal file
29
packages/services/src/auditoria/AuditoriaService.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
37
packages/services/src/auditoria/EdicionService.ts
Normal file
37
packages/services/src/auditoria/EdicionService.ts
Normal 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);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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 ?? [] } };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user