From ba18b73ed97993aa994875df85058a128e48caaa Mon Sep 17 00:00:00 2001 From: mono Date: Sun, 25 Oct 2026 20:03:36 -0500 Subject: [PATCH] feat: mejorar pos movil y cierre de jornada --- .../src/__tests__/sale-payment.test.ts | 8 +++++ .../__tests__/use-cases.integration.test.ts | 13 +++++++- apps/toldo-app/src/components/Layout.vue | 21 ++++--------- apps/toldo-app/src/router/index.ts | 4 --- apps/toldo-app/src/views/Ajustes.vue | 2 +- apps/toldo-app/src/views/Gastos.vue | 22 +++---------- apps/toldo-app/src/views/Inventario.vue | 30 +++++++++++++++--- apps/toldo-app/src/views/Jornada.vue | 30 ++++++++++++++---- apps/toldo-app/src/views/Ventas.vue | 23 +++++++++++--- apps/toldo-app/tests/e2e/navigation.spec.ts | 2 -- .../improve-mobile-pos-closing/.openspec.yaml | 2 ++ .../improve-mobile-pos-closing/design.md | 25 +++++++++++++++ .../improve-mobile-pos-closing/proposal.md | 31 +++++++++++++++++++ .../specs/mobile-pos-flow/spec.md | 28 +++++++++++++++++ .../specs/unified-day-closing/spec.md | 28 +++++++++++++++++ .../improve-mobile-pos-closing/tasks.md | 24 ++++++++++++++ packages/core/src/index.ts | 1 + packages/core/src/rules/ventaRules.ts | 8 +++++ packages/services/src/JornadaService.ts | 31 ++++++++++--------- .../src/__tests__/jornada.usecase.test.ts | 2 +- 20 files changed, 262 insertions(+), 73 deletions(-) create mode 100644 apps/toldo-app/src/__tests__/sale-payment.test.ts create mode 100644 openspec/changes/improve-mobile-pos-closing/.openspec.yaml create mode 100644 openspec/changes/improve-mobile-pos-closing/design.md create mode 100644 openspec/changes/improve-mobile-pos-closing/proposal.md create mode 100644 openspec/changes/improve-mobile-pos-closing/specs/mobile-pos-flow/spec.md create mode 100644 openspec/changes/improve-mobile-pos-closing/specs/unified-day-closing/spec.md create mode 100644 openspec/changes/improve-mobile-pos-closing/tasks.md create mode 100644 packages/core/src/rules/ventaRules.ts diff --git a/apps/toldo-app/src/__tests__/sale-payment.test.ts b/apps/toldo-app/src/__tests__/sale-payment.test.ts new file mode 100644 index 0000000..dd87254 --- /dev/null +++ b/apps/toldo-app/src/__tests__/sale-payment.test.ts @@ -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()); +}); diff --git a/apps/toldo-app/src/__tests__/use-cases.integration.test.ts b/apps/toldo-app/src/__tests__/use-cases.integration.test.ts index 9853b37..f56ad89 100644 --- a/apps/toldo-app/src/__tests__/use-cases.integration.test.ts +++ b/apps/toldo-app/src/__tests__/use-cases.integration.test.ts @@ -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(); diff --git a/apps/toldo-app/src/components/Layout.vue b/apps/toldo-app/src/components/Layout.vue index 9709655..3b1034a 100644 --- a/apps/toldo-app/src/components/Layout.vue +++ b/apps/toldo-app/src/components/Layout.vue @@ -16,14 +16,9 @@ -
- - - -
- Atrás - Inicio -
+
+ Atrás + Inicio
@@ -32,30 +27,26 @@ diff --git a/apps/toldo-app/src/router/index.ts b/apps/toldo-app/src/router/index.ts index 2f8cf48..71805c0 100644 --- a/apps/toldo-app/src/router/index.ts +++ b/apps/toldo-app/src/router/index.ts @@ -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' } }, ] } diff --git a/apps/toldo-app/src/views/Ajustes.vue b/apps/toldo-app/src/views/Ajustes.vue index 82387e4..ca8275c 100644 --- a/apps/toldo-app/src/views/Ajustes.vue +++ b/apps/toldo-app/src/views/Ajustes.vue @@ -1,6 +1,6 @@ diff --git a/apps/toldo-app/src/views/Jornada.vue b/apps/toldo-app/src/views/Jornada.vue index b0d0c25..9d6e936 100644 --- a/apps/toldo-app/src/views/Jornada.vue +++ b/apps/toldo-app/src/views/Jornada.vue @@ -26,9 +26,13 @@
Ventas: ${{ resumen.totalVentas }}
Gastos: ${{ resumen.totalGastos }}
-
Saldo esperado: ${{ jornadaAbierta ? jornadaAbierta.baseInicial + resumen.totalVentas - resumen.totalGastos : 0 }}
+
Saldo esperado en efectivo: ${{ saldoEsperadoEfectivo.toLocaleString() }}
- Cerrar Jornada (generar cuadre) + + + Diferencia de efectivo: ${{ diferenciaEfectivo.toLocaleString() }} + {{ cierreError }} + Cerrar caja y jornada @@ -37,14 +41,21 @@ diff --git a/apps/toldo-app/tests/e2e/navigation.spec.ts b/apps/toldo-app/tests/e2e/navigation.spec.ts index a36f51f..d3653ef 100644 --- a/apps/toldo-app/tests/e2e/navigation.spec.ts +++ b/apps/toldo-app/tests/e2e/navigation.spec.ts @@ -21,13 +21,11 @@ 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(); }); diff --git a/openspec/changes/improve-mobile-pos-closing/.openspec.yaml b/openspec/changes/improve-mobile-pos-closing/.openspec.yaml new file mode 100644 index 0000000..7ccf7c3 --- /dev/null +++ b/openspec/changes/improve-mobile-pos-closing/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-10-25 diff --git a/openspec/changes/improve-mobile-pos-closing/design.md b/openspec/changes/improve-mobile-pos-closing/design.md new file mode 100644 index 0000000..fd2a7a0 --- /dev/null +++ b/openspec/changes/improve-mobile-pos-closing/design.md @@ -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. diff --git a/openspec/changes/improve-mobile-pos-closing/proposal.md b/openspec/changes/improve-mobile-pos-closing/proposal.md new file mode 100644 index 0000000..f801ddb --- /dev/null +++ b/openspec/changes/improve-mobile-pos-closing/proposal.md @@ -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. diff --git a/openspec/changes/improve-mobile-pos-closing/specs/mobile-pos-flow/spec.md b/openspec/changes/improve-mobile-pos-closing/specs/mobile-pos-flow/spec.md new file mode 100644 index 0000000..bf16f14 --- /dev/null +++ b/openspec/changes/improve-mobile-pos-closing/specs/mobile-pos-flow/spec.md @@ -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 diff --git a/openspec/changes/improve-mobile-pos-closing/specs/unified-day-closing/spec.md b/openspec/changes/improve-mobile-pos-closing/specs/unified-day-closing/spec.md new file mode 100644 index 0000000..73640fe --- /dev/null +++ b/openspec/changes/improve-mobile-pos-closing/specs/unified-day-closing/spec.md @@ -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 diff --git a/openspec/changes/improve-mobile-pos-closing/tasks.md b/openspec/changes/improve-mobile-pos-closing/tasks.md new file mode 100644 index 0000000..0cca620 --- /dev/null +++ b/openspec/changes/improve-mobile-pos-closing/tasks.md @@ -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 diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 90a2642..26ef7d8 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1,4 +1,5 @@ export * from './entities'; export * from './rules/cuadreRules'; export * from './rules/jornadaRules'; +export * from './rules/ventaRules'; export * from './entities/JornadaToldo'; diff --git a/packages/core/src/rules/ventaRules.ts b/packages/core/src/rules/ventaRules.ts new file mode 100644 index 0000000..24c0ca9 --- /dev/null +++ b/packages/core/src/rules/ventaRules.ts @@ -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; +} diff --git a/packages/services/src/JornadaService.ts b/packages/services/src/JornadaService.ts index af7d340..1c01f38 100644 --- a/packages/services/src/JornadaService.ts +++ b/packages/services/src/JornadaService.ts @@ -60,15 +60,14 @@ export class JornadaService { return gastoId; } - async cerrarJornada(jornadaId: string, cuadreId?: string): Promise { + async cerrarJornada(jornadaId: string, saldoRealEfectivo: number, totalQRBanco?: number): Promise { + 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 = { + const cuadreParcial = await this.cuadreService.calcularCuadreDelDia(jornada.fecha, jornada.baseInicial); + const cuadre: CuadreCaja = { id: crypto.randomUUID(), fecha: jornada.fecha, baseInicial: jornada.baseInicial, @@ -78,17 +77,19 @@ export class JornadaService { totalGastosQR: cuadreParcial.totalGastosQR || 0, saldoEsperadoEfectivo: cuadreParcial.saldoEsperadoEfectivo || 0, totalVentas: cuadreParcial.totalVentas || 0, - createdAt: new Date() - } as CuadreCaja; - cid = await this.cuadreService.guardarCuadre(cuadre); + saldoRealEfectivo, + totalQRBanco, + createdAt: new Date() + } as CuadreCaja; + 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; } diff --git a/packages/services/src/__tests__/jornada.usecase.test.ts b/packages/services/src/__tests__/jornada.usecase.test.ts index 794abce..abb5412 100644 --- a/packages/services/src/__tests__/jornada.usecase.test.ts +++ b/packages/services/src/__tests__/jornada.usecase.test.ts @@ -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();