5 Commits

Author SHA1 Message Date
mono
488f383904 test: cubrir jornada completa en playwright 2026-10-25 21:46:48 -05:00
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
44 changed files with 545 additions and 91 deletions

View File

@@ -25,3 +25,16 @@ Skills del proyecto en `skills/`:
Seguir OpenSpec workflow: propose -> apply -> archive Seguir OpenSpec workflow: propose -> apply -> archive
Usar skills cuando aplique al contexto Usar skills cuando aplique al contexto
Mantener código siguiendo arquitectura limpia 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", "name": "toldo-app",
"version": "0.0.3", "version": "0.0.6",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "toldo-app", "name": "toldo-app",
"version": "0.0.3", "version": "0.0.6",
"dependencies": { "dependencies": {
"@capacitor/android": "^6.2.1", "@capacitor/android": "^6.2.1",
"@capacitor/core": "^6.2.1", "@capacitor/core": "^6.2.1",

View File

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

View File

@@ -3,6 +3,7 @@ import { defineConfig, devices } from '@playwright/test';
export default defineConfig({ export default defineConfig({
testDir: './tests/e2e', testDir: './tests/e2e',
timeout: 30_000, timeout: 30_000,
workers: 1,
use: { baseURL: 'http://127.0.0.1:4173', trace: 'on-first-retry' }, use: { baseURL: 'http://127.0.0.1:4173', trace: 'on-first-retry' },
webServer: { command: 'npm run dev -- --host 127.0.0.1 --port 4173', url: 'http://127.0.0.1:4173', reuseExistingServer: true }, webServer: { command: 'npm run dev -- --host 127.0.0.1 --port 4173', url: 'http://127.0.0.1:4173', reuseExistingServer: true },
projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'], launchOptions: { executablePath: '/usr/bin/chromium' } } }, { name: 'mobile', use: { ...devices['Pixel 5'], launchOptions: { executablePath: '/usr/bin/chromium' } } }], projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'], launchOptions: { executablePath: '/usr/bin/chromium' } } }, { name: 'mobile', use: { ...devices['Pixel 5'], launchOptions: { executablePath: '/usr/bin/chromium' } } }],

View File

@@ -19,7 +19,7 @@ describe('contratos de operación y respaldo', () => {
it('expone la versión actual en las exportaciones', () => { it('expone la versión actual en las exportaciones', () => {
const result = new ExportImportService().exportAll({ productores: [], productos: [], movimientosInventario: [], ventas: [], ventaLineas: [], gastos: [], ajustes: [], cuadres: [] }); 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', () => { 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 service = new JornadaService();
const jornadaId = await service.abrirJornada(['Ana'], 50000); const jornadaId = await service.abrirJornada(['Ana'], 50000);
await expect(service.abrirJornada(['Luis'], 10000)).rejects.toThrow('Ya existe una jornada abierta'); 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'); 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 () => { it('calcula cuadre mixto de efectivo y QR y registra ambos descuadres', async () => {
await crearProducto(); await crearProducto();
const jornada = new JornadaService(); const jornada = new JornadaService();

View File

@@ -16,14 +16,9 @@
<v-main> <v-main>
<v-container fluid class="pa-4 pa-md-6"> <v-container fluid class="pa-4 pa-md-6">
<div class="d-flex flex-wrap align-center justify-space-between mb-3 ga-2"> <div class="d-flex justify-end mb-3 ga-2">
<v-breadcrumbs :items="breadcrumbs" class="pa-0" aria-label="Migas de pan"> <v-btn variant="text" size="small" prepend-icon="mdi-arrow-left" data-testid="back-button" @click="goBack">Atrás</v-btn>
<template #prepend><v-icon icon="mdi-home-outline" size="small" /></template> <v-btn to="/" variant="text" size="small" prepend-icon="mdi-home">Inicio</v-btn>
</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> </div>
<router-view /> <router-view />
</v-container> </v-container>
@@ -32,30 +27,26 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, onUnmounted, ref } from 'vue'; import { onMounted, onUnmounted, ref } from 'vue';
import { JornadaService } from '@toldo/services'; import { JornadaService } from '@toldo/services';
import { useRoute, useRouter } from 'vue-router'; import { useRouter } from 'vue-router';
const drawer = ref(true); const drawer = ref(true);
const isMobile = ref(false); const isMobile = ref(false);
const jornada = ref<any>(null); const jornada = ref<any>(null);
const jornadaService = new JornadaService(); const jornadaService = new JornadaService();
const route = useRoute();
const router = useRouter(); const router = useRouter();
const navItems = [ const navItems = [
{ to: '/jornada', icon: 'mdi-calendar-check', title: 'Jornada' }, { to: '/jornada', icon: 'mdi-calendar-check', title: 'Jornada' },
{ to: '/inventario', icon: 'mdi-package-variant', title: 'Inventario' }, { to: '/inventario', icon: 'mdi-package-variant', title: 'Inventario' },
{ to: '/ventas', icon: 'mdi-cart', title: 'Ventas POS' }, { to: '/ventas', icon: 'mdi-cart', title: 'Ventas POS' },
{ to: '/gastos', icon: 'mdi-cash-minus', title: 'Gastos' }, { 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: '/reportes', icon: 'mdi-chart-bar', title: 'Reportes' },
{ to: '/exportar', icon: 'mdi-export', title: 'Exportar / importar' }, { 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 updateViewport() { isMobile.value = window.innerWidth < 960; if (isMobile.value) drawer.value = false; }
function closeOnMobile() { 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(); }); onMounted(async () => { updateViewport(); window.addEventListener('resize', updateViewport); jornada.value = await jornadaService.getJornadaAbierta(); });
onUnmounted(() => window.removeEventListener('resize', updateViewport)); onUnmounted(() => window.removeEventListener('resize', updateViewport));
</script> </script>

View File

@@ -2,5 +2,27 @@ import { createApp } from 'vue'
import App from './App.vue' import App from './App.vue'
import router from './router' import router from './router'
import vuetify from './plugins/vuetify' 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 Inventario from '../views/Inventario.vue';
import Ventas from '../views/Ventas.vue'; import Ventas from '../views/Ventas.vue';
import Gastos from '../views/Gastos.vue'; import Gastos from '../views/Gastos.vue';
import Cuadre from '../views/Cuadre.vue';
import Reportes from '../views/Reportes.vue'; import Reportes from '../views/Reportes.vue';
import Ajustes from '../views/Ajustes.vue';
import Exportar from '../views/Exportar.vue'; import Exportar from '../views/Exportar.vue';
import Jornada from '../views/Jornada.vue'; import Jornada from '../views/Jornada.vue';
@@ -22,9 +20,7 @@ const router = createRouter({
{ path: 'inventario', component: Inventario, meta: { title: 'Inventario' } }, { path: 'inventario', component: Inventario, meta: { title: 'Inventario' } },
{ path: 'ventas', component: Ventas, meta: { title: 'Ventas POS' } }, { path: 'ventas', component: Ventas, meta: { title: 'Ventas POS' } },
{ path: 'gastos', component: Gastos, meta: { title: 'Gastos' } }, { path: 'gastos', component: Gastos, meta: { title: 'Gastos' } },
{ path: 'cuadre', component: Cuadre, meta: { title: 'Cierre de caja' } },
{ path: 'reportes', component: Reportes, meta: { title: 'Reportes' } }, { path: 'reportes', component: Reportes, meta: { title: 'Reportes' } },
{ path: 'ajustes', component: Ajustes, meta: { title: 'Ajustes' } },
{ path: 'exportar', component: Exportar, meta: { title: 'Exportar / importar' } }, { path: 'exportar', component: Exportar, meta: { title: 'Exportar / importar' } },
] ]
} }

View File

@@ -56,6 +56,11 @@ body {
background: #f5f7f2; 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, h1,
h2 { h2 {
font-family: var(--heading); font-family: var(--heading);

View File

@@ -1,6 +1,6 @@
<template> <template>
<div> <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> <v-alert v-if="mensaje" :type="tipoMensaje" variant="tonal" class="mb-3">{{ mensaje }}</v-alert>
<p><strong>Requiere motivo obligatorio</strong></p> <p><strong>Requiere motivo obligatorio</strong></p>
<section> <section>

View File

@@ -8,19 +8,25 @@
<section> <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><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> </section>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref } from 'vue'; import { ref } from 'vue';
import { db } from '@toldo/data'; import { db, resetDatabase } from '@toldo/data';
import { ExportImportService } from '@toldo/services'; import { ExportImportService } from '@toldo/services';
const exportService = new ExportImportService(); const exportService = new ExportImportService();
const archivo = ref<File | null>(null); const archivo = ref<File | null>(null);
const mensaje = ref(''); const mensaje = ref('');
const tipoMensaje = ref<'success' | 'error'>('success'); 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) { function onFileChange(e: any) {
archivo.value = e.target.files[0]; archivo.value = e.target.files[0];

View File

@@ -1,24 +1,10 @@
<template> <template>
<div> <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> <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> <v-alert :type="jornadaAbierta ? 'success' : 'warning'" variant="tonal" class="mb-4">{{ jornadaAbierta ? 'Jornada abierta' : 'Abre una jornada para registrar gastos' }}</v-alert>
<p v-else style="color: green">Jornada abierta</p> <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>
<section> <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>
<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>
</div> </div>
</template> </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-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-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-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-col cols="12" md="6">
<v-card class="mb-4"> <v-card class="mb-4">
<v-card-title>Productores</v-card-title> <v-card-title>Productores</v-card-title>
@@ -45,7 +46,7 @@
</v-col> </v-col>
</v-row> </v-row>
<v-row> <v-row v-show="!isMobile || inventoryTab === 'ingresos'">
<v-col cols="12" md="6"> <v-col cols="12" md="6">
<v-card> <v-card>
<v-card-title>Ingreso Inicial del Día</v-card-title> <v-card-title>Ingreso Inicial del Día</v-card-title>
@@ -69,16 +70,19 @@
</v-card> </v-card>
</v-col> </v-col>
</v-row> </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> <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> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, onMounted } from 'vue'; import { ref, computed, onMounted, onUnmounted } from 'vue';
import { db } from '@toldo/data'; 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 invService = new InventarioService();
const ajustesService = new AjustesService();
const productores = ref<any[]>([]); const productores = ref<any[]>([]);
const productos = ref<any[]>([]); const productos = ref<any[]>([]);
const nuevoProductor = ref(''); const nuevoProductor = ref('');
@@ -103,11 +107,26 @@ const edicionProducto = ref<any>({});
const originalEdicion = ref<any>({}); const originalEdicion = ref<any>({});
const actor = ref(''); const actor = ref('');
const motivo = 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 busquedaProductores = ref('');
const busquedaProductos = ref(''); const busquedaProductos = ref('');
const productoresFiltrados = computed(() => productores.value.filter((productor) => !busquedaProductores.value.trim() || productor.nombre.toLocaleLowerCase().includes(busquedaProductores.value.trim().toLocaleLowerCase()))); 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()))); 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 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 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; } function editarProducto(producto: any) { entidadEdicion.value = 'PRODUCTO'; originalEdicion.value = { ...producto }; edicionProducto.value = { ...producto }; actor.value = ''; motivo.value = ''; dialogoEdicion.value = true; }
async function guardarEdicion() { async function guardarEdicion() {
@@ -165,5 +184,6 @@ async function ingresoSemana() {
avisar('Ingreso entre semana registrado.', 'success'); avisar('Ingreso entre semana registrado.', 'success');
} }
onMounted(cargar); onMounted(() => { updateViewport(); window.addEventListener('resize', updateViewport); cargar(); });
onUnmounted(() => window.removeEventListener('resize', updateViewport));
</script> </script>

View File

@@ -26,9 +26,13 @@
<div v-if="resumen"> <div v-if="resumen">
<div class="mb-2">Ventas: ${{ resumen.totalVentas }}</div> <div class="mb-2">Ventas: ${{ resumen.totalVentas }}</div>
<div class="mb-2">Gastos: ${{ resumen.totalGastos }}</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> </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-text>
</v-card> </v-card>
</v-col> </v-col>
@@ -37,14 +41,21 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted } from 'vue'; import { ref, computed, onMounted } from 'vue';
import { JornadaService } from '@toldo/services'; import { JornadaService, CuadreService } from '@toldo/services';
const service = new JornadaService(); const service = new JornadaService();
const cuadreService = new CuadreService();
const jornadaAbierta = ref<any>(null); const jornadaAbierta = ref<any>(null);
const responsablesStr = ref(''); const responsablesStr = ref('');
const baseInicial = ref(0); const baseInicial = ref(0);
const resumen = ref<any>(null); 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) { function formatFecha(d: any) {
try { return new Date(d).toLocaleString(); } catch { return d; } try { return new Date(d).toLocaleString(); } catch { return d; }
@@ -54,8 +65,11 @@ async function cargar() {
jornadaAbierta.value = await service.getJornadaAbierta(); jornadaAbierta.value = await service.getJornadaAbierta();
if (jornadaAbierta.value) { if (jornadaAbierta.value) {
resumen.value = await service.getResumenJornada(jornadaAbierta.value.id); 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 { } else {
resumen.value = null; resumen.value = null;
saldoEsperadoEfectivo.value = 0;
} }
} }
@@ -75,10 +89,14 @@ async function abrir() {
async function cerrar() { async function cerrar() {
if (!jornadaAbierta.value) return; 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 { 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(); await cargar();
alert(`Jornada cerrada. Cuadre: ${cid}`); cierreCompletado.value = true;
cierreError.value = `Cierre de caja y jornada completado: ${cid}`;
} catch (e: any) { } catch (e: any) {
alert(e.message); alert(e.message);
} }

View File

@@ -1,8 +1,8 @@
<template> <template>
<div> <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-row>
<v-col cols="12" md="8"> <v-col v-if="!isMobile" cols="12" md="8">
<v-card> <v-card>
<v-card-title>Productos disponibles</v-card-title> <v-card-title>Productos disponibles</v-card-title>
<v-card-text> <v-card-text>
@@ -47,22 +47,26 @@
<v-divider class="my-3"></v-divider> <v-divider class="my-3"></v-divider>
<div class="text-h5 font-weight-bold">Total: ${{ total.toLocaleString() }}</div> <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-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="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-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-text>
</v-card> </v-card>
</v-col> </v-col>
</v-row> </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-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="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> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, onMounted } from 'vue'; import { ref, computed, onMounted, onUnmounted } from 'vue';
import { db } from '@toldo/data'; import { db } from '@toldo/data';
import { VentasService, JornadaService, EdicionService } from '@toldo/services'; import { VentasService, JornadaService, EdicionService } from '@toldo/services';
import { calcularDevuelta, validarPagoEfectivo } from '@toldo/core';
const ventasService = new VentasService(); const ventasService = new VentasService();
const jornadaService = new JornadaService(); const jornadaService = new JornadaService();
@@ -71,6 +75,10 @@ const stockByProduct = ref<Record<string, number>>({});
const lineas = ref<any[]>([]); const lineas = ref<any[]>([]);
const formaPago = ref('EFECTIVO'); const formaPago = ref('EFECTIVO');
const jornadaAbierta = ref<any>(null); 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 busqueda = ref('');
const productosFiltrados = computed(() => { const productosFiltrados = computed(() => {
const termino = busqueda.value.trim().toLocaleLowerCase(); const termino = busqueda.value.trim().toLocaleLowerCase();
@@ -102,6 +110,8 @@ function getProdNombre(id: string) {
return productos.value.find(p => p.id === id)?.nombrePresentacion || id; return productos.value.find(p => p.id === id)?.nombrePresentacion || id;
} }
function getStock(id: string) { return stockByProduct.value[id] ?? 0; } 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) { function agregarProducto(prod: any) {
if (getStock(prod.id) <= 0) { error.value = 'Este producto no tiene inventario disponible. Registra primero el inventario inicial.'; return; } 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.'; error.value = 'Debe abrir una jornada antes de registrar ventas.';
return; return;
} }
if (formaPago.value === 'EFECTIVO') { const errorPago = validarPagoEfectivo(total.value, efectivoRecibido.value); if (errorPago) { error.value = errorPago; return; } }
const ventaId = crypto.randomUUID(); const ventaId = crypto.randomUUID();
const lineasVenta = lineas.value.map(l => ({ ...l, ventaId, formaPago: formaPago.value })); const lineasVenta = lineas.value.map(l => ({ ...l, ventaId, formaPago: formaPago.value }));
const venta = { const venta = {
@@ -167,11 +178,13 @@ async function registrarVenta() {
try { try {
await jornadaService.registrarVentaEnJornada(jornadaAbierta.value.id, venta, lineasVenta); await jornadaService.registrarVentaEnJornada(jornadaAbierta.value.id, venta, lineasVenta);
lineas.value = []; lineas.value = [];
efectivoRecibido.value = 0;
exito.value = `Venta registrada por $${total.value.toLocaleString()} en ${formaPago.value}.`; exito.value = `Venta registrada por $${total.value.toLocaleString()} en ${formaPago.value}.`;
} catch (e: any) { } 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; 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> </script>

View File

@@ -0,0 +1,52 @@
import { expect, test } from '@playwright/test';
test.describe('jornada completa', () => {
test('abre jornada, crea catálogo, registra venta y gasto, y cierra caja', async ({ page }, testInfo) => {
test.skip(testInfo.project.name !== 'chromium', 'El flujo completo se ejecuta una vez en escritorio.');
await page.goto('/inventario');
await page.getByLabel('Nombre del productor').fill('Productor E2E');
await page.getByRole('button', { name: 'Agregar', exact: true }).click();
await expect(page.getByText('Productor E2E')).toBeVisible();
await page.getByRole('combobox', { name: 'Productor' }).click({ force: true });
await page.getByText('Productor E2E', { exact: true }).last().click();
await page.getByLabel(/Nombre\/Presentación/).fill('Producto E2E');
await page.getByLabel('Precio unitario').fill('12000');
await page.getByRole('button', { name: 'Agregar Producto' }).click();
await expect(page.getByText('Producto E2E')).toBeVisible();
await page.getByRole('combobox', { name: 'Producto', exact: true }).first().click({ force: true });
await page.locator('.v-overlay-container .v-list-item').filter({ hasText: 'Producto E2E' }).click();
await page.getByLabel('Cantidad').first().fill('3');
await page.getByRole('button', { name: 'Registrar Ingreso Inicial' }).click();
await expect(page.getByText('Ingreso inicial registrado.')).toBeVisible();
await page.goto('/jornada');
await page.getByLabel('Responsables (separados por coma)').fill('Ana E2E');
await page.getByLabel('Base inicial (efectivo)').fill('10000');
const openDialog = page.waitForEvent('dialog');
await page.getByRole('button', { name: 'Abrir Jornada' }).click();
await (await openDialog).accept();
await expect(page.getByText(/Jornada ABIERTA/)).toBeVisible();
await page.goto('/ventas');
await expect(page.getByText('Disponible: 3')).toBeVisible();
await page.getByText('Producto E2E', { exact: true }).first().click({ force: true });
await expect(page.getByRole('button', { name: /Cobrar/ })).toContainText('12,000');
await page.getByLabel('Efectivo recibido').fill('20000');
await expect(page.getByText('Devuelta: $8,000')).toBeVisible();
await page.getByRole('button', { name: /Cobrar/ }).click();
await expect(page.getByText(/Venta registrada/)).toBeVisible();
await page.goto('/gastos');
await page.getByLabel('Concepto').fill('Bolsas E2E');
await page.getByLabel('Monto').fill('2000');
await page.getByRole('button', { name: 'Registrar gasto' }).click();
await expect(page.getByText('Bolsas E2E')).toBeVisible();
await page.goto('/jornada');
await page.getByLabel('Efectivo contado (obligatorio)').fill('20000');
await page.getByRole('button', { name: 'Cerrar caja y jornada' }).click();
await expect(page.getByText(/Cierre de caja y jornada completado/)).toBeVisible();
});
});

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(); if ((await page.viewportSize())!.width < 960) await page.getByTestId('navigation-toggle').click();
await page.getByRole('link', { name: 'Jornada', exact: true }).click(); await page.getByRole('link', { name: 'Jornada', exact: true }).click();
await expect(page.getByText('Jornada del Toldo')).toBeVisible(); 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 page.getByRole('link', { name: 'Inicio' }).last().click();
await expect(page.getByText('Panel de control')).toBeVisible(); await expect(page.getByText('Panel de control')).toBeVisible();
if ((await page.viewportSize())!.width < 960) await page.getByTestId('navigation-toggle').click(); if ((await page.viewportSize())!.width < 960) await page.getByTestId('navigation-toggle').click();
await page.getByRole('link', { name: 'Inventario', exact: true }).click(); await page.getByRole('link', { name: 'Inventario', exact: true }).click();
await expect(page.getByText('Inventario y Productos')).toBeVisible(); await expect(page.getByText('Inventario y Productos')).toBeVisible();
await expect(page.locator('.mdi-home-outline')).toBeVisible();
await page.getByTestId('back-button').click(); await page.getByTestId('back-button').click();
await expect(page.getByText('Panel de control')).toBeVisible(); 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 versionName=0.0.6
versionCode=3 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

@@ -24,5 +24,5 @@
- [x] 4.5 Dashboard con jornada activa - [x] 4.5 Dashboard con jornada activa
## 5. Tests Playwright ## 5. Tests Playwright
- [ ] 5.1 Setup Playwright - [x] 5.1 Setup Playwright
- [ ] 5.2 Test caso uso completo: abrir jornada, agregar productos, venta múltiple, gastos, cierre - [x] 5.2 Test caso uso completo: abrir jornada, agregar productos, venta múltiple, gastos, cierre

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

@@ -23,8 +23,8 @@
## 4. Testing ## 4. Testing
- [x] 4.1 Setup Playwright - [x] 4.1 Setup Playwright
- [ ] 4.2 Add realistic service cases for QR, discrepancies, reports, and export - [x] 4.2 Add realistic service cases for QR, discrepancies, reports, and export
- [ ] 4.3 Test full day browser flow - [x] 4.3 Test full day browser flow
- [x] 4.4 Test responsive navigation and empty setup state - [x] 4.4 Test responsive navigation and empty setup state
## 5. Build & Deploy ## 5. Build & Deploy

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. - Definir `versionName` y `versionCode` en un archivo versionado compartido.
- Usar esos valores en Gradle, npm y exportaciones JSON. - 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. - Validar el `applicationId` y la versión en el APK generado.
## Capabilities ## Capabilities

View File

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

View File

@@ -2,7 +2,7 @@
## 1. Versionado ## 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.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 - [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 './entities';
export * from './rules/cuadreRules'; export * from './rules/cuadreRules';
export * from './rules/jornadaRules'; export * from './rules/jornadaRules';
export * from './rules/ventaRules';
export * from './entities/JornadaToldo'; 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 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,15 +60,14 @@ export class JornadaService {
return gastoId; 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); const jornada: any = await db.jornadas.get(jornadaId);
if (!jornada) throw new Error('Jornada no encontrada'); if (!jornada) throw new Error('Jornada no encontrada');
if (jornada.estado === 'CERRADA') throw new Error('Jornada ya cerrada'); if (jornada.estado === 'CERRADA') throw new Error('Jornada ya cerrada');
let cid = cuadreId; const cuadreParcial = await this.cuadreService.calcularCuadreDelDia(jornada.fecha, jornada.baseInicial);
if (!cid) { const cuadre: CuadreCaja = {
const cuadreParcial = await this.cuadreService.calcularCuadreDelDia(jornada.fecha, jornada.baseInicial);
const cuadre: CuadreCaja = {
id: crypto.randomUUID(), id: crypto.randomUUID(),
fecha: jornada.fecha, fecha: jornada.fecha,
baseInicial: jornada.baseInicial, baseInicial: jornada.baseInicial,
@@ -78,17 +77,19 @@ export class JornadaService {
totalGastosQR: cuadreParcial.totalGastosQR || 0, totalGastosQR: cuadreParcial.totalGastosQR || 0,
saldoEsperadoEfectivo: cuadreParcial.saldoEsperadoEfectivo || 0, saldoEsperadoEfectivo: cuadreParcial.saldoEsperadoEfectivo || 0,
totalVentas: cuadreParcial.totalVentas || 0, totalVentas: cuadreParcial.totalVentas || 0,
createdAt: new Date() saldoRealEfectivo,
} as CuadreCaja; totalQRBanco,
cid = await this.cuadreService.guardarCuadre(cuadre); createdAt: new Date()
} as CuadreCaja;
cuadre.descuadreEfectivo = saldoRealEfectivo - cuadre.saldoEsperadoEfectivo;
if (totalQRBanco !== undefined) {
cuadre.totalQRRegistrado = cuadre.totalVentasQR - cuadre.totalGastosQR;
cuadre.descuadreQR = totalQRBanco - cuadre.totalQRRegistrado;
} }
const cid = cuadre.id;
await db.jornadas.put({ await db.transaction('rw', db.jornadas, db.cuadres, async () => {
...jornada, await db.cuadres.add(cuadre);
estado: 'CERRADA', await db.jornadas.put({ ...jornada, estado: 'CERRADA', fechaCierre: new Date(), cuadreId: cid, updatedAt: new Date() });
fechaCierre: new Date(),
cuadreId: cid,
updatedAt: new Date()
}); });
return cid; return cid;
} }

View File

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

View File

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