4 Commits

Author SHA1 Message Date
mono
6a3352a96c fix: preservar datos y recuperar base local 2026-10-25 21:27:44 -05:00
mono
a21c43684c test: verificar apk y flujos beta 2026-10-25 20:23:13 -05:00
mono
1470ffd0de chore: preparar version 0.0.4 2026-10-25 20:06:57 -05:00
mono
ba18b73ed9 feat: mejorar pos movil y cierre de jornada 2026-10-25 20:03:36 -05:00
40 changed files with 488 additions and 87 deletions

View File

@@ -25,3 +25,16 @@ Skills del proyecto en `skills/`:
Seguir OpenSpec workflow: propose -> apply -> archive
Usar skills cuando aplique al contexto
Mantener código siguiendo arquitectura limpia
## Release Android
- Antes de publicar una APK, ejecutar `npm run test:run`, `npm run test:e2e`, `npm exec vue-tsc -- --noEmit` y `npm run build`.
- Ejecutar `npx cap sync android` y `./gradlew assembleDebug` usando el builder Android del repositorio cuando el host no tenga Java.
- Verificar el APK con `scripts/verify-apk.sh <apk> <versionName> <versionCode>` antes de adjuntarlo a Gitea.
- El script debe confirmar `applicationId`, `versionName`, `versionCode`, bundle JavaScript, fuentes MDI y textos críticos de la versión.
- Incrementar `versionCode` para cada APK publicable; mantener la misma firma y publicar versiones beta como prerelease.
- Después de subir un asset, comparar su SHA256 con el APK verificado localmente y comprobar el asset de la release mediante la API de Gitea.
## Persistencia local
- La app debe abrir Dexie explícitamente antes de montar Vue.
- Las migraciones deben agregar una nueva versión Dexie y transformar datos; nunca deben borrar datos automáticamente.
- El reset de la base es una acción manual, confirmada y visible sólo como último recurso.

View File

@@ -1,12 +1,12 @@
{
"name": "toldo-app",
"version": "0.0.3",
"version": "0.0.6",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "toldo-app",
"version": "0.0.3",
"version": "0.0.6",
"dependencies": {
"@capacitor/android": "^6.2.1",
"@capacitor/core": "^6.2.1",

View File

@@ -1,7 +1,7 @@
{
"name": "toldo-app",
"private": true,
"version": "0.0.3",
"version": "0.0.6",
"type": "module",
"scripts": {
"dev": "vite",

View File

@@ -19,7 +19,7 @@ describe('contratos de operación y respaldo', () => {
it('expone la versión actual en las exportaciones', () => {
const result = new ExportImportService().exportAll({ productores: [], productos: [], movimientosInventario: [], ventas: [], ventaLineas: [], gastos: [], ajustes: [], cuadres: [] });
expect(result.appVersion).toBe('0.0.3');
expect(result.appVersion).toBe('0.0.6');
});
it('exporta jornadas junto con los datos operativos', () => {

View File

@@ -0,0 +1,22 @@
import { beforeEach, describe, expect, it } from 'vitest';
import { db, openDatabase, resetDatabase } from '@toldo/data';
describe('bootstrap y recuperación de base local', () => {
beforeEach(async () => {
await resetDatabase();
});
it('abre el esquema actual y conserva datos al cerrar y abrir', async () => {
await db.productores.add({ id: 'persisted', nombre: 'Productor persistente', activo: true, createdAt: new Date() } as any);
db.close();
await openDatabase();
expect(await db.productores.get('persisted')).toMatchObject({ nombre: 'Productor persistente' });
expect(db.auditorias).toBeTruthy();
});
it('restablece sólo cuando se solicita explícitamente', async () => {
await db.productores.add({ id: 'to-reset', nombre: 'Temporal', activo: true, createdAt: new Date() } as any);
await resetDatabase();
expect(await db.productores.count()).toBe(0);
});
});

View File

@@ -0,0 +1,8 @@
import { describe, expect, it } from 'vitest';
import { calcularDevuelta, validarPagoEfectivo } from '@toldo/core';
describe('pago en efectivo', () => {
it('calcula la devuelta', () => expect(calcularDevuelta(12000, 20000)).toBe(8000));
it('rechaza efectivo insuficiente', () => expect(validarPagoEfectivo(12000, 10000)).toContain('no alcanza'));
it('acepta efectivo exacto', () => expect(validarPagoEfectivo(12000, 12000)).toBeNull());
});

View File

@@ -29,10 +29,21 @@ describe('casos de uso operativos offline', () => {
const service = new JornadaService();
const jornadaId = await service.abrirJornada(['Ana'], 50000);
await expect(service.abrirJornada(['Luis'], 10000)).rejects.toThrow('Ya existe una jornada abierta');
await service.cerrarJornada(jornadaId);
await service.cerrarJornada(jornadaId, 0);
await expect(service.registrarGastoEnJornada(jornadaId, { id: 'gasto-cerrado', fecha, concepto: 'Bolsas', monto: 1000, formaPago: 'EFECTIVO', createdAt: fecha } as any)).rejects.toThrow('jornada cerrada');
});
it('cierra caja y jornada juntos con banco QR opcional', async () => {
const service = new JornadaService();
const jornadaId = await service.abrirJornada(['Ana'], 50000);
const cuadreId = await service.cerrarJornada(jornadaId, 48000, 12000);
const cuadre = await db.cuadres.get(cuadreId);
const jornada = await db.jornadas.get(jornadaId);
expect(cuadre?.saldoRealEfectivo).toBe(48000);
expect(cuadre?.totalQRBanco).toBe(12000);
expect(jornada?.estado).toBe('CERRADA');
});
it('calcula cuadre mixto de efectivo y QR y registra ambos descuadres', async () => {
await crearProducto();
const jornada = new JornadaService();

View File

@@ -16,15 +16,10 @@
<v-main>
<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">
<div class="d-flex justify-end mb-3 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>
@@ -32,30 +27,26 @@
</template>
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref } from 'vue';
import { onMounted, onUnmounted, ref } from 'vue';
import { JornadaService } from '@toldo/services';
import { useRoute, useRouter } from 'vue-router';
import { 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' },
{ to: '/ventas', icon: 'mdi-cart', title: 'Ventas POS' },
{ to: '/gastos', icon: 'mdi-cash-minus', title: 'Gastos' },
{ to: '/cuadre', icon: 'mdi-cash-register', title: 'Cierre de caja' },
{ to: '/ajustes', icon: 'mdi-pencil', title: 'Ajustes' },
{ 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; }
function goBack() { if (window.history.state?.back) router.back(); else router.push('/'); }
onMounted(async () => { updateViewport(); window.addEventListener('resize', updateViewport); jornada.value = await jornadaService.getJornadaAbierta(); });
onUnmounted(() => window.removeEventListener('resize', updateViewport));
</script>

View File

@@ -2,5 +2,27 @@ import { createApp } from 'vue'
import App from './App.vue'
import router from './router'
import vuetify from './plugins/vuetify'
import { openDatabase, resetDatabase } from '@toldo/data'
createApp(App).use(router).use(vuetify).mount('#app')
async function bootstrap() {
try {
await openDatabase()
createApp(App).use(router).use(vuetify).mount('#app')
} catch (error) {
const root = document.querySelector('#app')
if (!root) return
root.innerHTML = ''
const card = document.createElement('main')
card.className = 'database-recovery'
card.innerHTML = '<h1>No se pudo abrir los datos locales</h1><p>La aplicación no continuará para evitar mostrar información incompleta. Puedes intentar recargar o restablecer el entorno como último recurso.</p>'
const details = document.createElement('pre')
details.textContent = error instanceof Error ? error.message : String(error)
const button = document.createElement('button')
button.textContent = 'Restablecer entorno local'
button.onclick = async () => { button.disabled = true; await resetDatabase(); window.location.reload() }
card.append(details, button)
root.append(card)
}
}
bootstrap()

View File

@@ -4,9 +4,7 @@ import Home from '../views/Home.vue';
import Inventario from '../views/Inventario.vue';
import Ventas from '../views/Ventas.vue';
import Gastos from '../views/Gastos.vue';
import Cuadre from '../views/Cuadre.vue';
import Reportes from '../views/Reportes.vue';
import Ajustes from '../views/Ajustes.vue';
import Exportar from '../views/Exportar.vue';
import Jornada from '../views/Jornada.vue';
@@ -22,9 +20,7 @@ const router = createRouter({
{ 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' } },
]
}

View File

@@ -56,6 +56,11 @@ body {
background: #f5f7f2;
}
.database-recovery { max-width: 640px; margin: 12vh auto; padding: 24px; font-family: system-ui, sans-serif; color: #26352b; background: #fffdf8; border: 1px solid #d9e2d7; border-radius: 12px; box-shadow: 0 8px 30px rgba(38, 53, 43, .12); }
.database-recovery p { margin: 12px 0; }
.database-recovery pre { white-space: pre-wrap; font-size: 12px; color: #6b4a25; background: #f7f1e7; padding: 12px; border-radius: 8px; }
.database-recovery button { padding: 12px 16px; color: white; background: #2d5f3b; border: 0; border-radius: 8px; cursor: pointer; }
h1,
h2 {
font-family: var(--heading);

View File

@@ -1,6 +1,6 @@
<template>
<div>
<h2>Ajustes</h2>
<div class="mb-5"><div class="text-overline text-primary">INVENTARIO</div><h1 class="text-h4 mb-1">Historial de ajustes</h1><div class="text-body-2 text-medium-emphasis">Los ajustes se realizan desde Inventario.</div></div>
<v-alert v-if="mensaje" :type="tipoMensaje" variant="tonal" class="mb-3">{{ mensaje }}</v-alert>
<p><strong>Requiere motivo obligatorio</strong></p>
<section>

View File

@@ -8,19 +8,25 @@
<section>
<v-card><v-card-title>Restaurar datos</v-card-title><v-card-text><v-file-input accept=".json" label="Seleccionar copia JSON" @update:model-value="archivo = $event" /><v-alert v-if="mensaje" :type="tipoMensaje" variant="tonal" class="mb-3">{{ mensaje }}</v-alert><v-btn @click="importar" :disabled="!archivo" color="secondary" prepend-icon="mdi-upload">Importar datos</v-btn></v-card-text></v-card>
<v-card class="mt-4" variant="tonal" color="error"><v-card-title>Mantenimiento</v-card-title><v-card-text><div class="text-body-2 mb-3">Restablece la base local sólo si la aplicación no puede migrarla. Esta acción elimina ventas, inventario, jornadas y respaldos locales.</div><v-btn color="error" variant="outlined" prepend-icon="mdi-database-refresh" @click="reiniciarEntorno">Restablecer entorno local</v-btn></v-card-text></v-card>
</section>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { db } from '@toldo/data';
import { db, resetDatabase } from '@toldo/data';
import { ExportImportService } from '@toldo/services';
const exportService = new ExportImportService();
const archivo = ref<File | null>(null);
const mensaje = ref('');
const tipoMensaje = ref<'success' | 'error'>('success');
async function reiniciarEntorno() {
if (!window.confirm('Se eliminarán todos los datos locales. Exporta un respaldo antes de continuar. ¿Restablecer?')) return;
await resetDatabase();
window.location.reload();
}
function onFileChange(e: any) {
archivo.value = e.target.files[0];

View File

@@ -1,24 +1,10 @@
<template>
<div>
<h2>Gastos</h2>
<div class="mb-5"><div class="text-overline text-primary">OPERACION</div><h1 class="text-h4 mb-1">Gastos</h1><div class="text-body-2 text-medium-emphasis">Registra compras y pagos de la jornada.</div></div>
<v-alert v-if="mensaje" :type="tipoMensaje" variant="tonal" class="mb-3">{{ mensaje }}</v-alert>
<p v-if="!jornadaAbierta" style="color: orange">Sin jornada abierta - debe abrir jornada para registrar gastos</p>
<p v-else style="color: green">Jornada abierta</p>
<section>
<input v-model="concepto" placeholder="Concepto (cinta, lapicero, bolsas...)" />
<input v-model.number="monto" type="number" placeholder="Monto" />
<select v-model="formaPago">
<option value="EFECTIVO">Efectivo</option>
<option value="QR">QR</option>
</select>
<button @click="registrar" :disabled="!jornadaAbierta">Registrar Gasto</button>
</section>
<section>
<h3>Gastos del día</h3>
<ul>
<li v-for="g in gastos" :key="g.id">{{ g.concepto }} - ${{ g.monto }} ({{ g.formaPago }})</li>
</ul>
</section>
<v-alert :type="jornadaAbierta ? 'success' : 'warning'" variant="tonal" class="mb-4">{{ jornadaAbierta ? 'Jornada abierta' : 'Abre una jornada para registrar gastos' }}</v-alert>
<v-card class="mb-4"><v-card-title>Registrar gasto</v-card-title><v-card-text><v-form @submit.prevent="registrar"><v-text-field v-model="concepto" label="Concepto" placeholder="Cinta, lapicero, bolsas..." prepend-inner-icon="mdi-receipt-text" /><v-row><v-col cols="12" sm="6"><v-text-field v-model.number="monto" type="number" label="Monto" prefix="$" /></v-col><v-col cols="12" sm="6"><v-select v-model="formaPago" :items="['EFECTIVO', 'QR']" label="Forma de pago" /></v-col></v-row><v-btn type="submit" color="primary" block size="large" :disabled="!jornadaAbierta" prepend-icon="mdi-plus-circle">Registrar gasto</v-btn></v-form></v-card-text></v-card>
<v-card><v-card-title>Gastos del día</v-card-title><v-list><v-list-item v-for="g in gastos" :key="g.id" :title="g.concepto" :subtitle="g.formaPago"><template #append><span class="font-weight-bold">${{ g.monto.toLocaleString() }}</span></template></v-list-item><v-list-item v-if="!gastos.length" title="Sin gastos registrados" /></v-list></v-card>
</div>
</template>

View File

@@ -6,8 +6,9 @@
<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-tabs v-model="inventoryTab" class="d-md-none mb-4" color="primary" grow><v-tab value="catalogo">Catálogo</v-tab><v-tab value="ingresos">Ingresos</v-tab><v-tab value="ajustes">Ajustes</v-tab></v-tabs>
<v-row>
<v-row v-show="!isMobile || inventoryTab === 'catalogo'">
<v-col cols="12" md="6">
<v-card class="mb-4">
<v-card-title>Productores</v-card-title>
@@ -45,7 +46,7 @@
</v-col>
</v-row>
<v-row>
<v-row v-show="!isMobile || inventoryTab === 'ingresos'">
<v-col cols="12" md="6">
<v-card>
<v-card-title>Ingreso Inicial del Día</v-card-title>
@@ -69,16 +70,19 @@
</v-card>
</v-col>
</v-row>
<v-card v-show="!isMobile || inventoryTab === 'ajustes'" class="mt-4"><v-card-title>Ajustar inventario</v-card-title><v-card-text><v-select v-model="productoAjuste" :items="productos" item-title="nombrePresentacion" item-value="id" label="Producto" /><v-text-field v-model.number="cantidadAjuste" type="number" label="Nueva cantidad" /><v-textarea v-model="motivoAjuste" label="Motivo obligatorio" rows="2" /><v-btn color="warning" block prepend-icon="mdi-scale-balance" @click="ajustarInventario">Guardar ajuste</v-btn></v-card-text></v-card>
<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, computed, onMounted } from 'vue';
import { ref, computed, onMounted, onUnmounted } from 'vue';
import { db } from '@toldo/data';
import { InventarioService, ImportInventarioCsvService, EdicionService } from '@toldo/services';
import { InventarioService, ImportInventarioCsvService, EdicionService, AjustesService } from '@toldo/services';
import { validateAjusteMotivo } from '@toldo/core';
const invService = new InventarioService();
const ajustesService = new AjustesService();
const productores = ref<any[]>([]);
const productos = ref<any[]>([]);
const nuevoProductor = ref('');
@@ -103,11 +107,26 @@ const edicionProducto = ref<any>({});
const originalEdicion = ref<any>({});
const actor = ref('');
const motivo = ref('');
const productoAjuste = ref('');
const cantidadAjuste = ref(0);
const motivoAjuste = ref('');
const inventoryTab = ref('catalogo');
const isMobile = ref(false);
const busquedaProductores = ref('');
const busquedaProductos = ref('');
const productoresFiltrados = computed(() => productores.value.filter((productor) => !busquedaProductores.value.trim() || productor.nombre.toLocaleLowerCase().includes(busquedaProductores.value.trim().toLocaleLowerCase())));
const productosFiltrados = computed(() => productos.value.filter((producto) => !busquedaProductos.value.trim() || producto.nombrePresentacion.toLocaleLowerCase().includes(busquedaProductos.value.trim().toLocaleLowerCase())));
function avisar(texto: string, tipo: 'success' | 'error' = 'error') { mensaje.value = texto; tipoMensaje.value = tipo; }
function updateViewport() { isMobile.value = window.innerWidth < 960; }
async function ajustarInventario() {
try {
validateAjusteMotivo(motivoAjuste.value);
if (!productoAjuste.value || cantidadAjuste.value < 0) return avisar('Selecciona un producto y una cantidad válida.');
const anterior = await invService.calcularSaldoProducto(productoAjuste.value);
await ajustesService.crearAjuste({ id: crypto.randomUUID(), fecha: new Date(), productoId: productoAjuste.value, cantidadAnterior: anterior, cantidadNueva: cantidadAjuste.value, diferencia: cantidadAjuste.value - anterior, motivo: motivoAjuste.value, createdAt: new Date() } as any);
cantidadAjuste.value = 0; motivoAjuste.value = ''; await cargar(); avisar('Ajuste registrado.', 'success');
} catch (error: any) { avisar(error.message); }
}
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() {
@@ -165,5 +184,6 @@ async function ingresoSemana() {
avisar('Ingreso entre semana registrado.', 'success');
}
onMounted(cargar);
onMounted(() => { updateViewport(); window.addEventListener('resize', updateViewport); cargar(); });
onUnmounted(() => window.removeEventListener('resize', updateViewport));
</script>

View File

@@ -26,9 +26,13 @@
<div v-if="resumen">
<div class="mb-2">Ventas: ${{ resumen.totalVentas }}</div>
<div class="mb-2">Gastos: ${{ resumen.totalGastos }}</div>
<div class="mb-2 font-weight-bold">Saldo esperado: ${{ jornadaAbierta ? jornadaAbierta.baseInicial + resumen.totalVentas - resumen.totalGastos : 0 }}</div>
<div class="mb-2 font-weight-bold">Saldo esperado en efectivo: ${{ saldoEsperadoEfectivo.toLocaleString() }}</div>
</div>
<v-btn @click="cerrar" color="error" block class="mt-2" :disabled="!jornadaAbierta">Cerrar Jornada (generar cuadre)</v-btn>
<v-text-field v-model.number="saldoRealEfectivo" type="number" label="Efectivo contado (obligatorio)" prefix="$" class="mt-4" />
<v-text-field v-model.number="totalQRBanco" type="number" label="Dinero en banco por QR (opcional)" prefix="$" />
<v-alert v-if="saldoRealEfectivo !== null && resumen" :type="diferenciaEfectivo === 0 ? 'success' : 'warning'" variant="tonal" class="mb-2">Diferencia de efectivo: ${{ diferenciaEfectivo.toLocaleString() }}</v-alert>
<v-alert v-if="cierreError" :type="cierreCompletado ? 'success' : 'error'" variant="tonal" class="mb-2">{{ cierreError }}</v-alert>
<v-btn @click="cerrar" color="error" block class="mt-2" :disabled="!jornadaAbierta || saldoRealEfectivo < 0" prepend-icon="mdi-lock-check">Cerrar caja y jornada</v-btn>
</v-card-text>
</v-card>
</v-col>
@@ -37,14 +41,21 @@
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import { JornadaService } from '@toldo/services';
import { ref, computed, onMounted } from 'vue';
import { JornadaService, CuadreService } from '@toldo/services';
const service = new JornadaService();
const cuadreService = new CuadreService();
const jornadaAbierta = ref<any>(null);
const responsablesStr = ref('');
const baseInicial = ref(0);
const resumen = ref<any>(null);
const saldoRealEfectivo = ref<number | null>(null);
const totalQRBanco = ref<number | null>(null);
const cierreError = ref('');
const cierreCompletado = ref(false);
const saldoEsperadoEfectivo = ref(0);
const diferenciaEfectivo = computed(() => saldoRealEfectivo.value === null ? 0 : saldoRealEfectivo.value - saldoEsperadoEfectivo.value);
function formatFecha(d: any) {
try { return new Date(d).toLocaleString(); } catch { return d; }
@@ -54,8 +65,11 @@ async function cargar() {
jornadaAbierta.value = await service.getJornadaAbierta();
if (jornadaAbierta.value) {
resumen.value = await service.getResumenJornada(jornadaAbierta.value.id);
const cuadre = await cuadreService.calcularCuadreDelDia(jornadaAbierta.value.fecha, jornadaAbierta.value.baseInicial);
saldoEsperadoEfectivo.value = cuadre.saldoEsperadoEfectivo ?? 0;
} else {
resumen.value = null;
saldoEsperadoEfectivo.value = 0;
}
}
@@ -75,10 +89,14 @@ async function abrir() {
async function cerrar() {
if (!jornadaAbierta.value) return;
cierreError.value = '';
cierreCompletado.value = false;
if (saldoRealEfectivo.value === null || saldoRealEfectivo.value === undefined || saldoRealEfectivo.value < 0) { cierreError.value = 'El efectivo contado es obligatorio.'; return; }
try {
const cid = await service.cerrarJornada(jornadaAbierta.value.id);
const cid = await service.cerrarJornada(jornadaAbierta.value.id, Number(saldoRealEfectivo.value), totalQRBanco.value === null ? undefined : Number(totalQRBanco.value));
await cargar();
alert(`Jornada cerrada. Cuadre: ${cid}`);
cierreCompletado.value = true;
cierreError.value = `Cierre de caja y jornada completado: ${cid}`;
} catch (e: any) {
alert(e.message);
}

View File

@@ -1,8 +1,8 @@
<template>
<div>
<div class="d-flex flex-wrap align-center justify-space-between mb-4 ga-2"><div><h1 class="text-h4 mb-1">Punto de venta</h1><div class="text-body-2 text-medium-emphasis">Toca un producto para agregarlo al carrito</div></div><v-chip :color="jornadaAbierta ? 'success' : 'warning'" variant="tonal" :prepend-icon="jornadaAbierta ? 'mdi-check-circle' : 'mdi-alert'">{{ jornadaAbierta ? 'Jornada abierta' : 'Abre una jornada' }}</v-chip></div>
<div class="d-flex flex-wrap align-center justify-space-between mb-4 ga-2"><div><h1 class="text-h4 mb-1">Punto de venta</h1><div class="text-body-2 text-medium-emphasis">Busca un producto y agrégalo al carrito</div></div><div class="d-flex align-center ga-2"><v-btn v-if="isMobile" color="primary" prepend-icon="mdi-store-search" @click="catalogoDialog = true">Buscar productos</v-btn><v-chip :color="jornadaAbierta ? 'success' : 'warning'" variant="tonal" :prepend-icon="jornadaAbierta ? 'mdi-check-circle' : 'mdi-alert'">{{ jornadaAbierta ? 'Jornada abierta' : 'Abre una jornada' }}</v-chip></div></div>
<v-row>
<v-col cols="12" md="8">
<v-col v-if="!isMobile" cols="12" md="8">
<v-card>
<v-card-title>Productos disponibles</v-card-title>
<v-card-text>
@@ -47,22 +47,26 @@
<v-divider class="my-3"></v-divider>
<div class="text-h5 font-weight-bold">Total: ${{ total.toLocaleString() }}</div>
<v-select v-model="formaPago" :items="['EFECTIVO', 'QR']" label="Forma de Pago" class="mt-3"></v-select>
<v-text-field v-if="formaPago === 'EFECTIVO'" v-model.number="efectivoRecibido" type="number" label="Efectivo recibido" prefix="$" prepend-inner-icon="mdi-cash" />
<v-alert v-if="formaPago === 'EFECTIVO' && efectivoRecibido > 0" :type="devuelta >= 0 ? 'success' : 'warning'" variant="tonal" class="mb-2">{{ devuelta >= 0 ? `Devuelta: $${devuelta.toLocaleString()}` : `Faltan $${Math.abs(devuelta).toLocaleString()}` }}</v-alert>
<v-alert v-if="error" type="error" variant="tonal" density="compact" class="mb-2">{{ error }}</v-alert>
<v-alert v-if="exito" type="success" variant="tonal" density="compact" class="mb-2">{{ exito }}</v-alert>
<v-btn @click="registrarVenta" color="success" block size="large" :disabled="lineas.length === 0 || !jornadaAbierta" class="mt-2" prepend-icon="mdi-cash-register">Cobrar ${{ total.toLocaleString() }}</v-btn>
<v-btn @click="registrarVenta" color="success" block size="large" :disabled="lineas.length === 0 || !jornadaAbierta || (formaPago === 'EFECTIVO' && devuelta < 0)" class="mt-2" prepend-icon="mdi-cash-register">Cobrar ${{ total.toLocaleString() }}</v-btn>
</v-card-text>
</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>
<v-dialog v-model="catalogoDialog" fullscreen><v-card><v-toolbar color="primary"><v-btn icon="mdi-close" aria-label="Cerrar catálogo" @click="catalogoDialog = false" /><v-toolbar-title>Seleccionar producto</v-toolbar-title></v-toolbar><v-card-text><v-text-field v-model="busqueda" label="Buscar producto o productor" prepend-inner-icon="mdi-magnify" clearable autofocus /><v-list><v-list-item v-for="prod in productosFiltrados" :key="prod.id" :title="prod.nombrePresentacion" :subtitle="`Disponible: ${prod.stock} · $${prod.precioUnitario.toLocaleString()}`" :disabled="prod.stock <= 0" @click="agregarDesdeDialogo(prod)"><template #append><v-icon icon="mdi-plus-circle" /></template></v-list-item></v-list></v-card-text></v-card></v-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue';
import { ref, computed, onMounted, onUnmounted } from 'vue';
import { db } from '@toldo/data';
import { VentasService, JornadaService, EdicionService } from '@toldo/services';
import { calcularDevuelta, validarPagoEfectivo } from '@toldo/core';
const ventasService = new VentasService();
const jornadaService = new JornadaService();
@@ -71,6 +75,10 @@ const stockByProduct = ref<Record<string, number>>({});
const lineas = ref<any[]>([]);
const formaPago = ref('EFECTIVO');
const jornadaAbierta = ref<any>(null);
const isMobile = ref(false);
const catalogoDialog = ref(false);
const efectivoRecibido = ref(0);
const devuelta = computed(() => calcularDevuelta(total.value, efectivoRecibido.value));
const busqueda = ref('');
const productosFiltrados = computed(() => {
const termino = busqueda.value.trim().toLocaleLowerCase();
@@ -102,6 +110,8 @@ function getProdNombre(id: string) {
return productos.value.find(p => p.id === id)?.nombrePresentacion || id;
}
function getStock(id: string) { return stockByProduct.value[id] ?? 0; }
function updateViewport() { isMobile.value = window.innerWidth < 960; }
function agregarDesdeDialogo(prod: any) { agregarProducto(prod); if (!error.value) catalogoDialog.value = false; }
function agregarProducto(prod: any) {
if (getStock(prod.id) <= 0) { error.value = 'Este producto no tiene inventario disponible. Registra primero el inventario inicial.'; return; }
@@ -154,6 +164,7 @@ async function registrarVenta() {
error.value = 'Debe abrir una jornada antes de registrar ventas.';
return;
}
if (formaPago.value === 'EFECTIVO') { const errorPago = validarPagoEfectivo(total.value, efectivoRecibido.value); if (errorPago) { error.value = errorPago; return; } }
const ventaId = crypto.randomUUID();
const lineasVenta = lineas.value.map(l => ({ ...l, ventaId, formaPago: formaPago.value }));
const venta = {
@@ -167,11 +178,13 @@ async function registrarVenta() {
try {
await jornadaService.registrarVentaEnJornada(jornadaAbierta.value.id, venta, lineasVenta);
lineas.value = [];
efectivoRecibido.value = 0;
exito.value = `Venta registrada por $${total.value.toLocaleString()} en ${formaPago.value}.`;
} catch (e: any) {
error.value = e.message.includes('objectStore') ? 'No se pudo abrir el almacenamiento local. Cierra y vuelve a abrir la aplicación para actualizar la base de datos.' : e.message;
}
}
onMounted(cargar);
onMounted(() => { updateViewport(); window.addEventListener('resize', updateViewport); cargar(); });
onUnmounted(() => window.removeEventListener('resize', updateViewport));
</script>

View File

@@ -21,13 +21,26 @@ test('permite volver a inicio desde Jornada e Inventario', async ({ page }) => {
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 expect(page.locator('.mdi-home-outline')).toBeVisible();
await page.getByTestId('back-button').click();
await expect(page.getByText('Panel de control')).toBeVisible();
});
test('muestra los flujos compactos en móvil', async ({ page }) => {
test.skip(test.info().project.name !== 'mobile', 'Este escenario se ejecuta en el proyecto móvil.');
await page.setViewportSize({ width: 390, height: 844 });
await page.goto('/inventario');
await expect(page.getByRole('tab', { name: 'Catálogo' })).toBeVisible();
await expect(page.getByRole('tab', { name: 'Ingresos' })).toBeVisible();
await expect(page.getByRole('tab', { name: 'Ajustes' })).toBeVisible();
await expect(page.getByText('Migas de pan')).toHaveCount(0);
await page.goto('/ventas');
await expect(page.getByRole('button', { name: 'Buscar productos' })).toBeVisible();
await page.goto('/jornada');
await expect(page.getByLabel('Efectivo contado (obligatorio)')).toBeVisible();
await expect(page.getByLabel('Dinero en banco por QR (opcional)')).toBeVisible();
});

View File

@@ -1,2 +1,2 @@
versionName=0.0.3
versionCode=3
versionName=0.0.6
versionCode=6

View File

@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-10-25

View File

@@ -0,0 +1,23 @@
# Design
## Context
Dexie abre la base al ejecutar la primera consulta. El esquema tiene migraciones 1, 2 y 3, pero `main.ts` monta Vue inmediatamente y no distingue una base en migración de un error de operación.
## Goals / Non-Goals
**Goals:** hacer explícita la apertura, evitar operaciones contra una base no lista, ofrecer recuperación manual y preservar datos durante actualizaciones compatibles.
**Non-Goals:** borrar o reconstruir automáticamente una base con error, sincronizar datos con servidor o reemplazar las migraciones Dexie.
## Decisions
- `db.open()` se ejecutará antes de montar la aplicación.
- Un fallo de apertura montará una vista mínima de recuperación con mensaje técnico resumido y botón de reset destructivo.
- El reset se expondrá también desde Exportar/Importar, exigiendo confirmación y recomendando exportar antes.
- Las migraciones se mantendrán como versiones Dexie; cada cambio futuro de esquema deberá agregar una versión y upgrade explícito.
## Risks / Trade-offs
- El usuario puede resetear por error -> confirmación explícita y texto de pérdida de datos.
- Una base realmente corrupta puede no permitir exportación -> la pantalla explicará que el reset es último recurso.

View File

@@ -0,0 +1,29 @@
# Proposal
## Why
La app abre Dexie de forma implícita y no informa cuando una migración o apertura de IndexedDB falla. Esto puede parecer una pérdida de datos y obliga a borrar manualmente el almacenamiento para continuar.
## What Changes
- Abrir y validar la base local antes de montar Vue.
- Mostrar una pantalla de recuperación si la apertura/migración falla.
- Añadir reset manual del entorno desde Exportar/Importar, con confirmación destructiva.
- Mantener las migraciones Dexie y documentar que nunca deben borrar datos automáticamente.
- Añadir pruebas de persistencia y reset.
## Capabilities
### New Capabilities
- `database-bootstrap-recovery`: apertura explícita, diagnóstico y recuperación controlada de IndexedDB.
### Modified Capabilities
- `toldo-poc`: persistencia local y operación offline.
## Impact
- `packages/data`: bootstrap/reset de base.
- `apps/toldo-app`: arranque, pantalla de error y mantenimiento.
- Tests Vitest, documentación de agentes y APK.

View File

@@ -0,0 +1,33 @@
# Spec Delta
## Purpose
Protege los datos locales durante actualizaciones y ofrece una recuperación explícita cuando IndexedDB no puede abrirse o migrarse.
## ADDED Requirements
### Requirement: Apertura explícita de base
The system SHALL open and complete local database migrations before allowing operational views to run.
#### Scenario: Actualización compatible
- **GIVEN** data exists in an older supported schema
- **WHEN** the app starts after an update
- **THEN** migrations complete before the main UI mounts
- **AND** existing records remain available
### Requirement: Recuperación ante error
The system SHALL show a recovery screen when the local database cannot be opened.
#### Scenario: Migración fallida
- **WHEN** database opening fails
- **THEN** the app shows an actionable error
- **AND** no operational action is rendered as if data were empty
- **AND** user can explicitly reset local data
### Requirement: Reset manual
The system SHALL require explicit confirmation before deleting all local data.
#### Scenario: Reset confirmado
- **WHEN** user confirms reset from maintenance
- **THEN** local database is deleted and recreated on reload
- **AND** the app starts with an empty environment

View File

@@ -0,0 +1,12 @@
# Tasks
## 1. Bootstrap y recuperación
- [x] 1.1 Añadir apertura explícita Dexie antes de montar Vue y vista de error recuperable
- [x] 1.2 Añadir helper de reset local sin usarlo automáticamente
- [x] 1.3 Añadir reset manual en Exportar/Importar con confirmación
## 2. Verificación
- [x] 2.1 Cubrir apertura, persistencia y reset con tests
- [x] 2.2 Ejecutar tests, `vue-tsc`, Playwright y build/APK

View File

@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-10-25

View File

@@ -0,0 +1,25 @@
# Design
## Context
El Layout ya tiene menú y navegación suficiente; breadcrumbs duplican acciones. `JornadaService.cerrarJornada` crea un cuadre sin saldo real, mientras `Cuadre.vue` obliga a trabajar en otra ruta. POS muestra el catálogo completo antes del carrito.
## Goals / Non-Goals
**Goals:** priorizar carrito y cobro en móvil, mantener operaciones densas separadas por tabs, y hacer que el cierre sea una operación única con datos completos.
**Non-Goals:** eliminar la capacidad de consultar cuadres históricos ni cambiar el esquema de pagos.
## Decisions
- **POS:** mostrar búsqueda y botón de selección; el catálogo completo vive en un `v-dialog` con lista filtrable. El carrito permanece visible arriba en móvil y en columna fija en pantallas grandes.
- **Devuelta:** sólo se solicita para efectivo; se calcula como recibido menos total y no permite confirmar si es negativo.
- **Cierre:** `JornadaService.cerrarJornada` recibe efectivo contado obligatorio y banco QR opcional, calcula diferencias, guarda `CuadreCaja` y marca la jornada cerrada en una transacción.
- **Inventario:** tabs `Catálogo`, `Ingresos` y `Ajustes` en móvil; el menú deja de exponer Ajustes como módulo aislado.
- **Vuetify:** reemplazar inputs/select/button HTML de Gastos y Ajustes por `v-form`, `v-text-field`, `v-select`, `v-btn`, `v-list` y alertas.
## Risks / Trade-offs
- [Cierre incompleto] El usuario puede no conocer el banco -> QR es opcional y se muestra como pendiente, sin bloquear el cierre.
- [Cambio de API] Cerrar jornadas existentes sin saldo real requiere migración de comportamiento -> los registros históricos permanecen intactos.
- [Modal POS] Un modal añade un paso -> evita que el carrito desaparezca y permite buscar entre catálogos grandes.

View File

@@ -0,0 +1,31 @@
# Proposal
## Why
En celulares el POS ocupa toda la pantalla con el catálogo y el carrito queda fuera de vista. Además, breadcrumbs ya no son necesarios, Gastos y Ajustes conservan HTML nativo, y el cierre de caja está separado de la jornada aunque ambos deben ser una única operación de cierre.
## What Changes
- Retirar breadcrumbs y usar tabs responsive en Inventario y otras secciones densas.
- Convertir el selector de productos del POS en una búsqueda compacta con modal/lista de productos y carrito siempre accesible.
- Rediseñar Gastos y Ajustes con componentes Vuetify.
- Mover el ajuste de inventario al flujo de Inventario y retirar Ajustes como operación principal del menú.
- Fusionar cierre de caja y cierre de jornada con efectivo contado obligatorio y banco QR opcional.
- Calcular devuelta para pagos en efectivo antes de confirmar la venta.
## Capabilities
### New Capabilities
- `mobile-pos-flow`: selección de productos, carrito y devuelta optimizados para pantalla pequeña.
- `unified-day-closing`: cierre atómico de caja y jornada.
### Modified Capabilities
- `toldo-poc`: navegación, inventario, gastos, ajustes y reportes operativos.
## Impact
- Vistas Vue/Vuetify de POS, Inventario, Gastos, Ajustes y Jornada.
- `JornadaService` y modelos de cuadre.
- Tests de casos de uso, Playwright y APK.

View File

@@ -0,0 +1,28 @@
# Spec Delta
## Purpose
Permite vender cómodamente desde celulares mostrando el carrito, la búsqueda de productos y la devuelta sin obligar a recorrer una lista larga.
## ADDED Requirements
### Requirement: Catálogo compacto en POS
The system SHALL keep the cart visible and allow selecting products through a searchable compact list or dialog.
#### Scenario: Selección en móvil
- **WHEN** user opens Ventas on a narrow viewport
- **THEN** the cart and total remain visible
- **AND** user can search and add a product from the catalog dialog
### Requirement: Calcular devuelta
The system SHALL calculate and display change for cash payments before saving a sale.
#### Scenario: Pago en efectivo
- **GIVEN** sale total is 12000
- **WHEN** user enters 20000 as received cash
- **THEN** the UI displays change of 8000
- **AND** the sale can be confirmed
#### Scenario: Efectivo insuficiente
- **WHEN** received cash is below the sale total
- **THEN** confirmation is disabled and an actionable message is shown

View File

@@ -0,0 +1,28 @@
# Spec Delta
## Purpose
Unifica la verificación del efectivo y pagos QR con el cierre de la jornada, evitando que el usuario cierre una jornada sin cuadrar los valores disponibles.
## ADDED Requirements
### Requirement: Cierre unificado
The system SHALL require counted cash and optionally accept the QR bank total before closing a day.
#### Scenario: Cierre con efectivo
- **GIVEN** an open day with recorded sales and expenses
- **WHEN** user enters counted cash and confirms closing
- **THEN** cash difference is calculated and saved
- **AND** the cash reconciliation and day are closed together
#### Scenario: Cierre con banco QR pendiente
- **WHEN** user closes with counted cash and no bank total
- **THEN** the day closes
- **AND** QR bank comparison remains explicitly pending
### Requirement: Ajuste dentro de inventario
The system SHALL expose inventory adjustments from the inventory area with product quantity and reason.
#### Scenario: Ajuste desde inventario
- **WHEN** user selects a product, enters a new quantity and reason
- **THEN** the inventory balance changes and the reason is recorded

View File

@@ -0,0 +1,24 @@
# Tasks
## 1. POS móvil y cobro
- [x] 1.1 Rediseñar POS con carrito visible, selección en diálogo/lista filtrable y tabs responsive
- [x] 1.2 Añadir efectivo recibido, devuelta y validación de pago insuficiente
- [x] 1.3 Cubrir selección móvil y devuelta con tests unitarios/E2E
## 2. Cierre de jornada
- [x] 2.1 Cambiar servicio de cierre para recibir efectivo obligatorio y banco QR opcional en una transacción
- [x] 2.2 Rediseñar Jornada con formulario de cierre y diferencias visibles
- [x] 2.3 Retirar Cierre de caja del menú y cubrir cierre efectivo/QR/pending con tests
## 3. Inventario y formularios
- [x] 3.1 Mover Ajustes al área Inventario con tabs y quitar módulo independiente del menú
- [x] 3.2 Convertir Gastos y Ajustes a formularios Vuetify
- [x] 3.3 Añadir pruebas de regresión de inventario, gastos y ajustes
## 4. Verificación y entrega
- [x] 4.1 Ejecutar tests unitarios, `vue-tsc` y Playwright responsive
- [x] 4.2 Generar APK y verificar metadatos/flujo de cierre

View File

@@ -8,7 +8,7 @@ La aplicación usa versiones distintas o hardcodeadas entre npm, Android y las e
- Definir `versionName` y `versionCode` en un archivo versionado compartido.
- Usar esos valores en Gradle, npm y exportaciones JSON.
- Publicar la siguiente versión como `0.0.3` con `versionCode 3`.
- Publicar la siguiente versión como `0.0.6` con `versionCode 6`.
- Validar el `applicationId` y la versión en el APK generado.
## Capabilities

View File

@@ -9,10 +9,10 @@ Asegura que cada actualización de ToldoConjunto pueda identificarse y actualiza
### Requirement: Versionado consistente
The system SHALL expose the same semantic version in npm metadata, exported data, and Android versionName.
#### Scenario: Version 0.0.3
#### Scenario: Version 0.0.6
- **WHEN** the app is built
- **THEN** npm and exported data report `0.0.3`
- **AND** Android reports versionName `0.0.3` and versionCode `3`
- **THEN** npm and exported data report `0.0.6`
- **AND** Android reports versionName `0.0.6` and versionCode `6`
### Requirement: Identidad Android estable
The system SHALL preserve the application id `com.ilusion.toldoconjunto` across releases.

View File

@@ -2,7 +2,7 @@
## 1. Versionado
- [x] 1.1 Crear la fuente compartida con `versionName=0.0.3` y `versionCode=3`, y actualizar npm/exportaciones
- [x] 1.1 Crear la fuente compartida con `versionName=0.0.6` y `versionCode=6`, y actualizar npm/exportaciones
- [x] 1.2 Configurar Gradle para leer la fuente compartida y conservar el applicationId
- [x] 1.3 Añadir test que compruebe versionado y compatibilidad de exportación

View File

@@ -1,4 +1,5 @@
export * from './entities';
export * from './rules/cuadreRules';
export * from './rules/jornadaRules';
export * from './rules/ventaRules';
export * from './entities/JornadaToldo';

View File

@@ -0,0 +1,8 @@
export function calcularDevuelta(total: number, recibido: number) {
return recibido - total;
}
export function validarPagoEfectivo(total: number, recibido: number) {
if (!Number.isFinite(recibido) || recibido < total) return 'El efectivo recibido no alcanza para cubrir la venta';
return null;
}

View File

@@ -52,3 +52,13 @@ export class ToldoDB extends Dexie {
}
export const db = new ToldoDB();
export async function openDatabase() {
await db.open();
return db;
}
export async function resetDatabase() {
await db.delete();
await db.open();
}

View File

@@ -60,13 +60,12 @@ export class JornadaService {
return gastoId;
}
async cerrarJornada(jornadaId: string, cuadreId?: string): Promise<string> {
async cerrarJornada(jornadaId: string, saldoRealEfectivo: number, totalQRBanco?: number): Promise<string> {
if (!Number.isFinite(saldoRealEfectivo) || saldoRealEfectivo < 0) throw new Error('El efectivo contado es obligatorio y no puede ser negativo');
const jornada: any = await db.jornadas.get(jornadaId);
if (!jornada) throw new Error('Jornada no encontrada');
if (jornada.estado === 'CERRADA') throw new Error('Jornada ya cerrada');
let cid = cuadreId;
if (!cid) {
const cuadreParcial = await this.cuadreService.calcularCuadreDelDia(jornada.fecha, jornada.baseInicial);
const cuadre: CuadreCaja = {
id: crypto.randomUUID(),
@@ -78,17 +77,19 @@ export class JornadaService {
totalGastosQR: cuadreParcial.totalGastosQR || 0,
saldoEsperadoEfectivo: cuadreParcial.saldoEsperadoEfectivo || 0,
totalVentas: cuadreParcial.totalVentas || 0,
saldoRealEfectivo,
totalQRBanco,
createdAt: new Date()
} as CuadreCaja;
cid = await this.cuadreService.guardarCuadre(cuadre);
cuadre.descuadreEfectivo = saldoRealEfectivo - cuadre.saldoEsperadoEfectivo;
if (totalQRBanco !== undefined) {
cuadre.totalQRRegistrado = cuadre.totalVentasQR - cuadre.totalGastosQR;
cuadre.descuadreQR = totalQRBanco - cuadre.totalQRRegistrado;
}
await db.jornadas.put({
...jornada,
estado: 'CERRADA',
fechaCierre: new Date(),
cuadreId: cid,
updatedAt: new Date()
const cid = cuadre.id;
await db.transaction('rw', db.jornadas, db.cuadres, async () => {
await db.cuadres.add(cuadre);
await db.jornadas.put({ ...jornada, estado: 'CERRADA', fechaCierre: new Date(), cuadreId: cid, updatedAt: new Date() });
});
return cid;
}

View File

@@ -30,7 +30,7 @@ describe('Caso de uso jornada', () => {
expect(resumen?.totalVentas).toBe(30000);
expect(resumen?.totalGastos).toBe(5000);
const cuadreId = await service.cerrarJornada(jid);
const cuadreId = await service.cerrarJornada(jid, 125000);
expect(cuadreId).toBeTruthy();
const abierta = await service.getJornadaAbierta();

View File

@@ -19,7 +19,7 @@ export interface ExportData {
}
export const CURRENT_SCHEMA_VERSION = 1;
export const APP_VERSION = '0.0.3';
export const APP_VERSION = '0.0.6';
export class ExportImportService {
exportAll(data: ExportData['data']): ExportData {

20
scripts/verify-apk.sh Executable file
View File

@@ -0,0 +1,20 @@
#!/usr/bin/env bash
set -euo pipefail
apk="${1:?Uso: verify-apk.sh <apk> <versionName> <versionCode>}"
version_name="${2:?Falta versionName}"
version_code="${3:?Falta versionCode}"
aapt_bin="${AAPT:-aapt2}"
tmp_dir="$(mktemp -d)"
trap 'rm -rf "$tmp_dir"' EXIT
badging="$($aapt_bin dump badging "$apk")"
printf '%s\n' "$badging" | grep -F "package: name='com.ilusion.toldoconjunto' versionCode='$version_code' versionName='$version_name'" >/dev/null
unzip -q "$apk" -d "$tmp_dir"
find "$tmp_dir/assets/public/assets" -type f -name '*.js' -print0 | xargs -0 grep -F -l 'Buscar productos' >/dev/null
find "$tmp_dir/assets/public/assets" -type f -name '*.js' -print0 | xargs -0 grep -F -l 'Dinero en banco por QR' >/dev/null
find "$tmp_dir/assets/public/assets" -type f -name '*.js' -print0 | xargs -0 grep -F -l 'Cerrar caja y jornada' >/dev/null
find "$tmp_dir/assets/public/assets" -type f -name '*.woff2' | grep materialdesignicons >/dev/null
printf 'APK verificada: applicationId=com.ilusion.toldoconjunto versionName=%s versionCode=%s\n' "$version_name" "$version_code"