3 Commits

Author SHA1 Message Date
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
30 changed files with 317 additions and 85 deletions

View File

@@ -25,3 +25,11 @@ 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.

View File

@@ -1,12 +1,12 @@
{ {
"name": "toldo-app", "name": "toldo-app",
"version": "0.0.3", "version": "0.0.5",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "toldo-app", "name": "toldo-app",
"version": "0.0.3", "version": "0.0.5",
"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.5",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",

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.5');
}); });
it('exporta jornadas junto con los datos operativos', () => { it('exporta jornadas junto con los datos operativos', () => {

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

@@ -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

@@ -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

@@ -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

@@ -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.5
versionCode=3 versionCode=5

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. - 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.5` con `versionCode 5`.
- 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.5
- **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.5`
- **AND** Android reports versionName `0.0.3` and versionCode `3` - **AND** Android reports versionName `0.0.5` and versionCode `5`
### 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.5` y `versionCode=5`, 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

@@ -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.5';
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"