Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6a3352a96c | ||
|
|
a21c43684c | ||
|
|
1470ffd0de | ||
|
|
ba18b73ed9 |
13
AGENTS.md
13
AGENTS.md
@@ -25,3 +25,16 @@ Skills del proyecto en `skills/`:
|
||||
Seguir OpenSpec workflow: propose -> apply -> archive
|
||||
Usar skills cuando aplique al contexto
|
||||
Mantener código siguiendo arquitectura limpia
|
||||
|
||||
## Release Android
|
||||
- Antes de publicar una APK, ejecutar `npm run test:run`, `npm run test:e2e`, `npm exec vue-tsc -- --noEmit` y `npm run build`.
|
||||
- Ejecutar `npx cap sync android` y `./gradlew assembleDebug` usando el builder Android del repositorio cuando el host no tenga Java.
|
||||
- Verificar el APK con `scripts/verify-apk.sh <apk> <versionName> <versionCode>` antes de adjuntarlo a Gitea.
|
||||
- El script debe confirmar `applicationId`, `versionName`, `versionCode`, bundle JavaScript, fuentes MDI y textos críticos de la versión.
|
||||
- Incrementar `versionCode` para cada APK publicable; mantener la misma firma y publicar versiones beta como prerelease.
|
||||
- Después de subir un asset, comparar su SHA256 con el APK verificado localmente y comprobar el asset de la release mediante la API de Gitea.
|
||||
|
||||
## Persistencia local
|
||||
- La app debe abrir Dexie explícitamente antes de montar Vue.
|
||||
- Las migraciones deben agregar una nueva versión Dexie y transformar datos; nunca deben borrar datos automáticamente.
|
||||
- El reset de la base es una acción manual, confirmada y visible sólo como último recurso.
|
||||
|
||||
4
apps/toldo-app/package-lock.json
generated
4
apps/toldo-app/package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "toldo-app",
|
||||
"version": "0.0.3",
|
||||
"version": "0.0.6",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "toldo-app",
|
||||
"version": "0.0.3",
|
||||
"version": "0.0.6",
|
||||
"dependencies": {
|
||||
"@capacitor/android": "^6.2.1",
|
||||
"@capacitor/core": "^6.2.1",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "toldo-app",
|
||||
"private": true,
|
||||
"version": "0.0.3",
|
||||
"version": "0.0.6",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -19,7 +19,7 @@ describe('contratos de operación y respaldo', () => {
|
||||
|
||||
it('expone la versión actual en las exportaciones', () => {
|
||||
const result = new ExportImportService().exportAll({ productores: [], productos: [], movimientosInventario: [], ventas: [], ventaLineas: [], gastos: [], ajustes: [], cuadres: [] });
|
||||
expect(result.appVersion).toBe('0.0.3');
|
||||
expect(result.appVersion).toBe('0.0.6');
|
||||
});
|
||||
|
||||
it('exporta jornadas junto con los datos operativos', () => {
|
||||
|
||||
22
apps/toldo-app/src/__tests__/database-bootstrap.test.ts
Normal file
22
apps/toldo-app/src/__tests__/database-bootstrap.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
8
apps/toldo-app/src/__tests__/sale-payment.test.ts
Normal file
8
apps/toldo-app/src/__tests__/sale-payment.test.ts
Normal 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());
|
||||
});
|
||||
@@ -29,10 +29,21 @@ describe('casos de uso operativos offline', () => {
|
||||
const service = new JornadaService();
|
||||
const jornadaId = await service.abrirJornada(['Ana'], 50000);
|
||||
await expect(service.abrirJornada(['Luis'], 10000)).rejects.toThrow('Ya existe una jornada abierta');
|
||||
await service.cerrarJornada(jornadaId);
|
||||
await service.cerrarJornada(jornadaId, 0);
|
||||
await expect(service.registrarGastoEnJornada(jornadaId, { id: 'gasto-cerrado', fecha, concepto: 'Bolsas', monto: 1000, formaPago: 'EFECTIVO', createdAt: fecha } as any)).rejects.toThrow('jornada cerrada');
|
||||
});
|
||||
|
||||
it('cierra caja y jornada juntos con banco QR opcional', async () => {
|
||||
const service = new JornadaService();
|
||||
const jornadaId = await service.abrirJornada(['Ana'], 50000);
|
||||
const cuadreId = await service.cerrarJornada(jornadaId, 48000, 12000);
|
||||
const cuadre = await db.cuadres.get(cuadreId);
|
||||
const jornada = await db.jornadas.get(jornadaId);
|
||||
expect(cuadre?.saldoRealEfectivo).toBe(48000);
|
||||
expect(cuadre?.totalQRBanco).toBe(12000);
|
||||
expect(jornada?.estado).toBe('CERRADA');
|
||||
});
|
||||
|
||||
it('calcula cuadre mixto de efectivo y QR y registra ambos descuadres', async () => {
|
||||
await crearProducto();
|
||||
const jornada = new JornadaService();
|
||||
|
||||
@@ -16,15 +16,10 @@
|
||||
|
||||
<v-main>
|
||||
<v-container fluid class="pa-4 pa-md-6">
|
||||
<div class="d-flex flex-wrap align-center justify-space-between mb-3 ga-2">
|
||||
<v-breadcrumbs :items="breadcrumbs" class="pa-0" aria-label="Migas de pan">
|
||||
<template #prepend><v-icon icon="mdi-home-outline" size="small" /></template>
|
||||
</v-breadcrumbs>
|
||||
<div class="d-flex ga-2">
|
||||
<div class="d-flex justify-end mb-3 ga-2">
|
||||
<v-btn variant="text" size="small" prepend-icon="mdi-arrow-left" data-testid="back-button" @click="goBack">Atrás</v-btn>
|
||||
<v-btn to="/" variant="text" size="small" prepend-icon="mdi-home">Inicio</v-btn>
|
||||
</div>
|
||||
</div>
|
||||
<router-view />
|
||||
</v-container>
|
||||
</v-main>
|
||||
@@ -32,30 +27,26 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue';
|
||||
import { onMounted, onUnmounted, ref } from 'vue';
|
||||
import { JornadaService } from '@toldo/services';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { useRouter } from 'vue-router';
|
||||
|
||||
const drawer = ref(true);
|
||||
const isMobile = ref(false);
|
||||
const jornada = ref<any>(null);
|
||||
const jornadaService = new JornadaService();
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const navItems = [
|
||||
{ to: '/jornada', icon: 'mdi-calendar-check', title: 'Jornada' },
|
||||
{ to: '/inventario', icon: 'mdi-package-variant', title: 'Inventario' },
|
||||
{ to: '/ventas', icon: 'mdi-cart', title: 'Ventas POS' },
|
||||
{ to: '/gastos', icon: 'mdi-cash-minus', title: 'Gastos' },
|
||||
{ to: '/cuadre', icon: 'mdi-cash-register', title: 'Cierre de caja' },
|
||||
{ to: '/ajustes', icon: 'mdi-pencil', title: 'Ajustes' },
|
||||
{ to: '/reportes', icon: 'mdi-chart-bar', title: 'Reportes' },
|
||||
{ to: '/exportar', icon: 'mdi-export', title: 'Exportar / importar' },
|
||||
];
|
||||
const breadcrumbs = computed(() => [{ title: 'Inicio', disabled: false, to: '/' }, ...(route.path === '/' ? [] : [{ title: String(route.meta.title ?? 'Sección'), disabled: true }])]);
|
||||
function goBack() { if (window.history.state?.back) router.back(); else router.push('/'); }
|
||||
function updateViewport() { isMobile.value = window.innerWidth < 960; if (isMobile.value) drawer.value = false; }
|
||||
function closeOnMobile() { if (isMobile.value) drawer.value = false; }
|
||||
function goBack() { if (window.history.state?.back) router.back(); else router.push('/'); }
|
||||
onMounted(async () => { updateViewport(); window.addEventListener('resize', updateViewport); jornada.value = await jornadaService.getJornadaAbierta(); });
|
||||
onUnmounted(() => window.removeEventListener('resize', updateViewport));
|
||||
</script>
|
||||
|
||||
@@ -2,5 +2,27 @@ import { createApp } from 'vue'
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
import vuetify from './plugins/vuetify'
|
||||
import { openDatabase, resetDatabase } from '@toldo/data'
|
||||
|
||||
createApp(App).use(router).use(vuetify).mount('#app')
|
||||
async function bootstrap() {
|
||||
try {
|
||||
await openDatabase()
|
||||
createApp(App).use(router).use(vuetify).mount('#app')
|
||||
} catch (error) {
|
||||
const root = document.querySelector('#app')
|
||||
if (!root) return
|
||||
root.innerHTML = ''
|
||||
const card = document.createElement('main')
|
||||
card.className = 'database-recovery'
|
||||
card.innerHTML = '<h1>No se pudo abrir los datos locales</h1><p>La aplicación no continuará para evitar mostrar información incompleta. Puedes intentar recargar o restablecer el entorno como último recurso.</p>'
|
||||
const details = document.createElement('pre')
|
||||
details.textContent = error instanceof Error ? error.message : String(error)
|
||||
const button = document.createElement('button')
|
||||
button.textContent = 'Restablecer entorno local'
|
||||
button.onclick = async () => { button.disabled = true; await resetDatabase(); window.location.reload() }
|
||||
card.append(details, button)
|
||||
root.append(card)
|
||||
}
|
||||
}
|
||||
|
||||
bootstrap()
|
||||
|
||||
@@ -4,9 +4,7 @@ import Home from '../views/Home.vue';
|
||||
import Inventario from '../views/Inventario.vue';
|
||||
import Ventas from '../views/Ventas.vue';
|
||||
import Gastos from '../views/Gastos.vue';
|
||||
import Cuadre from '../views/Cuadre.vue';
|
||||
import Reportes from '../views/Reportes.vue';
|
||||
import Ajustes from '../views/Ajustes.vue';
|
||||
import Exportar from '../views/Exportar.vue';
|
||||
import Jornada from '../views/Jornada.vue';
|
||||
|
||||
@@ -22,9 +20,7 @@ const router = createRouter({
|
||||
{ path: 'inventario', component: Inventario, meta: { title: 'Inventario' } },
|
||||
{ path: 'ventas', component: Ventas, meta: { title: 'Ventas POS' } },
|
||||
{ path: 'gastos', component: Gastos, meta: { title: 'Gastos' } },
|
||||
{ path: 'cuadre', component: Cuadre, meta: { title: 'Cierre de caja' } },
|
||||
{ path: 'reportes', component: Reportes, meta: { title: 'Reportes' } },
|
||||
{ path: 'ajustes', component: Ajustes, meta: { title: 'Ajustes' } },
|
||||
{ path: 'exportar', component: Exportar, meta: { title: 'Exportar / importar' } },
|
||||
]
|
||||
}
|
||||
|
||||
@@ -56,6 +56,11 @@ body {
|
||||
background: #f5f7f2;
|
||||
}
|
||||
|
||||
.database-recovery { max-width: 640px; margin: 12vh auto; padding: 24px; font-family: system-ui, sans-serif; color: #26352b; background: #fffdf8; border: 1px solid #d9e2d7; border-radius: 12px; box-shadow: 0 8px 30px rgba(38, 53, 43, .12); }
|
||||
.database-recovery p { margin: 12px 0; }
|
||||
.database-recovery pre { white-space: pre-wrap; font-size: 12px; color: #6b4a25; background: #f7f1e7; padding: 12px; border-radius: 8px; }
|
||||
.database-recovery button { padding: 12px 16px; color: white; background: #2d5f3b; border: 0; border-radius: 8px; cursor: pointer; }
|
||||
|
||||
h1,
|
||||
h2 {
|
||||
font-family: var(--heading);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div>
|
||||
<h2>Ajustes</h2>
|
||||
<div class="mb-5"><div class="text-overline text-primary">INVENTARIO</div><h1 class="text-h4 mb-1">Historial de ajustes</h1><div class="text-body-2 text-medium-emphasis">Los ajustes se realizan desde Inventario.</div></div>
|
||||
<v-alert v-if="mensaje" :type="tipoMensaje" variant="tonal" class="mb-3">{{ mensaje }}</v-alert>
|
||||
<p><strong>Requiere motivo obligatorio</strong></p>
|
||||
<section>
|
||||
|
||||
@@ -8,19 +8,25 @@
|
||||
|
||||
<section>
|
||||
<v-card><v-card-title>Restaurar datos</v-card-title><v-card-text><v-file-input accept=".json" label="Seleccionar copia JSON" @update:model-value="archivo = $event" /><v-alert v-if="mensaje" :type="tipoMensaje" variant="tonal" class="mb-3">{{ mensaje }}</v-alert><v-btn @click="importar" :disabled="!archivo" color="secondary" prepend-icon="mdi-upload">Importar datos</v-btn></v-card-text></v-card>
|
||||
<v-card class="mt-4" variant="tonal" color="error"><v-card-title>Mantenimiento</v-card-title><v-card-text><div class="text-body-2 mb-3">Restablece la base local sólo si la aplicación no puede migrarla. Esta acción elimina ventas, inventario, jornadas y respaldos locales.</div><v-btn color="error" variant="outlined" prepend-icon="mdi-database-refresh" @click="reiniciarEntorno">Restablecer entorno local</v-btn></v-card-text></v-card>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import { db } from '@toldo/data';
|
||||
import { db, resetDatabase } from '@toldo/data';
|
||||
import { ExportImportService } from '@toldo/services';
|
||||
|
||||
const exportService = new ExportImportService();
|
||||
const archivo = ref<File | null>(null);
|
||||
const mensaje = ref('');
|
||||
const tipoMensaje = ref<'success' | 'error'>('success');
|
||||
async function reiniciarEntorno() {
|
||||
if (!window.confirm('Se eliminarán todos los datos locales. Exporta un respaldo antes de continuar. ¿Restablecer?')) return;
|
||||
await resetDatabase();
|
||||
window.location.reload();
|
||||
}
|
||||
|
||||
function onFileChange(e: any) {
|
||||
archivo.value = e.target.files[0];
|
||||
|
||||
@@ -1,24 +1,10 @@
|
||||
<template>
|
||||
<div>
|
||||
<h2>Gastos</h2>
|
||||
<div class="mb-5"><div class="text-overline text-primary">OPERACION</div><h1 class="text-h4 mb-1">Gastos</h1><div class="text-body-2 text-medium-emphasis">Registra compras y pagos de la jornada.</div></div>
|
||||
<v-alert v-if="mensaje" :type="tipoMensaje" variant="tonal" class="mb-3">{{ mensaje }}</v-alert>
|
||||
<p v-if="!jornadaAbierta" style="color: orange">Sin jornada abierta - debe abrir jornada para registrar gastos</p>
|
||||
<p v-else style="color: green">Jornada abierta</p>
|
||||
<section>
|
||||
<input v-model="concepto" placeholder="Concepto (cinta, lapicero, bolsas...)" />
|
||||
<input v-model.number="monto" type="number" placeholder="Monto" />
|
||||
<select v-model="formaPago">
|
||||
<option value="EFECTIVO">Efectivo</option>
|
||||
<option value="QR">QR</option>
|
||||
</select>
|
||||
<button @click="registrar" :disabled="!jornadaAbierta">Registrar Gasto</button>
|
||||
</section>
|
||||
<section>
|
||||
<h3>Gastos del día</h3>
|
||||
<ul>
|
||||
<li v-for="g in gastos" :key="g.id">{{ g.concepto }} - ${{ g.monto }} ({{ g.formaPago }})</li>
|
||||
</ul>
|
||||
</section>
|
||||
<v-alert :type="jornadaAbierta ? 'success' : 'warning'" variant="tonal" class="mb-4">{{ jornadaAbierta ? 'Jornada abierta' : 'Abre una jornada para registrar gastos' }}</v-alert>
|
||||
<v-card class="mb-4"><v-card-title>Registrar gasto</v-card-title><v-card-text><v-form @submit.prevent="registrar"><v-text-field v-model="concepto" label="Concepto" placeholder="Cinta, lapicero, bolsas..." prepend-inner-icon="mdi-receipt-text" /><v-row><v-col cols="12" sm="6"><v-text-field v-model.number="monto" type="number" label="Monto" prefix="$" /></v-col><v-col cols="12" sm="6"><v-select v-model="formaPago" :items="['EFECTIVO', 'QR']" label="Forma de pago" /></v-col></v-row><v-btn type="submit" color="primary" block size="large" :disabled="!jornadaAbierta" prepend-icon="mdi-plus-circle">Registrar gasto</v-btn></v-form></v-card-text></v-card>
|
||||
<v-card><v-card-title>Gastos del día</v-card-title><v-list><v-list-item v-for="g in gastos" :key="g.id" :title="g.concepto" :subtitle="g.formaPago"><template #append><span class="font-weight-bold">${{ g.monto.toLocaleString() }}</span></template></v-list-item><v-list-item v-if="!gastos.length" title="Sin gastos registrados" /></v-list></v-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -6,8 +6,9 @@
|
||||
<v-card-title><v-icon icon="mdi-file-delimited" class="mr-2" />Importar inventario inicial</v-card-title>
|
||||
<v-card-text><div class="text-body-2 mb-3">CSV con: Nombre de producto, Nombre Productor, precio unitario y cantidad.</div><v-file-input v-model="csvFile" accept=".csv,text/csv" label="Seleccionar CSV" density="compact" prepend-icon="mdi-paperclip" @update:model-value="previsualizarCsv" /><v-alert v-if="previewRows.length" variant="outlined" density="compact" class="mb-3"><div class="text-caption font-weight-bold mb-1">Previsualización</div><div v-for="(row, index) in previewRows" :key="index" class="text-caption text-truncate">{{ row }}</div></v-alert><v-btn :disabled="!csvFile || !previewRows.length" color="info" @click="importarCsv" prepend-icon="mdi-upload">Confirmar importación</v-btn></v-card-text>
|
||||
</v-card>
|
||||
<v-tabs v-model="inventoryTab" class="d-md-none mb-4" color="primary" grow><v-tab value="catalogo">Catálogo</v-tab><v-tab value="ingresos">Ingresos</v-tab><v-tab value="ajustes">Ajustes</v-tab></v-tabs>
|
||||
|
||||
<v-row>
|
||||
<v-row v-show="!isMobile || inventoryTab === 'catalogo'">
|
||||
<v-col cols="12" md="6">
|
||||
<v-card class="mb-4">
|
||||
<v-card-title>Productores</v-card-title>
|
||||
@@ -45,7 +46,7 @@
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<v-row>
|
||||
<v-row v-show="!isMobile || inventoryTab === 'ingresos'">
|
||||
<v-col cols="12" md="6">
|
||||
<v-card>
|
||||
<v-card-title>Ingreso Inicial del Día</v-card-title>
|
||||
@@ -69,16 +70,19 @@
|
||||
</v-card>
|
||||
</v-col>
|
||||
</v-row>
|
||||
<v-card v-show="!isMobile || inventoryTab === 'ajustes'" class="mt-4"><v-card-title>Ajustar inventario</v-card-title><v-card-text><v-select v-model="productoAjuste" :items="productos" item-title="nombrePresentacion" item-value="id" label="Producto" /><v-text-field v-model.number="cantidadAjuste" type="number" label="Nueva cantidad" /><v-textarea v-model="motivoAjuste" label="Motivo obligatorio" rows="2" /><v-btn color="warning" block prepend-icon="mdi-scale-balance" @click="ajustarInventario">Guardar ajuste</v-btn></v-card-text></v-card>
|
||||
<v-dialog v-model="dialogoEdicion" max-width="520"><v-card><v-card-title>Editar {{ entidadEdicion === 'PRODUCTOR' ? 'productor' : 'producto' }}</v-card-title><v-card-text><v-text-field v-if="entidadEdicion === 'PRODUCTOR'" v-model="edicionProductor.nombre" label="Nombre" /><template v-else><v-text-field v-model="edicionProducto.nombrePresentacion" label="Nombre / presentación" /><v-text-field v-model.number="edicionProducto.precioUnitario" type="number" label="Precio unitario" /></template><v-divider class="my-3" /><v-text-field v-model="actor" label="Quién realiza el cambio" /><v-textarea v-model="motivo" label="Motivo del cambio" rows="2" /></v-card-text><v-card-actions><v-spacer /><v-btn variant="text" @click="dialogoEdicion = false">Cancelar</v-btn><v-btn color="primary" @click="guardarEdicion">Guardar cambio</v-btn></v-card-actions></v-card></v-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue';
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue';
|
||||
import { db } from '@toldo/data';
|
||||
import { InventarioService, ImportInventarioCsvService, EdicionService } from '@toldo/services';
|
||||
import { InventarioService, ImportInventarioCsvService, EdicionService, AjustesService } from '@toldo/services';
|
||||
import { validateAjusteMotivo } from '@toldo/core';
|
||||
|
||||
const invService = new InventarioService();
|
||||
const ajustesService = new AjustesService();
|
||||
const productores = ref<any[]>([]);
|
||||
const productos = ref<any[]>([]);
|
||||
const nuevoProductor = ref('');
|
||||
@@ -103,11 +107,26 @@ const edicionProducto = ref<any>({});
|
||||
const originalEdicion = ref<any>({});
|
||||
const actor = ref('');
|
||||
const motivo = ref('');
|
||||
const productoAjuste = ref('');
|
||||
const cantidadAjuste = ref(0);
|
||||
const motivoAjuste = ref('');
|
||||
const inventoryTab = ref('catalogo');
|
||||
const isMobile = ref(false);
|
||||
const busquedaProductores = ref('');
|
||||
const busquedaProductos = ref('');
|
||||
const productoresFiltrados = computed(() => productores.value.filter((productor) => !busquedaProductores.value.trim() || productor.nombre.toLocaleLowerCase().includes(busquedaProductores.value.trim().toLocaleLowerCase())));
|
||||
const productosFiltrados = computed(() => productos.value.filter((producto) => !busquedaProductos.value.trim() || producto.nombrePresentacion.toLocaleLowerCase().includes(busquedaProductos.value.trim().toLocaleLowerCase())));
|
||||
function avisar(texto: string, tipo: 'success' | 'error' = 'error') { mensaje.value = texto; tipoMensaje.value = tipo; }
|
||||
function updateViewport() { isMobile.value = window.innerWidth < 960; }
|
||||
async function ajustarInventario() {
|
||||
try {
|
||||
validateAjusteMotivo(motivoAjuste.value);
|
||||
if (!productoAjuste.value || cantidadAjuste.value < 0) return avisar('Selecciona un producto y una cantidad válida.');
|
||||
const anterior = await invService.calcularSaldoProducto(productoAjuste.value);
|
||||
await ajustesService.crearAjuste({ id: crypto.randomUUID(), fecha: new Date(), productoId: productoAjuste.value, cantidadAnterior: anterior, cantidadNueva: cantidadAjuste.value, diferencia: cantidadAjuste.value - anterior, motivo: motivoAjuste.value, createdAt: new Date() } as any);
|
||||
cantidadAjuste.value = 0; motivoAjuste.value = ''; await cargar(); avisar('Ajuste registrado.', 'success');
|
||||
} catch (error: any) { avisar(error.message); }
|
||||
}
|
||||
function editarProductor(productor: any) { entidadEdicion.value = 'PRODUCTOR'; originalEdicion.value = { ...productor }; edicionProductor.value = { ...productor }; actor.value = ''; motivo.value = ''; dialogoEdicion.value = true; }
|
||||
function editarProducto(producto: any) { entidadEdicion.value = 'PRODUCTO'; originalEdicion.value = { ...producto }; edicionProducto.value = { ...producto }; actor.value = ''; motivo.value = ''; dialogoEdicion.value = true; }
|
||||
async function guardarEdicion() {
|
||||
@@ -165,5 +184,6 @@ async function ingresoSemana() {
|
||||
avisar('Ingreso entre semana registrado.', 'success');
|
||||
}
|
||||
|
||||
onMounted(cargar);
|
||||
onMounted(() => { updateViewport(); window.addEventListener('resize', updateViewport); cargar(); });
|
||||
onUnmounted(() => window.removeEventListener('resize', updateViewport));
|
||||
</script>
|
||||
|
||||
@@ -26,9 +26,13 @@
|
||||
<div v-if="resumen">
|
||||
<div class="mb-2">Ventas: ${{ resumen.totalVentas }}</div>
|
||||
<div class="mb-2">Gastos: ${{ resumen.totalGastos }}</div>
|
||||
<div class="mb-2 font-weight-bold">Saldo esperado: ${{ jornadaAbierta ? jornadaAbierta.baseInicial + resumen.totalVentas - resumen.totalGastos : 0 }}</div>
|
||||
<div class="mb-2 font-weight-bold">Saldo esperado en efectivo: ${{ saldoEsperadoEfectivo.toLocaleString() }}</div>
|
||||
</div>
|
||||
<v-btn @click="cerrar" color="error" block class="mt-2" :disabled="!jornadaAbierta">Cerrar Jornada (generar cuadre)</v-btn>
|
||||
<v-text-field v-model.number="saldoRealEfectivo" type="number" label="Efectivo contado (obligatorio)" prefix="$" class="mt-4" />
|
||||
<v-text-field v-model.number="totalQRBanco" type="number" label="Dinero en banco por QR (opcional)" prefix="$" />
|
||||
<v-alert v-if="saldoRealEfectivo !== null && resumen" :type="diferenciaEfectivo === 0 ? 'success' : 'warning'" variant="tonal" class="mb-2">Diferencia de efectivo: ${{ diferenciaEfectivo.toLocaleString() }}</v-alert>
|
||||
<v-alert v-if="cierreError" :type="cierreCompletado ? 'success' : 'error'" variant="tonal" class="mb-2">{{ cierreError }}</v-alert>
|
||||
<v-btn @click="cerrar" color="error" block class="mt-2" :disabled="!jornadaAbierta || saldoRealEfectivo < 0" prepend-icon="mdi-lock-check">Cerrar caja y jornada</v-btn>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</v-col>
|
||||
@@ -37,14 +41,21 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { JornadaService } from '@toldo/services';
|
||||
import { ref, computed, onMounted } from 'vue';
|
||||
import { JornadaService, CuadreService } from '@toldo/services';
|
||||
|
||||
const service = new JornadaService();
|
||||
const cuadreService = new CuadreService();
|
||||
const jornadaAbierta = ref<any>(null);
|
||||
const responsablesStr = ref('');
|
||||
const baseInicial = ref(0);
|
||||
const resumen = ref<any>(null);
|
||||
const saldoRealEfectivo = ref<number | null>(null);
|
||||
const totalQRBanco = ref<number | null>(null);
|
||||
const cierreError = ref('');
|
||||
const cierreCompletado = ref(false);
|
||||
const saldoEsperadoEfectivo = ref(0);
|
||||
const diferenciaEfectivo = computed(() => saldoRealEfectivo.value === null ? 0 : saldoRealEfectivo.value - saldoEsperadoEfectivo.value);
|
||||
|
||||
function formatFecha(d: any) {
|
||||
try { return new Date(d).toLocaleString(); } catch { return d; }
|
||||
@@ -54,8 +65,11 @@ async function cargar() {
|
||||
jornadaAbierta.value = await service.getJornadaAbierta();
|
||||
if (jornadaAbierta.value) {
|
||||
resumen.value = await service.getResumenJornada(jornadaAbierta.value.id);
|
||||
const cuadre = await cuadreService.calcularCuadreDelDia(jornadaAbierta.value.fecha, jornadaAbierta.value.baseInicial);
|
||||
saldoEsperadoEfectivo.value = cuadre.saldoEsperadoEfectivo ?? 0;
|
||||
} else {
|
||||
resumen.value = null;
|
||||
saldoEsperadoEfectivo.value = 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -75,10 +89,14 @@ async function abrir() {
|
||||
|
||||
async function cerrar() {
|
||||
if (!jornadaAbierta.value) return;
|
||||
cierreError.value = '';
|
||||
cierreCompletado.value = false;
|
||||
if (saldoRealEfectivo.value === null || saldoRealEfectivo.value === undefined || saldoRealEfectivo.value < 0) { cierreError.value = 'El efectivo contado es obligatorio.'; return; }
|
||||
try {
|
||||
const cid = await service.cerrarJornada(jornadaAbierta.value.id);
|
||||
const cid = await service.cerrarJornada(jornadaAbierta.value.id, Number(saldoRealEfectivo.value), totalQRBanco.value === null ? undefined : Number(totalQRBanco.value));
|
||||
await cargar();
|
||||
alert(`Jornada cerrada. Cuadre: ${cid}`);
|
||||
cierreCompletado.value = true;
|
||||
cierreError.value = `Cierre de caja y jornada completado: ${cid}`;
|
||||
} catch (e: any) {
|
||||
alert(e.message);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="d-flex flex-wrap align-center justify-space-between mb-4 ga-2"><div><h1 class="text-h4 mb-1">Punto de venta</h1><div class="text-body-2 text-medium-emphasis">Toca un producto para agregarlo al carrito</div></div><v-chip :color="jornadaAbierta ? 'success' : 'warning'" variant="tonal" :prepend-icon="jornadaAbierta ? 'mdi-check-circle' : 'mdi-alert'">{{ jornadaAbierta ? 'Jornada abierta' : 'Abre una jornada' }}</v-chip></div>
|
||||
<div class="d-flex flex-wrap align-center justify-space-between mb-4 ga-2"><div><h1 class="text-h4 mb-1">Punto de venta</h1><div class="text-body-2 text-medium-emphasis">Busca un producto y agrégalo al carrito</div></div><div class="d-flex align-center ga-2"><v-btn v-if="isMobile" color="primary" prepend-icon="mdi-store-search" @click="catalogoDialog = true">Buscar productos</v-btn><v-chip :color="jornadaAbierta ? 'success' : 'warning'" variant="tonal" :prepend-icon="jornadaAbierta ? 'mdi-check-circle' : 'mdi-alert'">{{ jornadaAbierta ? 'Jornada abierta' : 'Abre una jornada' }}</v-chip></div></div>
|
||||
<v-row>
|
||||
<v-col cols="12" md="8">
|
||||
<v-col v-if="!isMobile" cols="12" md="8">
|
||||
<v-card>
|
||||
<v-card-title>Productos disponibles</v-card-title>
|
||||
<v-card-text>
|
||||
@@ -47,22 +47,26 @@
|
||||
<v-divider class="my-3"></v-divider>
|
||||
<div class="text-h5 font-weight-bold">Total: ${{ total.toLocaleString() }}</div>
|
||||
<v-select v-model="formaPago" :items="['EFECTIVO', 'QR']" label="Forma de Pago" class="mt-3"></v-select>
|
||||
<v-text-field v-if="formaPago === 'EFECTIVO'" v-model.number="efectivoRecibido" type="number" label="Efectivo recibido" prefix="$" prepend-inner-icon="mdi-cash" />
|
||||
<v-alert v-if="formaPago === 'EFECTIVO' && efectivoRecibido > 0" :type="devuelta >= 0 ? 'success' : 'warning'" variant="tonal" class="mb-2">{{ devuelta >= 0 ? `Devuelta: $${devuelta.toLocaleString()}` : `Faltan $${Math.abs(devuelta).toLocaleString()}` }}</v-alert>
|
||||
<v-alert v-if="error" type="error" variant="tonal" density="compact" class="mb-2">{{ error }}</v-alert>
|
||||
<v-alert v-if="exito" type="success" variant="tonal" density="compact" class="mb-2">{{ exito }}</v-alert>
|
||||
<v-btn @click="registrarVenta" color="success" block size="large" :disabled="lineas.length === 0 || !jornadaAbierta" class="mt-2" prepend-icon="mdi-cash-register">Cobrar ${{ total.toLocaleString() }}</v-btn>
|
||||
<v-btn @click="registrarVenta" color="success" block size="large" :disabled="lineas.length === 0 || !jornadaAbierta || (formaPago === 'EFECTIVO' && devuelta < 0)" class="mt-2" prepend-icon="mdi-cash-register">Cobrar ${{ total.toLocaleString() }}</v-btn>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</v-col>
|
||||
</v-row>
|
||||
<v-card class="mt-4"><v-card-title>Ventas recientes</v-card-title><v-card-text><v-list v-if="ventas.length"><v-list-item v-for="venta in ventas" :key="venta.id" :title="`Venta $${venta.total.toLocaleString()}`" :subtitle="`${new Date(venta.fecha).toLocaleString()} · ${venta.lineas.length} productos`"><template #append><v-btn variant="tonal" size="small" prepend-icon="mdi-pencil" @click="editarVenta(venta)">Editar</v-btn></template></v-list-item></v-list><v-alert v-else type="info" variant="tonal">Las ventas registradas durante el día aparecerán aquí.</v-alert></v-card-text></v-card>
|
||||
<v-dialog v-model="dialogoVenta" max-width="560"><v-card><v-card-title>Editar venta</v-card-title><v-card-text><v-list><v-list-item v-for="linea in ventaEditada.lineas" :key="linea.id" :title="getProdNombre(linea.productoId)"><template #append><v-text-field v-model.number="linea.cantidad" type="number" label="Cantidad" density="compact" hide-details style="max-width: 130px" /></template></v-list-item></v-list><v-divider class="my-3" /><v-text-field v-model="actorVenta" label="Quién realiza el cambio" /><v-textarea v-model="motivoVenta" label="Motivo del cambio" rows="2" /></v-card-text><v-card-actions><v-spacer /><v-btn variant="text" @click="dialogoVenta = false">Cancelar</v-btn><v-btn color="primary" @click="guardarVenta">Guardar cambio</v-btn></v-card-actions></v-card></v-dialog>
|
||||
<v-dialog v-model="catalogoDialog" fullscreen><v-card><v-toolbar color="primary"><v-btn icon="mdi-close" aria-label="Cerrar catálogo" @click="catalogoDialog = false" /><v-toolbar-title>Seleccionar producto</v-toolbar-title></v-toolbar><v-card-text><v-text-field v-model="busqueda" label="Buscar producto o productor" prepend-inner-icon="mdi-magnify" clearable autofocus /><v-list><v-list-item v-for="prod in productosFiltrados" :key="prod.id" :title="prod.nombrePresentacion" :subtitle="`Disponible: ${prod.stock} · $${prod.precioUnitario.toLocaleString()}`" :disabled="prod.stock <= 0" @click="agregarDesdeDialogo(prod)"><template #append><v-icon icon="mdi-plus-circle" /></template></v-list-item></v-list></v-card-text></v-card></v-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue';
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue';
|
||||
import { db } from '@toldo/data';
|
||||
import { VentasService, JornadaService, EdicionService } from '@toldo/services';
|
||||
import { calcularDevuelta, validarPagoEfectivo } from '@toldo/core';
|
||||
|
||||
const ventasService = new VentasService();
|
||||
const jornadaService = new JornadaService();
|
||||
@@ -71,6 +75,10 @@ const stockByProduct = ref<Record<string, number>>({});
|
||||
const lineas = ref<any[]>([]);
|
||||
const formaPago = ref('EFECTIVO');
|
||||
const jornadaAbierta = ref<any>(null);
|
||||
const isMobile = ref(false);
|
||||
const catalogoDialog = ref(false);
|
||||
const efectivoRecibido = ref(0);
|
||||
const devuelta = computed(() => calcularDevuelta(total.value, efectivoRecibido.value));
|
||||
const busqueda = ref('');
|
||||
const productosFiltrados = computed(() => {
|
||||
const termino = busqueda.value.trim().toLocaleLowerCase();
|
||||
@@ -102,6 +110,8 @@ function getProdNombre(id: string) {
|
||||
return productos.value.find(p => p.id === id)?.nombrePresentacion || id;
|
||||
}
|
||||
function getStock(id: string) { return stockByProduct.value[id] ?? 0; }
|
||||
function updateViewport() { isMobile.value = window.innerWidth < 960; }
|
||||
function agregarDesdeDialogo(prod: any) { agregarProducto(prod); if (!error.value) catalogoDialog.value = false; }
|
||||
|
||||
function agregarProducto(prod: any) {
|
||||
if (getStock(prod.id) <= 0) { error.value = 'Este producto no tiene inventario disponible. Registra primero el inventario inicial.'; return; }
|
||||
@@ -154,6 +164,7 @@ async function registrarVenta() {
|
||||
error.value = 'Debe abrir una jornada antes de registrar ventas.';
|
||||
return;
|
||||
}
|
||||
if (formaPago.value === 'EFECTIVO') { const errorPago = validarPagoEfectivo(total.value, efectivoRecibido.value); if (errorPago) { error.value = errorPago; return; } }
|
||||
const ventaId = crypto.randomUUID();
|
||||
const lineasVenta = lineas.value.map(l => ({ ...l, ventaId, formaPago: formaPago.value }));
|
||||
const venta = {
|
||||
@@ -167,11 +178,13 @@ async function registrarVenta() {
|
||||
try {
|
||||
await jornadaService.registrarVentaEnJornada(jornadaAbierta.value.id, venta, lineasVenta);
|
||||
lineas.value = [];
|
||||
efectivoRecibido.value = 0;
|
||||
exito.value = `Venta registrada por $${total.value.toLocaleString()} en ${formaPago.value}.`;
|
||||
} catch (e: any) {
|
||||
error.value = e.message.includes('objectStore') ? 'No se pudo abrir el almacenamiento local. Cierra y vuelve a abrir la aplicación para actualizar la base de datos.' : e.message;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(cargar);
|
||||
onMounted(() => { updateViewport(); window.addEventListener('resize', updateViewport); cargar(); });
|
||||
onUnmounted(() => window.removeEventListener('resize', updateViewport));
|
||||
</script>
|
||||
|
||||
@@ -21,13 +21,26 @@ test('permite volver a inicio desde Jornada e Inventario', async ({ page }) => {
|
||||
if ((await page.viewportSize())!.width < 960) await page.getByTestId('navigation-toggle').click();
|
||||
await page.getByRole('link', { name: 'Jornada', exact: true }).click();
|
||||
await expect(page.getByText('Jornada del Toldo')).toBeVisible();
|
||||
await expect(page.getByLabel('Migas de pan')).toContainText('Inicio');
|
||||
await page.getByRole('link', { name: 'Inicio' }).last().click();
|
||||
await expect(page.getByText('Panel de control')).toBeVisible();
|
||||
if ((await page.viewportSize())!.width < 960) await page.getByTestId('navigation-toggle').click();
|
||||
await page.getByRole('link', { name: 'Inventario', exact: true }).click();
|
||||
await expect(page.getByText('Inventario y Productos')).toBeVisible();
|
||||
await expect(page.locator('.mdi-home-outline')).toBeVisible();
|
||||
await page.getByTestId('back-button').click();
|
||||
await expect(page.getByText('Panel de control')).toBeVisible();
|
||||
});
|
||||
|
||||
test('muestra los flujos compactos en móvil', async ({ page }) => {
|
||||
test.skip(test.info().project.name !== 'mobile', 'Este escenario se ejecuta en el proyecto móvil.');
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await page.goto('/inventario');
|
||||
await expect(page.getByRole('tab', { name: 'Catálogo' })).toBeVisible();
|
||||
await expect(page.getByRole('tab', { name: 'Ingresos' })).toBeVisible();
|
||||
await expect(page.getByRole('tab', { name: 'Ajustes' })).toBeVisible();
|
||||
await expect(page.getByText('Migas de pan')).toHaveCount(0);
|
||||
await page.goto('/ventas');
|
||||
await expect(page.getByRole('button', { name: 'Buscar productos' })).toBeVisible();
|
||||
await page.goto('/jornada');
|
||||
await expect(page.getByLabel('Efectivo contado (obligatorio)')).toBeVisible();
|
||||
await expect(page.getByLabel('Dinero en banco por QR (opcional)')).toBeVisible();
|
||||
});
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
versionName=0.0.3
|
||||
versionCode=3
|
||||
versionName=0.0.6
|
||||
versionCode=6
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-10-25
|
||||
23
openspec/changes/harden-database-bootstrap-reset/design.md
Normal file
23
openspec/changes/harden-database-bootstrap-reset/design.md
Normal 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.
|
||||
29
openspec/changes/harden-database-bootstrap-reset/proposal.md
Normal file
29
openspec/changes/harden-database-bootstrap-reset/proposal.md
Normal 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.
|
||||
@@ -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
|
||||
12
openspec/changes/harden-database-bootstrap-reset/tasks.md
Normal file
12
openspec/changes/harden-database-bootstrap-reset/tasks.md
Normal 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
|
||||
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-10-25
|
||||
25
openspec/changes/improve-mobile-pos-closing/design.md
Normal file
25
openspec/changes/improve-mobile-pos-closing/design.md
Normal 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.
|
||||
31
openspec/changes/improve-mobile-pos-closing/proposal.md
Normal file
31
openspec/changes/improve-mobile-pos-closing/proposal.md
Normal 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.
|
||||
@@ -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
|
||||
@@ -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
|
||||
24
openspec/changes/improve-mobile-pos-closing/tasks.md
Normal file
24
openspec/changes/improve-mobile-pos-closing/tasks.md
Normal 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
|
||||
@@ -8,7 +8,7 @@ La aplicación usa versiones distintas o hardcodeadas entre npm, Android y las e
|
||||
|
||||
- Definir `versionName` y `versionCode` en un archivo versionado compartido.
|
||||
- Usar esos valores en Gradle, npm y exportaciones JSON.
|
||||
- Publicar la siguiente versión como `0.0.3` con `versionCode 3`.
|
||||
- Publicar la siguiente versión como `0.0.6` con `versionCode 6`.
|
||||
- Validar el `applicationId` y la versión en el APK generado.
|
||||
|
||||
## Capabilities
|
||||
|
||||
@@ -9,10 +9,10 @@ Asegura que cada actualización de ToldoConjunto pueda identificarse y actualiza
|
||||
### Requirement: Versionado consistente
|
||||
The system SHALL expose the same semantic version in npm metadata, exported data, and Android versionName.
|
||||
|
||||
#### Scenario: Version 0.0.3
|
||||
#### Scenario: Version 0.0.6
|
||||
- **WHEN** the app is built
|
||||
- **THEN** npm and exported data report `0.0.3`
|
||||
- **AND** Android reports versionName `0.0.3` and versionCode `3`
|
||||
- **THEN** npm and exported data report `0.0.6`
|
||||
- **AND** Android reports versionName `0.0.6` and versionCode `6`
|
||||
|
||||
### Requirement: Identidad Android estable
|
||||
The system SHALL preserve the application id `com.ilusion.toldoconjunto` across releases.
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
## 1. Versionado
|
||||
|
||||
- [x] 1.1 Crear la fuente compartida con `versionName=0.0.3` y `versionCode=3`, y actualizar npm/exportaciones
|
||||
- [x] 1.1 Crear la fuente compartida con `versionName=0.0.6` y `versionCode=6`, y actualizar npm/exportaciones
|
||||
- [x] 1.2 Configurar Gradle para leer la fuente compartida y conservar el applicationId
|
||||
- [x] 1.3 Añadir test que compruebe versionado y compatibilidad de exportación
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
export * from './entities';
|
||||
export * from './rules/cuadreRules';
|
||||
export * from './rules/jornadaRules';
|
||||
export * from './rules/ventaRules';
|
||||
export * from './entities/JornadaToldo';
|
||||
|
||||
8
packages/core/src/rules/ventaRules.ts
Normal file
8
packages/core/src/rules/ventaRules.ts
Normal 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;
|
||||
}
|
||||
@@ -52,3 +52,13 @@ export class ToldoDB extends Dexie {
|
||||
}
|
||||
|
||||
export const db = new ToldoDB();
|
||||
|
||||
export async function openDatabase() {
|
||||
await db.open();
|
||||
return db;
|
||||
}
|
||||
|
||||
export async function resetDatabase() {
|
||||
await db.delete();
|
||||
await db.open();
|
||||
}
|
||||
|
||||
@@ -60,13 +60,12 @@ export class JornadaService {
|
||||
return gastoId;
|
||||
}
|
||||
|
||||
async cerrarJornada(jornadaId: string, cuadreId?: string): Promise<string> {
|
||||
async cerrarJornada(jornadaId: string, saldoRealEfectivo: number, totalQRBanco?: number): Promise<string> {
|
||||
if (!Number.isFinite(saldoRealEfectivo) || saldoRealEfectivo < 0) throw new Error('El efectivo contado es obligatorio y no puede ser negativo');
|
||||
const jornada: any = await db.jornadas.get(jornadaId);
|
||||
if (!jornada) throw new Error('Jornada no encontrada');
|
||||
if (jornada.estado === 'CERRADA') throw new Error('Jornada ya cerrada');
|
||||
|
||||
let cid = cuadreId;
|
||||
if (!cid) {
|
||||
const cuadreParcial = await this.cuadreService.calcularCuadreDelDia(jornada.fecha, jornada.baseInicial);
|
||||
const cuadre: CuadreCaja = {
|
||||
id: crypto.randomUUID(),
|
||||
@@ -78,17 +77,19 @@ export class JornadaService {
|
||||
totalGastosQR: cuadreParcial.totalGastosQR || 0,
|
||||
saldoEsperadoEfectivo: cuadreParcial.saldoEsperadoEfectivo || 0,
|
||||
totalVentas: cuadreParcial.totalVentas || 0,
|
||||
saldoRealEfectivo,
|
||||
totalQRBanco,
|
||||
createdAt: new Date()
|
||||
} as CuadreCaja;
|
||||
cid = await this.cuadreService.guardarCuadre(cuadre);
|
||||
cuadre.descuadreEfectivo = saldoRealEfectivo - cuadre.saldoEsperadoEfectivo;
|
||||
if (totalQRBanco !== undefined) {
|
||||
cuadre.totalQRRegistrado = cuadre.totalVentasQR - cuadre.totalGastosQR;
|
||||
cuadre.descuadreQR = totalQRBanco - cuadre.totalQRRegistrado;
|
||||
}
|
||||
|
||||
await db.jornadas.put({
|
||||
...jornada,
|
||||
estado: 'CERRADA',
|
||||
fechaCierre: new Date(),
|
||||
cuadreId: cid,
|
||||
updatedAt: new Date()
|
||||
const cid = cuadre.id;
|
||||
await db.transaction('rw', db.jornadas, db.cuadres, async () => {
|
||||
await db.cuadres.add(cuadre);
|
||||
await db.jornadas.put({ ...jornada, estado: 'CERRADA', fechaCierre: new Date(), cuadreId: cid, updatedAt: new Date() });
|
||||
});
|
||||
return cid;
|
||||
}
|
||||
|
||||
@@ -30,7 +30,7 @@ describe('Caso de uso jornada', () => {
|
||||
expect(resumen?.totalVentas).toBe(30000);
|
||||
expect(resumen?.totalGastos).toBe(5000);
|
||||
|
||||
const cuadreId = await service.cerrarJornada(jid);
|
||||
const cuadreId = await service.cerrarJornada(jid, 125000);
|
||||
expect(cuadreId).toBeTruthy();
|
||||
|
||||
const abierta = await service.getJornadaAbierta();
|
||||
|
||||
@@ -19,7 +19,7 @@ export interface ExportData {
|
||||
}
|
||||
|
||||
export const CURRENT_SCHEMA_VERSION = 1;
|
||||
export const APP_VERSION = '0.0.3';
|
||||
export const APP_VERSION = '0.0.6';
|
||||
|
||||
export class ExportImportService {
|
||||
exportAll(data: ExportData['data']): ExportData {
|
||||
|
||||
20
scripts/verify-apk.sh
Executable file
20
scripts/verify-apk.sh
Executable 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"
|
||||
Reference in New Issue
Block a user