commit 3b3da382d79e9b5d60aa14c45011b04b8ce57cbd Author: rckrdmrd Date: Sun Jan 4 06:12:11 2026 -0600 Initial commit - erp-retail diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..629b06d --- /dev/null +++ b/.env.example @@ -0,0 +1,129 @@ +# =========================================== +# RETAIL / POS - Variables de Entorno +# =========================================== +# Copiar este archivo a .env y configurar valores +# Puertos según DEVENV-PORTS.md + +# ------------------------------------------- +# BASE DE DATOS POSTGRESQL +# ------------------------------------------- +DB_HOST=localhost +DB_PORT=5436 +DB_NAME=retail_db +DB_USER=retail_user +DB_PASSWORD=retail_secret_2025 + +# URL de conexion completa +DATABASE_URL=postgresql://${DB_USER}:${DB_PASSWORD}@${DB_HOST}:${DB_PORT}/${DB_NAME} + +# ------------------------------------------- +# SCHEMAS DE BASE DE DATOS +# ------------------------------------------- +# Schemas heredados de erp-core +DB_SCHEMA_AUTH=auth +DB_SCHEMA_CORE=core +DB_SCHEMA_INVENTORY=inventory +DB_SCHEMA_SALES=sales + +# Schemas propios de retail +DB_SCHEMA_POS=pos +DB_SCHEMA_LOYALTY=loyalty +DB_SCHEMA_PRICING=pricing +DB_SCHEMA_ECOMMERCE=ecommerce + +# ------------------------------------------- +# APLICACION +# ------------------------------------------- +APP_NAME=retail +APP_ENV=development +APP_PORT=3051 +APP_URL=http://localhost:3051 + +# ------------------------------------------- +# FRONTEND +# ------------------------------------------- +FRONTEND_PORT=3050 +FRONTEND_URL=http://localhost:3050 + +# ------------------------------------------- +# AUTENTICACION JWT +# ------------------------------------------- +JWT_SECRET=your_jwt_secret_here_change_in_production +JWT_EXPIRES_IN=24h +JWT_REFRESH_EXPIRES_IN=7d + +# ------------------------------------------- +# MULTI-TENANT +# ------------------------------------------- +TENANT_ID_HEADER=X-Tenant-ID +TENANT_ID_PARAM=tenant_id + +# ------------------------------------------- +# ALMACENAMIENTO DE ARCHIVOS +# ------------------------------------------- +STORAGE_TYPE=local +STORAGE_PATH=./uploads + +# ------------------------------------------- +# NOTIFICACIONES +# ------------------------------------------- +# Email (SMTP) +SMTP_HOST=smtp.gmail.com +SMTP_PORT=587 +SMTP_USER= +SMTP_PASSWORD= +SMTP_FROM=noreply@retail-erp.com + +# WhatsApp (Twilio) +TWILIO_ACCOUNT_SID= +TWILIO_AUTH_TOKEN= +TWILIO_WHATSAPP_FROM= + +# ------------------------------------------- +# FACTURACION ELECTRONICA (SAT) +# ------------------------------------------- +SAT_ENVIRONMENT=sandbox +SAT_RFC= +SAT_CER_PATH=./certs/csd.cer +SAT_KEY_PATH=./certs/csd.key +SAT_KEY_PASSWORD= + +# ------------------------------------------- +# LOGGING +# ------------------------------------------- +LOG_LEVEL=debug +LOG_FORMAT=json + +# ------------------------------------------- +# REDIS (Cache y Colas) +# ------------------------------------------- +REDIS_HOST=localhost +REDIS_PORT=6383 +REDIS_PASSWORD= + +# ------------------------------------------- +# CORS +# ------------------------------------------- +CORS_ORIGIN=http://localhost:3050,http://localhost:3051 + +# ------------------------------------------- +# PUNTO DE VENTA (POS) - Específico +# ------------------------------------------- +# Modo offline +POS_OFFLINE_ENABLED=true +POS_SYNC_INTERVAL_SECONDS=30 +POS_LOCAL_STORAGE=indexeddb + +# Impresoras térmicas +POS_PRINTER_PROTOCOL=escpos +POS_PRINTER_WIDTH=80 + +# Caja +POS_CASH_DRAWER_AUTO_OPEN=true +POS_REQUIRE_CASH_COUNT_CLOSE=true + +# ------------------------------------------- +# ECOMMERCE (Opcional) +# ------------------------------------------- +ECOMMERCE_ENABLED=false +ECOMMERCE_WEBHOOK_SECRET= diff --git a/INVENTARIO.yml b/INVENTARIO.yml new file mode 100644 index 0000000..3a8a2c0 --- /dev/null +++ b/INVENTARIO.yml @@ -0,0 +1,31 @@ +# Inventario generado por EPIC-008 +proyecto: erp-retail +fecha: "2026-01-04" +generado_por: "inventory-project.sh v1.0.0" + +inventario: + docs: + total: 23 + por_tipo: + markdown: 23 + yaml: 0 + json: 0 + orchestration: + total: 48 + por_tipo: + markdown: 38 + yaml: 10 + json: 0 + +problemas: + archivos_obsoletos: 0 + referencias_antiguas: 0 + simco_faltantes: + - _MAP.md en docs/ + - PROJECT-STATUS.md + +estado_simco: + herencia_simco: true + contexto_proyecto: true + map_docs: false + project_status: false diff --git a/PROJECT-STATUS.md b/PROJECT-STATUS.md new file mode 100644 index 0000000..7eadb08 --- /dev/null +++ b/PROJECT-STATUS.md @@ -0,0 +1,156 @@ +# ESTADO DEL PROYECTO - ERP Retail + +**Proyecto:** ERP Retail (Proyecto Independiente) +**Estado:** 📋 En planificación +**Progreso:** 25% +**Última actualización:** 2025-12-08 + +--- + +## 📊 RESUMEN EJECUTIVO + +| Área | Estado | Descripción | +|------|--------|-------------| +| **Documentación** | 🟡 Inicial | 10 módulos definidos, estructura base | +| **DDL/Schemas** | ❌ No iniciado | Pendiente diseño de BD | +| **Backend** | ❌ No iniciado | Pendiente desarrollo | +| **Frontend** | ❌ No iniciado | Pendiente desarrollo | + +--- + +## 📋 MÓDULOS DEFINIDOS (10) + +| Código | Nombre | Descripción | Reutilización | Estado | +|--------|--------|-------------|---------------|--------| +| RT-001 | Fundamentos | Auth, Users, Tenants | 100% core | PLANIFICADO | +| RT-002 | POS | Punto de venta | 20% core | PLANIFICADO | +| RT-003 | Inventario | Stock multi-sucursal | 60% core | PLANIFICADO | +| RT-004 | Compras | Reabastecimiento | 80% core | PLANIFICADO | +| RT-005 | Clientes | Programa fidelidad | 40% core | PLANIFICADO | +| RT-006 | Precios | Promociones y descuentos | 30% core | PLANIFICADO | +| RT-007 | Caja | Arqueos y cortes | 10% core | PLANIFICADO | +| RT-008 | Reportes | Dashboard de ventas | 70% core | PLANIFICADO | +| RT-009 | E-commerce | Tienda online | 20% core | PLANIFICADO | +| RT-010 | Facturación | CFDI 4.0 | 60% core | PLANIFICADO | + +**Story Points Estimados:** 353 SP (detallado en épicas) + +--- + +## 🏪 DOMINIO DE NEGOCIO + +### Modelo de Negocio +- Cadena de tiendas minoristas +- Multi-sucursal +- Inventario centralizado y distribuido +- Programa de lealtad + +### Proceso Principal +``` +Cliente → POS → Pago → Factura → Actualización Inventario + ↑ + Programa de Puntos +``` + +### Características Específicas +- Venta rápida en mostrador (POS) +- Operación offline (PWA) +- Múltiples formas de pago +- Transferencias entre sucursales +- Promociones y cupones +- Integración e-commerce + +--- + +## 📁 ESTRUCTURA DE DOCUMENTACIÓN + +``` +docs/ +├── 00-vision-general/ +│ └── VISION-RETAIL.md ✅ +├── 02-definicion-modulos/ +│ ├── INDICE-MODULOS.md ✅ +│ ├── RT-001-fundamentos/README.md ✅ +│ ├── RT-002-pos/README.md ✅ +│ ├── RT-003-inventario/README.md ✅ +│ ├── RT-004-compras/README.md ✅ +│ ├── RT-005-clientes/README.md ✅ +│ ├── RT-006-precios/README.md ✅ +│ ├── RT-007-caja/README.md ✅ +│ ├── RT-008-reportes/README.md ✅ +│ ├── RT-009-ecommerce/README.md ✅ +│ └── RT-010-facturacion/README.md ✅ +└── 08-epicas/ + └── EPIC-RT-001-fundamentos.md ✅ +``` + +--- + +## 🎯 PRÓXIMOS PASOS + +### Fase 1: Documentación Detallada +1. [ ] Crear épicas completas (EPIC-RT-002 a 010) +2. [ ] Documentar User Stories por módulo +3. [ ] Definir requerimientos funcionales (RF) +4. [ ] Crear especificaciones técnicas (ET) + +### Fase 2: Diseño de Base de Datos +5. [ ] Diseñar schemas de BD +6. [ ] Implementar DDL +7. [ ] Documentar modelo de datos + +### Fase 3: Desarrollo +8. [ ] Implementar backend (TypeScript/Express) +9. [ ] Implementar frontend POS (React PWA) +10. [ ] Testing + +--- + +## 📈 MÉTRICAS + +| Métrica | Valor | +|---------|-------| +| Módulos definidos | 10 | +| Épicas creadas | 10/10 ✅ | +| User Stories | 0 (pendiente) | +| Story Points | 353 | +| Archivos MD | 29 | +| Archivos SQL | 0 | +| Archivos TS | 0 | + +--- + +## 🏗️ ARQUITECTURA + +**Tipo:** Proyecto Independiente (fork conceptual del ERP-Core) + +**Patrones a reutilizar del ERP-Core:** +- Multi-tenancy con RLS (para franquicias) +- Estructura de autenticación +- Patrones de inventario +- Sistema de compras +- Reportes y analytics + +**Módulos 100% nuevos:** +- RT-002: POS (punto de venta con PWA) +- RT-007: Caja (arqueos y movimientos) + +**Características técnicas:** +- PWA para operación offline +- Sincronización bidireccional +- Integración con hardware (impresoras, cajas) + +**Opera de forma autónoma:** No requiere ERP-Core instalado + +--- + +## 🔗 REFERENCIAS + +- Índice de módulos: `docs/02-definicion-modulos/INDICE-MODULOS.md` +- Visión: `docs/00-vision-general/VISION-RETAIL.md` +- SPECS heredadas: `orchestration/00-guidelines/HERENCIA-SPECS-CORE.md` +- Directivas: `orchestration/directivas/` + +--- + +**Última actualización:** 2025-12-08 diff --git a/README.md b/README.md new file mode 100644 index 0000000..7d664b5 --- /dev/null +++ b/README.md @@ -0,0 +1,36 @@ +# ERP Retail + +Sistema ERP especializado para comercio minorista. + +## Descripcion + +Vertical del sistema ERP-Core adaptado para el sector retail, con funcionalidades especificas para: +- Punto de venta (POS) +- Gestion de inventario multi-sucursal +- Control de stock +- Facturacion electronica +- Reportes de ventas + +## Estructura + +``` +erp-retail/ +├── docs/ # Documentacion del proyecto +├── orchestration/ # Configuracion de orquestacion SIMCO +└── apps/ # Aplicaciones (si aplica) +``` + +## Estado + +Este proyecto hereda del nucleo ERP-Core y aplica las directivas SIMCO del workspace. + +## Referencias + +- ERP-Core: Nucleo base del sistema +- ERP-Suite: Suite completa de ERP +- Directivas SIMCO: Ver orchestration/00-guidelines/ + +--- + +**Sistema:** NEXUS v3.4 +**Tipo:** Vertical ERP diff --git a/backend/docs/SPRINT-4-SUMMARY.md b/backend/docs/SPRINT-4-SUMMARY.md new file mode 100644 index 0000000..976c81c --- /dev/null +++ b/backend/docs/SPRINT-4-SUMMARY.md @@ -0,0 +1,225 @@ +# Sprint 4: Motor de Precios - Resumen + +## Objetivo +Implementar el motor de precios completo para el vertical de Retail, incluyendo gestión de promociones, cupones y cálculo dinámico de precios. + +## Componentes Implementados + +### 1. Servicios + +#### PriceEngineService (`src/modules/pricing/services/price-engine.service.ts`) +Motor central de cálculo de precios con las siguientes capacidades: + +- **calculatePrices()**: Calcula precios para un conjunto de líneas de pedido + - Aplica promociones automáticas activas + - Soporta múltiples tipos de promoción + - Gestiona stackability (promociones acumulables) + - Aplica cupones si se proporciona código + +- **getActivePromotions()**: Obtiene promociones activas para sucursal/canal + +- **applyCoupon()**: Aplica un cupón al resultado de precios + +- **validateCoupon()**: Valida un código de cupón sin aplicarlo + +**Tipos de Promoción Soportados:** +| Tipo | Descripción | +|------|-------------| +| `percentage_discount` | Descuento porcentual | +| `fixed_discount` | Descuento de monto fijo | +| `buy_x_get_y` | Compra X, lleva Y | +| `bundle` | Precio especial por paquete | +| `flash_sale` | Venta relámpago | +| `quantity_discount` | Descuento por volumen | +| `free_shipping` | Envío gratis | +| `gift_with_purchase` | Regalo con compra | + +#### PromotionService (`src/modules/pricing/services/promotion.service.ts`) +Servicio CRUD para gestión de promociones: + +- **createPromotion()**: Crear promoción con validación de código único +- **updatePromotion()**: Actualizar promoción (solo en estado draft/scheduled) +- **listPromotions()**: Listar con filtros paginados +- **activatePromotion()**: Activar promoción +- **pausePromotion()**: Pausar promoción activa +- **endPromotion()**: Finalizar promoción +- **cancelPromotion()**: Cancelar promoción +- **addProducts()**: Agregar productos a promoción +- **removeProduct()**: Remover producto de promoción +- **updatePromotionStatuses()**: Actualizar estados automáticamente según fechas + +**Flujo de Estados:** +``` +draft → scheduled → active → ended + ↓ + paused + ↓ + cancelled +``` + +#### CouponService (`src/modules/pricing/services/coupon.service.ts`) +Servicio completo para gestión de cupones: + +- **createCoupon()**: Crear cupón individual +- **generateBulkCoupons()**: Generar cupones en lote (hasta 1000) +- **updateCoupon()**: Actualizar cupón +- **activateCoupon()**: Activar cupón +- **deactivateCoupon()**: Desactivar cupón +- **redeemCoupon()**: Canjear cupón con validación completa +- **reverseRedemption()**: Revertir un canje (devolución) +- **getRedemptionHistory()**: Historial de canjes paginado +- **getCouponStats()**: Estadísticas del cupón + +**Tipos de Cupón:** +- `percentage`: Descuento porcentual +- `fixed_amount`: Monto fijo +- `free_shipping`: Envío gratis +- `free_product`: Producto gratis + +### 2. Validación Zod (`src/modules/pricing/validation/pricing.schema.ts`) + +Schemas completos para: + +**Promociones:** +- `createPromotionSchema`: Creación con todas las opciones +- `updatePromotionSchema`: Actualización parcial +- `addPromotionProductsSchema`: Agregar productos +- `listPromotionsQuerySchema`: Filtros de listado + +**Cupones:** +- `createCouponSchema`: Creación con opciones completas +- `updateCouponSchema`: Actualización parcial +- `generateBulkCouponsSchema`: Generación en lote +- `redeemCouponSchema`: Canje de cupón +- `validateCouponSchema`: Validación de código +- `reverseRedemptionSchema`: Reversión de canje +- `listCouponsQuerySchema`: Filtros de listado + +**Motor de Precios:** +- `calculatePricesSchema`: Entrada para cálculo +- `lineItemSchema`: Línea de producto + +### 3. Controladores (`src/modules/pricing/controllers/pricing.controller.ts`) + +**Endpoints del Motor de Precios (3):** +| Método | Ruta | Descripción | +|--------|------|-------------| +| POST | `/api/pricing/calculate` | Calcular precios | +| POST | `/api/pricing/validate-coupon` | Validar cupón | +| GET | `/api/pricing/active-promotions` | Promociones activas | + +**Endpoints de Promociones (11):** +| Método | Ruta | Descripción | +|--------|------|-------------| +| GET | `/api/pricing/promotions` | Listar promociones | +| GET | `/api/pricing/promotions/:id` | Obtener promoción | +| POST | `/api/pricing/promotions` | Crear promoción | +| PUT | `/api/pricing/promotions/:id` | Actualizar promoción | +| POST | `/api/pricing/promotions/:id/activate` | Activar | +| POST | `/api/pricing/promotions/:id/pause` | Pausar | +| POST | `/api/pricing/promotions/:id/end` | Finalizar | +| POST | `/api/pricing/promotions/:id/cancel` | Cancelar | +| POST | `/api/pricing/promotions/:id/products` | Agregar productos | +| DELETE | `/api/pricing/promotions/:id/products/:productId` | Remover producto | +| DELETE | `/api/pricing/promotions/:id` | Eliminar (soft) | + +**Endpoints de Cupones (14):** +| Método | Ruta | Descripción | +|--------|------|-------------| +| GET | `/api/pricing/coupons` | Listar cupones | +| GET | `/api/pricing/coupons/:id` | Obtener cupón | +| GET | `/api/pricing/coupons/code/:code` | Obtener por código | +| POST | `/api/pricing/coupons` | Crear cupón | +| POST | `/api/pricing/coupons/bulk` | Generar lote | +| PUT | `/api/pricing/coupons/:id` | Actualizar cupón | +| POST | `/api/pricing/coupons/:id/activate` | Activar | +| POST | `/api/pricing/coupons/:id/deactivate` | Desactivar | +| POST | `/api/pricing/coupons/:id/redeem` | Canjear por ID | +| POST | `/api/pricing/coupons/redeem` | Canjear por código | +| POST | `/api/pricing/coupons/redemptions/:id/reverse` | Revertir canje | +| GET | `/api/pricing/coupons/:id/redemptions` | Historial canjes | +| GET | `/api/pricing/coupons/:id/stats` | Estadísticas | +| DELETE | `/api/pricing/coupons/:id` | Eliminar (soft) | + +**Total: 28 endpoints** + +### 4. Rutas (`src/modules/pricing/routes/pricing.routes.ts`) + +Configuración de rutas con: +- Autenticación JWT requerida +- Middleware de sucursal donde aplica +- Validación Zod de entrada +- Control de permisos granular + +**Permisos Requeridos:** +- `pricing:calculate`, `pricing:validate`, `pricing:view` +- `promotions:view`, `promotions:create`, `promotions:update`, `promotions:activate`, `promotions:delete` +- `coupons:view`, `coupons:create`, `coupons:update`, `coupons:activate`, `coupons:redeem`, `coupons:reverse`, `coupons:delete` + +## Características Destacadas + +### Restricciones de Promociones +- Por fechas (inicio/fin) +- Por días de la semana +- Por horarios específicos +- Por monto mínimo de orden +- Por cantidad mínima de productos +- Por categorías incluidas/excluidas +- Por productos excluidos +- Por sucursales específicas +- Por niveles de cliente +- Solo nuevos clientes +- Solo miembros de lealtad + +### Stackability (Acumulabilidad) +- Promociones pueden ser acumulables o exclusivas +- Lista de promociones con las que puede combinarse +- Prioridad numérica para resolver conflictos + +### Tracking de Cupones +- Límite de usos totales +- Límite de usos por cliente +- Tracking de canjes con orden y canal +- Posibilidad de reversión con razón + +## Estructura de Archivos + +``` +src/modules/pricing/ +├── controllers/ +│ ├── index.ts +│ └── pricing.controller.ts +├── entities/ +│ ├── index.ts +│ ├── promotion.entity.ts +│ ├── promotion-product.entity.ts +│ ├── coupon.entity.ts +│ └── coupon-redemption.entity.ts +├── routes/ +│ ├── index.ts +│ └── pricing.routes.ts +├── services/ +│ ├── index.ts +│ ├── price-engine.service.ts +│ ├── promotion.service.ts +│ └── coupon.service.ts +├── validation/ +│ ├── index.ts +│ └── pricing.schema.ts +└── index.ts +``` + +## Dependencias + +- TypeORM 0.3.x +- Zod para validación +- Express para routing +- Entidades existentes: Promotion, Coupon, PromotionProduct, CouponRedemption + +## Próximos Pasos + +El Sprint 5 cubrirá CFDI/Facturación según el roadmap: +- Integración con PAC para timbrado +- Generación de XML CFDI 4.0 +- Cancelación de facturas +- Notas de crédito diff --git a/backend/docs/SPRINT-5-SUMMARY.md b/backend/docs/SPRINT-5-SUMMARY.md new file mode 100644 index 0000000..dc2e80c --- /dev/null +++ b/backend/docs/SPRINT-5-SUMMARY.md @@ -0,0 +1,232 @@ +# Sprint 5: CFDI/Facturación - Resumen + +## Objetivo +Implementar el sistema completo de facturación electrónica CFDI 4.0 para el vertical de Retail, con integración a PACs y soporte para autofactura. + +## Componentes Implementados + +### 1. Servicios + +#### XMLService (`src/modules/invoicing/services/xml.service.ts`) +Servicio para construcción y manipulación de XML CFDI 4.0: + +- **buildCFDIXML()**: Construye XML completo según especificación SAT +- **addSealToXML()**: Agrega sello del contribuyente +- **addCertificateToXML()**: Agrega certificado CSD +- **addTimbreToXML()**: Agrega complemento TimbreFiscalDigital +- **buildCadenaOriginal()**: Genera cadena original para firma +- **parseStampedXML()**: Parsea respuesta de PAC +- **generateQRData()**: Genera URL de verificación SAT para QR +- **validateXMLStructure()**: Validación básica de estructura + +**Características:** +- Namespaces CFDI 4.0 y TFD 1.1 +- Soporte para CFDIs relacionados +- Formateo de montos y tasas según SAT +- Generación de cadena original del SAT + +#### PACService (`src/modules/invoicing/services/pac.service.ts`) +Servicio de integración con Proveedores Autorizados de Certificación: + +- **stampCFDI()**: Timbrado de CFDI +- **cancelCFDI()**: Cancelación con motivo +- **verifyCFDIStatus()**: Consulta estado en SAT +- **encrypt()/decrypt()**: Cifrado de credenciales +- **loadCertificate()**: Carga de CSD +- **signData()**: Firma con llave privada + +**PACs Soportados:** +| PAC | Timbrado | Cancelación | Verificación | +|-----|----------|-------------|--------------| +| Finkok | ✓ | ✓ | ✓ | +| Facturapi | ✓ | ✓ | - | +| SW Sapien | ✓ | - | - | +| Soluciones Fiscales | Config | Config | Config | +| Digisat | Config | Config | Config | + +**Motivos de Cancelación (SAT):** +- `01`: Comprobante emitido con errores con relación +- `02`: Comprobante emitido con errores sin relación +- `03`: No se llevó a cabo la operación +- `04`: Operación nominativa relacionada en factura global + +#### CFDIBuilderService (`src/modules/invoicing/services/cfdi-builder.service.ts`) +Constructor de estructuras CFDI desde datos de orden: + +- **buildFromOrder()**: Construye CFDI desde líneas de pedido +- **buildCreditNote()**: Genera nota de crédito +- **buildPaymentComplement()**: Genera complemento de pago +- **validateReceptor()**: Valida datos del receptor (CFDI 4.0) +- **getUsoCFDIOptions()**: Opciones de uso CFDI por tipo de persona +- **getCommonClavesProdServ()**: Claves SAT comunes para retail + +**Cálculos Automáticos:** +- Desglose de impuestos (IVA, ISR, IEPS) +- Agregación por tipo de impuesto +- Manejo de precios con/sin impuesto +- Retenciones IVA e ISR + +#### CFDIService (`src/modules/invoicing/services/cfdi.service.ts`) +Servicio principal de facturación: + +- **createCFDI()**: Crear factura (draft o timbrada) +- **stampCFDI()**: Timbrar factura en borrador +- **cancelCFDI()**: Cancelar factura timbrada +- **verifyCFDIStatus()**: Verificar estado en SAT +- **listCFDIs()**: Listado con filtros paginados +- **getCFDIById()**: Obtener por ID +- **getCFDIByUUID()**: Obtener por UUID +- **downloadXML()**: Descargar XML +- **resendEmail()**: Reenviar por correo +- **createCreditNote()**: Crear nota de crédito +- **getStats()**: Estadísticas de facturación +- **getActiveConfig()**: Obtener configuración activa + +### 2. Validación Zod (`src/modules/invoicing/validation/cfdi.schema.ts`) + +**Schemas de CFDI:** +- `receptorSchema`: Datos del receptor con validación RFC +- `orderLineSchema`: Líneas con claves SAT +- `createCFDISchema`: Creación completa +- `cancelCFDISchema`: Cancelación con motivo +- `listCFDIsQuerySchema`: Filtros de búsqueda +- `createCreditNoteSchema`: Nota de crédito + +**Schemas de Configuración:** +- `createCFDIConfigSchema`: Configuración PAC y CSD +- `updateCFDIConfigSchema`: Actualización parcial +- `validateCFDIConfigSchema`: Validación de config + +**Schemas de Autofactura:** +- `autofacturaRequestSchema`: Solicitud de autofactura +- `lookupOrderSchema`: Búsqueda de ticket + +### 3. Controladores (`src/modules/invoicing/controllers/cfdi.controller.ts`) + +**Endpoints de CFDI (14):** +| Método | Ruta | Descripción | +|--------|------|-------------| +| GET | `/cfdis` | Listar CFDIs | +| GET | `/cfdis/:id` | Obtener por ID | +| GET | `/cfdis/uuid/:uuid` | Obtener por UUID | +| POST | `/cfdis` | Crear CFDI | +| POST | `/cfdis/:id/stamp` | Timbrar | +| POST | `/cfdis/:id/cancel` | Cancelar | +| GET | `/cfdis/:id/verify` | Verificar en SAT | +| GET | `/cfdis/:id/xml` | Descargar XML | +| POST | `/cfdis/:id/resend-email` | Reenviar email | +| POST | `/cfdis/:id/credit-note` | Crear nota crédito | +| GET | `/stats` | Estadísticas | +| GET | `/cancellation-reasons` | Motivos cancelación | +| GET | `/uso-cfdi-options` | Opciones UsoCFDI | +| GET | `/claves-prod-serv` | Claves SAT | + +**Endpoints de Configuración (5):** +| Método | Ruta | Descripción | +|--------|------|-------------| +| GET | `/config` | Obtener config | +| POST | `/config` | Crear/actualizar config | +| PUT | `/config` | Actualizar config | +| POST | `/config/certificate` | Subir CSD | +| POST | `/config/validate` | Validar config | + +**Endpoints de Autofactura (2):** +| Método | Ruta | Descripción | +|--------|------|-------------| +| POST | `/autofactura/lookup` | Buscar ticket | +| POST | `/autofactura/create` | Generar factura | + +**Total: 21 endpoints** + +### 4. Rutas (`src/modules/invoicing/routes/cfdi.routes.ts`) + +**Rutas Públicas:** +- Autofactura (lookup, create) +- Catálogos SAT (uso CFDI, claves prod/serv, motivos cancelación) + +**Rutas Autenticadas:** +- CFDI CRUD y operaciones +- Configuración (requiere `cfdi:config:*`) + +**Permisos Requeridos:** +- `cfdi:view`, `cfdi:create`, `cfdi:stamp`, `cfdi:cancel`, `cfdi:send` +- `cfdi:config:view`, `cfdi:config:manage` + +## Estructura de Archivos + +``` +src/modules/invoicing/ +├── controllers/ +│ ├── index.ts +│ └── cfdi.controller.ts +├── entities/ +│ ├── index.ts +│ ├── cfdi.entity.ts +│ └── cfdi-config.entity.ts +├── routes/ +│ ├── index.ts +│ └── cfdi.routes.ts +├── services/ +│ ├── index.ts +│ ├── cfdi.service.ts +│ ├── cfdi-builder.service.ts +│ ├── xml.service.ts +│ └── pac.service.ts +├── validation/ +│ ├── index.ts +│ └── cfdi.schema.ts +└── index.ts +``` + +## Flujo de Facturación + +``` +┌────────────┐ ┌──────────────┐ ┌─────────────┐ +│ Order │────▶│ CFDIBuilder │────▶│ CFDI │ +│ Data │ │ Service │ │ (draft) │ +└────────────┘ └──────────────┘ └──────┬──────┘ + │ + ▼ +┌────────────┐ ┌──────────────┐ ┌─────────────┐ +│ Stamped │◀────│ PAC │◀────│ XML │ +│ CFDI │ │ Service │ │ Service │ +└────────────┘ └──────────────┘ └─────────────┘ +``` + +## Estados del CFDI + +``` +draft ──────▶ pending ──────▶ stamped ──────▶ cancelled + │ │ + ▼ ▼ + error cancellation_pending +``` + +## Características CFDI 4.0 + +- Validación de RFC y régimen fiscal del receptor +- Código postal del domicilio fiscal requerido +- Objeto de impuesto por concepto +- Relacionados con tipo de relación +- Exportación (01 = No exportación) + +## Dependencias + +- xmlbuilder2: Construcción de XML +- crypto (Node.js): Cifrado y firma +- TypeORM 0.3.x +- Zod para validación + +## Variables de Entorno Requeridas + +```env +PAC_ENCRYPTION_KEY=your-32-byte-encryption-key +``` + +## Próximos Pasos + +El Sprint 6 según el roadmap cubrirá POS Backend: +- Sesiones de POS +- Órdenes y líneas +- Pagos múltiples +- Integración con motor de precios diff --git a/backend/docs/SPRINT-6-SUMMARY.md b/backend/docs/SPRINT-6-SUMMARY.md new file mode 100644 index 0000000..9f8ade8 --- /dev/null +++ b/backend/docs/SPRINT-6-SUMMARY.md @@ -0,0 +1,216 @@ +# Sprint 6: POS Backend - Resumen + +## Objetivo +Completar y mejorar el módulo de Punto de Venta (POS) con funcionalidades avanzadas: reembolsos, órdenes en espera (hold/recall), integración con cupones y estadísticas de sesión. + +## Estado Inicial +El módulo POS ya contaba con funcionalidades base: +- Apertura/cierre de sesiones +- Creación de órdenes y líneas +- Procesamiento de pagos múltiples +- Anulación de órdenes +- Búsqueda de órdenes + +## Componentes Mejorados + +### 1. POSOrderService (Mejoras) + +#### Nuevos Métodos de Reembolso: +- **createRefund()**: Crea orden de reembolso desde orden original + - Valida orden original pagada + - Valida cantidades a reembolsar + - Genera líneas negativas + - Relaciona con orden original + +- **processRefundPayment()**: Procesa pago de reembolso + - Genera movimiento de efectivo negativo + - Actualiza totales de sesión + - Marca reembolso como completado + +#### Nuevos Métodos Hold/Recall: +- **holdOrder()**: Pone orden en espera ("parking") + - Solo aplica a órdenes en draft + - Guarda nombre/referencia de la orden + - Cambia estado a confirmado con metadata + +- **getHeldOrders()**: Lista órdenes en espera por sucursal + +- **recallOrder()**: Recupera orden en espera + - Asigna a sesión/caja actual + - Vuelve a estado draft + +#### Métodos Adicionales: +- **applyCouponToOrder()**: Aplica cupón a orden +- **markReceiptPrinted()**: Marca ticket como impreso +- **sendReceiptEmail()**: Envía ticket por correo +- **getSessionStats()**: Estadísticas de sesión + +### 2. Controladores Nuevos + +**Endpoints de Reembolso (2):** +| Método | Ruta | Descripción | Permisos | +|--------|------|-------------|----------| +| POST | `/pos/refunds` | Crear reembolso | supervisor+ | +| POST | `/pos/refunds/:id/process` | Procesar pago | supervisor+ | + +**Endpoints Hold/Recall (3):** +| Método | Ruta | Descripción | +|--------|------|-------------| +| POST | `/pos/orders/:id/hold` | Poner en espera | +| GET | `/pos/orders/held` | Listar en espera | +| POST | `/pos/orders/:id/recall` | Recuperar orden | + +**Endpoints Adicionales (4):** +| Método | Ruta | Descripción | +|--------|------|-------------| +| POST | `/pos/orders/:id/coupon` | Aplicar cupón | +| POST | `/pos/orders/:id/receipt/print` | Marcar impreso | +| POST | `/pos/orders/:id/receipt/send` | Enviar email | +| GET | `/pos/sessions/:id/stats` | Estadísticas | + +**Total nuevos endpoints: 9** +**Total endpoints POS: 21** + +### 3. Validación Zod (Nuevos Schemas) + +```typescript +// Refunds +refundLineSchema +createRefundSchema +processRefundPaymentSchema + +// Hold/Recall +holdOrderSchema +recallOrderSchema + +// Coupon +applyCouponSchema +``` + +## Estructura de Archivos + +``` +src/modules/pos/ +├── controllers/ +│ └── pos.controller.ts (605 líneas) +├── entities/ +│ ├── index.ts +│ ├── pos-session.entity.ts +│ ├── pos-order.entity.ts +│ ├── pos-order-line.entity.ts +│ └── pos-payment.entity.ts +├── routes/ +│ └── pos.routes.ts (143 líneas) +├── services/ +│ ├── index.ts +│ ├── pos-session.service.ts (361 líneas) +│ └── pos-order.service.ts (915 líneas) +├── validation/ +│ └── pos.schema.ts (207 líneas) +└── index.ts +``` + +## Flujos de Negocio + +### Flujo de Reembolso +``` +┌─────────────┐ ┌─────────────┐ ┌─────────────┐ +│ Original │────▶│ Create │────▶│ Refund │ +│ Order │ │ Refund │ │ (draft) │ +│ (paid) │ │ │ │ │ +└─────────────┘ └─────────────┘ └──────┬──────┘ + │ + ▼ + ┌─────────────┐ ┌─────────────┐ + │ Refund │◀────│ Process │ + │ (paid) │ │ Payment │ + └─────────────┘ └─────────────┘ +``` + +### Flujo Hold/Recall +``` +┌─────────────┐ ┌─────────────┐ ┌─────────────┐ +│ Order │────▶│ Hold │────▶│ Order │ +│ (draft) │ │ Order │ │ (held) │ +└─────────────┘ └─────────────┘ └──────┬──────┘ + │ + ┌────────────────────────────────────────┘ + │ + ▼ +┌─────────────┐ ┌─────────────┐ +│ Recall │────▶│ Order │ +│ Order │ │ (draft) │ +└─────────────┘ └─────────────┘ +``` + +## Tipos de Orden + +| Tipo | Código | Descripción | +|------|--------|-------------| +| SALE | sale | Venta normal | +| REFUND | refund | Devolución | +| EXCHANGE | exchange | Cambio | + +## Estados de Orden + +| Estado | Descripción | +|--------|-------------| +| DRAFT | Orden en proceso | +| CONFIRMED | Confirmada / En espera (hold) | +| PAID | Pagada completamente | +| PARTIALLY_PAID | Pago parcial | +| VOIDED | Anulada | +| REFUNDED | Reembolsada | + +## Permisos Requeridos + +| Acción | Roles Permitidos | +|--------|------------------| +| Crear orden | Todos autenticados | +| Agregar pago | Todos autenticados | +| Anular orden | admin, manager, supervisor | +| Crear reembolso | admin, manager, supervisor | +| Procesar reembolso | admin, manager, supervisor | +| Hold/Recall | Todos autenticados | + +## Estadísticas de Sesión + +El endpoint `/pos/sessions/:id/stats` devuelve: +```typescript +{ + totalOrders: number; // Órdenes completadas + totalSales: number; // Ventas totales + totalRefunds: number; // Reembolsos totales + totalDiscounts: number; // Descuentos aplicados + averageTicket: number; // Ticket promedio + paymentBreakdown: { // Desglose por método + cash: number; + credit_card: number; + debit_card: number; + // ... + }; +} +``` + +## Integración Pendiente + +- [ ] Integración completa con PriceEngineService para cupones +- [ ] Envío real de email para receipts +- [ ] Generación de PDF de ticket +- [ ] Integración con impresora térmica + +## Dependencias + +- TypeORM 0.3.x +- Zod para validación +- BaseService para operaciones CRUD +- Middleware de autenticación y roles + +## Próximos Pasos + +Sprint 7 según roadmap: POS Frontend +- Layout de interfaz POS +- Pantalla de ventas +- Pantalla de cobro +- Componentes de producto +- Componentes de pago diff --git a/backend/package.json b/backend/package.json new file mode 100644 index 0000000..8532620 --- /dev/null +++ b/backend/package.json @@ -0,0 +1,61 @@ +{ + "name": "@erp-suite/retail-backend", + "version": "0.1.0", + "description": "Backend for ERP Retail vertical - Point of Sale, Inventory, E-commerce", + "main": "dist/index.js", + "scripts": { + "dev": "tsx watch src/index.ts", + "build": "tsc", + "start": "node dist/index.js", + "lint": "eslint src --ext .ts", + "typecheck": "tsc --noEmit", + "test": "jest", + "typeorm": "typeorm-ts-node-commonjs", + "migration:generate": "npm run typeorm -- migration:generate -d src/config/typeorm.ts", + "migration:run": "npm run typeorm -- migration:run -d src/config/typeorm.ts", + "migration:revert": "npm run typeorm -- migration:revert -d src/config/typeorm.ts" + }, + "dependencies": { + "express": "^4.18.2", + "cors": "^2.8.5", + "helmet": "^7.1.0", + "compression": "^1.7.4", + "morgan": "^1.10.0", + "dotenv": "^16.3.1", + "pg": "^8.11.3", + "typeorm": "^0.3.28", + "reflect-metadata": "^0.2.2", + "ioredis": "^5.8.2", + "jsonwebtoken": "^9.0.2", + "bcryptjs": "^2.4.3", + "uuid": "^9.0.1", + "zod": "^3.22.4", + "winston": "^3.11.0", + "swagger-jsdoc": "^6.2.8", + "swagger-ui-express": "^5.0.1", + "socket.io": "^4.7.4" + }, + "devDependencies": { + "@types/express": "^4.17.21", + "@types/cors": "^2.8.17", + "@types/compression": "^1.7.5", + "@types/morgan": "^1.9.9", + "@types/node": "^20.10.6", + "@types/jsonwebtoken": "^9.0.5", + "@types/bcryptjs": "^2.4.6", + "@types/uuid": "^9.0.7", + "@types/swagger-jsdoc": "^6.0.4", + "@types/swagger-ui-express": "^4.1.6", + "typescript": "^5.3.3", + "tsx": "^4.6.2", + "eslint": "^8.56.0", + "@typescript-eslint/eslint-plugin": "^6.18.1", + "@typescript-eslint/parser": "^6.18.1", + "jest": "^29.7.0", + "ts-jest": "^29.1.1", + "@types/jest": "^29.5.11" + }, + "engines": { + "node": ">=20.0.0" + } +} diff --git a/backend/src/app.ts b/backend/src/app.ts new file mode 100644 index 0000000..c958da4 --- /dev/null +++ b/backend/src/app.ts @@ -0,0 +1,120 @@ +import 'reflect-metadata'; +import express, { Application, Request, Response, NextFunction } from 'express'; +import cors from 'cors'; +import helmet from 'helmet'; +import compression from 'compression'; +import morgan from 'morgan'; + +// Route imports +import branchRoutes from './modules/branches/routes/branch.routes'; +import posRoutes from './modules/pos/routes/pos.routes'; + +// Error type +interface AppError extends Error { + statusCode?: number; + status?: string; + isOperational?: boolean; +} + +// Create Express app +const app: Application = express(); + +// Security middleware +app.use(helmet()); + +// CORS configuration +app.use(cors({ + origin: process.env.CORS_ORIGIN?.split(',') || ['http://localhost:3000', 'http://localhost:5173'], + credentials: true, + methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'], + allowedHeaders: ['Content-Type', 'Authorization', 'X-Tenant-ID', 'X-Branch-ID'], +})); + +// Compression +app.use(compression()); + +// Body parsing +app.use(express.json({ limit: '10mb' })); +app.use(express.urlencoded({ extended: true, limit: '10mb' })); + +// Request logging +if (process.env.NODE_ENV !== 'test') { + app.use(morgan(process.env.NODE_ENV === 'production' ? 'combined' : 'dev')); +} + +// Health check endpoint +app.get('/health', (_req: Request, res: Response) => { + res.json({ + status: 'healthy', + timestamp: new Date().toISOString(), + version: process.env.npm_package_version || '0.1.0', + environment: process.env.NODE_ENV || 'development', + }); +}); + +// API version info +app.get('/api', (_req: Request, res: Response) => { + res.json({ + name: 'ERP Retail API', + version: '0.1.0', + modules: [ + 'branches', + 'pos', + 'cash', + 'inventory', + 'customers', + 'pricing', + 'invoicing', + 'ecommerce', + 'purchases', + ], + }); +}); + +// API Routes +app.use('/api/branches', branchRoutes); +app.use('/api/pos', posRoutes); +// TODO: Add remaining route modules as they are implemented +// app.use('/api/cash', cashRouter); +// app.use('/api/inventory', inventoryRouter); +// app.use('/api/customers', customersRouter); +// app.use('/api/pricing', pricingRouter); +// app.use('/api/invoicing', invoicingRouter); +// app.use('/api/ecommerce', ecommerceRouter); +// app.use('/api/purchases', purchasesRouter); + +// 404 handler +app.use((_req: Request, res: Response) => { + res.status(404).json({ + success: false, + error: { + code: 'NOT_FOUND', + message: 'The requested resource was not found', + }, + }); +}); + +// Global error handler +app.use((err: AppError, _req: Request, res: Response, _next: NextFunction) => { + const statusCode = err.statusCode || 500; + const status = err.status || 'error'; + + // Log error + console.error('Error:', { + message: err.message, + stack: process.env.NODE_ENV === 'development' ? err.stack : undefined, + statusCode, + }); + + // Send response + res.status(statusCode).json({ + success: false, + error: { + code: status.toUpperCase().replace(/ /g, '_'), + message: err.isOperational ? err.message : 'An unexpected error occurred', + ...(process.env.NODE_ENV === 'development' && { stack: err.stack }), + }, + }); +}); + +export default app; diff --git a/backend/src/config/database.ts b/backend/src/config/database.ts new file mode 100644 index 0000000..f70a136 --- /dev/null +++ b/backend/src/config/database.ts @@ -0,0 +1,102 @@ +import { Pool, PoolClient, QueryResult } from 'pg'; +import dotenv from 'dotenv'; + +dotenv.config(); + +const pool = new Pool({ + host: process.env.DB_HOST || 'localhost', + port: parseInt(process.env.DB_PORT || '5432'), + database: process.env.DB_NAME || 'erp_retail', + user: process.env.DB_USER || 'retail_admin', + password: process.env.DB_PASSWORD, + max: 20, + idleTimeoutMillis: 30000, + connectionTimeoutMillis: 2000, +}); + +pool.on('error', (err) => { + console.error('Unexpected error on idle client', err); + process.exit(-1); +}); + +export async function query( + text: string, + params?: any[] +): Promise> { + const start = Date.now(); + const res = await pool.query(text, params); + const duration = Date.now() - start; + + if (process.env.NODE_ENV === 'development') { + console.log('executed query', { text: text.substring(0, 100), duration, rows: res.rowCount }); + } + + return res; +} + +export async function queryOne( + text: string, + params?: any[] +): Promise { + const res = await query(text, params); + return res.rows[0] || null; +} + +export async function getClient(): Promise { + const client = await pool.connect(); + const release = client.release.bind(client); + + // Override release to track slow queries + client.release = () => { + release(); + }; + + return client; +} + +export async function transaction( + callback: (client: PoolClient) => Promise +): Promise { + const client = await getClient(); + + try { + await client.query('BEGIN'); + const result = await callback(client); + await client.query('COMMIT'); + return result; + } catch (error) { + await client.query('ROLLBACK'); + throw error; + } finally { + client.release(); + } +} + +export async function setTenantContext( + client: PoolClient, + tenantId: string +): Promise { + await client.query( + `SELECT set_config('app.current_tenant_id', $1, true)`, + [tenantId] + ); +} + +export async function initDatabase(): Promise { + try { + const client = await pool.connect(); + await client.query('SELECT NOW()'); + client.release(); + console.log('PostgreSQL pool connected successfully'); + } catch (error) { + console.error('Failed to connect to PostgreSQL:', error); + throw error; + } +} + +export async function closeDatabase(): Promise { + await pool.end(); + console.log('PostgreSQL pool closed'); +} + +export { pool }; diff --git a/backend/src/config/typeorm.ts b/backend/src/config/typeorm.ts new file mode 100644 index 0000000..3379dc5 --- /dev/null +++ b/backend/src/config/typeorm.ts @@ -0,0 +1,144 @@ +import 'reflect-metadata'; +import { DataSource, DataSourceOptions } from 'typeorm'; +import dotenv from 'dotenv'; + +// Entities - Branches +import { Branch } from '../modules/branches/entities/branch.entity'; +import { CashRegister } from '../modules/branches/entities/cash-register.entity'; +import { BranchUser } from '../modules/branches/entities/branch-user.entity'; + +// Entities - POS +import { POSSession } from '../modules/pos/entities/pos-session.entity'; +import { POSOrder } from '../modules/pos/entities/pos-order.entity'; +import { POSOrderLine } from '../modules/pos/entities/pos-order-line.entity'; +import { POSPayment } from '../modules/pos/entities/pos-payment.entity'; + +// Entities - Cash +import { CashMovement } from '../modules/cash/entities/cash-movement.entity'; +import { CashClosing } from '../modules/cash/entities/cash-closing.entity'; +import { CashCount } from '../modules/cash/entities/cash-count.entity'; + +// Entities - Inventory +import { StockTransfer } from '../modules/inventory/entities/stock-transfer.entity'; +import { StockTransferLine } from '../modules/inventory/entities/stock-transfer-line.entity'; +import { StockAdjustment } from '../modules/inventory/entities/stock-adjustment.entity'; +import { StockAdjustmentLine } from '../modules/inventory/entities/stock-adjustment-line.entity'; + +// Entities - Customers +import { LoyaltyProgram } from '../modules/customers/entities/loyalty-program.entity'; +import { MembershipLevel } from '../modules/customers/entities/membership-level.entity'; +import { LoyaltyTransaction } from '../modules/customers/entities/loyalty-transaction.entity'; +import { CustomerMembership } from '../modules/customers/entities/customer-membership.entity'; + +// Entities - Pricing +import { Promotion } from '../modules/pricing/entities/promotion.entity'; +import { PromotionProduct } from '../modules/pricing/entities/promotion-product.entity'; +import { Coupon } from '../modules/pricing/entities/coupon.entity'; +import { CouponRedemption } from '../modules/pricing/entities/coupon-redemption.entity'; + +// Entities - Invoicing +import { CFDIConfig } from '../modules/invoicing/entities/cfdi-config.entity'; +import { CFDI } from '../modules/invoicing/entities/cfdi.entity'; + +// Entities - E-commerce +import { Cart } from '../modules/ecommerce/entities/cart.entity'; +import { CartItem } from '../modules/ecommerce/entities/cart-item.entity'; +import { EcommerceOrder } from '../modules/ecommerce/entities/ecommerce-order.entity'; +import { EcommerceOrderLine } from '../modules/ecommerce/entities/ecommerce-order-line.entity'; +import { ShippingRate } from '../modules/ecommerce/entities/shipping-rate.entity'; + +// Entities - Purchases +import { PurchaseSuggestion } from '../modules/purchases/entities/purchase-suggestion.entity'; +import { SupplierOrder } from '../modules/purchases/entities/supplier-order.entity'; +import { SupplierOrderLine } from '../modules/purchases/entities/supplier-order-line.entity'; +import { GoodsReceipt } from '../modules/purchases/entities/goods-receipt.entity'; +import { GoodsReceiptLine } from '../modules/purchases/entities/goods-receipt-line.entity'; + +dotenv.config(); + +const isProduction = process.env.NODE_ENV === 'production'; + +const baseConfig: DataSourceOptions = { + type: 'postgres', + host: process.env.DB_HOST || 'localhost', + port: parseInt(process.env.DB_PORT || '5432'), + username: process.env.DB_USER || 'retail_admin', + password: process.env.DB_PASSWORD, + database: process.env.DB_NAME || 'erp_retail', + schema: 'retail', + entities: [ + // Branches + Branch, + CashRegister, + BranchUser, + // POS + POSSession, + POSOrder, + POSOrderLine, + POSPayment, + // Cash + CashMovement, + CashClosing, + CashCount, + // Inventory + StockTransfer, + StockTransferLine, + StockAdjustment, + StockAdjustmentLine, + // Customers + LoyaltyProgram, + MembershipLevel, + LoyaltyTransaction, + CustomerMembership, + // Pricing + Promotion, + PromotionProduct, + Coupon, + CouponRedemption, + // Invoicing + CFDIConfig, + CFDI, + // E-commerce + Cart, + CartItem, + EcommerceOrder, + EcommerceOrderLine, + ShippingRate, + // Purchases + PurchaseSuggestion, + SupplierOrder, + SupplierOrderLine, + GoodsReceipt, + GoodsReceiptLine, + ], + migrations: ['src/migrations/*.ts'], + synchronize: false, // NEVER use in production + logging: isProduction ? ['error'] : ['query', 'error'], + maxQueryExecutionTime: 1000, // Log slow queries + extra: { + max: 10, + idleTimeoutMillis: 30000, + }, +}; + +export const AppDataSource = new DataSource(baseConfig); + +export async function initTypeORM(): Promise { + try { + if (!AppDataSource.isInitialized) { + await AppDataSource.initialize(); + console.log('TypeORM DataSource initialized successfully'); + } + return AppDataSource; + } catch (error) { + console.error('Failed to initialize TypeORM:', error); + throw error; + } +} + +export async function closeTypeORM(): Promise { + if (AppDataSource.isInitialized) { + await AppDataSource.destroy(); + console.log('TypeORM DataSource closed'); + } +} diff --git a/backend/src/index.ts b/backend/src/index.ts new file mode 100644 index 0000000..8235774 --- /dev/null +++ b/backend/src/index.ts @@ -0,0 +1,76 @@ +import 'reflect-metadata'; +import dotenv from 'dotenv'; + +// Load environment variables first +dotenv.config(); + +import app from './app'; +import { initDatabase, closeDatabase } from './config/database'; +import { initTypeORM, closeTypeORM } from './config/typeorm'; + +const PORT = parseInt(process.env.PORT || '3001', 10); +const HOST = process.env.HOST || '0.0.0.0'; + +async function bootstrap(): Promise { + try { + console.log('Starting ERP Retail Backend...'); + console.log(`Environment: ${process.env.NODE_ENV || 'development'}`); + + // Initialize database connections + console.log('Initializing database connections...'); + await initDatabase(); + await initTypeORM(); + + // Start server + const server = app.listen(PORT, HOST, () => { + console.log(`Server running on http://${HOST}:${PORT}`); + console.log(`Health check: http://${HOST}:${PORT}/health`); + console.log(`API info: http://${HOST}:${PORT}/api`); + }); + + // Graceful shutdown handlers + const shutdown = async (signal: string): Promise => { + console.log(`\n${signal} received. Starting graceful shutdown...`); + + server.close(async () => { + console.log('HTTP server closed'); + + try { + await closeTypeORM(); + await closeDatabase(); + console.log('All connections closed'); + process.exit(0); + } catch (error) { + console.error('Error during shutdown:', error); + process.exit(1); + } + }); + + // Force shutdown after 30 seconds + setTimeout(() => { + console.error('Forced shutdown after timeout'); + process.exit(1); + }, 30000); + }; + + process.on('SIGTERM', () => shutdown('SIGTERM')); + process.on('SIGINT', () => shutdown('SIGINT')); + + // Handle uncaught errors + process.on('uncaughtException', (error) => { + console.error('Uncaught Exception:', error); + shutdown('uncaughtException'); + }); + + process.on('unhandledRejection', (reason, promise) => { + console.error('Unhandled Rejection at:', promise, 'reason:', reason); + }); + + } catch (error) { + console.error('Failed to start server:', error); + process.exit(1); + } +} + +// Start the application +bootstrap(); diff --git a/backend/src/modules/branches/controllers/branch.controller.ts b/backend/src/modules/branches/controllers/branch.controller.ts new file mode 100644 index 0000000..5e9a849 --- /dev/null +++ b/backend/src/modules/branches/controllers/branch.controller.ts @@ -0,0 +1,236 @@ +import { Response, NextFunction } from 'express'; +import { BaseController } from '../../../shared/controllers/base.controller'; +import { AuthenticatedRequest } from '../../../shared/types'; +import { branchService } from '../services/branch.service'; +import { BranchStatus, BranchType } from '../entities/branch.entity'; + +class BranchController extends BaseController { + /** + * GET /branches - List all branches + */ + async list(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const tenantId = this.getTenantId(req); + const pagination = this.parsePagination(req.query); + + const { status, type, search } = req.query; + + const result = await branchService.findAll(tenantId, { + pagination, + filters: [ + ...(status ? [{ field: 'status', operator: 'eq' as const, value: status }] : []), + ...(type ? [{ field: 'type', operator: 'eq' as const, value: type }] : []), + ], + search: search + ? { + fields: ['name', 'code', 'city'], + term: search as string, + } + : undefined, + }); + + return this.paginated(res, result); + } catch (error) { + next(error); + } + } + + /** + * GET /branches/active - List active branches + */ + async listActive(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const tenantId = this.getTenantId(req); + const branches = await branchService.findActiveBranches(tenantId); + return this.success(res, branches); + } catch (error) { + next(error); + } + } + + /** + * GET /branches/:id - Get branch by ID + */ + async getById(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const tenantId = this.getTenantId(req); + const { id } = req.params; + + const branch = await branchService.findById(tenantId, id, ['cashRegisters']); + + if (!branch) { + return this.notFound(res, 'Branch'); + } + + return this.success(res, branch); + } catch (error) { + next(error); + } + } + + /** + * GET /branches/code/:code - Get branch by code + */ + async getByCode(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const tenantId = this.getTenantId(req); + const { code } = req.params; + + const branch = await branchService.findByCode(tenantId, code); + + if (!branch) { + return this.notFound(res, 'Branch'); + } + + return this.success(res, branch); + } catch (error) { + next(error); + } + } + + /** + * POST /branches - Create branch + */ + async create(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const tenantId = this.getTenantId(req); + const userId = this.getUserId(req); + + const result = await branchService.createWithRegister(tenantId, req.body, userId); + + if (!result.success) { + if (result.error.code === 'DUPLICATE_CODE') { + return this.conflict(res, result.error.message); + } + return this.error(res, result.error.code, result.error.message); + } + + return this.success(res, result.data, 201); + } catch (error) { + next(error); + } + } + + /** + * PUT /branches/:id - Update branch + */ + async update(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const tenantId = this.getTenantId(req); + const userId = this.getUserId(req); + const { id } = req.params; + + const result = await branchService.update(tenantId, id, req.body, userId); + + if (!result.success) { + if (result.error.code === 'NOT_FOUND') { + return this.notFound(res, 'Branch'); + } + return this.error(res, result.error.code, result.error.message); + } + + return this.success(res, result.data); + } catch (error) { + next(error); + } + } + + /** + * PATCH /branches/:id/status - Update branch status + */ + async updateStatus(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const tenantId = this.getTenantId(req); + const userId = this.getUserId(req); + const { id } = req.params; + const { status } = req.body; + + if (!Object.values(BranchStatus).includes(status)) { + return this.validationError(res, { status: 'Invalid status value' }); + } + + const result = await branchService.updateStatus(tenantId, id, status, userId); + + if (!result.success) { + if (result.error.code === 'NOT_FOUND') { + return this.notFound(res, 'Branch'); + } + return this.error(res, result.error.code, result.error.message); + } + + return this.success(res, result.data); + } catch (error) { + next(error); + } + } + + /** + * DELETE /branches/:id - Delete branch + */ + async delete(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const tenantId = this.getTenantId(req); + const { id } = req.params; + + const result = await branchService.delete(tenantId, id); + + if (!result.success) { + if (result.error.code === 'NOT_FOUND') { + return this.notFound(res, 'Branch'); + } + return this.error(res, result.error.code, result.error.message); + } + + return this.success(res, { deleted: true }); + } catch (error) { + next(error); + } + } + + /** + * GET /branches/:id/stats - Get branch statistics + */ + async getStats(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const tenantId = this.getTenantId(req); + const { id } = req.params; + + const exists = await branchService.exists(tenantId, id); + if (!exists) { + return this.notFound(res, 'Branch'); + } + + const stats = await branchService.getBranchStats(tenantId, id); + return this.success(res, stats); + } catch (error) { + next(error); + } + } + + /** + * GET /branches/nearby - Find nearby branches + */ + async findNearby(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const tenantId = this.getTenantId(req); + const { lat, lng, radius } = req.query; + + if (!lat || !lng) { + return this.validationError(res, { lat: 'Required', lng: 'Required' }); + } + + const branches = await branchService.findNearby( + tenantId, + parseFloat(lat as string), + parseFloat(lng as string), + radius ? parseFloat(radius as string) : 10 + ); + + return this.success(res, branches); + } catch (error) { + next(error); + } + } +} + +export const branchController = new BranchController(); diff --git a/backend/src/modules/branches/entities/branch-user.entity.ts b/backend/src/modules/branches/entities/branch-user.entity.ts new file mode 100644 index 0000000..69df3f9 --- /dev/null +++ b/backend/src/modules/branches/entities/branch-user.entity.ts @@ -0,0 +1,122 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + ManyToOne, + JoinColumn, + Index, +} from 'typeorm'; +import { Branch } from './branch.entity'; + +export enum BranchUserRole { + CASHIER = 'cashier', + SUPERVISOR = 'supervisor', + MANAGER = 'manager', + INVENTORY = 'inventory', + ADMIN = 'admin', +} + +@Entity('branch_users', { schema: 'retail' }) +@Index(['tenantId', 'branchId', 'userId'], { unique: true }) +@Index(['tenantId', 'userId']) +export class BranchUser { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'tenant_id', type: 'uuid' }) + @Index() + tenantId: string; + + @Column({ name: 'branch_id', type: 'uuid' }) + branchId: string; + + // Reference to erp-core user + @Column({ name: 'user_id', type: 'uuid' }) + userId: string; + + @Column({ + type: 'enum', + enum: BranchUserRole, + default: BranchUserRole.CASHIER, + }) + role: BranchUserRole; + + // PIN for quick login at POS + @Column({ name: 'pos_pin', length: 6, nullable: true }) + posPin: string; + + // Permissions (granular override of role) + @Column({ type: 'jsonb', nullable: true }) + permissions: { + canOpenSession?: boolean; + canCloseSession?: boolean; + canVoidSale?: boolean; + canApplyDiscount?: boolean; + maxDiscountPercent?: number; + canRefund?: boolean; + canViewReports?: boolean; + canManageInventory?: boolean; + canManagePrices?: boolean; + canManageUsers?: boolean; + }; + + // Commission settings + @Column({ name: 'commission_rate', type: 'decimal', precision: 5, scale: 4, nullable: true }) + commissionRate: number; + + @Column({ name: 'commission_type', length: 20, nullable: true }) + commissionType: 'percentage' | 'fixed_per_sale' | 'fixed_per_item'; + + // Work schedule + @Column({ name: 'work_schedule', type: 'jsonb', nullable: true }) + workSchedule: { + monday?: { start: string; end: string }; + tuesday?: { start: string; end: string }; + wednesday?: { start: string; end: string }; + thursday?: { start: string; end: string }; + friday?: { start: string; end: string }; + saturday?: { start: string; end: string }; + sunday?: { start: string; end: string }; + }; + + // Default register assignment + @Column({ name: 'default_register_id', type: 'uuid', nullable: true }) + defaultRegisterId: string; + + // Activity tracking + @Column({ name: 'last_login_at', type: 'timestamp with time zone', nullable: true }) + lastLoginAt: Date; + + @Column({ name: 'last_activity_at', type: 'timestamp with time zone', nullable: true }) + lastActivityAt: Date; + + // Status + @Column({ name: 'is_active', type: 'boolean', default: true }) + isActive: boolean; + + @Column({ name: 'is_primary_branch', type: 'boolean', default: false }) + isPrimaryBranch: boolean; + + // Metadata + @Column({ type: 'jsonb', nullable: true }) + metadata: Record; + + @CreateDateColumn({ name: 'created_at' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at' }) + updatedAt: Date; + + @Column({ name: 'created_by', type: 'uuid', nullable: true }) + createdBy: string; + + @Column({ name: 'updated_by', type: 'uuid', nullable: true }) + updatedBy: string; + + // Relations + @ManyToOne(() => Branch, (branch) => branch.branchUsers) + @JoinColumn({ name: 'branch_id' }) + branch: Branch; +} diff --git a/backend/src/modules/branches/entities/branch.entity.ts b/backend/src/modules/branches/entities/branch.entity.ts new file mode 100644 index 0000000..bb7096d --- /dev/null +++ b/backend/src/modules/branches/entities/branch.entity.ts @@ -0,0 +1,148 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + OneToMany, + Index, +} from 'typeorm'; +import { CashRegister } from './cash-register.entity'; +import { BranchUser } from './branch-user.entity'; + +export enum BranchType { + STORE = 'store', + WAREHOUSE = 'warehouse', + HYBRID = 'hybrid', +} + +export enum BranchStatus { + ACTIVE = 'active', + INACTIVE = 'inactive', + TEMPORARILY_CLOSED = 'temporarily_closed', +} + +@Entity('branches', { schema: 'retail' }) +@Index(['tenantId', 'code'], { unique: true }) +@Index(['tenantId', 'status']) +export class Branch { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'tenant_id', type: 'uuid' }) + @Index() + tenantId: string; + + @Column({ length: 20, unique: true }) + code: string; + + @Column({ length: 100 }) + name: string; + + @Column({ + type: 'enum', + enum: BranchType, + default: BranchType.STORE, + }) + type: BranchType; + + @Column({ + type: 'enum', + enum: BranchStatus, + default: BranchStatus.ACTIVE, + }) + status: BranchStatus; + + // Address + @Column({ length: 255, nullable: true }) + address: string; + + @Column({ length: 100, nullable: true }) + city: string; + + @Column({ length: 100, nullable: true }) + state: string; + + @Column({ name: 'postal_code', length: 10, nullable: true }) + postalCode: string; + + @Column({ name: 'country_code', length: 3, default: 'MEX' }) + countryCode: string; + + // Contact + @Column({ length: 20, nullable: true }) + phone: string; + + @Column({ length: 100, nullable: true }) + email: string; + + // Manager reference (from erp-core users) + @Column({ name: 'manager_id', type: 'uuid', nullable: true }) + managerId: string; + + // Inventory reference (from erp-core) + @Column({ name: 'warehouse_id', type: 'uuid', nullable: true }) + warehouseId: string; + + // Operating hours (JSON) + @Column({ name: 'operating_hours', type: 'jsonb', nullable: true }) + operatingHours: { + monday?: { open: string; close: string }; + tuesday?: { open: string; close: string }; + wednesday?: { open: string; close: string }; + thursday?: { open: string; close: string }; + friday?: { open: string; close: string }; + saturday?: { open: string; close: string }; + sunday?: { open: string; close: string }; + }; + + // POS Configuration + @Column({ name: 'default_price_list_id', type: 'uuid', nullable: true }) + defaultPriceListId: string; + + @Column({ name: 'allow_negative_stock', type: 'boolean', default: false }) + allowNegativeStock: boolean; + + @Column({ name: 'require_customer_for_sale', type: 'boolean', default: false }) + requireCustomerForSale: boolean; + + @Column({ name: 'auto_print_receipt', type: 'boolean', default: true }) + autoPrintReceipt: boolean; + + // Fiscal + @Column({ name: 'fiscal_address', type: 'text', nullable: true }) + fiscalAddress: string; + + @Column({ name: 'cfdi_config_id', type: 'uuid', nullable: true }) + cfdiConfigId: string; + + // Geolocation + @Column({ type: 'decimal', precision: 10, scale: 7, nullable: true }) + latitude: number; + + @Column({ type: 'decimal', precision: 10, scale: 7, nullable: true }) + longitude: number; + + // Metadata + @Column({ type: 'jsonb', nullable: true }) + metadata: Record; + + @CreateDateColumn({ name: 'created_at' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at' }) + updatedAt: Date; + + @Column({ name: 'created_by', type: 'uuid', nullable: true }) + createdBy: string; + + @Column({ name: 'updated_by', type: 'uuid', nullable: true }) + updatedBy: string; + + // Relations + @OneToMany(() => CashRegister, (register) => register.branch) + cashRegisters: CashRegister[]; + + @OneToMany(() => BranchUser, (bu) => bu.branch) + branchUsers: BranchUser[]; +} diff --git a/backend/src/modules/branches/entities/cash-register.entity.ts b/backend/src/modules/branches/entities/cash-register.entity.ts new file mode 100644 index 0000000..faf8134 --- /dev/null +++ b/backend/src/modules/branches/entities/cash-register.entity.ts @@ -0,0 +1,147 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + ManyToOne, + JoinColumn, + Index, +} from 'typeorm'; +import { Branch } from './branch.entity'; + +export enum RegisterStatus { + AVAILABLE = 'available', + IN_USE = 'in_use', + CLOSED = 'closed', + MAINTENANCE = 'maintenance', +} + +export enum RegisterType { + STANDARD = 'standard', + EXPRESS = 'express', + SELF_SERVICE = 'self_service', +} + +@Entity('cash_registers', { schema: 'retail' }) +@Index(['tenantId', 'branchId', 'code'], { unique: true }) +@Index(['tenantId', 'status']) +export class CashRegister { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'tenant_id', type: 'uuid' }) + @Index() + tenantId: string; + + @Column({ name: 'branch_id', type: 'uuid' }) + branchId: string; + + @Column({ length: 20 }) + code: string; + + @Column({ length: 100 }) + name: string; + + @Column({ + type: 'enum', + enum: RegisterType, + default: RegisterType.STANDARD, + }) + type: RegisterType; + + @Column({ + type: 'enum', + enum: RegisterStatus, + default: RegisterStatus.AVAILABLE, + }) + status: RegisterStatus; + + // Hardware configuration + @Column({ name: 'printer_config', type: 'jsonb', nullable: true }) + printerConfig: { + type: 'thermal' | 'inkjet' | 'none'; + connectionType: 'usb' | 'network' | 'bluetooth'; + address?: string; + port?: number; + paperWidth: 58 | 80; + }; + + @Column({ name: 'scanner_config', type: 'jsonb', nullable: true }) + scannerConfig: { + enabled: boolean; + type: 'usb' | 'bluetooth'; + }; + + @Column({ name: 'cash_drawer_config', type: 'jsonb', nullable: true }) + cashDrawerConfig: { + enabled: boolean; + openCommand: string; + }; + + @Column({ name: 'display_config', type: 'jsonb', nullable: true }) + displayConfig: { + enabled: boolean; + type: 'lcd' | 'pole' | 'none'; + }; + + // Payment methods allowed + @Column({ name: 'allowed_payment_methods', type: 'jsonb', default: '["cash", "card", "transfer"]' }) + allowedPaymentMethods: string[]; + + // Current session info (denormalized for quick access) + @Column({ name: 'current_session_id', type: 'uuid', nullable: true }) + currentSessionId: string; + + @Column({ name: 'current_user_id', type: 'uuid', nullable: true }) + currentUserId: string; + + // Cash limits + @Column({ name: 'initial_cash_amount', type: 'decimal', precision: 15, scale: 2, default: 0 }) + initialCashAmount: number; + + @Column({ name: 'max_cash_amount', type: 'decimal', precision: 15, scale: 2, nullable: true }) + maxCashAmount: number; + + // Settings + @Column({ name: 'require_closing_count', type: 'boolean', default: true }) + requireClosingCount: boolean; + + @Column({ name: 'allow_blind_close', type: 'boolean', default: false }) + allowBlindClose: boolean; + + @Column({ name: 'auto_logout_minutes', type: 'int', default: 30 }) + autoLogoutMinutes: number; + + // Device identifier for offline sync + @Column({ name: 'device_uuid', type: 'uuid', nullable: true }) + deviceUuid: string; + + @Column({ name: 'last_sync_at', type: 'timestamp with time zone', nullable: true }) + lastSyncAt: Date; + + // Active flag + @Column({ name: 'is_active', type: 'boolean', default: true }) + isActive: boolean; + + // Metadata + @Column({ type: 'jsonb', nullable: true }) + metadata: Record; + + @CreateDateColumn({ name: 'created_at' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at' }) + updatedAt: Date; + + @Column({ name: 'created_by', type: 'uuid', nullable: true }) + createdBy: string; + + @Column({ name: 'updated_by', type: 'uuid', nullable: true }) + updatedBy: string; + + // Relations + @ManyToOne(() => Branch, (branch) => branch.cashRegisters) + @JoinColumn({ name: 'branch_id' }) + branch: Branch; +} diff --git a/backend/src/modules/branches/entities/index.ts b/backend/src/modules/branches/entities/index.ts new file mode 100644 index 0000000..8a15505 --- /dev/null +++ b/backend/src/modules/branches/entities/index.ts @@ -0,0 +1,3 @@ +export * from './branch.entity'; +export * from './cash-register.entity'; +export * from './branch-user.entity'; diff --git a/backend/src/modules/branches/index.ts b/backend/src/modules/branches/index.ts new file mode 100644 index 0000000..be64034 --- /dev/null +++ b/backend/src/modules/branches/index.ts @@ -0,0 +1,3 @@ +export * from './entities'; +export * from './services'; +export { default as branchRoutes } from './routes/branch.routes'; diff --git a/backend/src/modules/branches/routes/branch.routes.ts b/backend/src/modules/branches/routes/branch.routes.ts new file mode 100644 index 0000000..0128cd7 --- /dev/null +++ b/backend/src/modules/branches/routes/branch.routes.ts @@ -0,0 +1,48 @@ +import { Router } from 'express'; +import { branchController } from '../controllers/branch.controller'; +import { authMiddleware, requireRoles } from '../../../shared/middleware/auth.middleware'; +import { tenantMiddleware } from '../../../shared/middleware/tenant.middleware'; +import { AuthenticatedRequest } from '../../../shared/types'; + +const router = Router(); + +// All routes require tenant and authentication +router.use(tenantMiddleware); +router.use(authMiddleware); + +// List routes +router.get('/', (req, res, next) => branchController.list(req as AuthenticatedRequest, res, next)); +router.get('/active', (req, res, next) => branchController.listActive(req as AuthenticatedRequest, res, next)); +router.get('/nearby', (req, res, next) => branchController.findNearby(req as AuthenticatedRequest, res, next)); + +// Single branch routes +router.get('/code/:code', (req, res, next) => branchController.getByCode(req as AuthenticatedRequest, res, next)); +router.get('/:id', (req, res, next) => branchController.getById(req as AuthenticatedRequest, res, next)); +router.get('/:id/stats', (req, res, next) => branchController.getStats(req as AuthenticatedRequest, res, next)); + +// Admin routes (require manager or admin role) +router.post( + '/', + requireRoles('admin', 'manager'), + (req, res, next) => branchController.create(req as AuthenticatedRequest, res, next) +); + +router.put( + '/:id', + requireRoles('admin', 'manager'), + (req, res, next) => branchController.update(req as AuthenticatedRequest, res, next) +); + +router.patch( + '/:id/status', + requireRoles('admin', 'manager'), + (req, res, next) => branchController.updateStatus(req as AuthenticatedRequest, res, next) +); + +router.delete( + '/:id', + requireRoles('admin'), + (req, res, next) => branchController.delete(req as AuthenticatedRequest, res, next) +); + +export default router; diff --git a/backend/src/modules/branches/services/branch.service.ts b/backend/src/modules/branches/services/branch.service.ts new file mode 100644 index 0000000..b5652b3 --- /dev/null +++ b/backend/src/modules/branches/services/branch.service.ts @@ -0,0 +1,243 @@ +import { Repository, DeepPartial } from 'typeorm'; +import { AppDataSource } from '../../../config/typeorm'; +import { BaseService } from '../../../shared/services/base.service'; +import { ServiceResult, QueryOptions } from '../../../shared/types'; +import { Branch, BranchStatus, BranchType } from '../entities/branch.entity'; +import { CashRegister } from '../entities/cash-register.entity'; + +export class BranchService extends BaseService { + private cashRegisterRepository: Repository; + + constructor() { + super(AppDataSource.getRepository(Branch)); + this.cashRegisterRepository = AppDataSource.getRepository(CashRegister); + } + + /** + * Find all active branches for a tenant + */ + async findActiveBranches(tenantId: string): Promise { + return this.repository.find({ + where: { tenantId, status: BranchStatus.ACTIVE }, + order: { name: 'ASC' }, + }); + } + + /** + * Find branch by code + */ + async findByCode(tenantId: string, code: string): Promise { + return this.repository.findOne({ + where: { tenantId, code }, + }); + } + + /** + * Find branches by type + */ + async findByType(tenantId: string, type: BranchType): Promise { + return this.repository.find({ + where: { tenantId, type }, + order: { name: 'ASC' }, + }); + } + + /** + * Create branch with default cash register + */ + async createWithRegister( + tenantId: string, + data: DeepPartial, + userId?: string + ): Promise> { + const queryRunner = AppDataSource.createQueryRunner(); + await queryRunner.connect(); + await queryRunner.startTransaction(); + + try { + // Check if code already exists + const existing = await this.findByCode(tenantId, data.code as string); + if (existing) { + return { + success: false, + error: { + code: 'DUPLICATE_CODE', + message: `Branch with code '${data.code}' already exists`, + }, + }; + } + + // Create branch + const branch = this.repository.create({ + ...data, + tenantId, + createdBy: userId, + updatedBy: userId, + }); + const savedBranch = await queryRunner.manager.save(branch); + + // Create default cash register + const register = this.cashRegisterRepository.create({ + tenantId, + branchId: savedBranch.id, + code: `${savedBranch.code}-01`, + name: `Caja Principal - ${savedBranch.name}`, + createdBy: userId, + updatedBy: userId, + }); + await queryRunner.manager.save(register); + + await queryRunner.commitTransaction(); + + return { success: true, data: savedBranch }; + } catch (error: any) { + await queryRunner.rollbackTransaction(); + return { + success: false, + error: { + code: 'CREATE_FAILED', + message: error.message || 'Failed to create branch', + details: error, + }, + }; + } finally { + await queryRunner.release(); + } + } + + /** + * Update branch status + */ + async updateStatus( + tenantId: string, + branchId: string, + status: BranchStatus, + userId?: string + ): Promise> { + const branch = await this.findById(tenantId, branchId); + if (!branch) { + return { + success: false, + error: { + code: 'NOT_FOUND', + message: 'Branch not found', + }, + }; + } + + // If closing branch, check for open sessions + if (status === BranchStatus.INACTIVE) { + const openSessions = await this.hasOpenSessions(tenantId, branchId); + if (openSessions) { + return { + success: false, + error: { + code: 'OPEN_SESSIONS', + message: 'Cannot deactivate branch with open POS sessions', + }, + }; + } + } + + return this.update(tenantId, branchId, { status }, userId); + } + + /** + * Check if branch has open POS sessions + */ + private async hasOpenSessions(tenantId: string, branchId: string): Promise { + const count = await AppDataSource.getRepository('pos_sessions').count({ + where: { + tenantId, + branchId, + status: 'open', + }, + }); + return count > 0; + } + + /** + * Get branch with cash registers + */ + async getBranchWithRegisters( + tenantId: string, + branchId: string + ): Promise { + return this.repository.findOne({ + where: { tenantId, id: branchId }, + relations: ['cashRegisters'], + }); + } + + /** + * Get branch statistics + */ + async getBranchStats(tenantId: string, branchId: string): Promise<{ + totalRegisters: number; + activeRegisters: number; + todaySales: number; + openSessions: number; + }> { + const registers = await this.cashRegisterRepository.count({ + where: { tenantId, branchId }, + }); + + const activeRegisters = await this.cashRegisterRepository.count({ + where: { tenantId, branchId, isActive: true }, + }); + + // TODO: Add sales and sessions queries when those services are implemented + + return { + totalRegisters: registers, + activeRegisters, + todaySales: 0, + openSessions: 0, + }; + } + + /** + * Find nearby branches by coordinates + */ + async findNearby( + tenantId: string, + latitude: number, + longitude: number, + radiusKm: number = 10 + ): Promise { + // Using Haversine formula in SQL for distance calculation + const branches = await this.repository + .createQueryBuilder('branch') + .where('branch.tenantId = :tenantId', { tenantId }) + .andWhere('branch.status = :status', { status: BranchStatus.ACTIVE }) + .andWhere('branch.latitude IS NOT NULL') + .andWhere('branch.longitude IS NOT NULL') + .andWhere( + `( + 6371 * acos( + cos(radians(:lat)) * cos(radians(branch.latitude)) * + cos(radians(branch.longitude) - radians(:lng)) + + sin(radians(:lat)) * sin(radians(branch.latitude)) + ) + ) <= :radius`, + { lat: latitude, lng: longitude, radius: radiusKm } + ) + .orderBy( + `( + 6371 * acos( + cos(radians(:lat)) * cos(radians(branch.latitude)) * + cos(radians(branch.longitude) - radians(:lng)) + + sin(radians(:lat)) * sin(radians(branch.latitude)) + ) + )`, + 'ASC' + ) + .setParameters({ lat: latitude, lng: longitude }) + .getMany(); + + return branches; + } +} + +// Export singleton instance +export const branchService = new BranchService(); diff --git a/backend/src/modules/branches/services/index.ts b/backend/src/modules/branches/services/index.ts new file mode 100644 index 0000000..ef1d184 --- /dev/null +++ b/backend/src/modules/branches/services/index.ts @@ -0,0 +1 @@ +export * from './branch.service'; diff --git a/backend/src/modules/branches/validation/branch.schema.ts b/backend/src/modules/branches/validation/branch.schema.ts new file mode 100644 index 0000000..cedad78 --- /dev/null +++ b/backend/src/modules/branches/validation/branch.schema.ts @@ -0,0 +1,82 @@ +import { z } from 'zod'; +import { + uuidSchema, + emailSchema, + phoneSchema, + postalCodeSchema, + operatingHoursSchema, + coordinatesSchema, + paginationSchema, +} from '../../../shared/validation/common.schema'; + +// Enums +export const branchTypeEnum = z.enum(['store', 'warehouse', 'hybrid']); +export const branchStatusEnum = z.enum(['active', 'inactive', 'temporarily_closed']); + +// Create branch schema +export const createBranchSchema = z.object({ + code: z.string().min(1).max(20).regex(/^[A-Z0-9-]+$/, 'Code must be alphanumeric uppercase'), + name: z.string().min(1).max(100), + type: branchTypeEnum.default('store'), + status: branchStatusEnum.default('active'), + + // Address + address: z.string().max(255).optional(), + city: z.string().max(100).optional(), + state: z.string().max(100).optional(), + postalCode: postalCodeSchema.optional(), + countryCode: z.string().length(3).default('MEX'), + + // Contact + phone: phoneSchema.optional(), + email: emailSchema.optional(), + + // References + managerId: uuidSchema.optional(), + warehouseId: uuidSchema.optional(), + + // Configuration + operatingHours: operatingHoursSchema, + defaultPriceListId: uuidSchema.optional(), + allowNegativeStock: z.boolean().default(false), + requireCustomerForSale: z.boolean().default(false), + autoPrintReceipt: z.boolean().default(true), + + // Fiscal + fiscalAddress: z.string().optional(), + cfdiConfigId: uuidSchema.optional(), + + // Geolocation + latitude: z.coerce.number().min(-90).max(90).optional(), + longitude: z.coerce.number().min(-180).max(180).optional(), + + // Metadata + metadata: z.record(z.any()).optional(), +}); + +// Update branch schema (all fields optional) +export const updateBranchSchema = createBranchSchema.partial().omit({ code: true }); + +// Update status schema +export const updateBranchStatusSchema = z.object({ + status: branchStatusEnum, +}); + +// List branches query schema +export const listBranchesQuerySchema = paginationSchema.extend({ + status: branchStatusEnum.optional(), + type: branchTypeEnum.optional(), + search: z.string().optional(), +}); + +// Find nearby query schema +export const findNearbyQuerySchema = z.object({ + lat: z.coerce.number().min(-90).max(90), + lng: z.coerce.number().min(-180).max(180), + radius: z.coerce.number().min(0.1).max(100).default(10), +}); + +// Types +export type CreateBranchInput = z.infer; +export type UpdateBranchInput = z.infer; +export type ListBranchesQuery = z.infer; diff --git a/backend/src/modules/cash/controllers/cash.controller.ts b/backend/src/modules/cash/controllers/cash.controller.ts new file mode 100644 index 0000000..eee370c --- /dev/null +++ b/backend/src/modules/cash/controllers/cash.controller.ts @@ -0,0 +1,330 @@ +import { Request, Response } from 'express'; +import { BaseController } from '../../../shared/controllers/base.controller'; +import { CashMovementService, MovementQueryOptions } from '../services/cash-movement.service'; +import { CashClosingService, ClosingQueryOptions } from '../services/cash-closing.service'; + +export class CashController extends BaseController { + constructor( + private readonly movementService: CashMovementService, + private readonly closingService: CashClosingService + ) { + super(); + } + + // ==================== MOVEMENT ENDPOINTS ==================== + + /** + * Create a cash movement + * POST /cash/movements + */ + createMovement = async (req: Request, res: Response): Promise => { + const tenantId = req.tenantContext!.tenantId; + const userId = req.userContext!.userId; + + const result = await this.movementService.createMovement(tenantId, req.body, userId); + + if (!result.success) { + this.error(res, result.error!.message, 400, result.error!.code); + return; + } + + this.created(res, result.data); + }; + + /** + * List movements + * GET /cash/movements + */ + listMovements = async (req: Request, res: Response): Promise => { + const tenantId = req.tenantContext!.tenantId; + const options: MovementQueryOptions = { + page: Number(req.query.page) || 1, + limit: Number(req.query.limit) || 20, + branchId: req.query.branchId as string, + sessionId: req.query.sessionId as string, + registerId: req.query.registerId as string, + type: req.query.type as any, + reason: req.query.reason as any, + status: req.query.status as any, + startDate: req.query.startDate ? new Date(req.query.startDate as string) : undefined, + endDate: req.query.endDate ? new Date(req.query.endDate as string) : undefined, + }; + + const result = await this.movementService.findMovements(tenantId, options); + this.paginated(res, result.data, result.total, options.page!, options.limit!); + }; + + /** + * Get movement by ID + * GET /cash/movements/:id + */ + getMovement = async (req: Request, res: Response): Promise => { + const tenantId = req.tenantContext!.tenantId; + const { id } = req.params; + + const movement = await this.movementService.findById(tenantId, id); + + if (!movement) { + this.notFound(res, 'Movement'); + return; + } + + this.success(res, movement); + }; + + /** + * Approve movement + * POST /cash/movements/:id/approve + */ + approveMovement = async (req: Request, res: Response): Promise => { + const tenantId = req.tenantContext!.tenantId; + const userId = req.userContext!.userId; + const { id } = req.params; + + const result = await this.movementService.approveMovement(tenantId, id, userId); + + if (!result.success) { + this.error(res, result.error!.message, 400, result.error!.code); + return; + } + + this.success(res, result.data, 'Movement approved'); + }; + + /** + * Reject movement + * POST /cash/movements/:id/reject + */ + rejectMovement = async (req: Request, res: Response): Promise => { + const tenantId = req.tenantContext!.tenantId; + const userId = req.userContext!.userId; + const { id } = req.params; + const { reason } = req.body; + + const result = await this.movementService.rejectMovement(tenantId, id, userId, reason); + + if (!result.success) { + this.error(res, result.error!.message, 400, result.error!.code); + return; + } + + this.success(res, result.data, 'Movement rejected'); + }; + + /** + * Cancel movement + * POST /cash/movements/:id/cancel + */ + cancelMovement = async (req: Request, res: Response): Promise => { + const tenantId = req.tenantContext!.tenantId; + const userId = req.userContext!.userId; + const { id } = req.params; + const { reason } = req.body; + + const result = await this.movementService.cancelMovement(tenantId, id, userId, reason); + + if (!result.success) { + this.error(res, result.error!.message, 400, result.error!.code); + return; + } + + this.success(res, result.data, 'Movement cancelled'); + }; + + /** + * Get session summary + * GET /cash/sessions/:sessionId/summary + */ + getSessionSummary = async (req: Request, res: Response): Promise => { + const tenantId = req.tenantContext!.tenantId; + const { sessionId } = req.params; + + const summary = await this.movementService.getSessionSummary(tenantId, sessionId); + this.success(res, summary); + }; + + // ==================== CLOSING ENDPOINTS ==================== + + /** + * Create a cash closing + * POST /cash/closings + */ + createClosing = async (req: Request, res: Response): Promise => { + const tenantId = req.tenantContext!.tenantId; + const userId = req.userContext!.userId; + + const result = await this.closingService.createClosing(tenantId, req.body, userId); + + if (!result.success) { + this.error(res, result.error!.message, 400, result.error!.code); + return; + } + + this.created(res, result.data); + }; + + /** + * List closings + * GET /cash/closings + */ + listClosings = async (req: Request, res: Response): Promise => { + const tenantId = req.tenantContext!.tenantId; + const options: ClosingQueryOptions = { + page: Number(req.query.page) || 1, + limit: Number(req.query.limit) || 20, + branchId: req.query.branchId as string, + sessionId: req.query.sessionId as string, + status: req.query.status as any, + type: req.query.type as any, + startDate: req.query.startDate ? new Date(req.query.startDate as string) : undefined, + endDate: req.query.endDate ? new Date(req.query.endDate as string) : undefined, + }; + + const result = await this.closingService.findClosings(tenantId, options); + this.paginated(res, result.data, result.total, options.page!, options.limit!); + }; + + /** + * Get closing by ID + * GET /cash/closings/:id + */ + getClosing = async (req: Request, res: Response): Promise => { + const tenantId = req.tenantContext!.tenantId; + const { id } = req.params; + + const closing = await this.closingService.getClosingWithCounts(tenantId, id); + + if (!closing) { + this.notFound(res, 'Closing'); + return; + } + + this.success(res, closing); + }; + + /** + * Submit cash count + * POST /cash/closings/:id/count + */ + submitCashCount = async (req: Request, res: Response): Promise => { + const tenantId = req.tenantContext!.tenantId; + const userId = req.userContext!.userId; + const { id } = req.params; + const { denominations } = req.body; + + const result = await this.closingService.submitCashCount(tenantId, id, denominations, userId); + + if (!result.success) { + this.error(res, result.error!.message, 400, result.error!.code); + return; + } + + this.success(res, result.data, 'Cash count submitted'); + }; + + /** + * Submit payment counts + * POST /cash/closings/:id/payments + */ + submitPaymentCounts = async (req: Request, res: Response): Promise => { + const tenantId = req.tenantContext!.tenantId; + const userId = req.userContext!.userId; + const { id } = req.params; + + const result = await this.closingService.submitPaymentCounts(tenantId, id, req.body, userId); + + if (!result.success) { + this.error(res, result.error!.message, 400, result.error!.code); + return; + } + + this.success(res, result.data, 'Payment counts submitted'); + }; + + /** + * Approve closing + * POST /cash/closings/:id/approve + */ + approveClosing = async (req: Request, res: Response): Promise => { + const tenantId = req.tenantContext!.tenantId; + const userId = req.userContext!.userId; + const { id } = req.params; + const { notes } = req.body; + + const result = await this.closingService.approveClosing(tenantId, id, userId, notes); + + if (!result.success) { + this.error(res, result.error!.message, 400, result.error!.code); + return; + } + + this.success(res, result.data, 'Closing approved'); + }; + + /** + * Reject closing + * POST /cash/closings/:id/reject + */ + rejectClosing = async (req: Request, res: Response): Promise => { + const tenantId = req.tenantContext!.tenantId; + const userId = req.userContext!.userId; + const { id } = req.params; + const { notes } = req.body; + + const result = await this.closingService.rejectClosing(tenantId, id, userId, notes); + + if (!result.success) { + this.error(res, result.error!.message, 400, result.error!.code); + return; + } + + this.success(res, result.data, 'Closing rejected'); + }; + + /** + * Reconcile closing + * POST /cash/closings/:id/reconcile + */ + reconcileClosing = async (req: Request, res: Response): Promise => { + const tenantId = req.tenantContext!.tenantId; + const userId = req.userContext!.userId; + const { id } = req.params; + const { depositAmount, depositReference, depositDate } = req.body; + + const result = await this.closingService.reconcileClosing( + tenantId, + id, + { + amount: depositAmount, + reference: depositReference, + date: new Date(depositDate), + }, + userId + ); + + if (!result.success) { + this.error(res, result.error!.message, 400, result.error!.code); + return; + } + + this.success(res, result.data, 'Closing reconciled'); + }; + + /** + * Get daily summary + * GET /cash/summary/daily + */ + getDailySummary = async (req: Request, res: Response): Promise => { + const tenantId = req.tenantContext!.tenantId; + const branchId = req.branchContext?.branchId || (req.query.branchId as string); + const date = req.query.date ? new Date(req.query.date as string) : new Date(); + + if (!branchId) { + this.error(res, 'Branch ID is required', 400); + return; + } + + const summary = await this.closingService.getDailySummary(tenantId, branchId, date); + this.success(res, summary); + }; +} diff --git a/backend/src/modules/cash/entities/cash-closing.entity.ts b/backend/src/modules/cash/entities/cash-closing.entity.ts new file mode 100644 index 0000000..48899fe --- /dev/null +++ b/backend/src/modules/cash/entities/cash-closing.entity.ts @@ -0,0 +1,205 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + OneToMany, + Index, +} from 'typeorm'; +import { CashCount } from './cash-count.entity'; + +export enum ClosingStatus { + IN_PROGRESS = 'in_progress', + PENDING_REVIEW = 'pending_review', + APPROVED = 'approved', + REJECTED = 'rejected', + RECONCILED = 'reconciled', +} + +export enum ClosingType { + SHIFT = 'shift', + DAILY = 'daily', + WEEKLY = 'weekly', + MONTHLY = 'monthly', +} + +@Entity('cash_closings', { schema: 'retail' }) +@Index(['tenantId', 'branchId', 'closingDate']) +@Index(['tenantId', 'status']) +@Index(['tenantId', 'sessionId']) +export class CashClosing { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'tenant_id', type: 'uuid' }) + @Index() + tenantId: string; + + @Column({ name: 'branch_id', type: 'uuid' }) + branchId: string; + + @Column({ name: 'register_id', type: 'uuid', nullable: true }) + registerId: string; + + @Column({ name: 'session_id', type: 'uuid', nullable: true }) + sessionId: string; + + @Column({ length: 30, unique: true }) + number: string; + + @Column({ + type: 'enum', + enum: ClosingType, + default: ClosingType.SHIFT, + }) + type: ClosingType; + + @Column({ + type: 'enum', + enum: ClosingStatus, + default: ClosingStatus.IN_PROGRESS, + }) + status: ClosingStatus; + + @Column({ name: 'closing_date', type: 'date' }) + closingDate: Date; + + @Column({ name: 'period_start', type: 'timestamp with time zone' }) + periodStart: Date; + + @Column({ name: 'period_end', type: 'timestamp with time zone' }) + periodEnd: Date; + + // Opening balance + @Column({ name: 'opening_balance', type: 'decimal', precision: 15, scale: 2, default: 0 }) + openingBalance: number; + + // Expected amounts by payment method + @Column({ name: 'expected_cash', type: 'decimal', precision: 15, scale: 2, default: 0 }) + expectedCash: number; + + @Column({ name: 'expected_card', type: 'decimal', precision: 15, scale: 2, default: 0 }) + expectedCard: number; + + @Column({ name: 'expected_transfer', type: 'decimal', precision: 15, scale: 2, default: 0 }) + expectedTransfer: number; + + @Column({ name: 'expected_other', type: 'decimal', precision: 15, scale: 2, default: 0 }) + expectedOther: number; + + @Column({ name: 'expected_total', type: 'decimal', precision: 15, scale: 2, default: 0 }) + expectedTotal: number; + + // Counted amounts + @Column({ name: 'counted_cash', type: 'decimal', precision: 15, scale: 2, nullable: true }) + countedCash: number; + + @Column({ name: 'counted_card', type: 'decimal', precision: 15, scale: 2, nullable: true }) + countedCard: number; + + @Column({ name: 'counted_transfer', type: 'decimal', precision: 15, scale: 2, nullable: true }) + countedTransfer: number; + + @Column({ name: 'counted_other', type: 'decimal', precision: 15, scale: 2, nullable: true }) + countedOther: number; + + @Column({ name: 'counted_total', type: 'decimal', precision: 15, scale: 2, nullable: true }) + countedTotal: number; + + // Differences + @Column({ name: 'cash_difference', type: 'decimal', precision: 15, scale: 2, nullable: true }) + cashDifference: number; + + @Column({ name: 'card_difference', type: 'decimal', precision: 15, scale: 2, nullable: true }) + cardDifference: number; + + @Column({ name: 'total_difference', type: 'decimal', precision: 15, scale: 2, nullable: true }) + totalDifference: number; + + // Transaction summary + @Column({ name: 'total_sales', type: 'decimal', precision: 15, scale: 2, default: 0 }) + totalSales: number; + + @Column({ name: 'total_refunds', type: 'decimal', precision: 15, scale: 2, default: 0 }) + totalRefunds: number; + + @Column({ name: 'total_discounts', type: 'decimal', precision: 15, scale: 2, default: 0 }) + totalDiscounts: number; + + @Column({ name: 'net_sales', type: 'decimal', precision: 15, scale: 2, default: 0 }) + netSales: number; + + @Column({ name: 'orders_count', type: 'int', default: 0 }) + ordersCount: number; + + @Column({ name: 'refunds_count', type: 'int', default: 0 }) + refundsCount: number; + + @Column({ name: 'voided_count', type: 'int', default: 0 }) + voidedCount: number; + + // Cash movements + @Column({ name: 'cash_in_total', type: 'decimal', precision: 15, scale: 2, default: 0 }) + cashInTotal: number; + + @Column({ name: 'cash_out_total', type: 'decimal', precision: 15, scale: 2, default: 0 }) + cashOutTotal: number; + + // Deposit info + @Column({ name: 'deposit_amount', type: 'decimal', precision: 15, scale: 2, nullable: true }) + depositAmount: number; + + @Column({ name: 'deposit_reference', length: 100, nullable: true }) + depositReference: string; + + @Column({ name: 'deposit_date', type: 'date', nullable: true }) + depositDate: Date; + + // User info + @Column({ name: 'closed_by', type: 'uuid' }) + closedBy: string; + + @Column({ name: 'reviewed_by', type: 'uuid', nullable: true }) + reviewedBy: string; + + @Column({ name: 'reviewed_at', type: 'timestamp with time zone', nullable: true }) + reviewedAt: Date; + + @Column({ name: 'approved_by', type: 'uuid', nullable: true }) + approvedBy: string; + + @Column({ name: 'approved_at', type: 'timestamp with time zone', nullable: true }) + approvedAt: Date; + + // Notes + @Column({ name: 'closing_notes', type: 'text', nullable: true }) + closingNotes: string; + + @Column({ name: 'review_notes', type: 'text', nullable: true }) + reviewNotes: string; + + // Detailed breakdown by payment method + @Column({ name: 'payment_breakdown', type: 'jsonb', nullable: true }) + paymentBreakdown: { + method: string; + expected: number; + counted: number; + difference: number; + transactionCount: number; + }[]; + + // Metadata + @Column({ type: 'jsonb', nullable: true }) + metadata: Record; + + @CreateDateColumn({ name: 'created_at' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at' }) + updatedAt: Date; + + // Relations + @OneToMany(() => CashCount, (count) => count.closing) + cashCounts: CashCount[]; +} diff --git a/backend/src/modules/cash/entities/cash-count.entity.ts b/backend/src/modules/cash/entities/cash-count.entity.ts new file mode 100644 index 0000000..fca51ab --- /dev/null +++ b/backend/src/modules/cash/entities/cash-count.entity.ts @@ -0,0 +1,78 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + ManyToOne, + JoinColumn, + Index, +} from 'typeorm'; +import { CashClosing } from './cash-closing.entity'; + +export enum DenominationType { + BILL = 'bill', + COIN = 'coin', +} + +@Entity('cash_counts', { schema: 'retail' }) +@Index(['tenantId', 'closingId']) +@Index(['tenantId', 'sessionId']) +export class CashCount { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'tenant_id', type: 'uuid' }) + @Index() + tenantId: string; + + @Column({ name: 'closing_id', type: 'uuid', nullable: true }) + closingId: string; + + @Column({ name: 'session_id', type: 'uuid', nullable: true }) + sessionId: string; + + @Column({ name: 'register_id', type: 'uuid' }) + registerId: string; + + @Column({ + type: 'enum', + enum: DenominationType, + }) + type: DenominationType; + + // Denomination value (e.g., 500, 200, 100, 50, 20, 10, 5, 2, 1, 0.50, 0.20, 0.10) + @Column({ type: 'decimal', precision: 10, scale: 2 }) + denomination: number; + + // Count + @Column({ type: 'int', default: 0 }) + quantity: number; + + // Calculated total (denomination * quantity) + @Column({ type: 'decimal', precision: 15, scale: 2, default: 0 }) + total: number; + + // Currency + @Column({ name: 'currency_code', length: 3, default: 'MXN' }) + currencyCode: string; + + // Count context + @Column({ name: 'count_type', length: 20, default: 'closing' }) + countType: 'opening' | 'closing' | 'audit' | 'transfer'; + + // User who counted + @Column({ name: 'counted_by', type: 'uuid' }) + countedBy: string; + + // Metadata + @Column({ type: 'jsonb', nullable: true }) + metadata: Record; + + @CreateDateColumn({ name: 'created_at' }) + createdAt: Date; + + // Relations + @ManyToOne(() => CashClosing, (closing) => closing.cashCounts) + @JoinColumn({ name: 'closing_id' }) + closing: CashClosing; +} diff --git a/backend/src/modules/cash/entities/cash-movement.entity.ts b/backend/src/modules/cash/entities/cash-movement.entity.ts new file mode 100644 index 0000000..c6c30ef --- /dev/null +++ b/backend/src/modules/cash/entities/cash-movement.entity.ts @@ -0,0 +1,152 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + Index, +} from 'typeorm'; + +export enum MovementType { + CASH_IN = 'cash_in', + CASH_OUT = 'cash_out', + DEPOSIT = 'deposit', + WITHDRAWAL = 'withdrawal', + ADJUSTMENT = 'adjustment', + OPENING = 'opening', + CLOSING = 'closing', +} + +export enum MovementReason { + CHANGE_FUND = 'change_fund', + PETTY_CASH = 'petty_cash', + BANK_DEPOSIT = 'bank_deposit', + SUPPLIER_PAYMENT = 'supplier_payment', + EXPENSE = 'expense', + SALARY_ADVANCE = 'salary_advance', + CORRECTION = 'correction', + OTHER = 'other', +} + +export enum MovementStatus { + PENDING = 'pending', + APPROVED = 'approved', + REJECTED = 'rejected', + CANCELLED = 'cancelled', +} + +@Entity('cash_movements', { schema: 'retail' }) +@Index(['tenantId', 'branchId', 'createdAt']) +@Index(['tenantId', 'sessionId']) +@Index(['tenantId', 'type', 'status']) +export class CashMovement { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'tenant_id', type: 'uuid' }) + @Index() + tenantId: string; + + @Column({ name: 'branch_id', type: 'uuid' }) + branchId: string; + + @Column({ name: 'register_id', type: 'uuid' }) + registerId: string; + + @Column({ name: 'session_id', type: 'uuid' }) + sessionId: string; + + @Column({ length: 30, unique: true }) + number: string; + + @Column({ + type: 'enum', + enum: MovementType, + }) + type: MovementType; + + @Column({ + type: 'enum', + enum: MovementReason, + default: MovementReason.OTHER, + }) + reason: MovementReason; + + @Column({ + type: 'enum', + enum: MovementStatus, + default: MovementStatus.PENDING, + }) + status: MovementStatus; + + // Amount (positive for in, negative for out stored as absolute) + @Column({ type: 'decimal', precision: 15, scale: 2 }) + amount: number; + + // Running balance after movement + @Column({ name: 'balance_after', type: 'decimal', precision: 15, scale: 2, nullable: true }) + balanceAfter: number; + + // Description + @Column({ type: 'text' }) + description: string; + + // Reference (external document, invoice, etc.) + @Column({ name: 'reference_type', length: 50, nullable: true }) + referenceType: string; + + @Column({ name: 'reference_id', type: 'uuid', nullable: true }) + referenceId: string; + + @Column({ name: 'reference_number', length: 50, nullable: true }) + referenceNumber: string; + + // Authorization + @Column({ name: 'requires_approval', type: 'boolean', default: false }) + requiresApproval: boolean; + + @Column({ name: 'approved_by', type: 'uuid', nullable: true }) + approvedBy: string; + + @Column({ name: 'approved_at', type: 'timestamp with time zone', nullable: true }) + approvedAt: Date; + + @Column({ name: 'rejection_reason', length: 255, nullable: true }) + rejectionReason: string; + + // Person who receives/gives cash (for expenses, advances, etc.) + @Column({ name: 'recipient_name', length: 200, nullable: true }) + recipientName: string; + + @Column({ name: 'recipient_id', type: 'uuid', nullable: true }) + recipientId: string; + + // Supporting documents + @Column({ name: 'attachment_ids', type: 'jsonb', nullable: true }) + attachmentIds: string[]; + + // Financial account (for accounting integration) + @Column({ name: 'account_id', type: 'uuid', nullable: true }) + accountId: string; + + // Bank deposit info + @Column({ name: 'bank_account', length: 50, nullable: true }) + bankAccount: string; + + @Column({ name: 'deposit_slip_number', length: 50, nullable: true }) + depositSlipNumber: string; + + // User who created the movement + @Column({ name: 'created_by', type: 'uuid' }) + createdBy: string; + + // Notes + @Column({ type: 'text', nullable: true }) + notes: string; + + // Metadata + @Column({ type: 'jsonb', nullable: true }) + metadata: Record; + + @CreateDateColumn({ name: 'created_at' }) + createdAt: Date; +} diff --git a/backend/src/modules/cash/entities/index.ts b/backend/src/modules/cash/entities/index.ts new file mode 100644 index 0000000..d1b8e43 --- /dev/null +++ b/backend/src/modules/cash/entities/index.ts @@ -0,0 +1,3 @@ +export * from './cash-movement.entity'; +export * from './cash-closing.entity'; +export * from './cash-count.entity'; diff --git a/backend/src/modules/cash/index.ts b/backend/src/modules/cash/index.ts new file mode 100644 index 0000000..43cf4c8 --- /dev/null +++ b/backend/src/modules/cash/index.ts @@ -0,0 +1,5 @@ +export * from './entities'; +export * from './services'; +export * from './controllers/cash.controller'; +export * from './routes/cash.routes'; +export * from './validation/cash.schema'; diff --git a/backend/src/modules/cash/routes/cash.routes.ts b/backend/src/modules/cash/routes/cash.routes.ts new file mode 100644 index 0000000..0b8a67b --- /dev/null +++ b/backend/src/modules/cash/routes/cash.routes.ts @@ -0,0 +1,155 @@ +import { Router } from 'express'; +import { CashController } from '../controllers/cash.controller'; +import { requireAuth, requireRoles, requirePermissions } from '../../../shared/middleware/auth.middleware'; +import { validateBody, validateQuery } from '../../../shared/validation/validation.middleware'; +import { idParamSchema } from '../../../shared/validation/common.schema'; +import { + createMovementSchema, + movementActionSchema, + listMovementsQuerySchema, + createClosingSchema, + submitCashCountSchema, + submitPaymentCountsSchema, + closingActionSchema, + rejectClosingSchema, + reconcileClosingSchema, + listClosingsQuerySchema, +} from '../validation/cash.schema'; + +export function createCashRoutes(controller: CashController): Router { + const router = Router(); + + // Apply auth middleware to all routes + router.use(requireAuth); + + // ==================== MOVEMENT ROUTES ==================== + + // Create movement + router.post( + '/movements', + requirePermissions(['cash.movements.create']), + validateBody(createMovementSchema), + controller.createMovement + ); + + // List movements + router.get( + '/movements', + requirePermissions(['cash.movements.view']), + validateQuery(listMovementsQuerySchema), + controller.listMovements + ); + + // Get movement by ID + router.get( + '/movements/:id', + requirePermissions(['cash.movements.view']), + controller.getMovement + ); + + // Approve movement + router.post( + '/movements/:id/approve', + requireRoles(['admin', 'supervisor', 'manager']), + controller.approveMovement + ); + + // Reject movement + router.post( + '/movements/:id/reject', + requireRoles(['admin', 'supervisor', 'manager']), + validateBody(movementActionSchema), + controller.rejectMovement + ); + + // Cancel movement + router.post( + '/movements/:id/cancel', + requirePermissions(['cash.movements.cancel']), + validateBody(movementActionSchema), + controller.cancelMovement + ); + + // Get session summary + router.get( + '/sessions/:sessionId/summary', + requirePermissions(['cash.movements.view']), + controller.getSessionSummary + ); + + // ==================== CLOSING ROUTES ==================== + + // Create closing + router.post( + '/closings', + requirePermissions(['cash.closings.create']), + validateBody(createClosingSchema), + controller.createClosing + ); + + // List closings + router.get( + '/closings', + requirePermissions(['cash.closings.view']), + validateQuery(listClosingsQuerySchema), + controller.listClosings + ); + + // Get closing by ID + router.get( + '/closings/:id', + requirePermissions(['cash.closings.view']), + controller.getClosing + ); + + // Submit cash count + router.post( + '/closings/:id/count', + requirePermissions(['cash.closings.count']), + validateBody(submitCashCountSchema), + controller.submitCashCount + ); + + // Submit payment counts + router.post( + '/closings/:id/payments', + requirePermissions(['cash.closings.count']), + validateBody(submitPaymentCountsSchema), + controller.submitPaymentCounts + ); + + // Approve closing + router.post( + '/closings/:id/approve', + requireRoles(['admin', 'supervisor', 'manager']), + validateBody(closingActionSchema), + controller.approveClosing + ); + + // Reject closing + router.post( + '/closings/:id/reject', + requireRoles(['admin', 'supervisor', 'manager']), + validateBody(rejectClosingSchema), + controller.rejectClosing + ); + + // Reconcile closing + router.post( + '/closings/:id/reconcile', + requireRoles(['admin', 'accountant']), + validateBody(reconcileClosingSchema), + controller.reconcileClosing + ); + + // ==================== SUMMARY ROUTES ==================== + + // Get daily summary + router.get( + '/summary/daily', + requirePermissions(['cash.reports.view']), + controller.getDailySummary + ); + + return router; +} diff --git a/backend/src/modules/cash/services/cash-closing.service.ts b/backend/src/modules/cash/services/cash-closing.service.ts new file mode 100644 index 0000000..df32521 --- /dev/null +++ b/backend/src/modules/cash/services/cash-closing.service.ts @@ -0,0 +1,568 @@ +import { Repository, DataSource, Between } from 'typeorm'; +import { BaseService } from '../../../shared/services/base.service'; +import { ServiceResult, QueryOptions } from '../../../shared/types'; +import { + CashClosing, + ClosingStatus, + ClosingType, +} from '../entities/cash-closing.entity'; +import { CashCount, DenominationType } from '../entities/cash-count.entity'; +import { CashMovement, MovementStatus } from '../entities/cash-movement.entity'; + +export interface CreateClosingInput { + branchId: string; + registerId?: string; + sessionId?: string; + type: ClosingType; + periodStart: Date; + periodEnd: Date; + openingBalance: number; + closingNotes?: string; +} + +export interface ClosingQueryOptions extends QueryOptions { + branchId?: string; + sessionId?: string; + status?: ClosingStatus; + type?: ClosingType; + startDate?: Date; + endDate?: Date; +} + +export interface DenominationCount { + type: DenominationType; + denomination: number; + quantity: number; +} + +export interface PaymentTotals { + cash: number; + card: number; + transfer: number; + other: number; +} + +export class CashClosingService extends BaseService { + constructor( + repository: Repository, + private readonly countRepository: Repository, + private readonly movementRepository: Repository, + private readonly dataSource: DataSource + ) { + super(repository); + } + + /** + * Generate closing number + */ + private async generateClosingNumber( + tenantId: string, + branchId: string, + type: ClosingType + ): Promise { + const today = new Date(); + const datePrefix = today.toISOString().slice(0, 10).replace(/-/g, ''); + const typePrefix = type === ClosingType.SHIFT ? 'CLS' : type.toUpperCase().slice(0, 3); + + const count = await this.repository.count({ + where: { + tenantId, + branchId, + type, + createdAt: Between( + new Date(today.setHours(0, 0, 0, 0)), + new Date(today.setHours(23, 59, 59, 999)) + ), + }, + }); + + return `${typePrefix}-${datePrefix}-${String(count + 1).padStart(3, '0')}`; + } + + /** + * Calculate expected amounts from POS orders and movements + */ + private async calculateExpectedAmounts( + tenantId: string, + sessionId: string | null, + branchId: string, + periodStart: Date, + periodEnd: Date + ): Promise<{ + expected: PaymentTotals; + movements: { in: number; out: number }; + transactions: { sales: number; refunds: number; discounts: number; orders: number; refundsCount: number; voidedCount: number }; + }> { + // Get movements in period + const movements = await this.movementRepository.find({ + where: { + tenantId, + branchId, + status: MovementStatus.APPROVED, + ...(sessionId ? { sessionId } : {}), + }, + }); + + let cashIn = 0; + let cashOut = 0; + for (const m of movements) { + if (['cash_in', 'opening'].includes(m.type)) { + cashIn += Number(m.amount); + } else if (['cash_out', 'closing', 'deposit', 'withdrawal'].includes(m.type)) { + cashOut += Number(m.amount); + } + } + + // TODO: Query POS orders to calculate payment totals + // This would require the POSOrder and POSPayment repositories + // For now, return placeholder values that can be populated + + return { + expected: { + cash: cashIn - cashOut, + card: 0, + transfer: 0, + other: 0, + }, + movements: { + in: cashIn, + out: cashOut, + }, + transactions: { + sales: 0, + refunds: 0, + discounts: 0, + orders: 0, + refundsCount: 0, + voidedCount: 0, + }, + }; + } + + /** + * Create a new cash closing + */ + async createClosing( + tenantId: string, + input: CreateClosingInput, + userId: string + ): Promise> { + const queryRunner = this.dataSource.createQueryRunner(); + await queryRunner.connect(); + await queryRunner.startTransaction(); + + try { + const number = await this.generateClosingNumber(tenantId, input.branchId, input.type); + + // Calculate expected amounts + const { expected, movements, transactions } = await this.calculateExpectedAmounts( + tenantId, + input.sessionId ?? null, + input.branchId, + input.periodStart, + input.periodEnd + ); + + const closing = queryRunner.manager.create(CashClosing, { + tenantId, + branchId: input.branchId, + registerId: input.registerId, + sessionId: input.sessionId, + number, + type: input.type, + status: ClosingStatus.IN_PROGRESS, + closingDate: new Date(), + periodStart: input.periodStart, + periodEnd: input.periodEnd, + openingBalance: input.openingBalance, + expectedCash: expected.cash, + expectedCard: expected.card, + expectedTransfer: expected.transfer, + expectedOther: expected.other, + expectedTotal: expected.cash + expected.card + expected.transfer + expected.other, + totalSales: transactions.sales, + totalRefunds: transactions.refunds, + totalDiscounts: transactions.discounts, + netSales: transactions.sales - transactions.refunds - transactions.discounts, + ordersCount: transactions.orders, + refundsCount: transactions.refundsCount, + voidedCount: transactions.voidedCount, + cashInTotal: movements.in, + cashOutTotal: movements.out, + closedBy: userId, + closingNotes: input.closingNotes, + }); + + const savedClosing = await queryRunner.manager.save(closing); + await queryRunner.commitTransaction(); + + return { success: true, data: savedClosing }; + } catch (error) { + await queryRunner.rollbackTransaction(); + return { + success: false, + error: { + code: 'CREATE_CLOSING_ERROR', + message: error instanceof Error ? error.message : 'Failed to create closing', + }, + }; + } finally { + await queryRunner.release(); + } + } + + /** + * Submit cash count for a closing + */ + async submitCashCount( + tenantId: string, + closingId: string, + denominations: DenominationCount[], + userId: string + ): Promise> { + const queryRunner = this.dataSource.createQueryRunner(); + await queryRunner.connect(); + await queryRunner.startTransaction(); + + try { + const closing = await queryRunner.manager.findOne(CashClosing, { + where: { id: closingId, tenantId }, + }); + + if (!closing) { + return { + success: false, + error: { code: 'NOT_FOUND', message: 'Closing not found' }, + }; + } + + if (closing.status !== ClosingStatus.IN_PROGRESS) { + return { + success: false, + error: { code: 'INVALID_STATUS', message: 'Closing is not in progress' }, + }; + } + + // Delete existing counts + await queryRunner.manager.delete(CashCount, { closingId, tenantId }); + + // Create new counts + let totalCounted = 0; + for (const d of denominations) { + const total = d.denomination * d.quantity; + totalCounted += total; + + const count = queryRunner.manager.create(CashCount, { + tenantId, + closingId, + sessionId: closing.sessionId, + registerId: closing.registerId, + type: d.type, + denomination: d.denomination, + quantity: d.quantity, + total, + countType: 'closing', + countedBy: userId, + }); + + await queryRunner.manager.save(count); + } + + // Update closing with counted values + closing.countedCash = totalCounted; + closing.countedTotal = totalCounted + (closing.countedCard ?? 0) + + (closing.countedTransfer ?? 0) + (closing.countedOther ?? 0); + closing.cashDifference = totalCounted - Number(closing.expectedCash); + closing.totalDifference = Number(closing.countedTotal) - Number(closing.expectedTotal); + closing.status = ClosingStatus.PENDING_REVIEW; + + await queryRunner.manager.save(closing); + await queryRunner.commitTransaction(); + + return { success: true, data: closing }; + } catch (error) { + await queryRunner.rollbackTransaction(); + return { + success: false, + error: { + code: 'SUBMIT_COUNT_ERROR', + message: error instanceof Error ? error.message : 'Failed to submit cash count', + }, + }; + } finally { + await queryRunner.release(); + } + } + + /** + * Submit other payment method counts (card, transfer, etc.) + */ + async submitPaymentCounts( + tenantId: string, + closingId: string, + counts: PaymentTotals, + userId: string + ): Promise> { + const closing = await this.repository.findOne({ + where: { id: closingId, tenantId }, + }); + + if (!closing) { + return { + success: false, + error: { code: 'NOT_FOUND', message: 'Closing not found' }, + }; + } + + closing.countedCard = counts.card; + closing.countedTransfer = counts.transfer; + closing.countedOther = counts.other; + closing.cardDifference = counts.card - Number(closing.expectedCard); + closing.countedTotal = (closing.countedCash ?? 0) + counts.card + counts.transfer + counts.other; + closing.totalDifference = Number(closing.countedTotal) - Number(closing.expectedTotal); + + // Build payment breakdown + closing.paymentBreakdown = [ + { + method: 'cash', + expected: Number(closing.expectedCash), + counted: Number(closing.countedCash ?? 0), + difference: Number(closing.cashDifference ?? 0), + transactionCount: 0, + }, + { + method: 'card', + expected: Number(closing.expectedCard), + counted: counts.card, + difference: counts.card - Number(closing.expectedCard), + transactionCount: 0, + }, + { + method: 'transfer', + expected: Number(closing.expectedTransfer), + counted: counts.transfer, + difference: counts.transfer - Number(closing.expectedTransfer), + transactionCount: 0, + }, + { + method: 'other', + expected: Number(closing.expectedOther), + counted: counts.other, + difference: counts.other - Number(closing.expectedOther), + transactionCount: 0, + }, + ]; + + const saved = await this.repository.save(closing); + return { success: true, data: saved }; + } + + /** + * Approve a closing + */ + async approveClosing( + tenantId: string, + closingId: string, + approverId: string, + notes?: string + ): Promise> { + const closing = await this.repository.findOne({ + where: { id: closingId, tenantId }, + }); + + if (!closing) { + return { + success: false, + error: { code: 'NOT_FOUND', message: 'Closing not found' }, + }; + } + + if (closing.status !== ClosingStatus.PENDING_REVIEW) { + return { + success: false, + error: { code: 'INVALID_STATUS', message: 'Closing is not pending review' }, + }; + } + + closing.status = ClosingStatus.APPROVED; + closing.approvedBy = approverId; + closing.approvedAt = new Date(); + if (notes) { + closing.reviewNotes = notes; + } + + const saved = await this.repository.save(closing); + return { success: true, data: saved }; + } + + /** + * Reject a closing + */ + async rejectClosing( + tenantId: string, + closingId: string, + reviewerId: string, + notes: string + ): Promise> { + const closing = await this.repository.findOne({ + where: { id: closingId, tenantId }, + }); + + if (!closing) { + return { + success: false, + error: { code: 'NOT_FOUND', message: 'Closing not found' }, + }; + } + + if (closing.status !== ClosingStatus.PENDING_REVIEW) { + return { + success: false, + error: { code: 'INVALID_STATUS', message: 'Closing is not pending review' }, + }; + } + + closing.status = ClosingStatus.REJECTED; + closing.reviewedBy = reviewerId; + closing.reviewedAt = new Date(); + closing.reviewNotes = notes; + + const saved = await this.repository.save(closing); + return { success: true, data: saved }; + } + + /** + * Mark closing as reconciled + */ + async reconcileClosing( + tenantId: string, + closingId: string, + depositInfo: { amount: number; reference: string; date: Date }, + userId: string + ): Promise> { + const closing = await this.repository.findOne({ + where: { id: closingId, tenantId }, + }); + + if (!closing) { + return { + success: false, + error: { code: 'NOT_FOUND', message: 'Closing not found' }, + }; + } + + if (closing.status !== ClosingStatus.APPROVED) { + return { + success: false, + error: { code: 'INVALID_STATUS', message: 'Closing must be approved before reconciliation' }, + }; + } + + closing.status = ClosingStatus.RECONCILED; + closing.depositAmount = depositInfo.amount; + closing.depositReference = depositInfo.reference; + closing.depositDate = depositInfo.date; + + const saved = await this.repository.save(closing); + return { success: true, data: saved }; + } + + /** + * Find closings with filters + */ + async findClosings( + tenantId: string, + options: ClosingQueryOptions + ): Promise<{ data: CashClosing[]; total: number }> { + const qb = this.repository.createQueryBuilder('closing') + .where('closing.tenantId = :tenantId', { tenantId }); + + if (options.branchId) { + qb.andWhere('closing.branchId = :branchId', { branchId: options.branchId }); + } + if (options.sessionId) { + qb.andWhere('closing.sessionId = :sessionId', { sessionId: options.sessionId }); + } + if (options.status) { + qb.andWhere('closing.status = :status', { status: options.status }); + } + if (options.type) { + qb.andWhere('closing.type = :type', { type: options.type }); + } + if (options.startDate && options.endDate) { + qb.andWhere('closing.closingDate BETWEEN :startDate AND :endDate', { + startDate: options.startDate, + endDate: options.endDate, + }); + } + + const page = options.page ?? 1; + const limit = options.limit ?? 20; + qb.skip((page - 1) * limit).take(limit); + qb.orderBy('closing.closingDate', 'DESC'); + + const [data, total] = await qb.getManyAndCount(); + return { data, total }; + } + + /** + * Get closing with cash counts + */ + async getClosingWithCounts( + tenantId: string, + closingId: string + ): Promise { + return this.repository.findOne({ + where: { id: closingId, tenantId }, + relations: ['cashCounts'], + }); + } + + /** + * Get daily summary for branch + */ + async getDailySummary( + tenantId: string, + branchId: string, + date: Date + ): Promise<{ + closings: CashClosing[]; + totalSales: number; + totalRefunds: number; + netSales: number; + totalCashIn: number; + totalCashOut: number; + expectedDeposit: number; + }> { + const startOfDay = new Date(date); + startOfDay.setHours(0, 0, 0, 0); + const endOfDay = new Date(date); + endOfDay.setHours(23, 59, 59, 999); + + const closings = await this.repository.find({ + where: { + tenantId, + branchId, + closingDate: Between(startOfDay, endOfDay), + status: ClosingStatus.APPROVED, + }, + }); + + const summary = closings.reduce( + (acc, c) => ({ + totalSales: acc.totalSales + Number(c.totalSales), + totalRefunds: acc.totalRefunds + Number(c.totalRefunds), + netSales: acc.netSales + Number(c.netSales), + totalCashIn: acc.totalCashIn + Number(c.cashInTotal), + totalCashOut: acc.totalCashOut + Number(c.cashOutTotal), + }), + { totalSales: 0, totalRefunds: 0, netSales: 0, totalCashIn: 0, totalCashOut: 0 } + ); + + return { + closings, + ...summary, + expectedDeposit: summary.totalCashIn - summary.totalCashOut, + }; + } +} diff --git a/backend/src/modules/cash/services/cash-movement.service.ts b/backend/src/modules/cash/services/cash-movement.service.ts new file mode 100644 index 0000000..c1ec2dd --- /dev/null +++ b/backend/src/modules/cash/services/cash-movement.service.ts @@ -0,0 +1,398 @@ +import { Repository, DataSource, Between, In } from 'typeorm'; +import { BaseService } from '../../../shared/services/base.service'; +import { ServiceResult, QueryOptions } from '../../../shared/types'; +import { + CashMovement, + MovementType, + MovementReason, + MovementStatus, +} from '../entities/cash-movement.entity'; + +export interface CreateMovementInput { + branchId: string; + registerId: string; + sessionId: string; + type: MovementType; + reason?: MovementReason; + amount: number; + description: string; + referenceType?: string; + referenceId?: string; + referenceNumber?: string; + requiresApproval?: boolean; + recipientName?: string; + recipientId?: string; + accountId?: string; + bankAccount?: string; + depositSlipNumber?: string; + notes?: string; + metadata?: Record; +} + +export interface MovementQueryOptions extends QueryOptions { + branchId?: string; + sessionId?: string; + registerId?: string; + type?: MovementType; + reason?: MovementReason; + status?: MovementStatus; + startDate?: Date; + endDate?: Date; +} + +export class CashMovementService extends BaseService { + constructor( + repository: Repository, + private readonly dataSource: DataSource + ) { + super(repository); + } + + /** + * Generate a unique movement number + */ + private async generateMovementNumber(tenantId: string, branchId: string): Promise { + const today = new Date(); + const datePrefix = today.toISOString().slice(0, 10).replace(/-/g, ''); + + const count = await this.repository.count({ + where: { + tenantId, + branchId, + createdAt: Between( + new Date(today.setHours(0, 0, 0, 0)), + new Date(today.setHours(23, 59, 59, 999)) + ), + }, + }); + + return `MOV-${datePrefix}-${String(count + 1).padStart(4, '0')}`; + } + + /** + * Calculate running balance after movement + */ + private async calculateBalance( + tenantId: string, + sessionId: string, + amount: number, + type: MovementType + ): Promise { + const lastMovement = await this.repository.findOne({ + where: { tenantId, sessionId }, + order: { createdAt: 'DESC' }, + }); + + const previousBalance = lastMovement?.balanceAfter ?? 0; + const isInflow = [MovementType.CASH_IN, MovementType.OPENING].includes(type); + + return isInflow ? previousBalance + amount : previousBalance - amount; + } + + /** + * Create a cash movement + */ + async createMovement( + tenantId: string, + input: CreateMovementInput, + userId: string + ): Promise> { + const queryRunner = this.dataSource.createQueryRunner(); + await queryRunner.connect(); + await queryRunner.startTransaction(); + + try { + const number = await this.generateMovementNumber(tenantId, input.branchId); + + // Determine if approval is required based on amount thresholds + const approvalThreshold = 5000; // Could be configurable per branch + const requiresApproval = input.requiresApproval ?? + (input.type === MovementType.CASH_OUT && input.amount >= approvalThreshold); + + const balanceAfter = await this.calculateBalance( + tenantId, + input.sessionId, + input.amount, + input.type + ); + + const movement = queryRunner.manager.create(CashMovement, { + tenantId, + branchId: input.branchId, + registerId: input.registerId, + sessionId: input.sessionId, + number, + type: input.type, + reason: input.reason ?? MovementReason.OTHER, + status: requiresApproval ? MovementStatus.PENDING : MovementStatus.APPROVED, + amount: Math.abs(input.amount), + balanceAfter, + description: input.description, + referenceType: input.referenceType, + referenceId: input.referenceId, + referenceNumber: input.referenceNumber, + requiresApproval, + recipientName: input.recipientName, + recipientId: input.recipientId, + accountId: input.accountId, + bankAccount: input.bankAccount, + depositSlipNumber: input.depositSlipNumber, + createdBy: userId, + notes: input.notes, + metadata: input.metadata, + }); + + const savedMovement = await queryRunner.manager.save(movement); + await queryRunner.commitTransaction(); + + return { success: true, data: savedMovement }; + } catch (error) { + await queryRunner.rollbackTransaction(); + return { + success: false, + error: { + code: 'CREATE_MOVEMENT_ERROR', + message: error instanceof Error ? error.message : 'Failed to create movement', + }, + }; + } finally { + await queryRunner.release(); + } + } + + /** + * Find movements with extended filters + */ + async findMovements( + tenantId: string, + options: MovementQueryOptions + ): Promise<{ data: CashMovement[]; total: number }> { + const qb = this.repository.createQueryBuilder('movement') + .where('movement.tenantId = :tenantId', { tenantId }); + + if (options.branchId) { + qb.andWhere('movement.branchId = :branchId', { branchId: options.branchId }); + } + if (options.sessionId) { + qb.andWhere('movement.sessionId = :sessionId', { sessionId: options.sessionId }); + } + if (options.registerId) { + qb.andWhere('movement.registerId = :registerId', { registerId: options.registerId }); + } + if (options.type) { + qb.andWhere('movement.type = :type', { type: options.type }); + } + if (options.reason) { + qb.andWhere('movement.reason = :reason', { reason: options.reason }); + } + if (options.status) { + qb.andWhere('movement.status = :status', { status: options.status }); + } + if (options.startDate && options.endDate) { + qb.andWhere('movement.createdAt BETWEEN :startDate AND :endDate', { + startDate: options.startDate, + endDate: options.endDate, + }); + } + + const page = options.page ?? 1; + const limit = options.limit ?? 20; + qb.skip((page - 1) * limit).take(limit); + qb.orderBy('movement.createdAt', 'DESC'); + + const [data, total] = await qb.getManyAndCount(); + return { data, total }; + } + + /** + * Approve a pending movement + */ + async approveMovement( + tenantId: string, + id: string, + approverId: string + ): Promise> { + const movement = await this.repository.findOne({ + where: { id, tenantId }, + }); + + if (!movement) { + return { + success: false, + error: { code: 'NOT_FOUND', message: 'Movement not found' }, + }; + } + + if (movement.status !== MovementStatus.PENDING) { + return { + success: false, + error: { code: 'INVALID_STATUS', message: 'Movement is not pending approval' }, + }; + } + + movement.status = MovementStatus.APPROVED; + movement.approvedBy = approverId; + movement.approvedAt = new Date(); + + const saved = await this.repository.save(movement); + return { success: true, data: saved }; + } + + /** + * Reject a pending movement + */ + async rejectMovement( + tenantId: string, + id: string, + approverId: string, + reason: string + ): Promise> { + const movement = await this.repository.findOne({ + where: { id, tenantId }, + }); + + if (!movement) { + return { + success: false, + error: { code: 'NOT_FOUND', message: 'Movement not found' }, + }; + } + + if (movement.status !== MovementStatus.PENDING) { + return { + success: false, + error: { code: 'INVALID_STATUS', message: 'Movement is not pending approval' }, + }; + } + + movement.status = MovementStatus.REJECTED; + movement.approvedBy = approverId; + movement.approvedAt = new Date(); + movement.rejectionReason = reason; + + const saved = await this.repository.save(movement); + return { success: true, data: saved }; + } + + /** + * Cancel a movement + */ + async cancelMovement( + tenantId: string, + id: string, + userId: string, + reason: string + ): Promise> { + const movement = await this.repository.findOne({ + where: { id, tenantId }, + }); + + if (!movement) { + return { + success: false, + error: { code: 'NOT_FOUND', message: 'Movement not found' }, + }; + } + + if (movement.status === MovementStatus.CANCELLED) { + return { + success: false, + error: { code: 'ALREADY_CANCELLED', message: 'Movement is already cancelled' }, + }; + } + + movement.status = MovementStatus.CANCELLED; + movement.notes = `${movement.notes ?? ''}\nCancelled by ${userId}: ${reason}`.trim(); + + const saved = await this.repository.save(movement); + return { success: true, data: saved }; + } + + /** + * Get session cash balance + */ + async getSessionBalance(tenantId: string, sessionId: string): Promise { + const lastMovement = await this.repository.findOne({ + where: { tenantId, sessionId, status: MovementStatus.APPROVED }, + order: { createdAt: 'DESC' }, + }); + + return lastMovement?.balanceAfter ?? 0; + } + + /** + * Get session movements summary + */ + async getSessionSummary( + tenantId: string, + sessionId: string + ): Promise<{ + totalIn: number; + totalOut: number; + balance: number; + byType: Record; + byReason: Record; + pendingCount: number; + }> { + const movements = await this.repository.find({ + where: { tenantId, sessionId, status: MovementStatus.APPROVED }, + }); + + const byType: Record = {}; + const byReason: Record = {}; + let totalIn = 0; + let totalOut = 0; + + for (const m of movements) { + byType[m.type] = (byType[m.type] ?? 0) + Number(m.amount); + byReason[m.reason] = (byReason[m.reason] ?? 0) + Number(m.amount); + + if ([MovementType.CASH_IN, MovementType.OPENING].includes(m.type)) { + totalIn += Number(m.amount); + } else { + totalOut += Number(m.amount); + } + } + + const pendingCount = await this.repository.count({ + where: { tenantId, sessionId, status: MovementStatus.PENDING }, + }); + + return { + totalIn, + totalOut, + balance: totalIn - totalOut, + byType: byType as Record, + byReason: byReason as Record, + pendingCount, + }; + } + + /** + * Get movements by date range for reports + */ + async getMovementsForReport( + tenantId: string, + branchId: string, + startDate: Date, + endDate: Date, + types?: MovementType[] + ): Promise { + const qb = this.repository.createQueryBuilder('movement') + .where('movement.tenantId = :tenantId', { tenantId }) + .andWhere('movement.branchId = :branchId', { branchId }) + .andWhere('movement.status = :status', { status: MovementStatus.APPROVED }) + .andWhere('movement.createdAt BETWEEN :startDate AND :endDate', { + startDate, + endDate, + }); + + if (types && types.length > 0) { + qb.andWhere('movement.type IN (:...types)', { types }); + } + + qb.orderBy('movement.createdAt', 'ASC'); + + return qb.getMany(); + } +} diff --git a/backend/src/modules/cash/services/index.ts b/backend/src/modules/cash/services/index.ts new file mode 100644 index 0000000..4f1220e --- /dev/null +++ b/backend/src/modules/cash/services/index.ts @@ -0,0 +1,2 @@ +export * from './cash-movement.service'; +export * from './cash-closing.service'; diff --git a/backend/src/modules/cash/validation/cash.schema.ts b/backend/src/modules/cash/validation/cash.schema.ts new file mode 100644 index 0000000..e702154 --- /dev/null +++ b/backend/src/modules/cash/validation/cash.schema.ts @@ -0,0 +1,160 @@ +import { z } from 'zod'; +import { + uuidSchema, + moneySchema, + paginationSchema, +} from '../../../shared/validation/common.schema'; + +// Enums +export const movementTypeEnum = z.enum([ + 'cash_in', + 'cash_out', + 'deposit', + 'withdrawal', + 'adjustment', + 'opening', + 'closing', +]); + +export const movementReasonEnum = z.enum([ + 'change_fund', + 'petty_cash', + 'bank_deposit', + 'supplier_payment', + 'expense', + 'salary_advance', + 'correction', + 'other', +]); + +export const movementStatusEnum = z.enum([ + 'pending', + 'approved', + 'rejected', + 'cancelled', +]); + +export const closingTypeEnum = z.enum(['shift', 'daily', 'weekly', 'monthly']); +export const closingStatusEnum = z.enum([ + 'in_progress', + 'pending_review', + 'approved', + 'rejected', + 'reconciled', +]); + +export const denominationTypeEnum = z.enum(['bill', 'coin']); + +// ==================== MOVEMENT SCHEMAS ==================== + +// Create movement schema +export const createMovementSchema = z.object({ + branchId: uuidSchema, + registerId: uuidSchema, + sessionId: uuidSchema, + type: movementTypeEnum, + reason: movementReasonEnum.optional(), + amount: moneySchema.positive('Amount must be positive'), + description: z.string().min(1, 'Description is required').max(500), + referenceType: z.string().max(50).optional(), + referenceId: uuidSchema.optional(), + referenceNumber: z.string().max(50).optional(), + requiresApproval: z.boolean().optional(), + recipientName: z.string().max(200).optional(), + recipientId: uuidSchema.optional(), + accountId: uuidSchema.optional(), + bankAccount: z.string().max(50).optional(), + depositSlipNumber: z.string().max(50).optional(), + notes: z.string().max(1000).optional(), + metadata: z.record(z.any()).optional(), +}); + +// Approve/reject movement schema +export const movementActionSchema = z.object({ + reason: z.string().max(255).optional(), +}); + +// List movements query schema +export const listMovementsQuerySchema = paginationSchema.extend({ + branchId: uuidSchema.optional(), + sessionId: uuidSchema.optional(), + registerId: uuidSchema.optional(), + type: movementTypeEnum.optional(), + reason: movementReasonEnum.optional(), + status: movementStatusEnum.optional(), + startDate: z.coerce.date().optional(), + endDate: z.coerce.date().optional(), +}); + +// ==================== CLOSING SCHEMAS ==================== + +// Create closing schema +export const createClosingSchema = z.object({ + branchId: uuidSchema, + registerId: uuidSchema.optional(), + sessionId: uuidSchema.optional(), + type: closingTypeEnum, + periodStart: z.coerce.date(), + periodEnd: z.coerce.date(), + openingBalance: moneySchema.default(0), + closingNotes: z.string().max(1000).optional(), +}).refine(data => data.periodStart <= data.periodEnd, { + message: 'Period start must be before or equal to period end', + path: ['periodEnd'], +}); + +// Denomination count schema +export const denominationCountSchema = z.object({ + type: denominationTypeEnum, + denomination: z.number().positive(), + quantity: z.number().int().min(0), +}); + +// Submit cash count schema +export const submitCashCountSchema = z.object({ + denominations: z.array(denominationCountSchema).min(1, 'At least one denomination is required'), +}); + +// Submit payment counts schema +export const submitPaymentCountsSchema = z.object({ + cash: moneySchema.optional(), + card: moneySchema.default(0), + transfer: moneySchema.default(0), + other: moneySchema.default(0), +}); + +// Closing action schema (approve/reject) +export const closingActionSchema = z.object({ + notes: z.string().max(1000).optional(), +}); + +// Reject closing schema (requires notes) +export const rejectClosingSchema = z.object({ + notes: z.string().min(1, 'Rejection reason is required').max(1000), +}); + +// Reconcile closing schema +export const reconcileClosingSchema = z.object({ + depositAmount: moneySchema.positive('Deposit amount must be positive'), + depositReference: z.string().min(1).max(100), + depositDate: z.coerce.date(), +}); + +// List closings query schema +export const listClosingsQuerySchema = paginationSchema.extend({ + branchId: uuidSchema.optional(), + sessionId: uuidSchema.optional(), + status: closingStatusEnum.optional(), + type: closingTypeEnum.optional(), + startDate: z.coerce.date().optional(), + endDate: z.coerce.date().optional(), +}); + +// Types +export type CreateMovementInput = z.infer; +export type ListMovementsQuery = z.infer; +export type CreateClosingInput = z.infer; +export type SubmitCashCountInput = z.infer; +export type SubmitPaymentCountsInput = z.infer; +export type ReconcileClosingInput = z.infer; +export type ListClosingsQuery = z.infer; diff --git a/backend/src/modules/customers/controllers/loyalty.controller.ts b/backend/src/modules/customers/controllers/loyalty.controller.ts new file mode 100644 index 0000000..409b078 --- /dev/null +++ b/backend/src/modules/customers/controllers/loyalty.controller.ts @@ -0,0 +1,222 @@ +import { Request, Response } from 'express'; +import { BaseController } from '../../../shared/controllers/base.controller'; +import { LoyaltyService, MembershipQueryOptions } from '../services/loyalty.service'; + +export class LoyaltyController extends BaseController { + constructor(private readonly loyaltyService: LoyaltyService) { + super(); + } + + // ==================== PROGRAM ENDPOINTS ==================== + + /** + * Get active loyalty program + * GET /loyalty/program + */ + getActiveProgram = async (req: Request, res: Response): Promise => { + const tenantId = req.tenantContext!.tenantId; + + const program = await this.loyaltyService.getActiveProgram(tenantId); + + if (!program) { + this.notFound(res, 'Active loyalty program'); + return; + } + + this.success(res, program); + }; + + // ==================== MEMBERSHIP ENDPOINTS ==================== + + /** + * Enroll customer in loyalty program + * POST /loyalty/enroll + */ + enrollCustomer = async (req: Request, res: Response): Promise => { + const tenantId = req.tenantContext!.tenantId; + const userId = req.userContext!.userId; + + const result = await this.loyaltyService.enrollCustomer(tenantId, req.body, userId); + + if (!result.success) { + this.error(res, result.error!.message, 400, result.error!.code); + return; + } + + this.created(res, result.data); + }; + + /** + * List memberships + * GET /loyalty/memberships + */ + listMemberships = async (req: Request, res: Response): Promise => { + const tenantId = req.tenantContext!.tenantId; + const options: MembershipQueryOptions = { + page: Number(req.query.page) || 1, + limit: Number(req.query.limit) || 20, + programId: req.query.programId as string, + levelId: req.query.levelId as string, + status: req.query.status as any, + customerId: req.query.customerId as string, + }; + + const result = await this.loyaltyService.findMemberships(tenantId, options); + this.paginated(res, result.data, result.total, options.page!, options.limit!); + }; + + /** + * Get membership by customer ID + * GET /loyalty/memberships/customer/:customerId + */ + getMembershipByCustomer = async (req: Request, res: Response): Promise => { + const tenantId = req.tenantContext!.tenantId; + const { customerId } = req.params; + const programId = req.query.programId as string; + + const membership = await this.loyaltyService.getMembershipByCustomer( + tenantId, + customerId, + programId + ); + + if (!membership) { + this.notFound(res, 'Membership'); + return; + } + + this.success(res, membership); + }; + + /** + * Get membership by card number + * GET /loyalty/memberships/card/:cardNumber + */ + getMembershipByCard = async (req: Request, res: Response): Promise => { + const tenantId = req.tenantContext!.tenantId; + const { cardNumber } = req.params; + + const membership = await this.loyaltyService.getMembershipByCard(tenantId, cardNumber); + + if (!membership) { + this.notFound(res, 'Membership'); + return; + } + + this.success(res, membership); + }; + + // ==================== POINTS ENDPOINTS ==================== + + /** + * Calculate points preview + * POST /loyalty/points/calculate + */ + calculatePoints = async (req: Request, res: Response): Promise => { + const tenantId = req.tenantContext!.tenantId; + const { membershipId, purchaseAmount, categoryMultipliers } = req.body; + + const membership = await this.loyaltyService.getMembershipByCustomer(tenantId, membershipId); + if (!membership) { + this.error(res, 'Membership not found', 404); + return; + } + + const result = await this.loyaltyService.calculatePoints( + tenantId, + membership.programId, + membershipId, + purchaseAmount, + categoryMultipliers + ); + + this.success(res, result); + }; + + /** + * Earn points + * POST /loyalty/points/earn + */ + earnPoints = async (req: Request, res: Response): Promise => { + const tenantId = req.tenantContext!.tenantId; + const userId = req.userContext!.userId; + + const result = await this.loyaltyService.earnPoints(tenantId, req.body, userId); + + if (!result.success) { + this.error(res, result.error!.message, 400, result.error!.code); + return; + } + + this.success(res, result.data, 'Points earned successfully'); + }; + + /** + * Redeem points + * POST /loyalty/points/redeem + */ + redeemPoints = async (req: Request, res: Response): Promise => { + const tenantId = req.tenantContext!.tenantId; + const userId = req.userContext!.userId; + + const result = await this.loyaltyService.redeemPoints(tenantId, req.body, userId); + + if (!result.success) { + this.error(res, result.error!.message, 400, result.error!.code); + return; + } + + this.success(res, result.data, 'Points redeemed successfully'); + }; + + /** + * Adjust points + * POST /loyalty/points/adjust + */ + adjustPoints = async (req: Request, res: Response): Promise => { + const tenantId = req.tenantContext!.tenantId; + const userId = req.userContext!.userId; + + const result = await this.loyaltyService.adjustPoints(tenantId, req.body, userId); + + if (!result.success) { + this.error(res, result.error!.message, 400, result.error!.code); + return; + } + + this.success(res, result.data, 'Points adjusted'); + }; + + /** + * Get expiring points + * GET /loyalty/memberships/:membershipId/expiring + */ + getExpiringPoints = async (req: Request, res: Response): Promise => { + const tenantId = req.tenantContext!.tenantId; + const { membershipId } = req.params; + const daysAhead = Number(req.query.days) || 30; + + const result = await this.loyaltyService.getExpiringPoints(tenantId, membershipId, daysAhead); + this.success(res, result); + }; + + // ==================== TRANSACTION ENDPOINTS ==================== + + /** + * Get transaction history + * GET /loyalty/memberships/:membershipId/transactions + */ + getTransactionHistory = async (req: Request, res: Response): Promise => { + const tenantId = req.tenantContext!.tenantId; + const { membershipId } = req.params; + + const options = { + page: Number(req.query.page) || 1, + limit: Number(req.query.limit) || 20, + type: req.query.type as any, + }; + + const result = await this.loyaltyService.getTransactionHistory(tenantId, membershipId, options); + this.paginated(res, result.data, result.total, options.page, options.limit); + }; +} diff --git a/backend/src/modules/customers/entities/customer-membership.entity.ts b/backend/src/modules/customers/entities/customer-membership.entity.ts new file mode 100644 index 0000000..75ee66a --- /dev/null +++ b/backend/src/modules/customers/entities/customer-membership.entity.ts @@ -0,0 +1,161 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + Index, +} from 'typeorm'; + +export enum MembershipStatus { + ACTIVE = 'active', + INACTIVE = 'inactive', + SUSPENDED = 'suspended', + EXPIRED = 'expired', +} + +@Entity('customer_memberships', { schema: 'retail' }) +@Index(['tenantId', 'customerId', 'programId'], { unique: true }) +@Index(['tenantId', 'cardNumber'], { unique: true }) +@Index(['tenantId', 'status']) +@Index(['tenantId', 'levelId']) +export class CustomerMembership { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'tenant_id', type: 'uuid' }) + @Index() + tenantId: string; + + // Customer (from erp-core partners) + @Column({ name: 'customer_id', type: 'uuid' }) + customerId: string; + + @Column({ name: 'program_id', type: 'uuid' }) + programId: string; + + @Column({ name: 'level_id', type: 'uuid' }) + levelId: string; + + @Column({ name: 'card_number', length: 20, unique: true }) + cardNumber: string; + + @Column({ + type: 'enum', + enum: MembershipStatus, + default: MembershipStatus.ACTIVE, + }) + status: MembershipStatus; + + // Points + @Column({ name: 'points_balance', type: 'int', default: 0 }) + pointsBalance: number; + + @Column({ name: 'lifetime_points', type: 'int', default: 0 }) + lifetimePoints: number; + + @Column({ name: 'points_redeemed', type: 'int', default: 0 }) + pointsRedeemed: number; + + @Column({ name: 'points_expired', type: 'int', default: 0 }) + pointsExpired: number; + + @Column({ name: 'points_expiring_soon', type: 'int', default: 0 }) + pointsExpiringSoon: number; + + @Column({ name: 'next_expiration_date', type: 'date', nullable: true }) + nextExpirationDate: Date; + + @Column({ name: 'next_expiration_points', type: 'int', default: 0 }) + nextExpirationPoints: number; + + // Purchase stats + @Column({ name: 'total_purchases', type: 'int', default: 0 }) + totalPurchases: number; + + @Column({ name: 'total_spend', type: 'decimal', precision: 15, scale: 2, default: 0 }) + totalSpend: number; + + @Column({ name: 'average_order_value', type: 'decimal', precision: 15, scale: 2, default: 0 }) + averageOrderValue: number; + + // Period stats (for level qualification) + @Column({ name: 'period_purchases', type: 'int', default: 0 }) + periodPurchases: number; + + @Column({ name: 'period_spend', type: 'decimal', precision: 15, scale: 2, default: 0 }) + periodSpend: number; + + @Column({ name: 'period_points', type: 'int', default: 0 }) + periodPoints: number; + + @Column({ name: 'period_start_date', type: 'date', nullable: true }) + periodStartDate: Date; + + @Column({ name: 'period_end_date', type: 'date', nullable: true }) + periodEndDate: Date; + + // Level history + @Column({ name: 'level_achieved_at', type: 'timestamp with time zone', nullable: true }) + levelAchievedAt: Date; + + @Column({ name: 'level_expires_at', type: 'date', nullable: true }) + levelExpiresAt: Date; + + @Column({ name: 'previous_level_id', type: 'uuid', nullable: true }) + previousLevelId: string; + + // Dates + @Column({ name: 'enrolled_at', type: 'timestamp with time zone' }) + enrolledAt: Date; + + @Column({ name: 'last_activity_at', type: 'timestamp with time zone', nullable: true }) + lastActivityAt: Date; + + @Column({ name: 'last_purchase_at', type: 'timestamp with time zone', nullable: true }) + lastPurchaseAt: Date; + + @Column({ name: 'last_redemption_at', type: 'timestamp with time zone', nullable: true }) + lastRedemptionAt: Date; + + // Referral + @Column({ name: 'referral_code', length: 20, nullable: true }) + referralCode: string; + + @Column({ name: 'referred_by_id', type: 'uuid', nullable: true }) + referredById: string; + + @Column({ name: 'referrals_count', type: 'int', default: 0 }) + referralsCount: number; + + // Communication preferences + @Column({ name: 'email_notifications', type: 'boolean', default: true }) + emailNotifications: boolean; + + @Column({ name: 'sms_notifications', type: 'boolean', default: false }) + smsNotifications: boolean; + + @Column({ name: 'push_notifications', type: 'boolean', default: true }) + pushNotifications: boolean; + + // Favorite branch + @Column({ name: 'favorite_branch_id', type: 'uuid', nullable: true }) + favoriteBranchId: string; + + // Notes + @Column({ type: 'text', nullable: true }) + notes: string; + + // Metadata + @Column({ type: 'jsonb', nullable: true }) + metadata: Record; + + @CreateDateColumn({ name: 'created_at' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at' }) + updatedAt: Date; + + @Column({ name: 'created_by', type: 'uuid', nullable: true }) + createdBy: string; +} diff --git a/backend/src/modules/customers/entities/index.ts b/backend/src/modules/customers/entities/index.ts new file mode 100644 index 0000000..f0b523e --- /dev/null +++ b/backend/src/modules/customers/entities/index.ts @@ -0,0 +1,4 @@ +export * from './loyalty-program.entity'; +export * from './membership-level.entity'; +export * from './loyalty-transaction.entity'; +export * from './customer-membership.entity'; diff --git a/backend/src/modules/customers/entities/loyalty-program.entity.ts b/backend/src/modules/customers/entities/loyalty-program.entity.ts new file mode 100644 index 0000000..d783728 --- /dev/null +++ b/backend/src/modules/customers/entities/loyalty-program.entity.ts @@ -0,0 +1,161 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + OneToMany, + Index, +} from 'typeorm'; +import { MembershipLevel } from './membership-level.entity'; + +export enum ProgramStatus { + ACTIVE = 'active', + INACTIVE = 'inactive', + SUSPENDED = 'suspended', +} + +export enum PointsCalculation { + FIXED_PER_PURCHASE = 'fixed_per_purchase', + PERCENTAGE_OF_AMOUNT = 'percentage_of_amount', + POINTS_PER_CURRENCY = 'points_per_currency', +} + +@Entity('loyalty_programs', { schema: 'retail' }) +@Index(['tenantId', 'status']) +@Index(['tenantId', 'code'], { unique: true }) +export class LoyaltyProgram { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'tenant_id', type: 'uuid' }) + @Index() + tenantId: string; + + @Column({ length: 20, unique: true }) + code: string; + + @Column({ length: 100 }) + name: string; + + @Column({ type: 'text', nullable: true }) + description: string; + + @Column({ + type: 'enum', + enum: ProgramStatus, + default: ProgramStatus.ACTIVE, + }) + status: ProgramStatus; + + // Points configuration + @Column({ + name: 'points_calculation', + type: 'enum', + enum: PointsCalculation, + default: PointsCalculation.POINTS_PER_CURRENCY, + }) + pointsCalculation: PointsCalculation; + + @Column({ name: 'points_per_currency', type: 'decimal', precision: 10, scale: 4, default: 1 }) + pointsPerCurrency: number; // e.g., 1 point per $10 MXN + + @Column({ name: 'currency_per_point', type: 'decimal', precision: 10, scale: 2, default: 10 }) + currencyPerPoint: number; // e.g., $10 MXN = 1 point + + @Column({ name: 'minimum_purchase', type: 'decimal', precision: 15, scale: 2, default: 0 }) + minimumPurchase: number; + + @Column({ name: 'round_points', type: 'boolean', default: true }) + roundPoints: boolean; + + @Column({ name: 'round_direction', length: 10, default: 'down' }) + roundDirection: 'up' | 'down' | 'nearest'; + + // Redemption configuration + @Column({ name: 'points_value', type: 'decimal', precision: 10, scale: 4, default: 0.1 }) + pointsValue: number; // Value of 1 point in currency (e.g., 1 point = $0.10) + + @Column({ name: 'min_points_redemption', type: 'int', default: 100 }) + minPointsRedemption: number; + + @Column({ name: 'max_redemption_percent', type: 'decimal', precision: 5, scale: 2, default: 100 }) + maxRedemptionPercent: number; // Max % of purchase that can be paid with points + + @Column({ name: 'allow_partial_redemption', type: 'boolean', default: true }) + allowPartialRedemption: boolean; + + // Expiration + @Column({ name: 'points_expire', type: 'boolean', default: true }) + pointsExpire: boolean; + + @Column({ name: 'expiration_months', type: 'int', default: 12 }) + expirationMonths: number; + + @Column({ name: 'expiration_policy', length: 20, default: 'fifo' }) + expirationPolicy: 'fifo' | 'lifo' | 'oldest_first'; + + // Bonus configuration + @Column({ name: 'welcome_bonus', type: 'int', default: 0 }) + welcomeBonus: number; + + @Column({ name: 'birthday_bonus', type: 'int', default: 0 }) + birthdayBonus: number; + + @Column({ name: 'referral_bonus', type: 'int', default: 0 }) + referralBonus: number; + + @Column({ name: 'referee_bonus', type: 'int', default: 0 }) + refereeBonus: number; + + // Multipliers + @Column({ name: 'double_points_days', type: 'jsonb', nullable: true }) + doublePointsDays: string[]; // ['monday', 'tuesday', etc.] + + @Column({ name: 'category_multipliers', type: 'jsonb', nullable: true }) + categoryMultipliers: { + categoryId: string; + multiplier: number; + }[]; + + // Restrictions + @Column({ name: 'excluded_categories', type: 'jsonb', nullable: true }) + excludedCategories: string[]; + + @Column({ name: 'excluded_products', type: 'jsonb', nullable: true }) + excludedProducts: string[]; + + @Column({ name: 'included_branches', type: 'jsonb', nullable: true }) + includedBranches: string[]; // null = all branches + + // Valid dates + @Column({ name: 'valid_from', type: 'date', nullable: true }) + validFrom: Date; + + @Column({ name: 'valid_until', type: 'date', nullable: true }) + validUntil: Date; + + // Terms + @Column({ name: 'terms_and_conditions', type: 'text', nullable: true }) + termsAndConditions: string; + + // Metadata + @Column({ type: 'jsonb', nullable: true }) + metadata: Record; + + @CreateDateColumn({ name: 'created_at' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at' }) + updatedAt: Date; + + @Column({ name: 'created_by', type: 'uuid', nullable: true }) + createdBy: string; + + @Column({ name: 'updated_by', type: 'uuid', nullable: true }) + updatedBy: string; + + // Relations + @OneToMany(() => MembershipLevel, (level) => level.program) + levels: MembershipLevel[]; +} diff --git a/backend/src/modules/customers/entities/loyalty-transaction.entity.ts b/backend/src/modules/customers/entities/loyalty-transaction.entity.ts new file mode 100644 index 0000000..93caf3d --- /dev/null +++ b/backend/src/modules/customers/entities/loyalty-transaction.entity.ts @@ -0,0 +1,144 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + Index, +} from 'typeorm'; + +export enum TransactionType { + EARN = 'earn', + REDEEM = 'redeem', + EXPIRE = 'expire', + ADJUST = 'adjust', + BONUS = 'bonus', + TRANSFER_IN = 'transfer_in', + TRANSFER_OUT = 'transfer_out', + REFUND = 'refund', +} + +export enum TransactionStatus { + PENDING = 'pending', + COMPLETED = 'completed', + CANCELLED = 'cancelled', + REVERSED = 'reversed', +} + +@Entity('loyalty_transactions', { schema: 'retail' }) +@Index(['tenantId', 'customerId', 'createdAt']) +@Index(['tenantId', 'programId', 'type']) +@Index(['tenantId', 'membershipId']) +@Index(['tenantId', 'orderId']) +export class LoyaltyTransaction { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'tenant_id', type: 'uuid' }) + @Index() + tenantId: string; + + @Column({ name: 'program_id', type: 'uuid' }) + programId: string; + + @Column({ name: 'membership_id', type: 'uuid' }) + membershipId: string; + + @Column({ name: 'customer_id', type: 'uuid' }) + customerId: string; + + @Column({ length: 30, unique: true }) + number: string; + + @Column({ + type: 'enum', + enum: TransactionType, + }) + type: TransactionType; + + @Column({ + type: 'enum', + enum: TransactionStatus, + default: TransactionStatus.COMPLETED, + }) + status: TransactionStatus; + + // Points + @Column({ type: 'int' }) + points: number; // positive for earn, negative for redeem + + @Column({ name: 'points_balance_after', type: 'int' }) + pointsBalanceAfter: number; + + // Value + @Column({ name: 'monetary_value', type: 'decimal', precision: 15, scale: 2, nullable: true }) + monetaryValue: number; + + @Column({ name: 'purchase_amount', type: 'decimal', precision: 15, scale: 2, nullable: true }) + purchaseAmount: number; + + // Reference + @Column({ name: 'order_id', type: 'uuid', nullable: true }) + orderId: string; + + @Column({ name: 'order_number', length: 30, nullable: true }) + orderNumber: string; + + @Column({ name: 'branch_id', type: 'uuid', nullable: true }) + branchId: string; + + // Bonus type (for bonus transactions) + @Column({ name: 'bonus_type', length: 30, nullable: true }) + bonusType: 'welcome' | 'birthday' | 'referral' | 'referee' | 'campaign' | 'manual'; + + @Column({ name: 'campaign_id', type: 'uuid', nullable: true }) + campaignId: string; + + // Multipliers applied + @Column({ name: 'base_points', type: 'int', nullable: true }) + basePoints: number; + + @Column({ name: 'multiplier_applied', type: 'decimal', precision: 5, scale: 2, nullable: true }) + multiplierApplied: number; + + @Column({ name: 'multiplier_reason', length: 100, nullable: true }) + multiplierReason: string; + + // Expiration + @Column({ name: 'expires_at', type: 'date', nullable: true }) + expiresAt: Date; + + @Column({ name: 'expired', type: 'boolean', default: false }) + expired: boolean; + + // For transfers + @Column({ name: 'transfer_to_customer_id', type: 'uuid', nullable: true }) + transferToCustomerId: string; + + @Column({ name: 'transfer_from_customer_id', type: 'uuid', nullable: true }) + transferFromCustomerId: string; + + // Description + @Column({ type: 'text', nullable: true }) + description: string; + + // Reversal + @Column({ name: 'reversed_by_id', type: 'uuid', nullable: true }) + reversedById: string; + + @Column({ name: 'reversal_of_id', type: 'uuid', nullable: true }) + reversalOfId: string; + + @Column({ name: 'reversal_reason', length: 255, nullable: true }) + reversalReason: string; + + // User + @Column({ name: 'created_by', type: 'uuid', nullable: true }) + createdBy: string; + + // Metadata + @Column({ type: 'jsonb', nullable: true }) + metadata: Record; + + @CreateDateColumn({ name: 'created_at' }) + createdAt: Date; +} diff --git a/backend/src/modules/customers/entities/membership-level.entity.ts b/backend/src/modules/customers/entities/membership-level.entity.ts new file mode 100644 index 0000000..fb18d57 --- /dev/null +++ b/backend/src/modules/customers/entities/membership-level.entity.ts @@ -0,0 +1,135 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + ManyToOne, + JoinColumn, + Index, +} from 'typeorm'; +import { LoyaltyProgram } from './loyalty-program.entity'; + +export enum LevelStatus { + ACTIVE = 'active', + INACTIVE = 'inactive', +} + +@Entity('membership_levels', { schema: 'retail' }) +@Index(['tenantId', 'programId', 'level']) +@Index(['tenantId', 'code'], { unique: true }) +export class MembershipLevel { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'tenant_id', type: 'uuid' }) + @Index() + tenantId: string; + + @Column({ name: 'program_id', type: 'uuid' }) + programId: string; + + @Column({ length: 20, unique: true }) + code: string; + + @Column({ length: 50 }) + name: string; + + @Column({ type: 'text', nullable: true }) + description: string; + + @Column({ type: 'int', default: 0 }) + level: number; // 0 = base level, higher = better + + @Column({ + type: 'enum', + enum: LevelStatus, + default: LevelStatus.ACTIVE, + }) + status: LevelStatus; + + // Qualification criteria + @Column({ name: 'min_points_required', type: 'int', default: 0 }) + minPointsRequired: number; + + @Column({ name: 'min_purchases_required', type: 'int', default: 0 }) + minPurchasesRequired: number; + + @Column({ name: 'min_spend_required', type: 'decimal', precision: 15, scale: 2, default: 0 }) + minSpendRequired: number; + + @Column({ name: 'qualification_period_months', type: 'int', default: 12 }) + qualificationPeriodMonths: number; + + // Benefits + @Column({ name: 'points_multiplier', type: 'decimal', precision: 5, scale: 2, default: 1 }) + pointsMultiplier: number; + + @Column({ name: 'discount_percent', type: 'decimal', precision: 5, scale: 2, default: 0 }) + discountPercent: number; + + @Column({ name: 'free_shipping', type: 'boolean', default: false }) + freeShipping: boolean; + + @Column({ name: 'free_shipping_threshold', type: 'decimal', precision: 15, scale: 2, nullable: true }) + freeShippingThreshold: number; + + @Column({ name: 'priority_support', type: 'boolean', default: false }) + prioritySupport: boolean; + + @Column({ name: 'early_access', type: 'boolean', default: false }) + earlyAccess: boolean; + + @Column({ name: 'exclusive_promotions', type: 'boolean', default: false }) + exclusivePromotions: boolean; + + @Column({ name: 'birthday_multiplier', type: 'decimal', precision: 5, scale: 2, default: 1 }) + birthdayMultiplier: number; + + // Custom benefits + @Column({ name: 'custom_benefits', type: 'jsonb', nullable: true }) + customBenefits: { + name: string; + description: string; + value?: string; + }[]; + + // Display + @Column({ length: 7, default: '#000000' }) + color: string; + + @Column({ name: 'icon_url', length: 255, nullable: true }) + iconUrl: string; + + @Column({ name: 'badge_url', length: 255, nullable: true }) + badgeUrl: string; + + // Retention + @Column({ name: 'retention_months', type: 'int', default: 12 }) + retentionMonths: number; + + @Column({ name: 'downgrade_level_id', type: 'uuid', nullable: true }) + downgradeLevelId: string; + + // Auto-upgrade + @Column({ name: 'auto_upgrade', type: 'boolean', default: true }) + autoUpgrade: boolean; + + @Column({ name: 'upgrade_notification', type: 'boolean', default: true }) + upgradeNotification: boolean; + + // Metadata + @Column({ type: 'jsonb', nullable: true }) + metadata: Record; + + @CreateDateColumn({ name: 'created_at' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at' }) + updatedAt: Date; + + // Relations + @ManyToOne(() => LoyaltyProgram, (program) => program.levels) + @JoinColumn({ name: 'program_id' }) + program: LoyaltyProgram; +} diff --git a/backend/src/modules/customers/index.ts b/backend/src/modules/customers/index.ts new file mode 100644 index 0000000..1433b71 --- /dev/null +++ b/backend/src/modules/customers/index.ts @@ -0,0 +1,5 @@ +export * from './entities'; +export * from './services'; +export * from './controllers/loyalty.controller'; +export * from './routes/loyalty.routes'; +export * from './validation/customers.schema'; diff --git a/backend/src/modules/customers/routes/loyalty.routes.ts b/backend/src/modules/customers/routes/loyalty.routes.ts new file mode 100644 index 0000000..070dda8 --- /dev/null +++ b/backend/src/modules/customers/routes/loyalty.routes.ts @@ -0,0 +1,113 @@ +import { Router } from 'express'; +import { LoyaltyController } from '../controllers/loyalty.controller'; +import { requireAuth, requireRoles, requirePermissions } from '../../../shared/middleware/auth.middleware'; +import { validateBody, validateQuery } from '../../../shared/validation/validation.middleware'; +import { + enrollCustomerSchema, + listMembershipsQuerySchema, + earnPointsSchema, + redeemPointsSchema, + adjustPointsSchema, + calculatePointsSchema, + lookupByCardSchema, + listTransactionsQuerySchema, +} from '../validation/customers.schema'; + +export function createLoyaltyRoutes(controller: LoyaltyController): Router { + const router = Router(); + + // Apply auth middleware to all routes + router.use(requireAuth); + + // ==================== PROGRAM ROUTES ==================== + + // Get active program + router.get( + '/program', + requirePermissions(['loyalty.view']), + controller.getActiveProgram + ); + + // ==================== MEMBERSHIP ROUTES ==================== + + // Enroll customer + router.post( + '/enroll', + requirePermissions(['loyalty.enroll']), + validateBody(enrollCustomerSchema), + controller.enrollCustomer + ); + + // List memberships + router.get( + '/memberships', + requirePermissions(['loyalty.view']), + validateQuery(listMembershipsQuerySchema), + controller.listMemberships + ); + + // Get membership by customer + router.get( + '/memberships/customer/:customerId', + requirePermissions(['loyalty.view']), + controller.getMembershipByCustomer + ); + + // Get membership by card + router.get( + '/memberships/card/:cardNumber', + requirePermissions(['loyalty.view']), + controller.getMembershipByCard + ); + + // Get expiring points + router.get( + '/memberships/:membershipId/expiring', + requirePermissions(['loyalty.view']), + controller.getExpiringPoints + ); + + // Get transaction history + router.get( + '/memberships/:membershipId/transactions', + requirePermissions(['loyalty.view']), + validateQuery(listTransactionsQuerySchema), + controller.getTransactionHistory + ); + + // ==================== POINTS ROUTES ==================== + + // Calculate points preview + router.post( + '/points/calculate', + requirePermissions(['loyalty.view']), + validateBody(calculatePointsSchema), + controller.calculatePoints + ); + + // Earn points + router.post( + '/points/earn', + requirePermissions(['loyalty.earn']), + validateBody(earnPointsSchema), + controller.earnPoints + ); + + // Redeem points + router.post( + '/points/redeem', + requirePermissions(['loyalty.redeem']), + validateBody(redeemPointsSchema), + controller.redeemPoints + ); + + // Adjust points + router.post( + '/points/adjust', + requireRoles(['admin', 'manager']), + validateBody(adjustPointsSchema), + controller.adjustPoints + ); + + return router; +} diff --git a/backend/src/modules/customers/services/index.ts b/backend/src/modules/customers/services/index.ts new file mode 100644 index 0000000..14ac4dc --- /dev/null +++ b/backend/src/modules/customers/services/index.ts @@ -0,0 +1 @@ +export * from './loyalty.service'; diff --git a/backend/src/modules/customers/services/loyalty.service.ts b/backend/src/modules/customers/services/loyalty.service.ts new file mode 100644 index 0000000..4cbb3a5 --- /dev/null +++ b/backend/src/modules/customers/services/loyalty.service.ts @@ -0,0 +1,842 @@ +import { Repository, DataSource, Between, LessThanOrEqual, MoreThan } from 'typeorm'; +import { ServiceResult, QueryOptions } from '../../../shared/types'; +import { LoyaltyProgram, ProgramStatus, PointsCalculation } from '../entities/loyalty-program.entity'; +import { MembershipLevel, LevelStatus } from '../entities/membership-level.entity'; +import { CustomerMembership, MembershipStatus } from '../entities/customer-membership.entity'; +import { LoyaltyTransaction, TransactionType, TransactionStatus } from '../entities/loyalty-transaction.entity'; + +export interface EnrollCustomerInput { + customerId: string; + programId: string; + referredById?: string; + emailNotifications?: boolean; + smsNotifications?: boolean; +} + +export interface EarnPointsInput { + membershipId: string; + purchaseAmount: number; + orderId?: string; + orderNumber?: string; + branchId?: string; + categoryMultipliers?: { categoryId: string; amount: number }[]; +} + +export interface RedeemPointsInput { + membershipId: string; + points: number; + orderId?: string; + orderNumber?: string; + branchId?: string; + description?: string; +} + +export interface AdjustPointsInput { + membershipId: string; + points: number; + reason: string; +} + +export interface MembershipQueryOptions extends QueryOptions { + programId?: string; + levelId?: string; + status?: MembershipStatus; + customerId?: string; +} + +export class LoyaltyService { + constructor( + private readonly programRepository: Repository, + private readonly levelRepository: Repository, + private readonly membershipRepository: Repository, + private readonly transactionRepository: Repository, + private readonly dataSource: DataSource + ) {} + + /** + * Generate a unique card number + */ + private async generateCardNumber(tenantId: string): Promise { + const prefix = 'LYL'; + const random = Math.floor(Math.random() * 1000000000).toString().padStart(10, '0'); + const checkDigit = this.calculateLuhnCheckDigit(random); + return `${prefix}${random}${checkDigit}`; + } + + /** + * Calculate Luhn check digit + */ + private calculateLuhnCheckDigit(number: string): number { + let sum = 0; + let isEven = true; + for (let i = number.length - 1; i >= 0; i--) { + let digit = parseInt(number[i], 10); + if (isEven) { + digit *= 2; + if (digit > 9) digit -= 9; + } + sum += digit; + isEven = !isEven; + } + return (10 - (sum % 10)) % 10; + } + + /** + * Generate unique referral code + */ + private generateReferralCode(): string { + const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'; + let code = ''; + for (let i = 0; i < 8; i++) { + code += chars.charAt(Math.floor(Math.random() * chars.length)); + } + return code; + } + + /** + * Generate transaction number + */ + private async generateTransactionNumber(tenantId: string): Promise { + const today = new Date(); + const datePrefix = today.toISOString().slice(0, 10).replace(/-/g, ''); + const count = await this.transactionRepository.count({ + where: { + tenantId, + createdAt: Between( + new Date(today.setHours(0, 0, 0, 0)), + new Date(today.setHours(23, 59, 59, 999)) + ), + }, + }); + return `LTX-${datePrefix}-${String(count + 1).padStart(5, '0')}`; + } + + /** + * Get active program for tenant + */ + async getActiveProgram(tenantId: string): Promise { + return this.programRepository.findOne({ + where: { + tenantId, + status: ProgramStatus.ACTIVE, + }, + relations: ['levels'], + }); + } + + /** + * Get base level for a program + */ + async getBaseLevel(tenantId: string, programId: string): Promise { + return this.levelRepository.findOne({ + where: { + tenantId, + programId, + level: 0, + status: LevelStatus.ACTIVE, + }, + }); + } + + /** + * Enroll a customer in loyalty program + */ + async enrollCustomer( + tenantId: string, + input: EnrollCustomerInput, + userId: string + ): Promise> { + const queryRunner = this.dataSource.createQueryRunner(); + await queryRunner.connect(); + await queryRunner.startTransaction(); + + try { + // Check if already enrolled + const existing = await queryRunner.manager.findOne(CustomerMembership, { + where: { + tenantId, + customerId: input.customerId, + programId: input.programId, + }, + }); + + if (existing) { + return { + success: false, + error: { code: 'ALREADY_ENROLLED', message: 'Customer is already enrolled in this program' }, + }; + } + + // Get program and base level + const program = await queryRunner.manager.findOne(LoyaltyProgram, { + where: { id: input.programId, tenantId, status: ProgramStatus.ACTIVE }, + }); + + if (!program) { + return { + success: false, + error: { code: 'PROGRAM_NOT_FOUND', message: 'Active loyalty program not found' }, + }; + } + + const baseLevel = await queryRunner.manager.findOne(MembershipLevel, { + where: { tenantId, programId: input.programId, level: 0, status: LevelStatus.ACTIVE }, + }); + + if (!baseLevel) { + return { + success: false, + error: { code: 'NO_BASE_LEVEL', message: 'Program has no base level configured' }, + }; + } + + const cardNumber = await this.generateCardNumber(tenantId); + const referralCode = this.generateReferralCode(); + const now = new Date(); + + // Calculate period dates + const periodStart = new Date(now.getFullYear(), now.getMonth(), 1); + const periodEnd = new Date(now.getFullYear() + 1, now.getMonth(), 0); + + const membership = queryRunner.manager.create(CustomerMembership, { + tenantId, + customerId: input.customerId, + programId: input.programId, + levelId: baseLevel.id, + cardNumber, + status: MembershipStatus.ACTIVE, + pointsBalance: program.welcomeBonus, + lifetimePoints: program.welcomeBonus, + enrolledAt: now, + referralCode, + referredById: input.referredById, + emailNotifications: input.emailNotifications ?? true, + smsNotifications: input.smsNotifications ?? false, + pushNotifications: true, + periodStartDate: periodStart, + periodEndDate: periodEnd, + createdBy: userId, + }); + + const savedMembership = await queryRunner.manager.save(membership); + + // Create welcome bonus transaction if applicable + if (program.welcomeBonus > 0) { + const txNumber = await this.generateTransactionNumber(tenantId); + const welcomeTx = queryRunner.manager.create(LoyaltyTransaction, { + tenantId, + programId: input.programId, + membershipId: savedMembership.id, + customerId: input.customerId, + number: txNumber, + type: TransactionType.BONUS, + status: TransactionStatus.COMPLETED, + points: program.welcomeBonus, + pointsBalanceAfter: program.welcomeBonus, + bonusType: 'welcome', + description: 'Welcome bonus', + createdBy: userId, + expiresAt: program.pointsExpire + ? new Date(now.getTime() + program.expirationMonths * 30 * 24 * 60 * 60 * 1000) + : null, + }); + await queryRunner.manager.save(welcomeTx); + } + + // Handle referral bonus + if (input.referredById && program.referralBonus > 0) { + const referrer = await queryRunner.manager.findOne(CustomerMembership, { + where: { tenantId, customerId: input.referredById, programId: input.programId }, + }); + + if (referrer) { + referrer.pointsBalance += program.referralBonus; + referrer.lifetimePoints += program.referralBonus; + referrer.referralsCount += 1; + await queryRunner.manager.save(referrer); + + const referralTxNumber = await this.generateTransactionNumber(tenantId); + const referralTx = queryRunner.manager.create(LoyaltyTransaction, { + tenantId, + programId: input.programId, + membershipId: referrer.id, + customerId: input.referredById, + number: referralTxNumber, + type: TransactionType.BONUS, + status: TransactionStatus.COMPLETED, + points: program.referralBonus, + pointsBalanceAfter: referrer.pointsBalance, + bonusType: 'referral', + description: `Referral bonus for referring customer`, + createdBy: userId, + }); + await queryRunner.manager.save(referralTx); + } + } + + await queryRunner.commitTransaction(); + return { success: true, data: savedMembership }; + } catch (error) { + await queryRunner.rollbackTransaction(); + return { + success: false, + error: { + code: 'ENROLL_ERROR', + message: error instanceof Error ? error.message : 'Failed to enroll customer', + }, + }; + } finally { + await queryRunner.release(); + } + } + + /** + * Calculate points for a purchase + */ + async calculatePoints( + tenantId: string, + programId: string, + membershipId: string, + purchaseAmount: number, + categoryAmounts?: { categoryId: string; amount: number }[] + ): Promise<{ points: number; multiplier: number; breakdown: any }> { + const program = await this.programRepository.findOne({ + where: { id: programId, tenantId }, + }); + + if (!program) { + return { points: 0, multiplier: 1, breakdown: {} }; + } + + const membership = await this.membershipRepository.findOne({ + where: { id: membershipId, tenantId }, + }); + + if (!membership) { + return { points: 0, multiplier: 1, breakdown: {} }; + } + + const level = await this.levelRepository.findOne({ + where: { id: membership.levelId, tenantId }, + }); + + // Check minimum purchase + if (purchaseAmount < Number(program.minimumPurchase)) { + return { points: 0, multiplier: 1, breakdown: { reason: 'Below minimum purchase' } }; + } + + // Base points calculation + let basePoints = 0; + switch (program.pointsCalculation) { + case PointsCalculation.FIXED_PER_PURCHASE: + basePoints = Number(program.pointsPerCurrency); + break; + case PointsCalculation.PERCENTAGE_OF_AMOUNT: + basePoints = purchaseAmount * Number(program.pointsPerCurrency) / 100; + break; + case PointsCalculation.POINTS_PER_CURRENCY: + default: + basePoints = purchaseAmount / Number(program.currencyPerPoint); + break; + } + + // Apply level multiplier + const levelMultiplier = level ? Number(level.pointsMultiplier) : 1; + let totalMultiplier = levelMultiplier; + + // Check for double points day + const today = new Date().toLocaleDateString('en-US', { weekday: 'lowercase' }); + if (program.doublePointsDays?.includes(today)) { + totalMultiplier *= 2; + } + + // Apply category multipliers + let categoryBonusPoints = 0; + if (categoryAmounts && program.categoryMultipliers) { + for (const ca of categoryAmounts) { + const catMultiplier = program.categoryMultipliers.find(cm => cm.categoryId === ca.categoryId); + if (catMultiplier) { + const catPoints = (ca.amount / Number(program.currencyPerPoint)) * catMultiplier.multiplier; + categoryBonusPoints += catPoints; + } + } + } + + let totalPoints = basePoints * totalMultiplier + categoryBonusPoints; + + // Round points + if (program.roundPoints) { + switch (program.roundDirection) { + case 'up': + totalPoints = Math.ceil(totalPoints); + break; + case 'down': + totalPoints = Math.floor(totalPoints); + break; + case 'nearest': + default: + totalPoints = Math.round(totalPoints); + break; + } + } + + return { + points: totalPoints, + multiplier: totalMultiplier, + breakdown: { + basePoints, + levelMultiplier, + categoryBonusPoints, + finalPoints: totalPoints, + }, + }; + } + + /** + * Earn points for a purchase + */ + async earnPoints( + tenantId: string, + input: EarnPointsInput, + userId: string + ): Promise> { + const queryRunner = this.dataSource.createQueryRunner(); + await queryRunner.connect(); + await queryRunner.startTransaction(); + + try { + const membership = await queryRunner.manager.findOne(CustomerMembership, { + where: { id: input.membershipId, tenantId, status: MembershipStatus.ACTIVE }, + }); + + if (!membership) { + return { + success: false, + error: { code: 'MEMBERSHIP_NOT_FOUND', message: 'Active membership not found' }, + }; + } + + const program = await queryRunner.manager.findOne(LoyaltyProgram, { + where: { id: membership.programId, tenantId, status: ProgramStatus.ACTIVE }, + }); + + if (!program) { + return { + success: false, + error: { code: 'PROGRAM_NOT_FOUND', message: 'Active program not found' }, + }; + } + + // Calculate points + const { points, multiplier, breakdown } = await this.calculatePoints( + tenantId, + membership.programId, + membership.id, + input.purchaseAmount, + input.categoryMultipliers + ); + + if (points <= 0) { + return { + success: false, + error: { code: 'NO_POINTS', message: 'No points earned for this purchase' }, + }; + } + + // Update membership + membership.pointsBalance += points; + membership.lifetimePoints += points; + membership.totalPurchases += 1; + membership.totalSpend = Number(membership.totalSpend) + input.purchaseAmount; + membership.averageOrderValue = Number(membership.totalSpend) / membership.totalPurchases; + membership.periodPurchases += 1; + membership.periodSpend = Number(membership.periodSpend) + input.purchaseAmount; + membership.periodPoints += points; + membership.lastPurchaseAt = new Date(); + membership.lastActivityAt = new Date(); + + await queryRunner.manager.save(membership); + + // Create transaction + const txNumber = await this.generateTransactionNumber(tenantId); + const expirationDate = program.pointsExpire + ? new Date(Date.now() + program.expirationMonths * 30 * 24 * 60 * 60 * 1000) + : null; + + const transaction = queryRunner.manager.create(LoyaltyTransaction, { + tenantId, + programId: membership.programId, + membershipId: membership.id, + customerId: membership.customerId, + number: txNumber, + type: TransactionType.EARN, + status: TransactionStatus.COMPLETED, + points, + pointsBalanceAfter: membership.pointsBalance, + purchaseAmount: input.purchaseAmount, + orderId: input.orderId, + orderNumber: input.orderNumber, + branchId: input.branchId, + basePoints: breakdown.basePoints, + multiplierApplied: multiplier, + multiplierReason: multiplier > 1 ? 'Level and/or promotional multiplier' : null, + expiresAt: expirationDate, + description: `Points earned from purchase ${input.orderNumber ?? ''}`, + createdBy: userId, + metadata: breakdown, + }); + + const savedTransaction = await queryRunner.manager.save(transaction); + + // Check for level upgrade + await this.checkLevelUpgrade(queryRunner, tenantId, membership); + + await queryRunner.commitTransaction(); + return { success: true, data: savedTransaction }; + } catch (error) { + await queryRunner.rollbackTransaction(); + return { + success: false, + error: { + code: 'EARN_POINTS_ERROR', + message: error instanceof Error ? error.message : 'Failed to earn points', + }, + }; + } finally { + await queryRunner.release(); + } + } + + /** + * Redeem points + */ + async redeemPoints( + tenantId: string, + input: RedeemPointsInput, + userId: string + ): Promise> { + const queryRunner = this.dataSource.createQueryRunner(); + await queryRunner.connect(); + await queryRunner.startTransaction(); + + try { + const membership = await queryRunner.manager.findOne(CustomerMembership, { + where: { id: input.membershipId, tenantId, status: MembershipStatus.ACTIVE }, + }); + + if (!membership) { + return { + success: false, + error: { code: 'MEMBERSHIP_NOT_FOUND', message: 'Active membership not found' }, + }; + } + + if (membership.pointsBalance < input.points) { + return { + success: false, + error: { code: 'INSUFFICIENT_POINTS', message: 'Insufficient points balance' }, + }; + } + + const program = await queryRunner.manager.findOne(LoyaltyProgram, { + where: { id: membership.programId, tenantId }, + }); + + if (!program) { + return { + success: false, + error: { code: 'PROGRAM_NOT_FOUND', message: 'Program not found' }, + }; + } + + if (input.points < program.minPointsRedemption) { + return { + success: false, + error: { + code: 'BELOW_MINIMUM', + message: `Minimum redemption is ${program.minPointsRedemption} points`, + }, + }; + } + + // Calculate monetary value + const monetaryValue = input.points * Number(program.pointsValue); + + // Update membership + membership.pointsBalance -= input.points; + membership.pointsRedeemed += input.points; + membership.lastRedemptionAt = new Date(); + membership.lastActivityAt = new Date(); + + await queryRunner.manager.save(membership); + + // Create transaction + const txNumber = await this.generateTransactionNumber(tenantId); + const transaction = queryRunner.manager.create(LoyaltyTransaction, { + tenantId, + programId: membership.programId, + membershipId: membership.id, + customerId: membership.customerId, + number: txNumber, + type: TransactionType.REDEEM, + status: TransactionStatus.COMPLETED, + points: -input.points, + pointsBalanceAfter: membership.pointsBalance, + monetaryValue, + orderId: input.orderId, + orderNumber: input.orderNumber, + branchId: input.branchId, + description: input.description ?? `Points redeemed for ${input.orderNumber ?? 'purchase'}`, + createdBy: userId, + }); + + const savedTransaction = await queryRunner.manager.save(transaction); + + await queryRunner.commitTransaction(); + return { success: true, data: { transaction: savedTransaction, monetaryValue } }; + } catch (error) { + await queryRunner.rollbackTransaction(); + return { + success: false, + error: { + code: 'REDEEM_ERROR', + message: error instanceof Error ? error.message : 'Failed to redeem points', + }, + }; + } finally { + await queryRunner.release(); + } + } + + /** + * Adjust points manually + */ + async adjustPoints( + tenantId: string, + input: AdjustPointsInput, + userId: string + ): Promise> { + const queryRunner = this.dataSource.createQueryRunner(); + await queryRunner.connect(); + await queryRunner.startTransaction(); + + try { + const membership = await queryRunner.manager.findOne(CustomerMembership, { + where: { id: input.membershipId, tenantId }, + }); + + if (!membership) { + return { + success: false, + error: { code: 'MEMBERSHIP_NOT_FOUND', message: 'Membership not found' }, + }; + } + + const newBalance = membership.pointsBalance + input.points; + if (newBalance < 0) { + return { + success: false, + error: { code: 'NEGATIVE_BALANCE', message: 'Adjustment would result in negative balance' }, + }; + } + + membership.pointsBalance = newBalance; + if (input.points > 0) { + membership.lifetimePoints += input.points; + } + membership.lastActivityAt = new Date(); + + await queryRunner.manager.save(membership); + + const txNumber = await this.generateTransactionNumber(tenantId); + const transaction = queryRunner.manager.create(LoyaltyTransaction, { + tenantId, + programId: membership.programId, + membershipId: membership.id, + customerId: membership.customerId, + number: txNumber, + type: TransactionType.ADJUST, + status: TransactionStatus.COMPLETED, + points: input.points, + pointsBalanceAfter: membership.pointsBalance, + description: input.reason, + createdBy: userId, + }); + + const savedTransaction = await queryRunner.manager.save(transaction); + await queryRunner.commitTransaction(); + + return { success: true, data: savedTransaction }; + } catch (error) { + await queryRunner.rollbackTransaction(); + return { + success: false, + error: { + code: 'ADJUST_ERROR', + message: error instanceof Error ? error.message : 'Failed to adjust points', + }, + }; + } finally { + await queryRunner.release(); + } + } + + /** + * Check and apply level upgrade + */ + private async checkLevelUpgrade( + queryRunner: any, + tenantId: string, + membership: CustomerMembership + ): Promise { + const levels = await queryRunner.manager.find(MembershipLevel, { + where: { tenantId, programId: membership.programId, status: LevelStatus.ACTIVE }, + order: { level: 'DESC' }, + }); + + for (const level of levels) { + if (level.id === membership.levelId) continue; + if (level.level <= 0) continue; + + const meetsPointsReq = membership.periodPoints >= level.minPointsRequired; + const meetsPurchasesReq = membership.periodPurchases >= level.minPurchasesRequired; + const meetsSpendReq = Number(membership.periodSpend) >= Number(level.minSpendRequired); + + if (meetsPointsReq && meetsPurchasesReq && meetsSpendReq && level.autoUpgrade) { + membership.previousLevelId = membership.levelId; + membership.levelId = level.id; + membership.levelAchievedAt = new Date(); + + const retentionEndDate = new Date(); + retentionEndDate.setMonth(retentionEndDate.getMonth() + level.retentionMonths); + membership.levelExpiresAt = retentionEndDate; + + await queryRunner.manager.save(membership); + return true; + } + } + + return false; + } + + /** + * Get membership by customer ID + */ + async getMembershipByCustomer( + tenantId: string, + customerId: string, + programId?: string + ): Promise { + const where: any = { tenantId, customerId }; + if (programId) { + where.programId = programId; + } + + return this.membershipRepository.findOne({ where }); + } + + /** + * Get membership by card number + */ + async getMembershipByCard( + tenantId: string, + cardNumber: string + ): Promise { + return this.membershipRepository.findOne({ + where: { tenantId, cardNumber }, + }); + } + + /** + * Get transaction history + */ + async getTransactionHistory( + tenantId: string, + membershipId: string, + options?: { page?: number; limit?: number; type?: TransactionType } + ): Promise<{ data: LoyaltyTransaction[]; total: number }> { + const qb = this.transactionRepository.createQueryBuilder('tx') + .where('tx.tenantId = :tenantId', { tenantId }) + .andWhere('tx.membershipId = :membershipId', { membershipId }); + + if (options?.type) { + qb.andWhere('tx.type = :type', { type: options.type }); + } + + const page = options?.page ?? 1; + const limit = options?.limit ?? 20; + qb.skip((page - 1) * limit).take(limit); + qb.orderBy('tx.createdAt', 'DESC'); + + const [data, total] = await qb.getManyAndCount(); + return { data, total }; + } + + /** + * Find memberships with filters + */ + async findMemberships( + tenantId: string, + options: MembershipQueryOptions + ): Promise<{ data: CustomerMembership[]; total: number }> { + const qb = this.membershipRepository.createQueryBuilder('membership') + .where('membership.tenantId = :tenantId', { tenantId }); + + if (options.programId) { + qb.andWhere('membership.programId = :programId', { programId: options.programId }); + } + if (options.levelId) { + qb.andWhere('membership.levelId = :levelId', { levelId: options.levelId }); + } + if (options.status) { + qb.andWhere('membership.status = :status', { status: options.status }); + } + if (options.customerId) { + qb.andWhere('membership.customerId = :customerId', { customerId: options.customerId }); + } + + const page = options.page ?? 1; + const limit = options.limit ?? 20; + qb.skip((page - 1) * limit).take(limit); + qb.orderBy('membership.enrolledAt', 'DESC'); + + const [data, total] = await qb.getManyAndCount(); + return { data, total }; + } + + /** + * Get points about to expire + */ + async getExpiringPoints( + tenantId: string, + membershipId: string, + daysAhead: number = 30 + ): Promise<{ points: number; expirationDate: Date | null }> { + const futureDate = new Date(); + futureDate.setDate(futureDate.getDate() + daysAhead); + + const expiringTransactions = await this.transactionRepository.find({ + where: { + tenantId, + membershipId, + type: TransactionType.EARN, + expired: false, + expiresAt: LessThanOrEqual(futureDate), + points: MoreThan(0), + }, + order: { expiresAt: 'ASC' }, + }); + + let totalExpiring = 0; + let nearestExpiration: Date | null = null; + + for (const tx of expiringTransactions) { + totalExpiring += tx.points; + if (!nearestExpiration && tx.expiresAt) { + nearestExpiration = tx.expiresAt; + } + } + + return { points: totalExpiring, expirationDate: nearestExpiration }; + } +} diff --git a/backend/src/modules/customers/validation/customers.schema.ts b/backend/src/modules/customers/validation/customers.schema.ts new file mode 100644 index 0000000..7052b96 --- /dev/null +++ b/backend/src/modules/customers/validation/customers.schema.ts @@ -0,0 +1,179 @@ +import { z } from 'zod'; +import { + uuidSchema, + moneySchema, + paginationSchema, +} from '../../../shared/validation/common.schema'; + +// Enums +export const programStatusEnum = z.enum(['active', 'inactive', 'suspended']); +export const membershipStatusEnum = z.enum(['active', 'inactive', 'suspended', 'expired']); +export const transactionTypeEnum = z.enum([ + 'earn', + 'redeem', + 'expire', + 'adjust', + 'bonus', + 'transfer_in', + 'transfer_out', + 'refund', +]); + +// ==================== PROGRAM SCHEMAS ==================== + +// Create program schema +export const createProgramSchema = z.object({ + code: z.string().min(1).max(20).regex(/^[A-Z0-9-]+$/, 'Code must be alphanumeric uppercase'), + name: z.string().min(1).max(100), + description: z.string().max(1000).optional(), + pointsCalculation: z.enum(['fixed_per_purchase', 'percentage_of_amount', 'points_per_currency']).default('points_per_currency'), + pointsPerCurrency: z.number().positive().default(1), + currencyPerPoint: z.number().positive().default(10), + minimumPurchase: moneySchema.default(0), + roundPoints: z.boolean().default(true), + roundDirection: z.enum(['up', 'down', 'nearest']).default('down'), + pointsValue: z.number().positive().default(0.1), + minPointsRedemption: z.number().int().positive().default(100), + maxRedemptionPercent: z.number().min(0).max(100).default(100), + allowPartialRedemption: z.boolean().default(true), + pointsExpire: z.boolean().default(true), + expirationMonths: z.number().int().min(1).max(120).default(12), + welcomeBonus: z.number().int().min(0).default(0), + birthdayBonus: z.number().int().min(0).default(0), + referralBonus: z.number().int().min(0).default(0), + refereeBonus: z.number().int().min(0).default(0), + validFrom: z.coerce.date().optional(), + validUntil: z.coerce.date().optional(), + termsAndConditions: z.string().max(10000).optional(), +}); + +// Update program schema +export const updateProgramSchema = createProgramSchema.partial().omit({ code: true }); + +// ==================== LEVEL SCHEMAS ==================== + +// Create level schema +export const createLevelSchema = z.object({ + programId: uuidSchema, + code: z.string().min(1).max(20).regex(/^[A-Z0-9-]+$/, 'Code must be alphanumeric uppercase'), + name: z.string().min(1).max(50), + description: z.string().max(500).optional(), + level: z.number().int().min(0).default(0), + minPointsRequired: z.number().int().min(0).default(0), + minPurchasesRequired: z.number().int().min(0).default(0), + minSpendRequired: moneySchema.default(0), + qualificationPeriodMonths: z.number().int().min(1).max(24).default(12), + pointsMultiplier: z.number().min(1).max(10).default(1), + discountPercent: z.number().min(0).max(100).default(0), + freeShipping: z.boolean().default(false), + freeShippingThreshold: moneySchema.optional(), + prioritySupport: z.boolean().default(false), + earlyAccess: z.boolean().default(false), + exclusivePromotions: z.boolean().default(false), + birthdayMultiplier: z.number().min(1).max(10).default(1), + color: z.string().regex(/^#[0-9A-Fa-f]{6}$/, 'Invalid color format').default('#000000'), + retentionMonths: z.number().int().min(1).max(36).default(12), + autoUpgrade: z.boolean().default(true), +}); + +// Update level schema +export const updateLevelSchema = createLevelSchema.partial().omit({ programId: true, code: true }); + +// ==================== MEMBERSHIP SCHEMAS ==================== + +// Enroll customer schema +export const enrollCustomerSchema = z.object({ + customerId: uuidSchema, + programId: uuidSchema, + referredById: uuidSchema.optional(), + emailNotifications: z.boolean().default(true), + smsNotifications: z.boolean().default(false), +}); + +// Update membership schema +export const updateMembershipSchema = z.object({ + status: membershipStatusEnum.optional(), + emailNotifications: z.boolean().optional(), + smsNotifications: z.boolean().optional(), + pushNotifications: z.boolean().optional(), + favoriteBranchId: uuidSchema.optional(), + notes: z.string().max(1000).optional(), +}); + +// List memberships query schema +export const listMembershipsQuerySchema = paginationSchema.extend({ + programId: uuidSchema.optional(), + levelId: uuidSchema.optional(), + status: membershipStatusEnum.optional(), + customerId: uuidSchema.optional(), +}); + +// ==================== TRANSACTION SCHEMAS ==================== + +// Category amount schema (for multipliers) +export const categoryAmountSchema = z.object({ + categoryId: uuidSchema, + amount: moneySchema.positive(), +}); + +// Earn points schema +export const earnPointsSchema = z.object({ + membershipId: uuidSchema, + purchaseAmount: moneySchema.positive('Purchase amount must be positive'), + orderId: uuidSchema.optional(), + orderNumber: z.string().max(30).optional(), + branchId: uuidSchema.optional(), + categoryMultipliers: z.array(categoryAmountSchema).optional(), +}); + +// Redeem points schema +export const redeemPointsSchema = z.object({ + membershipId: uuidSchema, + points: z.number().int().positive('Points must be positive'), + orderId: uuidSchema.optional(), + orderNumber: z.string().max(30).optional(), + branchId: uuidSchema.optional(), + description: z.string().max(255).optional(), +}); + +// Adjust points schema +export const adjustPointsSchema = z.object({ + membershipId: uuidSchema, + points: z.number().int().refine(v => v !== 0, 'Points cannot be zero'), + reason: z.string().min(1, 'Reason is required').max(255), +}); + +// List transactions query schema +export const listTransactionsQuerySchema = paginationSchema.extend({ + membershipId: uuidSchema.optional(), + type: transactionTypeEnum.optional(), + startDate: z.coerce.date().optional(), + endDate: z.coerce.date().optional(), +}); + +// ==================== LOOKUP SCHEMAS ==================== + +// Lookup by card number +export const lookupByCardSchema = z.object({ + cardNumber: z.string().min(10).max(20), +}); + +// Calculate points preview schema +export const calculatePointsSchema = z.object({ + membershipId: uuidSchema, + purchaseAmount: moneySchema.positive(), + categoryMultipliers: z.array(categoryAmountSchema).optional(), +}); + +// Types +export type CreateProgramInput = z.infer; +export type UpdateProgramInput = z.infer; +export type CreateLevelInput = z.infer; +export type UpdateLevelInput = z.infer; +export type EnrollCustomerInput = z.infer; +export type UpdateMembershipInput = z.infer; +export type EarnPointsInput = z.infer; +export type RedeemPointsInput = z.infer; +export type AdjustPointsInput = z.infer; +export type ListMembershipsQuery = z.infer; +export type ListTransactionsQuery = z.infer; diff --git a/backend/src/modules/ecommerce/entities/cart-item.entity.ts b/backend/src/modules/ecommerce/entities/cart-item.entity.ts new file mode 100644 index 0000000..300d074 --- /dev/null +++ b/backend/src/modules/ecommerce/entities/cart-item.entity.ts @@ -0,0 +1,156 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + ManyToOne, + JoinColumn, + Index, +} from 'typeorm'; +import { Cart } from './cart.entity'; + +@Entity('cart_items', { schema: 'retail' }) +@Index(['tenantId', 'cartId']) +@Index(['tenantId', 'productId']) +@Index(['tenantId', 'cartId', 'productId', 'variantId'], { unique: true }) +export class CartItem { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'tenant_id', type: 'uuid' }) + @Index() + tenantId: string; + + @Column({ name: 'cart_id', type: 'uuid' }) + cartId: string; + + // Product (from erp-core) + @Column({ name: 'product_id', type: 'uuid' }) + productId: string; + + @Column({ name: 'product_code', length: 50 }) + productCode: string; + + @Column({ name: 'product_name', length: 200 }) + productName: string; + + @Column({ name: 'product_slug', length: 255, nullable: true }) + productSlug: string; + + @Column({ name: 'product_image', length: 255, nullable: true }) + productImage: string; + + // Variant + @Column({ name: 'variant_id', type: 'uuid', nullable: true }) + variantId: string; + + @Column({ name: 'variant_name', length: 200, nullable: true }) + variantName: string; + + @Column({ name: 'variant_sku', length: 50, nullable: true }) + variantSku: string; + + // Variant options + @Column({ name: 'variant_options', type: 'jsonb', nullable: true }) + variantOptions: { + name: string; + value: string; + }[]; + + // Quantity + @Column({ type: 'decimal', precision: 15, scale: 4, default: 1 }) + quantity: number; + + // Pricing + @Column({ name: 'unit_price', type: 'decimal', precision: 15, scale: 4 }) + unitPrice: number; + + @Column({ name: 'original_price', type: 'decimal', precision: 15, scale: 4 }) + originalPrice: number; + + @Column({ name: 'price_list_id', type: 'uuid', nullable: true }) + priceListId: string; + + // Discounts + @Column({ name: 'discount_percent', type: 'decimal', precision: 5, scale: 2, default: 0 }) + discountPercent: number; + + @Column({ name: 'discount_amount', type: 'decimal', precision: 15, scale: 2, default: 0 }) + discountAmount: number; + + @Column({ name: 'promotion_id', type: 'uuid', nullable: true }) + promotionId: string; + + @Column({ name: 'promotion_name', length: 100, nullable: true }) + promotionName: string; + + // Tax + @Column({ name: 'tax_rate', type: 'decimal', precision: 5, scale: 4, default: 0 }) + taxRate: number; + + @Column({ name: 'tax_amount', type: 'decimal', precision: 15, scale: 2, default: 0 }) + taxAmount: number; + + @Column({ name: 'tax_included', type: 'boolean', default: true }) + taxIncluded: boolean; + + // Totals + @Column({ type: 'decimal', precision: 15, scale: 2, default: 0 }) + subtotal: number; + + @Column({ type: 'decimal', precision: 15, scale: 2, default: 0 }) + total: number; + + // Stock check + @Column({ name: 'stock_available', type: 'decimal', precision: 15, scale: 4, nullable: true }) + stockAvailable: number; + + @Column({ name: 'is_available', type: 'boolean', default: true }) + isAvailable: boolean; + + @Column({ name: 'availability_message', length: 255, nullable: true }) + availabilityMessage: string; + + // Warehouse + @Column({ name: 'warehouse_id', type: 'uuid', nullable: true }) + warehouseId: string; + + // Gift options + @Column({ name: 'is_gift', type: 'boolean', default: false }) + isGift: boolean; + + @Column({ name: 'gift_message', type: 'text', nullable: true }) + giftMessage: string; + + // Custom options (personalization, etc.) + @Column({ name: 'custom_options', type: 'jsonb', nullable: true }) + customOptions: { + name: string; + value: string; + price?: number; + }[]; + + // Saved for later + @Column({ name: 'saved_for_later', type: 'boolean', default: false }) + savedForLater: boolean; + + // Notes + @Column({ type: 'text', nullable: true }) + notes: string; + + // Metadata + @Column({ type: 'jsonb', nullable: true }) + metadata: Record; + + @CreateDateColumn({ name: 'created_at' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at' }) + updatedAt: Date; + + // Relations + @ManyToOne(() => Cart, (cart) => cart.items) + @JoinColumn({ name: 'cart_id' }) + cart: Cart; +} diff --git a/backend/src/modules/ecommerce/entities/cart.entity.ts b/backend/src/modules/ecommerce/entities/cart.entity.ts new file mode 100644 index 0000000..9efa479 --- /dev/null +++ b/backend/src/modules/ecommerce/entities/cart.entity.ts @@ -0,0 +1,210 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + OneToMany, + Index, +} from 'typeorm'; +import { CartItem } from './cart-item.entity'; + +export enum CartStatus { + ACTIVE = 'active', + ABANDONED = 'abandoned', + CONVERTED = 'converted', + MERGED = 'merged', + EXPIRED = 'expired', +} + +@Entity('carts', { schema: 'retail' }) +@Index(['tenantId', 'customerId']) +@Index(['tenantId', 'sessionId']) +@Index(['tenantId', 'status', 'updatedAt']) +export class Cart { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'tenant_id', type: 'uuid' }) + @Index() + tenantId: string; + + // Customer (if logged in, from erp-core partners) + @Column({ name: 'customer_id', type: 'uuid', nullable: true }) + customerId: string; + + // Session (for guest carts) + @Column({ name: 'session_id', length: 100, nullable: true }) + sessionId: string; + + @Column({ + type: 'enum', + enum: CartStatus, + default: CartStatus.ACTIVE, + }) + status: CartStatus; + + // Currency + @Column({ name: 'currency_code', length: 3, default: 'MXN' }) + currencyCode: string; + + // Totals + @Column({ name: 'items_count', type: 'int', default: 0 }) + itemsCount: number; + + @Column({ name: 'items_quantity', type: 'decimal', precision: 15, scale: 4, default: 0 }) + itemsQuantity: number; + + @Column({ type: 'decimal', precision: 15, scale: 2, default: 0 }) + subtotal: number; + + @Column({ name: 'discount_amount', type: 'decimal', precision: 15, scale: 2, default: 0 }) + discountAmount: number; + + @Column({ name: 'tax_amount', type: 'decimal', precision: 15, scale: 2, default: 0 }) + taxAmount: number; + + @Column({ name: 'shipping_amount', type: 'decimal', precision: 15, scale: 2, default: 0 }) + shippingAmount: number; + + @Column({ type: 'decimal', precision: 15, scale: 2, default: 0 }) + total: number; + + // Coupon + @Column({ name: 'coupon_id', type: 'uuid', nullable: true }) + couponId: string; + + @Column({ name: 'coupon_code', length: 50, nullable: true }) + couponCode: string; + + @Column({ name: 'coupon_discount', type: 'decimal', precision: 15, scale: 2, default: 0 }) + couponDiscount: number; + + // Loyalty points + @Column({ name: 'loyalty_points_to_use', type: 'int', default: 0 }) + loyaltyPointsToUse: number; + + @Column({ name: 'loyalty_points_value', type: 'decimal', precision: 15, scale: 2, default: 0 }) + loyaltyPointsValue: number; + + // Shipping + @Column({ name: 'shipping_method_id', type: 'uuid', nullable: true }) + shippingMethodId: string; + + @Column({ name: 'shipping_method_name', length: 100, nullable: true }) + shippingMethodName: string; + + // Shipping address + @Column({ name: 'shipping_address', type: 'jsonb', nullable: true }) + shippingAddress: { + firstName: string; + lastName: string; + company?: string; + address1: string; + address2?: string; + city: string; + state: string; + postalCode: string; + country: string; + phone: string; + instructions?: string; + }; + + // Billing address + @Column({ name: 'billing_address', type: 'jsonb', nullable: true }) + billingAddress: { + firstName: string; + lastName: string; + company?: string; + rfc?: string; + address1: string; + address2?: string; + city: string; + state: string; + postalCode: string; + country: string; + phone: string; + }; + + @Column({ name: 'billing_same_as_shipping', type: 'boolean', default: true }) + billingSameAsShipping: boolean; + + // Invoice request + @Column({ name: 'requires_invoice', type: 'boolean', default: false }) + requiresInvoice: boolean; + + @Column({ name: 'invoice_rfc', length: 13, nullable: true }) + invoiceRfc: string; + + @Column({ name: 'invoice_uso_cfdi', length: 4, nullable: true }) + invoiceUsoCfdi: string; + + // Notes + @Column({ type: 'text', nullable: true }) + notes: string; + + // Gift + @Column({ name: 'is_gift', type: 'boolean', default: false }) + isGift: boolean; + + @Column({ name: 'gift_message', type: 'text', nullable: true }) + giftMessage: string; + + // Abandoned cart recovery + @Column({ name: 'recovery_email_sent', type: 'boolean', default: false }) + recoveryEmailSent: boolean; + + @Column({ name: 'recovery_email_sent_at', type: 'timestamp with time zone', nullable: true }) + recoveryEmailSentAt: Date; + + // Conversion tracking + @Column({ name: 'converted_to_order_id', type: 'uuid', nullable: true }) + convertedToOrderId: string; + + @Column({ name: 'converted_at', type: 'timestamp with time zone', nullable: true }) + convertedAt: Date; + + // Merge tracking + @Column({ name: 'merged_into_cart_id', type: 'uuid', nullable: true }) + mergedIntoCartId: string; + + // Analytics + @Column({ name: 'utm_source', length: 100, nullable: true }) + utmSource: string; + + @Column({ name: 'utm_medium', length: 100, nullable: true }) + utmMedium: string; + + @Column({ name: 'utm_campaign', length: 100, nullable: true }) + utmCampaign: string; + + @Column({ name: 'referrer_url', length: 255, nullable: true }) + referrerUrl: string; + + // Device info + @Column({ name: 'device_type', length: 20, nullable: true }) + deviceType: 'desktop' | 'tablet' | 'mobile'; + + @Column({ name: 'ip_address', length: 45, nullable: true }) + ipAddress: string; + + @Column({ name: 'user_agent', type: 'text', nullable: true }) + userAgent: string; + + // Metadata + @Column({ type: 'jsonb', nullable: true }) + metadata: Record; + + @CreateDateColumn({ name: 'created_at' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at' }) + updatedAt: Date; + + @Column({ name: 'expires_at', type: 'timestamp with time zone', nullable: true }) + expiresAt: Date; + + // Relations + @OneToMany(() => CartItem, (item) => item.cart) + items: CartItem[]; +} diff --git a/backend/src/modules/ecommerce/entities/ecommerce-order-line.entity.ts b/backend/src/modules/ecommerce/entities/ecommerce-order-line.entity.ts new file mode 100644 index 0000000..bf187ce --- /dev/null +++ b/backend/src/modules/ecommerce/entities/ecommerce-order-line.entity.ts @@ -0,0 +1,198 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + ManyToOne, + JoinColumn, + Index, +} from 'typeorm'; +import { EcommerceOrder } from './ecommerce-order.entity'; + +export enum OrderLineStatus { + PENDING = 'pending', + RESERVED = 'reserved', + FULFILLED = 'fulfilled', + SHIPPED = 'shipped', + DELIVERED = 'delivered', + CANCELLED = 'cancelled', + RETURNED = 'returned', + REFUNDED = 'refunded', +} + +@Entity('ecommerce_order_lines', { schema: 'retail' }) +@Index(['tenantId', 'orderId']) +@Index(['tenantId', 'productId']) +export class EcommerceOrderLine { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'tenant_id', type: 'uuid' }) + @Index() + tenantId: string; + + @Column({ name: 'order_id', type: 'uuid' }) + orderId: string; + + @Column({ name: 'line_number', type: 'int' }) + lineNumber: number; + + @Column({ + type: 'enum', + enum: OrderLineStatus, + default: OrderLineStatus.PENDING, + }) + status: OrderLineStatus; + + // Product (from erp-core) + @Column({ name: 'product_id', type: 'uuid' }) + productId: string; + + @Column({ name: 'product_code', length: 50 }) + productCode: string; + + @Column({ name: 'product_name', length: 200 }) + productName: string; + + @Column({ name: 'product_slug', length: 255, nullable: true }) + productSlug: string; + + @Column({ name: 'product_image', length: 255, nullable: true }) + productImage: string; + + // Variant + @Column({ name: 'variant_id', type: 'uuid', nullable: true }) + variantId: string; + + @Column({ name: 'variant_name', length: 200, nullable: true }) + variantName: string; + + @Column({ name: 'variant_sku', length: 50, nullable: true }) + variantSku: string; + + @Column({ name: 'variant_options', type: 'jsonb', nullable: true }) + variantOptions: { + name: string; + value: string; + }[]; + + // Quantity + @Column({ type: 'decimal', precision: 15, scale: 4, default: 1 }) + quantity: number; + + @Column({ name: 'quantity_fulfilled', type: 'decimal', precision: 15, scale: 4, default: 0 }) + quantityFulfilled: number; + + @Column({ name: 'quantity_refunded', type: 'decimal', precision: 15, scale: 4, default: 0 }) + quantityRefunded: number; + + // UOM + @Column({ name: 'uom_id', type: 'uuid', nullable: true }) + uomId: string; + + @Column({ name: 'uom_name', length: 20, default: 'PZA' }) + uomName: string; + + // Pricing + @Column({ name: 'unit_price', type: 'decimal', precision: 15, scale: 4 }) + unitPrice: number; + + @Column({ name: 'original_price', type: 'decimal', precision: 15, scale: 4 }) + originalPrice: number; + + // Discounts + @Column({ name: 'discount_percent', type: 'decimal', precision: 5, scale: 2, default: 0 }) + discountPercent: number; + + @Column({ name: 'discount_amount', type: 'decimal', precision: 15, scale: 2, default: 0 }) + discountAmount: number; + + @Column({ name: 'promotion_id', type: 'uuid', nullable: true }) + promotionId: string; + + @Column({ name: 'promotion_name', length: 100, nullable: true }) + promotionName: string; + + // Tax + @Column({ name: 'tax_id', type: 'uuid', nullable: true }) + taxId: string; + + @Column({ name: 'tax_rate', type: 'decimal', precision: 5, scale: 4, default: 0 }) + taxRate: number; + + @Column({ name: 'tax_amount', type: 'decimal', precision: 15, scale: 2, default: 0 }) + taxAmount: number; + + @Column({ name: 'tax_included', type: 'boolean', default: true }) + taxIncluded: boolean; + + // Totals + @Column({ type: 'decimal', precision: 15, scale: 2, default: 0 }) + subtotal: number; + + @Column({ type: 'decimal', precision: 15, scale: 2, default: 0 }) + total: number; + + // Cost + @Column({ name: 'unit_cost', type: 'decimal', precision: 15, scale: 4, nullable: true }) + unitCost: number; + + // Weight (for shipping calculation) + @Column({ type: 'decimal', precision: 10, scale: 4, nullable: true }) + weight: number; + + @Column({ name: 'weight_unit', length: 5, default: 'kg' }) + weightUnit: string; + + // Warehouse + @Column({ name: 'warehouse_id', type: 'uuid', nullable: true }) + warehouseId: string; + + // Lot/Serial + @Column({ name: 'lot_number', length: 50, nullable: true }) + lotNumber: string; + + @Column({ name: 'serial_number', length: 50, nullable: true }) + serialNumber: string; + + // Custom options + @Column({ name: 'custom_options', type: 'jsonb', nullable: true }) + customOptions: { + name: string; + value: string; + price?: number; + }[]; + + // Gift + @Column({ name: 'is_gift', type: 'boolean', default: false }) + isGift: boolean; + + @Column({ name: 'gift_message', type: 'text', nullable: true }) + giftMessage: string; + + // Refund + @Column({ name: 'refunded_at', type: 'timestamp with time zone', nullable: true }) + refundedAt: Date; + + @Column({ name: 'refund_amount', type: 'decimal', precision: 15, scale: 2, default: 0 }) + refundAmount: number; + + @Column({ name: 'refund_reason', length: 255, nullable: true }) + refundReason: string; + + // Notes + @Column({ type: 'text', nullable: true }) + notes: string; + + // Metadata + @Column({ type: 'jsonb', nullable: true }) + metadata: Record; + + @CreateDateColumn({ name: 'created_at' }) + createdAt: Date; + + // Relations + @ManyToOne(() => EcommerceOrder, (order) => order.lines) + @JoinColumn({ name: 'order_id' }) + order: EcommerceOrder; +} diff --git a/backend/src/modules/ecommerce/entities/ecommerce-order.entity.ts b/backend/src/modules/ecommerce/entities/ecommerce-order.entity.ts new file mode 100644 index 0000000..21fb510 --- /dev/null +++ b/backend/src/modules/ecommerce/entities/ecommerce-order.entity.ts @@ -0,0 +1,309 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + OneToMany, + Index, +} from 'typeorm'; +import { EcommerceOrderLine } from './ecommerce-order-line.entity'; + +export enum EcommerceOrderStatus { + PENDING = 'pending', + CONFIRMED = 'confirmed', + PROCESSING = 'processing', + READY_FOR_PICKUP = 'ready_for_pickup', + SHIPPED = 'shipped', + DELIVERED = 'delivered', + CANCELLED = 'cancelled', + REFUNDED = 'refunded', +} + +export enum PaymentStatus { + PENDING = 'pending', + AUTHORIZED = 'authorized', + CAPTURED = 'captured', + FAILED = 'failed', + REFUNDED = 'refunded', + PARTIALLY_REFUNDED = 'partially_refunded', +} + +export enum FulfillmentType { + SHIP = 'ship', + PICKUP = 'pickup', + DELIVERY = 'delivery', +} + +@Entity('ecommerce_orders', { schema: 'retail' }) +@Index(['tenantId', 'status', 'createdAt']) +@Index(['tenantId', 'customerId']) +@Index(['tenantId', 'number'], { unique: true }) +@Index(['tenantId', 'paymentStatus']) +export class EcommerceOrder { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'tenant_id', type: 'uuid' }) + @Index() + tenantId: string; + + @Column({ length: 30, unique: true }) + number: string; + + @Column({ + type: 'enum', + enum: EcommerceOrderStatus, + default: EcommerceOrderStatus.PENDING, + }) + status: EcommerceOrderStatus; + + @Column({ + name: 'payment_status', + type: 'enum', + enum: PaymentStatus, + default: PaymentStatus.PENDING, + }) + paymentStatus: PaymentStatus; + + @Column({ + name: 'fulfillment_type', + type: 'enum', + enum: FulfillmentType, + default: FulfillmentType.SHIP, + }) + fulfillmentType: FulfillmentType; + + // Customer + @Column({ name: 'customer_id', type: 'uuid', nullable: true }) + customerId: string; + + @Column({ name: 'customer_email', length: 100 }) + customerEmail: string; + + @Column({ name: 'customer_phone', length: 20, nullable: true }) + customerPhone: string; + + @Column({ name: 'customer_name', length: 200 }) + customerName: string; + + @Column({ name: 'is_guest', type: 'boolean', default: false }) + isGuest: boolean; + + // Currency + @Column({ name: 'currency_code', length: 3, default: 'MXN' }) + currencyCode: string; + + @Column({ name: 'exchange_rate', type: 'decimal', precision: 10, scale: 6, default: 1 }) + exchangeRate: number; + + // Amounts + @Column({ type: 'decimal', precision: 15, scale: 2, default: 0 }) + subtotal: number; + + @Column({ name: 'discount_amount', type: 'decimal', precision: 15, scale: 2, default: 0 }) + discountAmount: number; + + @Column({ name: 'tax_amount', type: 'decimal', precision: 15, scale: 2, default: 0 }) + taxAmount: number; + + @Column({ name: 'shipping_amount', type: 'decimal', precision: 15, scale: 2, default: 0 }) + shippingAmount: number; + + @Column({ name: 'shipping_tax', type: 'decimal', precision: 15, scale: 2, default: 0 }) + shippingTax: number; + + @Column({ type: 'decimal', precision: 15, scale: 2, default: 0 }) + total: number; + + @Column({ name: 'amount_paid', type: 'decimal', precision: 15, scale: 2, default: 0 }) + amountPaid: number; + + @Column({ name: 'amount_refunded', type: 'decimal', precision: 15, scale: 2, default: 0 }) + amountRefunded: number; + + // Item counts + @Column({ name: 'items_count', type: 'int', default: 0 }) + itemsCount: number; + + @Column({ name: 'items_quantity', type: 'decimal', precision: 15, scale: 4, default: 0 }) + itemsQuantity: number; + + // Coupon + @Column({ name: 'coupon_id', type: 'uuid', nullable: true }) + couponId: string; + + @Column({ name: 'coupon_code', length: 50, nullable: true }) + couponCode: string; + + @Column({ name: 'coupon_discount', type: 'decimal', precision: 15, scale: 2, default: 0 }) + couponDiscount: number; + + // Loyalty + @Column({ name: 'loyalty_points_used', type: 'int', default: 0 }) + loyaltyPointsUsed: number; + + @Column({ name: 'loyalty_points_value', type: 'decimal', precision: 15, scale: 2, default: 0 }) + loyaltyPointsValue: number; + + @Column({ name: 'loyalty_points_earned', type: 'int', default: 0 }) + loyaltyPointsEarned: number; + + // Shipping address + @Column({ name: 'shipping_address', type: 'jsonb' }) + shippingAddress: { + firstName: string; + lastName: string; + company?: string; + address1: string; + address2?: string; + city: string; + state: string; + postalCode: string; + country: string; + phone: string; + instructions?: string; + }; + + // Billing address + @Column({ name: 'billing_address', type: 'jsonb' }) + billingAddress: { + firstName: string; + lastName: string; + company?: string; + rfc?: string; + address1: string; + address2?: string; + city: string; + state: string; + postalCode: string; + country: string; + phone: string; + }; + + // Shipping + @Column({ name: 'shipping_method_id', type: 'uuid', nullable: true }) + shippingMethodId: string; + + @Column({ name: 'shipping_method_name', length: 100, nullable: true }) + shippingMethodName: string; + + @Column({ name: 'shipping_carrier', length: 100, nullable: true }) + shippingCarrier: string; + + @Column({ name: 'tracking_number', length: 100, nullable: true }) + trackingNumber: string; + + @Column({ name: 'tracking_url', length: 255, nullable: true }) + trackingUrl: string; + + @Column({ name: 'estimated_delivery', type: 'date', nullable: true }) + estimatedDelivery: Date; + + @Column({ name: 'shipped_at', type: 'timestamp with time zone', nullable: true }) + shippedAt: Date; + + @Column({ name: 'delivered_at', type: 'timestamp with time zone', nullable: true }) + deliveredAt: Date; + + // Pickup + @Column({ name: 'pickup_branch_id', type: 'uuid', nullable: true }) + pickupBranchId: string; + + @Column({ name: 'pickup_ready_at', type: 'timestamp with time zone', nullable: true }) + pickupReadyAt: Date; + + @Column({ name: 'picked_up_at', type: 'timestamp with time zone', nullable: true }) + pickedUpAt: Date; + + // Payment + @Column({ name: 'payment_method', length: 50, nullable: true }) + paymentMethod: string; + + @Column({ name: 'payment_gateway', length: 50, nullable: true }) + paymentGateway: string; + + @Column({ name: 'payment_transaction_id', length: 100, nullable: true }) + paymentTransactionId: string; + + @Column({ name: 'payment_details', type: 'jsonb', nullable: true }) + paymentDetails: Record; + + // Invoice + @Column({ name: 'requires_invoice', type: 'boolean', default: false }) + requiresInvoice: boolean; + + @Column({ name: 'invoice_rfc', length: 13, nullable: true }) + invoiceRfc: string; + + @Column({ name: 'invoice_uso_cfdi', length: 4, nullable: true }) + invoiceUsoCfdi: string; + + @Column({ name: 'cfdi_id', type: 'uuid', nullable: true }) + cfdiId: string; + + @Column({ name: 'invoice_status', length: 20, nullable: true }) + invoiceStatus: string; + + // Gift + @Column({ name: 'is_gift', type: 'boolean', default: false }) + isGift: boolean; + + @Column({ name: 'gift_message', type: 'text', nullable: true }) + giftMessage: string; + + // Notes + @Column({ name: 'customer_notes', type: 'text', nullable: true }) + customerNotes: string; + + @Column({ name: 'internal_notes', type: 'text', nullable: true }) + internalNotes: string; + + // Cancellation + @Column({ name: 'cancelled_at', type: 'timestamp with time zone', nullable: true }) + cancelledAt: Date; + + @Column({ name: 'cancellation_reason', length: 255, nullable: true }) + cancellationReason: string; + + // Source cart + @Column({ name: 'cart_id', type: 'uuid', nullable: true }) + cartId: string; + + // Fulfillment warehouse + @Column({ name: 'warehouse_id', type: 'uuid', nullable: true }) + warehouseId: string; + + // Analytics + @Column({ name: 'utm_source', length: 100, nullable: true }) + utmSource: string; + + @Column({ name: 'utm_medium', length: 100, nullable: true }) + utmMedium: string; + + @Column({ name: 'utm_campaign', length: 100, nullable: true }) + utmCampaign: string; + + @Column({ name: 'device_type', length: 20, nullable: true }) + deviceType: string; + + @Column({ name: 'ip_address', length: 45, nullable: true }) + ipAddress: string; + + // Metadata + @Column({ type: 'jsonb', nullable: true }) + metadata: Record; + + @CreateDateColumn({ name: 'created_at' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at' }) + updatedAt: Date; + + @Column({ name: 'confirmed_at', type: 'timestamp with time zone', nullable: true }) + confirmedAt: Date; + + // Relations + @OneToMany(() => EcommerceOrderLine, (line) => line.order) + lines: EcommerceOrderLine[]; +} diff --git a/backend/src/modules/ecommerce/entities/index.ts b/backend/src/modules/ecommerce/entities/index.ts new file mode 100644 index 0000000..a61a16e --- /dev/null +++ b/backend/src/modules/ecommerce/entities/index.ts @@ -0,0 +1,5 @@ +export * from './cart.entity'; +export * from './cart-item.entity'; +export * from './ecommerce-order.entity'; +export * from './ecommerce-order-line.entity'; +export * from './shipping-rate.entity'; diff --git a/backend/src/modules/ecommerce/entities/shipping-rate.entity.ts b/backend/src/modules/ecommerce/entities/shipping-rate.entity.ts new file mode 100644 index 0000000..597e2b5 --- /dev/null +++ b/backend/src/modules/ecommerce/entities/shipping-rate.entity.ts @@ -0,0 +1,182 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + Index, +} from 'typeorm'; + +export enum ShippingRateStatus { + ACTIVE = 'active', + INACTIVE = 'inactive', +} + +export enum ShippingCalculation { + FLAT_RATE = 'flat_rate', + BY_WEIGHT = 'by_weight', + BY_PRICE = 'by_price', + BY_QUANTITY = 'by_quantity', + FREE = 'free', + CARRIER_CALCULATED = 'carrier_calculated', +} + +@Entity('shipping_rates', { schema: 'retail' }) +@Index(['tenantId', 'status']) +@Index(['tenantId', 'code'], { unique: true }) +export class ShippingRate { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'tenant_id', type: 'uuid' }) + @Index() + tenantId: string; + + @Column({ length: 20, unique: true }) + code: string; + + @Column({ length: 100 }) + name: string; + + @Column({ type: 'text', nullable: true }) + description: string; + + @Column({ + type: 'enum', + enum: ShippingRateStatus, + default: ShippingRateStatus.ACTIVE, + }) + status: ShippingRateStatus; + + @Column({ + name: 'calculation_type', + type: 'enum', + enum: ShippingCalculation, + default: ShippingCalculation.FLAT_RATE, + }) + calculationType: ShippingCalculation; + + // Flat rate + @Column({ name: 'flat_rate', type: 'decimal', precision: 15, scale: 2, nullable: true }) + flatRate: number; + + // Weight-based rates + @Column({ name: 'weight_rates', type: 'jsonb', nullable: true }) + weightRates: { + minWeight: number; + maxWeight: number; + rate: number; + }[]; + + @Column({ name: 'weight_unit', length: 5, default: 'kg' }) + weightUnit: string; + + // Price-based rates + @Column({ name: 'price_rates', type: 'jsonb', nullable: true }) + priceRates: { + minPrice: number; + maxPrice: number; + rate: number; + }[]; + + // Quantity-based rates + @Column({ name: 'quantity_rates', type: 'jsonb', nullable: true }) + quantityRates: { + minQuantity: number; + maxQuantity: number; + rate: number; + }[]; + + // Free shipping threshold + @Column({ name: 'free_shipping_threshold', type: 'decimal', precision: 15, scale: 2, nullable: true }) + freeShippingThreshold: number; + + // Carrier integration + @Column({ name: 'carrier_code', length: 50, nullable: true }) + carrierCode: string; + + @Column({ name: 'carrier_name', length: 100, nullable: true }) + carrierName: string; + + @Column({ name: 'carrier_service', length: 50, nullable: true }) + carrierService: string; + + @Column({ name: 'carrier_api_config', type: 'jsonb', nullable: true }) + carrierApiConfig: Record; + + // Delivery time + @Column({ name: 'min_delivery_days', type: 'int', nullable: true }) + minDeliveryDays: number; + + @Column({ name: 'max_delivery_days', type: 'int', nullable: true }) + maxDeliveryDays: number; + + @Column({ name: 'delivery_message', length: 255, nullable: true }) + deliveryMessage: string; + + // Geographic restrictions + @Column({ name: 'allowed_countries', type: 'jsonb', nullable: true }) + allowedCountries: string[]; + + @Column({ name: 'allowed_states', type: 'jsonb', nullable: true }) + allowedStates: string[]; + + @Column({ name: 'allowed_postal_codes', type: 'jsonb', nullable: true }) + allowedPostalCodes: string[]; + + @Column({ name: 'excluded_postal_codes', type: 'jsonb', nullable: true }) + excludedPostalCodes: string[]; + + // Product restrictions + @Column({ name: 'excluded_categories', type: 'jsonb', nullable: true }) + excludedCategories: string[]; + + @Column({ name: 'excluded_products', type: 'jsonb', nullable: true }) + excludedProducts: string[]; + + // Weight/dimension limits + @Column({ name: 'max_weight', type: 'decimal', precision: 10, scale: 4, nullable: true }) + maxWeight: number; + + @Column({ name: 'max_dimensions', type: 'jsonb', nullable: true }) + maxDimensions: { + length: number; + width: number; + height: number; + unit: string; + }; + + // Customer restrictions + @Column({ name: 'customer_levels', type: 'jsonb', nullable: true }) + customerLevels: string[]; + + // Tax + @Column({ name: 'is_taxable', type: 'boolean', default: true }) + isTaxable: boolean; + + @Column({ name: 'tax_rate', type: 'decimal', precision: 5, scale: 4, nullable: true }) + taxRate: number; + + // Display + @Column({ type: 'int', default: 0 }) + priority: number; + + @Column({ name: 'display_name', length: 100, nullable: true }) + displayName: string; + + @Column({ name: 'icon_url', length: 255, nullable: true }) + iconUrl: string; + + // Metadata + @Column({ type: 'jsonb', nullable: true }) + metadata: Record; + + @CreateDateColumn({ name: 'created_at' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at' }) + updatedAt: Date; + + @Column({ name: 'created_by', type: 'uuid', nullable: true }) + createdBy: string; +} diff --git a/backend/src/modules/inventory/controllers/inventory.controller.ts b/backend/src/modules/inventory/controllers/inventory.controller.ts new file mode 100644 index 0000000..9007143 --- /dev/null +++ b/backend/src/modules/inventory/controllers/inventory.controller.ts @@ -0,0 +1,401 @@ +import { Request, Response } from 'express'; +import { BaseController } from '../../../shared/controllers/base.controller'; +import { StockTransferService, TransferQueryOptions } from '../services/stock-transfer.service'; +import { StockAdjustmentService, AdjustmentQueryOptions } from '../services/stock-adjustment.service'; + +export class InventoryController extends BaseController { + constructor( + private readonly transferService: StockTransferService, + private readonly adjustmentService: StockAdjustmentService + ) { + super(); + } + + // ==================== TRANSFER ENDPOINTS ==================== + + /** + * Create a stock transfer + * POST /inventory/transfers + */ + createTransfer = async (req: Request, res: Response): Promise => { + const tenantId = req.tenantContext!.tenantId; + const userId = req.userContext!.userId; + + const result = await this.transferService.createTransfer(tenantId, req.body, userId); + + if (!result.success) { + this.error(res, result.error!.message, 400, result.error!.code); + return; + } + + this.created(res, result.data); + }; + + /** + * List transfers + * GET /inventory/transfers + */ + listTransfers = async (req: Request, res: Response): Promise => { + const tenantId = req.tenantContext!.tenantId; + const options: TransferQueryOptions = { + page: Number(req.query.page) || 1, + limit: Number(req.query.limit) || 20, + branchId: req.query.branchId as string, + sourceBranchId: req.query.sourceBranchId as string, + destBranchId: req.query.destBranchId as string, + status: req.query.status as any, + type: req.query.type as any, + startDate: req.query.startDate ? new Date(req.query.startDate as string) : undefined, + endDate: req.query.endDate ? new Date(req.query.endDate as string) : undefined, + }; + + const result = await this.transferService.findTransfers(tenantId, options); + this.paginated(res, result.data, result.total, options.page!, options.limit!); + }; + + /** + * Get transfer by ID + * GET /inventory/transfers/:id + */ + getTransfer = async (req: Request, res: Response): Promise => { + const tenantId = req.tenantContext!.tenantId; + const { id } = req.params; + + const transfer = await this.transferService.getTransferWithLines(tenantId, id); + + if (!transfer) { + this.notFound(res, 'Transfer'); + return; + } + + this.success(res, transfer); + }; + + /** + * Submit transfer for approval + * POST /inventory/transfers/:id/submit + */ + submitTransfer = async (req: Request, res: Response): Promise => { + const tenantId = req.tenantContext!.tenantId; + const userId = req.userContext!.userId; + const { id } = req.params; + + const result = await this.transferService.submitForApproval(tenantId, id, userId); + + if (!result.success) { + this.error(res, result.error!.message, 400, result.error!.code); + return; + } + + this.success(res, result.data, 'Transfer submitted for approval'); + }; + + /** + * Approve transfer + * POST /inventory/transfers/:id/approve + */ + approveTransfer = async (req: Request, res: Response): Promise => { + const tenantId = req.tenantContext!.tenantId; + const userId = req.userContext!.userId; + const { id } = req.params; + + const result = await this.transferService.approveTransfer(tenantId, id, userId); + + if (!result.success) { + this.error(res, result.error!.message, 400, result.error!.code); + return; + } + + this.success(res, result.data, 'Transfer approved'); + }; + + /** + * Ship transfer + * POST /inventory/transfers/:id/ship + */ + shipTransfer = async (req: Request, res: Response): Promise => { + const tenantId = req.tenantContext!.tenantId; + const userId = req.userContext!.userId; + const { id } = req.params; + const { lines, shippingMethod, trackingNumber, carrierName } = req.body; + + const result = await this.transferService.shipTransfer( + tenantId, + id, + lines, + { method: shippingMethod, trackingNumber, carrierName }, + userId + ); + + if (!result.success) { + this.error(res, result.error!.message, 400, result.error!.code); + return; + } + + this.success(res, result.data, 'Transfer shipped'); + }; + + /** + * Receive transfer + * POST /inventory/transfers/:id/receive + */ + receiveTransfer = async (req: Request, res: Response): Promise => { + const tenantId = req.tenantContext!.tenantId; + const userId = req.userContext!.userId; + const { id } = req.params; + const { lines, notes } = req.body; + + const result = await this.transferService.receiveTransfer(tenantId, id, lines, notes, userId); + + if (!result.success) { + this.error(res, result.error!.message, 400, result.error!.code); + return; + } + + this.success(res, result.data, 'Transfer received'); + }; + + /** + * Cancel transfer + * POST /inventory/transfers/:id/cancel + */ + cancelTransfer = async (req: Request, res: Response): Promise => { + const tenantId = req.tenantContext!.tenantId; + const userId = req.userContext!.userId; + const { id } = req.params; + const { reason } = req.body; + + const result = await this.transferService.cancelTransfer(tenantId, id, reason, userId); + + if (!result.success) { + this.error(res, result.error!.message, 400, result.error!.code); + return; + } + + this.success(res, result.data, 'Transfer cancelled'); + }; + + /** + * Get pending incoming transfers + * GET /inventory/transfers/incoming + */ + getIncomingTransfers = async (req: Request, res: Response): Promise => { + const tenantId = req.tenantContext!.tenantId; + const branchId = req.branchContext?.branchId || (req.query.branchId as string); + + if (!branchId) { + this.error(res, 'Branch ID is required', 400); + return; + } + + const transfers = await this.transferService.getPendingIncomingTransfers(tenantId, branchId); + this.success(res, transfers); + }; + + /** + * Get transfer summary + * GET /inventory/transfers/summary + */ + getTransferSummary = async (req: Request, res: Response): Promise => { + const tenantId = req.tenantContext!.tenantId; + const branchId = req.query.branchId as string; + + const summary = await this.transferService.getTransferSummary(tenantId, branchId); + this.success(res, summary); + }; + + // ==================== ADJUSTMENT ENDPOINTS ==================== + + /** + * Create a stock adjustment + * POST /inventory/adjustments + */ + createAdjustment = async (req: Request, res: Response): Promise => { + const tenantId = req.tenantContext!.tenantId; + const userId = req.userContext!.userId; + + const result = await this.adjustmentService.createAdjustment(tenantId, req.body, userId); + + if (!result.success) { + this.error(res, result.error!.message, 400, result.error!.code); + return; + } + + this.created(res, result.data); + }; + + /** + * List adjustments + * GET /inventory/adjustments + */ + listAdjustments = async (req: Request, res: Response): Promise => { + const tenantId = req.tenantContext!.tenantId; + const options: AdjustmentQueryOptions = { + page: Number(req.query.page) || 1, + limit: Number(req.query.limit) || 20, + branchId: req.query.branchId as string, + warehouseId: req.query.warehouseId as string, + status: req.query.status as any, + type: req.query.type as any, + startDate: req.query.startDate ? new Date(req.query.startDate as string) : undefined, + endDate: req.query.endDate ? new Date(req.query.endDate as string) : undefined, + }; + + const result = await this.adjustmentService.findAdjustments(tenantId, options); + this.paginated(res, result.data, result.total, options.page!, options.limit!); + }; + + /** + * Get adjustment by ID + * GET /inventory/adjustments/:id + */ + getAdjustment = async (req: Request, res: Response): Promise => { + const tenantId = req.tenantContext!.tenantId; + const { id } = req.params; + + const adjustment = await this.adjustmentService.getAdjustmentWithLines(tenantId, id); + + if (!adjustment) { + this.notFound(res, 'Adjustment'); + return; + } + + this.success(res, adjustment); + }; + + /** + * Submit adjustment for approval + * POST /inventory/adjustments/:id/submit + */ + submitAdjustment = async (req: Request, res: Response): Promise => { + const tenantId = req.tenantContext!.tenantId; + const userId = req.userContext!.userId; + const { id } = req.params; + + const result = await this.adjustmentService.submitForApproval(tenantId, id, userId); + + if (!result.success) { + this.error(res, result.error!.message, 400, result.error!.code); + return; + } + + this.success(res, result.data, 'Adjustment submitted'); + }; + + /** + * Approve adjustment + * POST /inventory/adjustments/:id/approve + */ + approveAdjustment = async (req: Request, res: Response): Promise => { + const tenantId = req.tenantContext!.tenantId; + const userId = req.userContext!.userId; + const { id } = req.params; + + const result = await this.adjustmentService.approveAdjustment(tenantId, id, userId); + + if (!result.success) { + this.error(res, result.error!.message, 400, result.error!.code); + return; + } + + this.success(res, result.data, 'Adjustment approved'); + }; + + /** + * Reject adjustment + * POST /inventory/adjustments/:id/reject + */ + rejectAdjustment = async (req: Request, res: Response): Promise => { + const tenantId = req.tenantContext!.tenantId; + const userId = req.userContext!.userId; + const { id } = req.params; + const { reason } = req.body; + + const result = await this.adjustmentService.rejectAdjustment(tenantId, id, userId, reason); + + if (!result.success) { + this.error(res, result.error!.message, 400, result.error!.code); + return; + } + + this.success(res, result.data, 'Adjustment rejected'); + }; + + /** + * Post adjustment + * POST /inventory/adjustments/:id/post + */ + postAdjustment = async (req: Request, res: Response): Promise => { + const tenantId = req.tenantContext!.tenantId; + const userId = req.userContext!.userId; + const { id } = req.params; + + const result = await this.adjustmentService.postAdjustment(tenantId, id, userId); + + if (!result.success) { + this.error(res, result.error!.message, 400, result.error!.code); + return; + } + + this.success(res, result.data, 'Adjustment posted to inventory'); + }; + + /** + * Cancel adjustment + * POST /inventory/adjustments/:id/cancel + */ + cancelAdjustment = async (req: Request, res: Response): Promise => { + const tenantId = req.tenantContext!.tenantId; + const userId = req.userContext!.userId; + const { id } = req.params; + const { reason } = req.body; + + const result = await this.adjustmentService.cancelAdjustment(tenantId, id, reason, userId); + + if (!result.success) { + this.error(res, result.error!.message, 400, result.error!.code); + return; + } + + this.success(res, result.data, 'Adjustment cancelled'); + }; + + /** + * Add line to adjustment + * POST /inventory/adjustments/:id/lines + */ + addAdjustmentLine = async (req: Request, res: Response): Promise => { + const tenantId = req.tenantContext!.tenantId; + const userId = req.userContext!.userId; + const { id } = req.params; + + const result = await this.adjustmentService.addLine(tenantId, id, req.body, userId); + + if (!result.success) { + this.error(res, result.error!.message, 400, result.error!.code); + return; + } + + this.created(res, result.data); + }; + + /** + * Get adjustment summary + * GET /inventory/adjustments/summary + */ + getAdjustmentSummary = async (req: Request, res: Response): Promise => { + const tenantId = req.tenantContext!.tenantId; + const branchId = req.query.branchId as string; + const startDate = req.query.startDate ? new Date(req.query.startDate as string) : undefined; + const endDate = req.query.endDate ? new Date(req.query.endDate as string) : undefined; + + const summary = await this.adjustmentService.getAdjustmentSummary( + tenantId, + branchId, + startDate, + endDate + ); + + this.success(res, summary); + }; +} diff --git a/backend/src/modules/inventory/entities/index.ts b/backend/src/modules/inventory/entities/index.ts new file mode 100644 index 0000000..8ce403e --- /dev/null +++ b/backend/src/modules/inventory/entities/index.ts @@ -0,0 +1,4 @@ +export * from './stock-transfer.entity'; +export * from './stock-transfer-line.entity'; +export * from './stock-adjustment.entity'; +export * from './stock-adjustment-line.entity'; diff --git a/backend/src/modules/inventory/entities/stock-adjustment-line.entity.ts b/backend/src/modules/inventory/entities/stock-adjustment-line.entity.ts new file mode 100644 index 0000000..f4319b8 --- /dev/null +++ b/backend/src/modules/inventory/entities/stock-adjustment-line.entity.ts @@ -0,0 +1,120 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + ManyToOne, + JoinColumn, + Index, +} from 'typeorm'; +import { StockAdjustment } from './stock-adjustment.entity'; + +export enum AdjustmentDirection { + INCREASE = 'increase', + DECREASE = 'decrease', +} + +@Entity('stock_adjustment_lines', { schema: 'retail' }) +@Index(['tenantId', 'adjustmentId']) +@Index(['tenantId', 'productId']) +export class StockAdjustmentLine { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'tenant_id', type: 'uuid' }) + @Index() + tenantId: string; + + @Column({ name: 'adjustment_id', type: 'uuid' }) + adjustmentId: string; + + @Column({ name: 'line_number', type: 'int' }) + lineNumber: number; + + // Product (from erp-core) + @Column({ name: 'product_id', type: 'uuid' }) + productId: string; + + @Column({ name: 'product_code', length: 50 }) + productCode: string; + + @Column({ name: 'product_name', length: 200 }) + productName: string; + + @Column({ name: 'product_barcode', length: 50, nullable: true }) + productBarcode: string; + + // Variant + @Column({ name: 'variant_id', type: 'uuid', nullable: true }) + variantId: string; + + @Column({ name: 'variant_name', length: 200, nullable: true }) + variantName: string; + + // Unit of measure + @Column({ name: 'uom_id', type: 'uuid', nullable: true }) + uomId: string; + + @Column({ name: 'uom_name', length: 20, default: 'PZA' }) + uomName: string; + + // Stock quantities + @Column({ name: 'system_quantity', type: 'decimal', precision: 15, scale: 4 }) + systemQuantity: number; + + @Column({ name: 'counted_quantity', type: 'decimal', precision: 15, scale: 4 }) + countedQuantity: number; + + @Column({ name: 'adjustment_quantity', type: 'decimal', precision: 15, scale: 4 }) + adjustmentQuantity: number; + + @Column({ + type: 'enum', + enum: AdjustmentDirection, + }) + direction: AdjustmentDirection; + + // Costs + @Column({ name: 'unit_cost', type: 'decimal', precision: 15, scale: 4, default: 0 }) + unitCost: number; + + @Column({ name: 'adjustment_value', type: 'decimal', precision: 15, scale: 2, default: 0 }) + adjustmentValue: number; + + // Location + @Column({ name: 'location_id', type: 'uuid', nullable: true }) + locationId: string; + + @Column({ name: 'location_code', length: 50, nullable: true }) + locationCode: string; + + // Lot/Serial tracking + @Column({ name: 'lot_number', length: 50, nullable: true }) + lotNumber: string; + + @Column({ name: 'serial_number', length: 50, nullable: true }) + serialNumber: string; + + @Column({ name: 'expiry_date', type: 'date', nullable: true }) + expiryDate: Date; + + // Reason (line-level) + @Column({ name: 'line_reason', length: 255, nullable: true }) + lineReason: string; + + // Notes + @Column({ type: 'text', nullable: true }) + notes: string; + + // Metadata + @Column({ type: 'jsonb', nullable: true }) + metadata: Record; + + @CreateDateColumn({ name: 'created_at' }) + createdAt: Date; + + // Relations + @ManyToOne(() => StockAdjustment, (adjustment) => adjustment.lines) + @JoinColumn({ name: 'adjustment_id' }) + adjustment: StockAdjustment; +} diff --git a/backend/src/modules/inventory/entities/stock-adjustment.entity.ts b/backend/src/modules/inventory/entities/stock-adjustment.entity.ts new file mode 100644 index 0000000..7ff62d5 --- /dev/null +++ b/backend/src/modules/inventory/entities/stock-adjustment.entity.ts @@ -0,0 +1,169 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + OneToMany, + Index, +} from 'typeorm'; +import { StockAdjustmentLine } from './stock-adjustment-line.entity'; + +export enum AdjustmentStatus { + DRAFT = 'draft', + PENDING_APPROVAL = 'pending_approval', + APPROVED = 'approved', + POSTED = 'posted', + REJECTED = 'rejected', + CANCELLED = 'cancelled', +} + +export enum AdjustmentType { + INVENTORY_COUNT = 'inventory_count', + DAMAGE = 'damage', + THEFT = 'theft', + EXPIRY = 'expiry', + CORRECTION = 'correction', + INITIAL_STOCK = 'initial_stock', + PRODUCTION = 'production', + OTHER = 'other', +} + +@Entity('stock_adjustments', { schema: 'retail' }) +@Index(['tenantId', 'branchId', 'status']) +@Index(['tenantId', 'adjustmentDate']) +@Index(['tenantId', 'number'], { unique: true }) +export class StockAdjustment { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'tenant_id', type: 'uuid' }) + @Index() + tenantId: string; + + @Column({ name: 'branch_id', type: 'uuid' }) + branchId: string; + + @Column({ name: 'warehouse_id', type: 'uuid' }) + warehouseId: string; + + @Column({ name: 'location_id', type: 'uuid', nullable: true }) + locationId: string; + + @Column({ length: 30, unique: true }) + number: string; + + @Column({ + type: 'enum', + enum: AdjustmentType, + default: AdjustmentType.INVENTORY_COUNT, + }) + type: AdjustmentType; + + @Column({ + type: 'enum', + enum: AdjustmentStatus, + default: AdjustmentStatus.DRAFT, + }) + status: AdjustmentStatus; + + @Column({ name: 'adjustment_date', type: 'date' }) + adjustmentDate: Date; + + // Count info (for inventory counts) + @Column({ name: 'is_full_count', type: 'boolean', default: false }) + isFullCount: boolean; + + @Column({ name: 'count_category_id', type: 'uuid', nullable: true }) + countCategoryId: string; + + // Summary + @Column({ name: 'lines_count', type: 'int', default: 0 }) + linesCount: number; + + @Column({ name: 'total_increase_qty', type: 'decimal', precision: 15, scale: 4, default: 0 }) + totalIncreaseQty: number; + + @Column({ name: 'total_decrease_qty', type: 'decimal', precision: 15, scale: 4, default: 0 }) + totalDecreaseQty: number; + + @Column({ name: 'total_increase_value', type: 'decimal', precision: 15, scale: 2, default: 0 }) + totalIncreaseValue: number; + + @Column({ name: 'total_decrease_value', type: 'decimal', precision: 15, scale: 2, default: 0 }) + totalDecreaseValue: number; + + @Column({ name: 'net_adjustment_value', type: 'decimal', precision: 15, scale: 2, default: 0 }) + netAdjustmentValue: number; + + // Financial account (for accounting integration) + @Column({ name: 'account_id', type: 'uuid', nullable: true }) + accountId: string; + + @Column({ name: 'counterpart_account_id', type: 'uuid', nullable: true }) + counterpartAccountId: string; + + // Approval workflow + @Column({ name: 'requires_approval', type: 'boolean', default: true }) + requiresApproval: boolean; + + @Column({ name: 'approval_threshold', type: 'decimal', precision: 15, scale: 2, nullable: true }) + approvalThreshold: number; + + // Users + @Column({ name: 'created_by', type: 'uuid' }) + createdBy: string; + + @Column({ name: 'approved_by', type: 'uuid', nullable: true }) + approvedBy: string; + + @Column({ name: 'approved_at', type: 'timestamp with time zone', nullable: true }) + approvedAt: Date; + + @Column({ name: 'posted_by', type: 'uuid', nullable: true }) + postedBy: string; + + @Column({ name: 'posted_at', type: 'timestamp with time zone', nullable: true }) + postedAt: Date; + + @Column({ name: 'rejected_by', type: 'uuid', nullable: true }) + rejectedBy: string; + + @Column({ name: 'rejected_at', type: 'timestamp with time zone', nullable: true }) + rejectedAt: Date; + + @Column({ name: 'rejection_reason', length: 255, nullable: true }) + rejectionReason: string; + + // Reason/Description + @Column({ type: 'text' }) + reason: string; + + // Reference + @Column({ name: 'reference_type', length: 50, nullable: true }) + referenceType: string; + + @Column({ name: 'reference_id', type: 'uuid', nullable: true }) + referenceId: string; + + @Column({ name: 'reference_number', length: 50, nullable: true }) + referenceNumber: string; + + // Notes + @Column({ type: 'text', nullable: true }) + notes: string; + + // Metadata + @Column({ type: 'jsonb', nullable: true }) + metadata: Record; + + @CreateDateColumn({ name: 'created_at' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at' }) + updatedAt: Date; + + // Relations + @OneToMany(() => StockAdjustmentLine, (line) => line.adjustment) + lines: StockAdjustmentLine[]; +} diff --git a/backend/src/modules/inventory/entities/stock-transfer-line.entity.ts b/backend/src/modules/inventory/entities/stock-transfer-line.entity.ts new file mode 100644 index 0000000..5b313d2 --- /dev/null +++ b/backend/src/modules/inventory/entities/stock-transfer-line.entity.ts @@ -0,0 +1,144 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + ManyToOne, + JoinColumn, + Index, +} from 'typeorm'; +import { StockTransfer } from './stock-transfer.entity'; + +export enum TransferLineStatus { + PENDING = 'pending', + PARTIALLY_SHIPPED = 'partially_shipped', + SHIPPED = 'shipped', + PARTIALLY_RECEIVED = 'partially_received', + RECEIVED = 'received', + CANCELLED = 'cancelled', +} + +@Entity('stock_transfer_lines', { schema: 'retail' }) +@Index(['tenantId', 'transferId']) +@Index(['tenantId', 'productId']) +export class StockTransferLine { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'tenant_id', type: 'uuid' }) + @Index() + tenantId: string; + + @Column({ name: 'transfer_id', type: 'uuid' }) + transferId: string; + + @Column({ name: 'line_number', type: 'int' }) + lineNumber: number; + + // Product (from erp-core) + @Column({ name: 'product_id', type: 'uuid' }) + productId: string; + + @Column({ name: 'product_code', length: 50 }) + productCode: string; + + @Column({ name: 'product_name', length: 200 }) + productName: string; + + @Column({ name: 'product_barcode', length: 50, nullable: true }) + productBarcode: string; + + // Variant + @Column({ name: 'variant_id', type: 'uuid', nullable: true }) + variantId: string; + + @Column({ name: 'variant_name', length: 200, nullable: true }) + variantName: string; + + // Unit of measure + @Column({ name: 'uom_id', type: 'uuid', nullable: true }) + uomId: string; + + @Column({ name: 'uom_name', length: 20, default: 'PZA' }) + uomName: string; + + // Quantities + @Column({ name: 'quantity_requested', type: 'decimal', precision: 15, scale: 4 }) + quantityRequested: number; + + @Column({ name: 'quantity_approved', type: 'decimal', precision: 15, scale: 4, nullable: true }) + quantityApproved: number; + + @Column({ name: 'quantity_shipped', type: 'decimal', precision: 15, scale: 4, default: 0 }) + quantityShipped: number; + + @Column({ name: 'quantity_received', type: 'decimal', precision: 15, scale: 4, default: 0 }) + quantityReceived: number; + + @Column({ name: 'quantity_difference', type: 'decimal', precision: 15, scale: 4, default: 0 }) + quantityDifference: number; + + // Costs + @Column({ name: 'unit_cost', type: 'decimal', precision: 15, scale: 4, default: 0 }) + unitCost: number; + + @Column({ name: 'total_cost', type: 'decimal', precision: 15, scale: 2, default: 0 }) + totalCost: number; + + // Stock info at time of request + @Column({ name: 'source_stock_before', type: 'decimal', precision: 15, scale: 4, nullable: true }) + sourceStockBefore: number; + + @Column({ name: 'dest_stock_before', type: 'decimal', precision: 15, scale: 4, nullable: true }) + destStockBefore: number; + + // Lot/Serial tracking + @Column({ name: 'lot_number', length: 50, nullable: true }) + lotNumber: string; + + @Column({ name: 'serial_numbers', type: 'jsonb', nullable: true }) + serialNumbers: string[]; + + @Column({ name: 'expiry_date', type: 'date', nullable: true }) + expiryDate: Date; + + // Status + @Column({ + type: 'enum', + enum: TransferLineStatus, + default: TransferLineStatus.PENDING, + }) + status: TransferLineStatus; + + // Receiving details + @Column({ name: 'received_date', type: 'timestamp with time zone', nullable: true }) + receivedDate: Date; + + @Column({ name: 'received_by', type: 'uuid', nullable: true }) + receivedBy: string; + + @Column({ name: 'receiving_notes', length: 255, nullable: true }) + receivingNotes: string; + + @Column({ name: 'damage_quantity', type: 'decimal', precision: 15, scale: 4, default: 0 }) + damageQuantity: number; + + @Column({ name: 'damage_reason', length: 255, nullable: true }) + damageReason: string; + + // Notes + @Column({ type: 'text', nullable: true }) + notes: string; + + // Metadata + @Column({ type: 'jsonb', nullable: true }) + metadata: Record; + + @CreateDateColumn({ name: 'created_at' }) + createdAt: Date; + + // Relations + @ManyToOne(() => StockTransfer, (transfer) => transfer.lines) + @JoinColumn({ name: 'transfer_id' }) + transfer: StockTransfer; +} diff --git a/backend/src/modules/inventory/entities/stock-transfer.entity.ts b/backend/src/modules/inventory/entities/stock-transfer.entity.ts new file mode 100644 index 0000000..dbaf3d7 --- /dev/null +++ b/backend/src/modules/inventory/entities/stock-transfer.entity.ts @@ -0,0 +1,173 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + OneToMany, + Index, +} from 'typeorm'; +import { StockTransferLine } from './stock-transfer-line.entity'; + +export enum TransferStatus { + DRAFT = 'draft', + PENDING_APPROVAL = 'pending_approval', + APPROVED = 'approved', + IN_TRANSIT = 'in_transit', + PARTIALLY_RECEIVED = 'partially_received', + RECEIVED = 'received', + CANCELLED = 'cancelled', +} + +export enum TransferType { + BRANCH_TO_BRANCH = 'branch_to_branch', + WAREHOUSE_TO_BRANCH = 'warehouse_to_branch', + BRANCH_TO_WAREHOUSE = 'branch_to_warehouse', + INTERNAL = 'internal', +} + +@Entity('stock_transfers', { schema: 'retail' }) +@Index(['tenantId', 'status', 'createdAt']) +@Index(['tenantId', 'sourceBranchId']) +@Index(['tenantId', 'destBranchId']) +@Index(['tenantId', 'number'], { unique: true }) +export class StockTransfer { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'tenant_id', type: 'uuid' }) + @Index() + tenantId: string; + + @Column({ length: 30, unique: true }) + number: string; + + @Column({ + type: 'enum', + enum: TransferType, + default: TransferType.BRANCH_TO_BRANCH, + }) + type: TransferType; + + @Column({ + type: 'enum', + enum: TransferStatus, + default: TransferStatus.DRAFT, + }) + status: TransferStatus; + + // Source + @Column({ name: 'source_branch_id', type: 'uuid', nullable: true }) + sourceBranchId: string; + + @Column({ name: 'source_warehouse_id', type: 'uuid' }) + sourceWarehouseId: string; + + @Column({ name: 'source_location_id', type: 'uuid', nullable: true }) + sourceLocationId: string; + + // Destination + @Column({ name: 'dest_branch_id', type: 'uuid', nullable: true }) + destBranchId: string; + + @Column({ name: 'dest_warehouse_id', type: 'uuid' }) + destWarehouseId: string; + + @Column({ name: 'dest_location_id', type: 'uuid', nullable: true }) + destLocationId: string; + + // Dates + @Column({ name: 'requested_date', type: 'date' }) + requestedDate: Date; + + @Column({ name: 'expected_date', type: 'date', nullable: true }) + expectedDate: Date; + + @Column({ name: 'shipped_date', type: 'timestamp with time zone', nullable: true }) + shippedDate: Date; + + @Column({ name: 'received_date', type: 'timestamp with time zone', nullable: true }) + receivedDate: Date; + + // Line counts + @Column({ name: 'lines_count', type: 'int', default: 0 }) + linesCount: number; + + @Column({ name: 'items_requested', type: 'decimal', precision: 15, scale: 4, default: 0 }) + itemsRequested: number; + + @Column({ name: 'items_shipped', type: 'decimal', precision: 15, scale: 4, default: 0 }) + itemsShipped: number; + + @Column({ name: 'items_received', type: 'decimal', precision: 15, scale: 4, default: 0 }) + itemsReceived: number; + + // Totals (for reporting) + @Column({ name: 'total_cost', type: 'decimal', precision: 15, scale: 2, default: 0 }) + totalCost: number; + + @Column({ name: 'total_value', type: 'decimal', precision: 15, scale: 2, default: 0 }) + totalValue: number; + + // Shipping info + @Column({ name: 'shipping_method', length: 50, nullable: true }) + shippingMethod: string; + + @Column({ name: 'tracking_number', length: 100, nullable: true }) + trackingNumber: string; + + @Column({ name: 'carrier_name', length: 100, nullable: true }) + carrierName: string; + + // Users + @Column({ name: 'requested_by', type: 'uuid' }) + requestedBy: string; + + @Column({ name: 'approved_by', type: 'uuid', nullable: true }) + approvedBy: string; + + @Column({ name: 'approved_at', type: 'timestamp with time zone', nullable: true }) + approvedAt: Date; + + @Column({ name: 'shipped_by', type: 'uuid', nullable: true }) + shippedBy: string; + + @Column({ name: 'received_by', type: 'uuid', nullable: true }) + receivedBy: string; + + // Priority + @Column({ type: 'int', default: 0 }) + priority: number; // 0=normal, 1=high, 2=urgent + + // Reason + @Column({ type: 'text', nullable: true }) + reason: string; + + // Reference (for transfers generated from sales, etc.) + @Column({ name: 'reference_type', length: 50, nullable: true }) + referenceType: string; + + @Column({ name: 'reference_id', type: 'uuid', nullable: true }) + referenceId: string; + + // Notes + @Column({ type: 'text', nullable: true }) + notes: string; + + @Column({ name: 'receiving_notes', type: 'text', nullable: true }) + receivingNotes: string; + + // Metadata + @Column({ type: 'jsonb', nullable: true }) + metadata: Record; + + @CreateDateColumn({ name: 'created_at' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at' }) + updatedAt: Date; + + // Relations + @OneToMany(() => StockTransferLine, (line) => line.transfer) + lines: StockTransferLine[]; +} diff --git a/backend/src/modules/inventory/index.ts b/backend/src/modules/inventory/index.ts new file mode 100644 index 0000000..4dd6d0e --- /dev/null +++ b/backend/src/modules/inventory/index.ts @@ -0,0 +1,5 @@ +export * from './entities'; +export * from './services'; +export * from './controllers/inventory.controller'; +export * from './routes/inventory.routes'; +export * from './validation/inventory.schema'; diff --git a/backend/src/modules/inventory/routes/inventory.routes.ts b/backend/src/modules/inventory/routes/inventory.routes.ts new file mode 100644 index 0000000..71d8dfe --- /dev/null +++ b/backend/src/modules/inventory/routes/inventory.routes.ts @@ -0,0 +1,179 @@ +import { Router } from 'express'; +import { InventoryController } from '../controllers/inventory.controller'; +import { requireAuth, requireRoles, requirePermissions } from '../../../shared/middleware/auth.middleware'; +import { validateBody, validateQuery } from '../../../shared/validation/validation.middleware'; +import { + createTransferSchema, + shipTransferSchema, + receiveTransferSchema, + cancelTransferSchema, + listTransfersQuerySchema, + createAdjustmentSchema, + addAdjustmentLineSchema, + rejectAdjustmentSchema, + cancelAdjustmentSchema, + listAdjustmentsQuerySchema, +} from '../validation/inventory.schema'; + +export function createInventoryRoutes(controller: InventoryController): Router { + const router = Router(); + + // Apply auth middleware to all routes + router.use(requireAuth); + + // ==================== TRANSFER ROUTES ==================== + + // Create transfer + router.post( + '/transfers', + requirePermissions(['inventory.transfers.create']), + validateBody(createTransferSchema), + controller.createTransfer + ); + + // List transfers + router.get( + '/transfers', + requirePermissions(['inventory.transfers.view']), + validateQuery(listTransfersQuerySchema), + controller.listTransfers + ); + + // Get transfer summary + router.get( + '/transfers/summary', + requirePermissions(['inventory.transfers.view']), + controller.getTransferSummary + ); + + // Get incoming transfers + router.get( + '/transfers/incoming', + requirePermissions(['inventory.transfers.view']), + controller.getIncomingTransfers + ); + + // Get transfer by ID + router.get( + '/transfers/:id', + requirePermissions(['inventory.transfers.view']), + controller.getTransfer + ); + + // Submit transfer for approval + router.post( + '/transfers/:id/submit', + requirePermissions(['inventory.transfers.submit']), + controller.submitTransfer + ); + + // Approve transfer + router.post( + '/transfers/:id/approve', + requireRoles(['admin', 'warehouse_manager', 'manager']), + controller.approveTransfer + ); + + // Ship transfer + router.post( + '/transfers/:id/ship', + requirePermissions(['inventory.transfers.ship']), + validateBody(shipTransferSchema), + controller.shipTransfer + ); + + // Receive transfer + router.post( + '/transfers/:id/receive', + requirePermissions(['inventory.transfers.receive']), + validateBody(receiveTransferSchema), + controller.receiveTransfer + ); + + // Cancel transfer + router.post( + '/transfers/:id/cancel', + requirePermissions(['inventory.transfers.cancel']), + validateBody(cancelTransferSchema), + controller.cancelTransfer + ); + + // ==================== ADJUSTMENT ROUTES ==================== + + // Create adjustment + router.post( + '/adjustments', + requirePermissions(['inventory.adjustments.create']), + validateBody(createAdjustmentSchema), + controller.createAdjustment + ); + + // List adjustments + router.get( + '/adjustments', + requirePermissions(['inventory.adjustments.view']), + validateQuery(listAdjustmentsQuerySchema), + controller.listAdjustments + ); + + // Get adjustment summary + router.get( + '/adjustments/summary', + requirePermissions(['inventory.adjustments.view']), + controller.getAdjustmentSummary + ); + + // Get adjustment by ID + router.get( + '/adjustments/:id', + requirePermissions(['inventory.adjustments.view']), + controller.getAdjustment + ); + + // Add line to adjustment + router.post( + '/adjustments/:id/lines', + requirePermissions(['inventory.adjustments.edit']), + validateBody(addAdjustmentLineSchema), + controller.addAdjustmentLine + ); + + // Submit adjustment for approval + router.post( + '/adjustments/:id/submit', + requirePermissions(['inventory.adjustments.submit']), + controller.submitAdjustment + ); + + // Approve adjustment + router.post( + '/adjustments/:id/approve', + requireRoles(['admin', 'warehouse_manager', 'manager']), + controller.approveAdjustment + ); + + // Reject adjustment + router.post( + '/adjustments/:id/reject', + requireRoles(['admin', 'warehouse_manager', 'manager']), + validateBody(rejectAdjustmentSchema), + controller.rejectAdjustment + ); + + // Post adjustment + router.post( + '/adjustments/:id/post', + requireRoles(['admin', 'warehouse_manager']), + controller.postAdjustment + ); + + // Cancel adjustment + router.post( + '/adjustments/:id/cancel', + requirePermissions(['inventory.adjustments.cancel']), + validateBody(cancelAdjustmentSchema), + controller.cancelAdjustment + ); + + return router; +} diff --git a/backend/src/modules/inventory/services/index.ts b/backend/src/modules/inventory/services/index.ts new file mode 100644 index 0000000..d225f9b --- /dev/null +++ b/backend/src/modules/inventory/services/index.ts @@ -0,0 +1,2 @@ +export * from './stock-transfer.service'; +export * from './stock-adjustment.service'; diff --git a/backend/src/modules/inventory/services/stock-adjustment.service.ts b/backend/src/modules/inventory/services/stock-adjustment.service.ts new file mode 100644 index 0000000..73c961d --- /dev/null +++ b/backend/src/modules/inventory/services/stock-adjustment.service.ts @@ -0,0 +1,630 @@ +import { Repository, DataSource, Between } from 'typeorm'; +import { BaseService } from '../../../shared/services/base.service'; +import { ServiceResult, QueryOptions } from '../../../shared/types'; +import { + StockAdjustment, + AdjustmentStatus, + AdjustmentType, +} from '../entities/stock-adjustment.entity'; +import { StockAdjustmentLine, AdjustmentDirection } from '../entities/stock-adjustment-line.entity'; + +export interface AdjustmentLineInput { + productId: string; + productCode: string; + productName: string; + productBarcode?: string; + variantId?: string; + variantName?: string; + uomId?: string; + uomName?: string; + systemQuantity: number; + countedQuantity: number; + unitCost?: number; + locationId?: string; + locationCode?: string; + lotNumber?: string; + serialNumber?: string; + expiryDate?: Date; + lineReason?: string; + notes?: string; +} + +export interface CreateAdjustmentInput { + branchId: string; + warehouseId: string; + locationId?: string; + type: AdjustmentType; + adjustmentDate: Date; + isFullCount?: boolean; + countCategoryId?: string; + reason: string; + notes?: string; + lines: AdjustmentLineInput[]; +} + +export interface AdjustmentQueryOptions extends QueryOptions { + branchId?: string; + warehouseId?: string; + status?: AdjustmentStatus; + type?: AdjustmentType; + startDate?: Date; + endDate?: Date; +} + +export class StockAdjustmentService extends BaseService { + constructor( + repository: Repository, + private readonly lineRepository: Repository, + private readonly dataSource: DataSource + ) { + super(repository); + } + + /** + * Generate adjustment number + */ + private async generateAdjustmentNumber(tenantId: string, type: AdjustmentType): Promise { + const today = new Date(); + const datePrefix = today.toISOString().slice(0, 10).replace(/-/g, ''); + const typePrefix = type === AdjustmentType.INVENTORY_COUNT ? 'CNT' : 'ADJ'; + + const count = await this.repository.count({ + where: { + tenantId, + createdAt: Between( + new Date(today.setHours(0, 0, 0, 0)), + new Date(today.setHours(23, 59, 59, 999)) + ), + }, + }); + + return `${typePrefix}-${datePrefix}-${String(count + 1).padStart(4, '0')}`; + } + + /** + * Create a new stock adjustment + */ + async createAdjustment( + tenantId: string, + input: CreateAdjustmentInput, + userId: string + ): Promise> { + const queryRunner = this.dataSource.createQueryRunner(); + await queryRunner.connect(); + await queryRunner.startTransaction(); + + try { + const number = await this.generateAdjustmentNumber(tenantId, input.type); + + // Calculate totals + let totalIncreaseQty = 0; + let totalDecreaseQty = 0; + let totalIncreaseValue = 0; + let totalDecreaseValue = 0; + + for (const line of input.lines) { + const adjustmentQty = line.countedQuantity - line.systemQuantity; + const unitCost = line.unitCost ?? 0; + + if (adjustmentQty > 0) { + totalIncreaseQty += adjustmentQty; + totalIncreaseValue += adjustmentQty * unitCost; + } else { + totalDecreaseQty += Math.abs(adjustmentQty); + totalDecreaseValue += Math.abs(adjustmentQty) * unitCost; + } + } + + const netAdjustmentValue = totalIncreaseValue - totalDecreaseValue; + + // Determine if approval is required + const approvalThreshold = 10000; // Could be configurable + const requiresApproval = Math.abs(netAdjustmentValue) >= approvalThreshold; + + const adjustment = queryRunner.manager.create(StockAdjustment, { + tenantId, + branchId: input.branchId, + warehouseId: input.warehouseId, + locationId: input.locationId, + number, + type: input.type, + status: AdjustmentStatus.DRAFT, + adjustmentDate: input.adjustmentDate, + isFullCount: input.isFullCount ?? false, + countCategoryId: input.countCategoryId, + linesCount: input.lines.length, + totalIncreaseQty, + totalDecreaseQty, + totalIncreaseValue, + totalDecreaseValue, + netAdjustmentValue, + requiresApproval, + approvalThreshold: requiresApproval ? approvalThreshold : null, + reason: input.reason, + notes: input.notes, + createdBy: userId, + }); + + const savedAdjustment = await queryRunner.manager.save(adjustment); + + // Create lines + let lineNumber = 1; + for (const lineInput of input.lines) { + const adjustmentQuantity = lineInput.countedQuantity - lineInput.systemQuantity; + const direction = adjustmentQuantity >= 0 ? AdjustmentDirection.INCREASE : AdjustmentDirection.DECREASE; + const unitCost = lineInput.unitCost ?? 0; + + const line = queryRunner.manager.create(StockAdjustmentLine, { + tenantId, + adjustmentId: savedAdjustment.id, + lineNumber: lineNumber++, + productId: lineInput.productId, + productCode: lineInput.productCode, + productName: lineInput.productName, + productBarcode: lineInput.productBarcode, + variantId: lineInput.variantId, + variantName: lineInput.variantName, + uomId: lineInput.uomId, + uomName: lineInput.uomName ?? 'PZA', + systemQuantity: lineInput.systemQuantity, + countedQuantity: lineInput.countedQuantity, + adjustmentQuantity: Math.abs(adjustmentQuantity), + direction, + unitCost, + adjustmentValue: Math.abs(adjustmentQuantity) * unitCost, + locationId: lineInput.locationId, + locationCode: lineInput.locationCode, + lotNumber: lineInput.lotNumber, + serialNumber: lineInput.serialNumber, + expiryDate: lineInput.expiryDate, + lineReason: lineInput.lineReason, + notes: lineInput.notes, + }); + + await queryRunner.manager.save(line); + } + + await queryRunner.commitTransaction(); + + // Reload with lines + const result = await this.repository.findOne({ + where: { id: savedAdjustment.id, tenantId }, + relations: ['lines'], + }); + + return { success: true, data: result! }; + } catch (error) { + await queryRunner.rollbackTransaction(); + return { + success: false, + error: { + code: 'CREATE_ADJUSTMENT_ERROR', + message: error instanceof Error ? error.message : 'Failed to create adjustment', + }, + }; + } finally { + await queryRunner.release(); + } + } + + /** + * Submit adjustment for approval + */ + async submitForApproval( + tenantId: string, + id: string, + userId: string + ): Promise> { + const adjustment = await this.repository.findOne({ + where: { id, tenantId }, + }); + + if (!adjustment) { + return { + success: false, + error: { code: 'NOT_FOUND', message: 'Adjustment not found' }, + }; + } + + if (adjustment.status !== AdjustmentStatus.DRAFT) { + return { + success: false, + error: { code: 'INVALID_STATUS', message: 'Adjustment is not in draft status' }, + }; + } + + adjustment.status = adjustment.requiresApproval + ? AdjustmentStatus.PENDING_APPROVAL + : AdjustmentStatus.APPROVED; + + if (!adjustment.requiresApproval) { + adjustment.approvedBy = userId; + adjustment.approvedAt = new Date(); + } + + const saved = await this.repository.save(adjustment); + return { success: true, data: saved }; + } + + /** + * Approve adjustment + */ + async approveAdjustment( + tenantId: string, + id: string, + approverId: string + ): Promise> { + const adjustment = await this.repository.findOne({ + where: { id, tenantId }, + }); + + if (!adjustment) { + return { + success: false, + error: { code: 'NOT_FOUND', message: 'Adjustment not found' }, + }; + } + + if (adjustment.status !== AdjustmentStatus.PENDING_APPROVAL) { + return { + success: false, + error: { code: 'INVALID_STATUS', message: 'Adjustment is not pending approval' }, + }; + } + + adjustment.status = AdjustmentStatus.APPROVED; + adjustment.approvedBy = approverId; + adjustment.approvedAt = new Date(); + + const saved = await this.repository.save(adjustment); + return { success: true, data: saved }; + } + + /** + * Reject adjustment + */ + async rejectAdjustment( + tenantId: string, + id: string, + rejecterId: string, + reason: string + ): Promise> { + const adjustment = await this.repository.findOne({ + where: { id, tenantId }, + }); + + if (!adjustment) { + return { + success: false, + error: { code: 'NOT_FOUND', message: 'Adjustment not found' }, + }; + } + + if (adjustment.status !== AdjustmentStatus.PENDING_APPROVAL) { + return { + success: false, + error: { code: 'INVALID_STATUS', message: 'Adjustment is not pending approval' }, + }; + } + + adjustment.status = AdjustmentStatus.REJECTED; + adjustment.rejectedBy = rejecterId; + adjustment.rejectedAt = new Date(); + adjustment.rejectionReason = reason; + + const saved = await this.repository.save(adjustment); + return { success: true, data: saved }; + } + + /** + * Post adjustment (apply to inventory) + */ + async postAdjustment( + tenantId: string, + id: string, + userId: string + ): Promise> { + const queryRunner = this.dataSource.createQueryRunner(); + await queryRunner.connect(); + await queryRunner.startTransaction(); + + try { + const adjustment = await queryRunner.manager.findOne(StockAdjustment, { + where: { id, tenantId }, + relations: ['lines'], + }); + + if (!adjustment) { + return { + success: false, + error: { code: 'NOT_FOUND', message: 'Adjustment not found' }, + }; + } + + if (adjustment.status !== AdjustmentStatus.APPROVED) { + return { + success: false, + error: { code: 'INVALID_STATUS', message: 'Adjustment must be approved before posting' }, + }; + } + + // TODO: Update actual inventory stock levels + // This would involve updating stock records in the inventory system + // For each line: + // - If direction is INCREASE, add adjustmentQuantity to stock + // - If direction is DECREASE, subtract adjustmentQuantity from stock + + adjustment.status = AdjustmentStatus.POSTED; + adjustment.postedBy = userId; + adjustment.postedAt = new Date(); + + await queryRunner.manager.save(adjustment); + await queryRunner.commitTransaction(); + + return { success: true, data: adjustment }; + } catch (error) { + await queryRunner.rollbackTransaction(); + return { + success: false, + error: { + code: 'POST_ADJUSTMENT_ERROR', + message: error instanceof Error ? error.message : 'Failed to post adjustment', + }, + }; + } finally { + await queryRunner.release(); + } + } + + /** + * Cancel adjustment + */ + async cancelAdjustment( + tenantId: string, + id: string, + reason: string, + userId: string + ): Promise> { + const adjustment = await this.repository.findOne({ + where: { id, tenantId }, + }); + + if (!adjustment) { + return { + success: false, + error: { code: 'NOT_FOUND', message: 'Adjustment not found' }, + }; + } + + if ([AdjustmentStatus.POSTED, AdjustmentStatus.CANCELLED].includes(adjustment.status)) { + return { + success: false, + error: { code: 'INVALID_STATUS', message: 'Cannot cancel a posted or already cancelled adjustment' }, + }; + } + + adjustment.status = AdjustmentStatus.CANCELLED; + adjustment.notes = `${adjustment.notes ?? ''}\nCancelled: ${reason}`.trim(); + + const saved = await this.repository.save(adjustment); + return { success: true, data: saved }; + } + + /** + * Find adjustments with filters + */ + async findAdjustments( + tenantId: string, + options: AdjustmentQueryOptions + ): Promise<{ data: StockAdjustment[]; total: number }> { + const qb = this.repository.createQueryBuilder('adjustment') + .where('adjustment.tenantId = :tenantId', { tenantId }); + + if (options.branchId) { + qb.andWhere('adjustment.branchId = :branchId', { branchId: options.branchId }); + } + if (options.warehouseId) { + qb.andWhere('adjustment.warehouseId = :warehouseId', { warehouseId: options.warehouseId }); + } + if (options.status) { + qb.andWhere('adjustment.status = :status', { status: options.status }); + } + if (options.type) { + qb.andWhere('adjustment.type = :type', { type: options.type }); + } + if (options.startDate && options.endDate) { + qb.andWhere('adjustment.adjustmentDate BETWEEN :startDate AND :endDate', { + startDate: options.startDate, + endDate: options.endDate, + }); + } + + const page = options.page ?? 1; + const limit = options.limit ?? 20; + qb.skip((page - 1) * limit).take(limit); + qb.orderBy('adjustment.adjustmentDate', 'DESC'); + + const [data, total] = await qb.getManyAndCount(); + return { data, total }; + } + + /** + * Get adjustment with lines + */ + async getAdjustmentWithLines( + tenantId: string, + id: string + ): Promise { + return this.repository.findOne({ + where: { id, tenantId }, + relations: ['lines'], + }); + } + + /** + * Get adjustment summary by type and status + */ + async getAdjustmentSummary( + tenantId: string, + branchId?: string, + startDate?: Date, + endDate?: Date + ): Promise<{ + byType: Record; + byStatus: Record; + totalIncreaseValue: number; + totalDecreaseValue: number; + netValue: number; + }> { + const qb = this.repository.createQueryBuilder('adjustment') + .where('adjustment.tenantId = :tenantId', { tenantId }); + + if (branchId) { + qb.andWhere('adjustment.branchId = :branchId', { branchId }); + } + if (startDate && endDate) { + qb.andWhere('adjustment.adjustmentDate BETWEEN :startDate AND :endDate', { + startDate, + endDate, + }); + } + + const adjustments = await qb.getMany(); + + const byType: Record = {}; + const byStatus: Record = {}; + let totalIncreaseValue = 0; + let totalDecreaseValue = 0; + + for (const adj of adjustments) { + // By type + if (!byType[adj.type]) { + byType[adj.type] = { count: 0, value: 0 }; + } + byType[adj.type].count++; + byType[adj.type].value += Number(adj.netAdjustmentValue); + + // By status + byStatus[adj.status] = (byStatus[adj.status] ?? 0) + 1; + + // Totals + totalIncreaseValue += Number(adj.totalIncreaseValue); + totalDecreaseValue += Number(adj.totalDecreaseValue); + } + + return { + byType: byType as Record, + byStatus: byStatus as Record, + totalIncreaseValue, + totalDecreaseValue, + netValue: totalIncreaseValue - totalDecreaseValue, + }; + } + + /** + * Add line to existing adjustment + */ + async addLine( + tenantId: string, + adjustmentId: string, + lineInput: AdjustmentLineInput, + userId: string + ): Promise> { + const adjustment = await this.repository.findOne({ + where: { id: adjustmentId, tenantId }, + }); + + if (!adjustment) { + return { + success: false, + error: { code: 'NOT_FOUND', message: 'Adjustment not found' }, + }; + } + + if (adjustment.status !== AdjustmentStatus.DRAFT) { + return { + success: false, + error: { code: 'INVALID_STATUS', message: 'Can only add lines to draft adjustments' }, + }; + } + + const lastLine = await this.lineRepository.findOne({ + where: { adjustmentId, tenantId }, + order: { lineNumber: 'DESC' }, + }); + + const adjustmentQuantity = lineInput.countedQuantity - lineInput.systemQuantity; + const direction = adjustmentQuantity >= 0 ? AdjustmentDirection.INCREASE : AdjustmentDirection.DECREASE; + const unitCost = lineInput.unitCost ?? 0; + + const line = this.lineRepository.create({ + tenantId, + adjustmentId, + lineNumber: (lastLine?.lineNumber ?? 0) + 1, + productId: lineInput.productId, + productCode: lineInput.productCode, + productName: lineInput.productName, + productBarcode: lineInput.productBarcode, + variantId: lineInput.variantId, + variantName: lineInput.variantName, + uomId: lineInput.uomId, + uomName: lineInput.uomName ?? 'PZA', + systemQuantity: lineInput.systemQuantity, + countedQuantity: lineInput.countedQuantity, + adjustmentQuantity: Math.abs(adjustmentQuantity), + direction, + unitCost, + adjustmentValue: Math.abs(adjustmentQuantity) * unitCost, + locationId: lineInput.locationId, + locationCode: lineInput.locationCode, + lotNumber: lineInput.lotNumber, + serialNumber: lineInput.serialNumber, + expiryDate: lineInput.expiryDate, + lineReason: lineInput.lineReason, + notes: lineInput.notes, + }); + + const saved = await this.lineRepository.save(line); + + // Update adjustment totals + await this.recalculateTotals(tenantId, adjustmentId); + + return { success: true, data: saved }; + } + + /** + * Recalculate adjustment totals from lines + */ + private async recalculateTotals(tenantId: string, adjustmentId: string): Promise { + const lines = await this.lineRepository.find({ + where: { adjustmentId, tenantId }, + }); + + let totalIncreaseQty = 0; + let totalDecreaseQty = 0; + let totalIncreaseValue = 0; + let totalDecreaseValue = 0; + + for (const line of lines) { + if (line.direction === AdjustmentDirection.INCREASE) { + totalIncreaseQty += Number(line.adjustmentQuantity); + totalIncreaseValue += Number(line.adjustmentValue); + } else { + totalDecreaseQty += Number(line.adjustmentQuantity); + totalDecreaseValue += Number(line.adjustmentValue); + } + } + + await this.repository.update( + { id: adjustmentId, tenantId }, + { + linesCount: lines.length, + totalIncreaseQty, + totalDecreaseQty, + totalIncreaseValue, + totalDecreaseValue, + netAdjustmentValue: totalIncreaseValue - totalDecreaseValue, + } + ); + } +} diff --git a/backend/src/modules/inventory/services/stock-transfer.service.ts b/backend/src/modules/inventory/services/stock-transfer.service.ts new file mode 100644 index 0000000..aeaf13e --- /dev/null +++ b/backend/src/modules/inventory/services/stock-transfer.service.ts @@ -0,0 +1,591 @@ +import { Repository, DataSource, Between, In } from 'typeorm'; +import { BaseService } from '../../../shared/services/base.service'; +import { ServiceResult, QueryOptions } from '../../../shared/types'; +import { + StockTransfer, + TransferStatus, + TransferType, +} from '../entities/stock-transfer.entity'; +import { StockTransferLine, TransferLineStatus } from '../entities/stock-transfer-line.entity'; + +export interface TransferLineInput { + productId: string; + productCode: string; + productName: string; + productBarcode?: string; + variantId?: string; + variantName?: string; + uomId?: string; + uomName?: string; + quantityRequested: number; + unitCost?: number; + lotNumber?: string; + serialNumbers?: string[]; + expiryDate?: Date; + notes?: string; +} + +export interface CreateTransferInput { + type: TransferType; + sourceBranchId?: string; + sourceWarehouseId: string; + sourceLocationId?: string; + destBranchId?: string; + destWarehouseId: string; + destLocationId?: string; + requestedDate: Date; + expectedDate?: Date; + priority?: number; + reason?: string; + notes?: string; + lines: TransferLineInput[]; +} + +export interface TransferQueryOptions extends QueryOptions { + branchId?: string; + sourceBranchId?: string; + destBranchId?: string; + status?: TransferStatus; + type?: TransferType; + startDate?: Date; + endDate?: Date; +} + +export interface ShipLineInput { + lineId: string; + quantityShipped: number; + lotNumber?: string; + serialNumbers?: string[]; +} + +export interface ReceiveLineInput { + lineId: string; + quantityReceived: number; + damageQuantity?: number; + damageReason?: string; + receivingNotes?: string; +} + +export class StockTransferService extends BaseService { + constructor( + repository: Repository, + private readonly lineRepository: Repository, + private readonly dataSource: DataSource + ) { + super(repository); + } + + /** + * Generate transfer number + */ + private async generateTransferNumber(tenantId: string): Promise { + const today = new Date(); + const datePrefix = today.toISOString().slice(0, 10).replace(/-/g, ''); + + const count = await this.repository.count({ + where: { + tenantId, + createdAt: Between( + new Date(today.setHours(0, 0, 0, 0)), + new Date(today.setHours(23, 59, 59, 999)) + ), + }, + }); + + return `TRF-${datePrefix}-${String(count + 1).padStart(4, '0')}`; + } + + /** + * Create a new stock transfer + */ + async createTransfer( + tenantId: string, + input: CreateTransferInput, + userId: string + ): Promise> { + const queryRunner = this.dataSource.createQueryRunner(); + await queryRunner.connect(); + await queryRunner.startTransaction(); + + try { + const number = await this.generateTransferNumber(tenantId); + + // Calculate totals + let totalItems = 0; + let totalCost = 0; + for (const line of input.lines) { + totalItems += line.quantityRequested; + totalCost += (line.unitCost ?? 0) * line.quantityRequested; + } + + const transfer = queryRunner.manager.create(StockTransfer, { + tenantId, + number, + type: input.type, + status: TransferStatus.DRAFT, + sourceBranchId: input.sourceBranchId, + sourceWarehouseId: input.sourceWarehouseId, + sourceLocationId: input.sourceLocationId, + destBranchId: input.destBranchId, + destWarehouseId: input.destWarehouseId, + destLocationId: input.destLocationId, + requestedDate: input.requestedDate, + expectedDate: input.expectedDate, + linesCount: input.lines.length, + itemsRequested: totalItems, + totalCost, + totalValue: totalCost, + priority: input.priority ?? 0, + reason: input.reason, + notes: input.notes, + requestedBy: userId, + }); + + const savedTransfer = await queryRunner.manager.save(transfer); + + // Create lines + let lineNumber = 1; + for (const lineInput of input.lines) { + const line = queryRunner.manager.create(StockTransferLine, { + tenantId, + transferId: savedTransfer.id, + lineNumber: lineNumber++, + productId: lineInput.productId, + productCode: lineInput.productCode, + productName: lineInput.productName, + productBarcode: lineInput.productBarcode, + variantId: lineInput.variantId, + variantName: lineInput.variantName, + uomId: lineInput.uomId, + uomName: lineInput.uomName ?? 'PZA', + quantityRequested: lineInput.quantityRequested, + unitCost: lineInput.unitCost ?? 0, + totalCost: (lineInput.unitCost ?? 0) * lineInput.quantityRequested, + lotNumber: lineInput.lotNumber, + serialNumbers: lineInput.serialNumbers, + expiryDate: lineInput.expiryDate, + notes: lineInput.notes, + status: TransferLineStatus.PENDING, + }); + + await queryRunner.manager.save(line); + } + + await queryRunner.commitTransaction(); + + // Reload with lines + const result = await this.repository.findOne({ + where: { id: savedTransfer.id, tenantId }, + relations: ['lines'], + }); + + return { success: true, data: result! }; + } catch (error) { + await queryRunner.rollbackTransaction(); + return { + success: false, + error: { + code: 'CREATE_TRANSFER_ERROR', + message: error instanceof Error ? error.message : 'Failed to create transfer', + }, + }; + } finally { + await queryRunner.release(); + } + } + + /** + * Submit transfer for approval + */ + async submitForApproval( + tenantId: string, + id: string, + userId: string + ): Promise> { + const transfer = await this.repository.findOne({ + where: { id, tenantId }, + }); + + if (!transfer) { + return { + success: false, + error: { code: 'NOT_FOUND', message: 'Transfer not found' }, + }; + } + + if (transfer.status !== TransferStatus.DRAFT) { + return { + success: false, + error: { code: 'INVALID_STATUS', message: 'Transfer is not in draft status' }, + }; + } + + transfer.status = TransferStatus.PENDING_APPROVAL; + const saved = await this.repository.save(transfer); + + return { success: true, data: saved }; + } + + /** + * Approve transfer + */ + async approveTransfer( + tenantId: string, + id: string, + approverId: string + ): Promise> { + const queryRunner = this.dataSource.createQueryRunner(); + await queryRunner.connect(); + await queryRunner.startTransaction(); + + try { + const transfer = await queryRunner.manager.findOne(StockTransfer, { + where: { id, tenantId }, + relations: ['lines'], + }); + + if (!transfer) { + return { + success: false, + error: { code: 'NOT_FOUND', message: 'Transfer not found' }, + }; + } + + if (transfer.status !== TransferStatus.PENDING_APPROVAL) { + return { + success: false, + error: { code: 'INVALID_STATUS', message: 'Transfer is not pending approval' }, + }; + } + + transfer.status = TransferStatus.APPROVED; + transfer.approvedBy = approverId; + transfer.approvedAt = new Date(); + + // Update lines with approved quantities + for (const line of transfer.lines) { + line.quantityApproved = line.quantityRequested; + await queryRunner.manager.save(line); + } + + await queryRunner.manager.save(transfer); + await queryRunner.commitTransaction(); + + return { success: true, data: transfer }; + } catch (error) { + await queryRunner.rollbackTransaction(); + return { + success: false, + error: { + code: 'APPROVE_ERROR', + message: error instanceof Error ? error.message : 'Failed to approve transfer', + }, + }; + } finally { + await queryRunner.release(); + } + } + + /** + * Ship transfer (mark as in transit) + */ + async shipTransfer( + tenantId: string, + id: string, + shipLines: ShipLineInput[], + shippingInfo: { method?: string; trackingNumber?: string; carrierName?: string }, + userId: string + ): Promise> { + const queryRunner = this.dataSource.createQueryRunner(); + await queryRunner.connect(); + await queryRunner.startTransaction(); + + try { + const transfer = await queryRunner.manager.findOne(StockTransfer, { + where: { id, tenantId }, + relations: ['lines'], + }); + + if (!transfer) { + return { + success: false, + error: { code: 'NOT_FOUND', message: 'Transfer not found' }, + }; + } + + if (transfer.status !== TransferStatus.APPROVED) { + return { + success: false, + error: { code: 'INVALID_STATUS', message: 'Transfer must be approved before shipping' }, + }; + } + + let totalShipped = 0; + for (const shipLine of shipLines) { + const line = transfer.lines.find(l => l.id === shipLine.lineId); + if (line) { + line.quantityShipped = shipLine.quantityShipped; + line.lotNumber = shipLine.lotNumber ?? line.lotNumber; + line.serialNumbers = shipLine.serialNumbers ?? line.serialNumbers; + line.status = shipLine.quantityShipped >= Number(line.quantityApproved ?? line.quantityRequested) + ? TransferLineStatus.SHIPPED + : TransferLineStatus.PARTIALLY_SHIPPED; + totalShipped += shipLine.quantityShipped; + await queryRunner.manager.save(line); + } + } + + transfer.status = TransferStatus.IN_TRANSIT; + transfer.itemsShipped = totalShipped; + transfer.shippedDate = new Date(); + transfer.shippedBy = userId; + transfer.shippingMethod = shippingInfo.method; + transfer.trackingNumber = shippingInfo.trackingNumber; + transfer.carrierName = shippingInfo.carrierName; + + await queryRunner.manager.save(transfer); + await queryRunner.commitTransaction(); + + return { success: true, data: transfer }; + } catch (error) { + await queryRunner.rollbackTransaction(); + return { + success: false, + error: { + code: 'SHIP_ERROR', + message: error instanceof Error ? error.message : 'Failed to ship transfer', + }, + }; + } finally { + await queryRunner.release(); + } + } + + /** + * Receive transfer + */ + async receiveTransfer( + tenantId: string, + id: string, + receiveLines: ReceiveLineInput[], + notes: string | null, + userId: string + ): Promise> { + const queryRunner = this.dataSource.createQueryRunner(); + await queryRunner.connect(); + await queryRunner.startTransaction(); + + try { + const transfer = await queryRunner.manager.findOne(StockTransfer, { + where: { id, tenantId }, + relations: ['lines'], + }); + + if (!transfer) { + return { + success: false, + error: { code: 'NOT_FOUND', message: 'Transfer not found' }, + }; + } + + if (![TransferStatus.IN_TRANSIT, TransferStatus.PARTIALLY_RECEIVED].includes(transfer.status)) { + return { + success: false, + error: { code: 'INVALID_STATUS', message: 'Transfer must be in transit to receive' }, + }; + } + + let totalReceived = 0; + let allFullyReceived = true; + + for (const receiveLine of receiveLines) { + const line = transfer.lines.find(l => l.id === receiveLine.lineId); + if (line) { + line.quantityReceived = Number(line.quantityReceived) + receiveLine.quantityReceived; + line.damageQuantity = receiveLine.damageQuantity ?? 0; + line.damageReason = receiveLine.damageReason; + line.receivingNotes = receiveLine.receivingNotes; + line.receivedDate = new Date(); + line.receivedBy = userId; + line.quantityDifference = Number(line.quantityShipped) - Number(line.quantityReceived); + + if (line.quantityReceived >= Number(line.quantityShipped)) { + line.status = TransferLineStatus.RECEIVED; + } else { + line.status = TransferLineStatus.PARTIALLY_RECEIVED; + allFullyReceived = false; + } + + totalReceived += receiveLine.quantityReceived; + await queryRunner.manager.save(line); + } + } + + transfer.itemsReceived = Number(transfer.itemsReceived) + totalReceived; + transfer.receivedDate = new Date(); + transfer.receivedBy = userId; + transfer.receivingNotes = notes; + transfer.status = allFullyReceived ? TransferStatus.RECEIVED : TransferStatus.PARTIALLY_RECEIVED; + + await queryRunner.manager.save(transfer); + await queryRunner.commitTransaction(); + + // TODO: Update actual inventory stock levels + + return { success: true, data: transfer }; + } catch (error) { + await queryRunner.rollbackTransaction(); + return { + success: false, + error: { + code: 'RECEIVE_ERROR', + message: error instanceof Error ? error.message : 'Failed to receive transfer', + }, + }; + } finally { + await queryRunner.release(); + } + } + + /** + * Cancel transfer + */ + async cancelTransfer( + tenantId: string, + id: string, + reason: string, + userId: string + ): Promise> { + const transfer = await this.repository.findOne({ + where: { id, tenantId }, + }); + + if (!transfer) { + return { + success: false, + error: { code: 'NOT_FOUND', message: 'Transfer not found' }, + }; + } + + if ([TransferStatus.RECEIVED, TransferStatus.CANCELLED].includes(transfer.status)) { + return { + success: false, + error: { code: 'INVALID_STATUS', message: 'Cannot cancel a received or already cancelled transfer' }, + }; + } + + transfer.status = TransferStatus.CANCELLED; + transfer.notes = `${transfer.notes ?? ''}\nCancelled: ${reason}`.trim(); + + // Update lines + await this.lineRepository.update( + { transferId: id, tenantId }, + { status: TransferLineStatus.CANCELLED } + ); + + const saved = await this.repository.save(transfer); + return { success: true, data: saved }; + } + + /** + * Find transfers with filters + */ + async findTransfers( + tenantId: string, + options: TransferQueryOptions + ): Promise<{ data: StockTransfer[]; total: number }> { + const qb = this.repository.createQueryBuilder('transfer') + .where('transfer.tenantId = :tenantId', { tenantId }); + + if (options.branchId) { + qb.andWhere('(transfer.sourceBranchId = :branchId OR transfer.destBranchId = :branchId)', { + branchId: options.branchId, + }); + } + if (options.sourceBranchId) { + qb.andWhere('transfer.sourceBranchId = :sourceBranchId', { sourceBranchId: options.sourceBranchId }); + } + if (options.destBranchId) { + qb.andWhere('transfer.destBranchId = :destBranchId', { destBranchId: options.destBranchId }); + } + if (options.status) { + qb.andWhere('transfer.status = :status', { status: options.status }); + } + if (options.type) { + qb.andWhere('transfer.type = :type', { type: options.type }); + } + if (options.startDate && options.endDate) { + qb.andWhere('transfer.requestedDate BETWEEN :startDate AND :endDate', { + startDate: options.startDate, + endDate: options.endDate, + }); + } + + const page = options.page ?? 1; + const limit = options.limit ?? 20; + qb.skip((page - 1) * limit).take(limit); + qb.orderBy('transfer.createdAt', 'DESC'); + + const [data, total] = await qb.getManyAndCount(); + return { data, total }; + } + + /** + * Get transfer with lines + */ + async getTransferWithLines( + tenantId: string, + id: string + ): Promise { + return this.repository.findOne({ + where: { id, tenantId }, + relations: ['lines'], + }); + } + + /** + * Get pending transfers for a branch (incoming) + */ + async getPendingIncomingTransfers( + tenantId: string, + branchId: string + ): Promise { + return this.repository.find({ + where: { + tenantId, + destBranchId: branchId, + status: In([TransferStatus.APPROVED, TransferStatus.IN_TRANSIT, TransferStatus.PARTIALLY_RECEIVED]), + }, + relations: ['lines'], + order: { priority: 'DESC', expectedDate: 'ASC' }, + }); + } + + /** + * Get transfer summary by status + */ + async getTransferSummary( + tenantId: string, + branchId?: string + ): Promise> { + const qb = this.repository.createQueryBuilder('transfer') + .select('transfer.status', 'status') + .addSelect('COUNT(*)', 'count') + .where('transfer.tenantId = :tenantId', { tenantId }); + + if (branchId) { + qb.andWhere('(transfer.sourceBranchId = :branchId OR transfer.destBranchId = :branchId)', { branchId }); + } + + qb.groupBy('transfer.status'); + + const results = await qb.getRawMany(); + const summary: Record = {}; + + for (const r of results) { + summary[r.status] = parseInt(r.count, 10); + } + + return summary as Record; + } +} diff --git a/backend/src/modules/inventory/validation/inventory.schema.ts b/backend/src/modules/inventory/validation/inventory.schema.ts new file mode 100644 index 0000000..44732f1 --- /dev/null +++ b/backend/src/modules/inventory/validation/inventory.schema.ts @@ -0,0 +1,200 @@ +import { z } from 'zod'; +import { + uuidSchema, + moneySchema, + quantitySchema, + paginationSchema, +} from '../../../shared/validation/common.schema'; + +// Enums +export const transferTypeEnum = z.enum([ + 'branch_to_branch', + 'warehouse_to_branch', + 'branch_to_warehouse', + 'internal', +]); + +export const transferStatusEnum = z.enum([ + 'draft', + 'pending_approval', + 'approved', + 'in_transit', + 'partially_received', + 'received', + 'cancelled', +]); + +export const adjustmentTypeEnum = z.enum([ + 'inventory_count', + 'damage', + 'theft', + 'expiry', + 'correction', + 'initial_stock', + 'production', + 'other', +]); + +export const adjustmentStatusEnum = z.enum([ + 'draft', + 'pending_approval', + 'approved', + 'posted', + 'rejected', + 'cancelled', +]); + +// ==================== TRANSFER SCHEMAS ==================== + +// Transfer line schema +export const transferLineSchema = z.object({ + productId: uuidSchema, + productCode: z.string().min(1).max(50), + productName: z.string().min(1).max(200), + productBarcode: z.string().max(50).optional(), + variantId: uuidSchema.optional(), + variantName: z.string().max(200).optional(), + uomId: uuidSchema.optional(), + uomName: z.string().max(20).optional(), + quantityRequested: quantitySchema.positive('Quantity must be positive'), + unitCost: moneySchema.optional(), + lotNumber: z.string().max(50).optional(), + serialNumbers: z.array(z.string().max(50)).optional(), + expiryDate: z.coerce.date().optional(), + notes: z.string().max(500).optional(), +}); + +// Create transfer schema +export const createTransferSchema = z.object({ + type: transferTypeEnum, + sourceBranchId: uuidSchema.optional(), + sourceWarehouseId: uuidSchema, + sourceLocationId: uuidSchema.optional(), + destBranchId: uuidSchema.optional(), + destWarehouseId: uuidSchema, + destLocationId: uuidSchema.optional(), + requestedDate: z.coerce.date(), + expectedDate: z.coerce.date().optional(), + priority: z.number().int().min(0).max(2).optional(), + reason: z.string().max(500).optional(), + notes: z.string().max(1000).optional(), + lines: z.array(transferLineSchema).min(1, 'At least one line is required'), +}); + +// Ship line schema +export const shipLineSchema = z.object({ + lineId: uuidSchema, + quantityShipped: quantitySchema.positive('Quantity must be positive'), + lotNumber: z.string().max(50).optional(), + serialNumbers: z.array(z.string().max(50)).optional(), +}); + +// Ship transfer schema +export const shipTransferSchema = z.object({ + lines: z.array(shipLineSchema).min(1, 'At least one line is required'), + shippingMethod: z.string().max(50).optional(), + trackingNumber: z.string().max(100).optional(), + carrierName: z.string().max(100).optional(), +}); + +// Receive line schema +export const receiveLineSchema = z.object({ + lineId: uuidSchema, + quantityReceived: quantitySchema.min(0), + damageQuantity: quantitySchema.optional(), + damageReason: z.string().max(255).optional(), + receivingNotes: z.string().max(255).optional(), +}); + +// Receive transfer schema +export const receiveTransferSchema = z.object({ + lines: z.array(receiveLineSchema).min(1, 'At least one line is required'), + notes: z.string().max(1000).optional(), +}); + +// Cancel transfer schema +export const cancelTransferSchema = z.object({ + reason: z.string().min(1, 'Reason is required').max(255), +}); + +// List transfers query schema +export const listTransfersQuerySchema = paginationSchema.extend({ + branchId: uuidSchema.optional(), + sourceBranchId: uuidSchema.optional(), + destBranchId: uuidSchema.optional(), + status: transferStatusEnum.optional(), + type: transferTypeEnum.optional(), + startDate: z.coerce.date().optional(), + endDate: z.coerce.date().optional(), +}); + +// ==================== ADJUSTMENT SCHEMAS ==================== + +// Adjustment line schema +export const adjustmentLineSchema = z.object({ + productId: uuidSchema, + productCode: z.string().min(1).max(50), + productName: z.string().min(1).max(200), + productBarcode: z.string().max(50).optional(), + variantId: uuidSchema.optional(), + variantName: z.string().max(200).optional(), + uomId: uuidSchema.optional(), + uomName: z.string().max(20).optional(), + systemQuantity: quantitySchema, + countedQuantity: quantitySchema, + unitCost: moneySchema.optional(), + locationId: uuidSchema.optional(), + locationCode: z.string().max(50).optional(), + lotNumber: z.string().max(50).optional(), + serialNumber: z.string().max(50).optional(), + expiryDate: z.coerce.date().optional(), + lineReason: z.string().max(255).optional(), + notes: z.string().max(500).optional(), +}); + +// Create adjustment schema +export const createAdjustmentSchema = z.object({ + branchId: uuidSchema, + warehouseId: uuidSchema, + locationId: uuidSchema.optional(), + type: adjustmentTypeEnum, + adjustmentDate: z.coerce.date(), + isFullCount: z.boolean().optional(), + countCategoryId: uuidSchema.optional(), + reason: z.string().min(1, 'Reason is required').max(500), + notes: z.string().max(1000).optional(), + lines: z.array(adjustmentLineSchema).min(1, 'At least one line is required'), +}); + +// Add adjustment line schema +export const addAdjustmentLineSchema = adjustmentLineSchema; + +// Reject adjustment schema +export const rejectAdjustmentSchema = z.object({ + reason: z.string().min(1, 'Rejection reason is required').max(255), +}); + +// Cancel adjustment schema +export const cancelAdjustmentSchema = z.object({ + reason: z.string().min(1, 'Cancellation reason is required').max(255), +}); + +// List adjustments query schema +export const listAdjustmentsQuerySchema = paginationSchema.extend({ + branchId: uuidSchema.optional(), + warehouseId: uuidSchema.optional(), + status: adjustmentStatusEnum.optional(), + type: adjustmentTypeEnum.optional(), + startDate: z.coerce.date().optional(), + endDate: z.coerce.date().optional(), +}); + +// Types +export type CreateTransferInput = z.infer; +export type TransferLineInput = z.infer; +export type ShipTransferInput = z.infer; +export type ReceiveTransferInput = z.infer; +export type ListTransfersQuery = z.infer; +export type CreateAdjustmentInput = z.infer; +export type AdjustmentLineInput = z.infer; +export type ListAdjustmentsQuery = z.infer; diff --git a/backend/src/modules/invoicing/controllers/cfdi.controller.ts b/backend/src/modules/invoicing/controllers/cfdi.controller.ts new file mode 100644 index 0000000..75d48cc --- /dev/null +++ b/backend/src/modules/invoicing/controllers/cfdi.controller.ts @@ -0,0 +1,742 @@ +import { Request, Response, NextFunction } from 'express'; +import { CFDIService } from '../services/cfdi.service'; +import { CFDIBuilderService } from '../services/cfdi-builder.service'; +import { AppDataSource } from '../../../config/database'; +import { CFDI } from '../entities/cfdi.entity'; +import { CFDIConfig } from '../entities/cfdi-config.entity'; +import { CANCELLATION_REASONS } from '../services/pac.service'; +import { + CreateCFDIInput, + CancelCFDIInput, + ListCFDIsQuery, + ResendEmailInput, + CreateCreditNoteInput, + CreateCFDIConfigInput, + UpdateCFDIConfigInput, + AutofacturaRequest, + LookupOrderInput, + CFDIStatsQuery, +} from '../validation/cfdi.schema'; + +const cfdiService = new CFDIService( + AppDataSource, + AppDataSource.getRepository(CFDI) +); +const builderService = new CFDIBuilderService(); +const configRepository = AppDataSource.getRepository(CFDIConfig); + +// ==================== CFDI ENDPOINTS ==================== + +/** + * Create a new CFDI (invoice) + * POST /api/invoicing/cfdis + */ +export const createCFDI = async ( + req: Request, + res: Response, + next: NextFunction +): Promise => { + try { + const tenantId = req.headers['x-tenant-id'] as string; + const userId = req.user?.id as string; + const input = req.body as CreateCFDIInput; + + const result = await cfdiService.createCFDI(tenantId, input, userId); + + if (!result.success) { + res.status(400).json({ + success: false, + error: result.error, + errors: result.errors, + }); + return; + } + + res.status(201).json({ + success: true, + data: result.data, + }); + } catch (error) { + next(error); + } +}; + +/** + * List CFDIs with filters + * GET /api/invoicing/cfdis + */ +export const listCFDIs = async ( + req: Request, + res: Response, + next: NextFunction +): Promise => { + try { + const tenantId = req.headers['x-tenant-id'] as string; + const query = req.query as unknown as ListCFDIsQuery; + + const result = await cfdiService.listCFDIs(tenantId, query); + + res.json({ + success: true, + data: result.data, + meta: result.meta, + }); + } catch (error) { + next(error); + } +}; + +/** + * Get CFDI by ID + * GET /api/invoicing/cfdis/:id + */ +export const getCFDI = async ( + req: Request, + res: Response, + next: NextFunction +): Promise => { + try { + const tenantId = req.headers['x-tenant-id'] as string; + const { id } = req.params; + + const result = await cfdiService.getCFDIById(tenantId, id); + + if (!result.success) { + res.status(404).json({ + success: false, + error: result.error, + }); + return; + } + + res.json({ + success: true, + data: result.data, + }); + } catch (error) { + next(error); + } +}; + +/** + * Get CFDI by UUID + * GET /api/invoicing/cfdis/uuid/:uuid + */ +export const getCFDIByUUID = async ( + req: Request, + res: Response, + next: NextFunction +): Promise => { + try { + const tenantId = req.headers['x-tenant-id'] as string; + const { uuid } = req.params; + + const result = await cfdiService.getCFDIByUUID(tenantId, uuid); + + if (!result.success) { + res.status(404).json({ + success: false, + error: result.error, + }); + return; + } + + res.json({ + success: true, + data: result.data, + }); + } catch (error) { + next(error); + } +}; + +/** + * Stamp a draft CFDI + * POST /api/invoicing/cfdis/:id/stamp + */ +export const stampCFDI = async ( + req: Request, + res: Response, + next: NextFunction +): Promise => { + try { + const tenantId = req.headers['x-tenant-id'] as string; + const userId = req.user?.id as string; + const { id } = req.params; + + const result = await cfdiService.stampCFDI(tenantId, id, userId); + + if (!result.success) { + res.status(400).json({ + success: false, + error: result.error, + data: result.data, // Include CFDI with error info + }); + return; + } + + res.json({ + success: true, + data: result.data, + }); + } catch (error) { + next(error); + } +}; + +/** + * Cancel a CFDI + * POST /api/invoicing/cfdis/:id/cancel + */ +export const cancelCFDI = async ( + req: Request, + res: Response, + next: NextFunction +): Promise => { + try { + const tenantId = req.headers['x-tenant-id'] as string; + const userId = req.user?.id as string; + const { id } = req.params; + const { reason, substituteUuid } = req.body as CancelCFDIInput; + + const result = await cfdiService.cancelCFDI( + tenantId, + id, + reason, + substituteUuid, + userId + ); + + if (!result.success) { + res.status(400).json({ + success: false, + error: result.error, + data: result.data, + }); + return; + } + + res.json({ + success: true, + data: result.data, + }); + } catch (error) { + next(error); + } +}; + +/** + * Verify CFDI status with SAT + * GET /api/invoicing/cfdis/:id/verify + */ +export const verifyCFDI = async ( + req: Request, + res: Response, + next: NextFunction +): Promise => { + try { + const tenantId = req.headers['x-tenant-id'] as string; + const { id } = req.params; + + const result = await cfdiService.verifyCFDIStatus(tenantId, id); + + if (!result.success) { + res.status(400).json({ + success: false, + error: result.error, + }); + return; + } + + res.json({ + success: true, + data: result.data, + }); + } catch (error) { + next(error); + } +}; + +/** + * Download CFDI XML + * GET /api/invoicing/cfdis/:id/xml + */ +export const downloadXML = async ( + req: Request, + res: Response, + next: NextFunction +): Promise => { + try { + const tenantId = req.headers['x-tenant-id'] as string; + const { id } = req.params; + + const result = await cfdiService.downloadXML(tenantId, id); + + if (!result.success || !result.data) { + res.status(404).json({ + success: false, + error: result.error, + }); + return; + } + + res.setHeader('Content-Type', 'application/xml'); + res.setHeader('Content-Disposition', `attachment; filename="${result.data.filename}"`); + res.send(result.data.xml); + } catch (error) { + next(error); + } +}; + +/** + * Resend CFDI via email + * POST /api/invoicing/cfdis/:id/resend-email + */ +export const resendEmail = async ( + req: Request, + res: Response, + next: NextFunction +): Promise => { + try { + const tenantId = req.headers['x-tenant-id'] as string; + const { id } = req.params; + const { email } = req.body as ResendEmailInput; + + const result = await cfdiService.resendEmail(tenantId, id, email); + + if (!result.success) { + res.status(400).json({ + success: false, + error: result.error, + }); + return; + } + + res.json({ + success: true, + message: 'Email sent successfully', + }); + } catch (error) { + next(error); + } +}; + +/** + * Create credit note for a CFDI + * POST /api/invoicing/cfdis/:id/credit-note + */ +export const createCreditNote = async ( + req: Request, + res: Response, + next: NextFunction +): Promise => { + try { + const tenantId = req.headers['x-tenant-id'] as string; + const userId = req.user?.id as string; + const { id } = req.params; + const { lines, reason } = req.body as Omit; + + const result = await cfdiService.createCreditNote( + tenantId, + id, + lines, + reason, + userId + ); + + if (!result.success) { + res.status(400).json({ + success: false, + error: result.error, + errors: result.errors, + }); + return; + } + + res.status(201).json({ + success: true, + data: result.data, + }); + } catch (error) { + next(error); + } +}; + +/** + * Get CFDI statistics + * GET /api/invoicing/stats + */ +export const getCFDIStats = async ( + req: Request, + res: Response, + next: NextFunction +): Promise => { + try { + const tenantId = req.headers['x-tenant-id'] as string; + const { startDate, endDate, branchId } = req.query as unknown as CFDIStatsQuery; + + const stats = await cfdiService.getStats( + tenantId, + startDate, + endDate, + branchId + ); + + res.json({ + success: true, + data: stats, + }); + } catch (error) { + next(error); + } +}; + +/** + * Get cancellation reasons + * GET /api/invoicing/cancellation-reasons + */ +export const getCancellationReasons = async ( + req: Request, + res: Response, + next: NextFunction +): Promise => { + try { + const reasons = Object.entries(CANCELLATION_REASONS).map(([code, description]) => ({ + code, + description, + })); + + res.json({ + success: true, + data: reasons, + }); + } catch (error) { + next(error); + } +}; + +/** + * Get UsoCFDI options + * GET /api/invoicing/uso-cfdi-options + */ +export const getUsoCFDIOptions = async ( + req: Request, + res: Response, + next: NextFunction +): Promise => { + try { + const isPersonaFisica = req.query.personaFisica === 'true'; + const options = builderService.getUsoCFDIOptions(isPersonaFisica); + + res.json({ + success: true, + data: options, + }); + } catch (error) { + next(error); + } +}; + +/** + * Get common SAT product codes + * GET /api/invoicing/claves-prod-serv + */ +export const getClavesProdServ = async ( + req: Request, + res: Response, + next: NextFunction +): Promise => { + try { + const claves = builderService.getCommonClavesProdServ(); + + res.json({ + success: true, + data: claves, + }); + } catch (error) { + next(error); + } +}; + +// ==================== CONFIG ENDPOINTS ==================== + +/** + * Get active CFDI configuration + * GET /api/invoicing/config + */ +export const getConfig = async ( + req: Request, + res: Response, + next: NextFunction +): Promise => { + try { + const tenantId = req.headers['x-tenant-id'] as string; + const branchId = req.headers['x-branch-id'] as string | undefined; + + const result = await cfdiService.getActiveConfig(tenantId, branchId); + + if (!result.success) { + res.status(404).json({ + success: false, + error: result.error, + }); + return; + } + + // Remove sensitive fields + const config = result.data!; + const safeConfig = { + ...config, + pacPasswordEncrypted: undefined, + certificateEncrypted: undefined, + privateKeyEncrypted: undefined, + certificatePasswordEncrypted: undefined, + }; + + res.json({ + success: true, + data: safeConfig, + }); + } catch (error) { + next(error); + } +}; + +/** + * Create or update CFDI configuration + * POST /api/invoicing/config + */ +export const upsertConfig = async ( + req: Request, + res: Response, + next: NextFunction +): Promise => { + try { + const tenantId = req.headers['x-tenant-id'] as string; + const userId = req.user?.id as string; + const input = req.body as CreateCFDIConfigInput; + + // Check if config exists + const existingConfig = await configRepository.findOne({ + where: { tenantId, branchId: input.branchId || (null as any) }, + }); + + let config: CFDIConfig; + + if (existingConfig) { + // Update existing + Object.assign(existingConfig, input); + existingConfig.updatedBy = userId; + config = await configRepository.save(existingConfig); + } else { + // Create new + config = configRepository.create({ + ...input, + tenantId, + createdBy: userId, + }); + config = await configRepository.save(config); + } + + res.json({ + success: true, + data: config, + }); + } catch (error) { + next(error); + } +}; + +/** + * Upload certificate (CSD) + * POST /api/invoicing/config/certificate + */ +export const uploadCertificate = async ( + req: Request, + res: Response, + next: NextFunction +): Promise => { + try { + const tenantId = req.headers['x-tenant-id'] as string; + const userId = req.user?.id as string; + const { certificate, privateKey, password, configId } = req.body; + + if (!certificate || !privateKey || !password) { + res.status(400).json({ + success: false, + error: 'Certificate, private key, and password are required', + }); + return; + } + + const config = await configRepository.findOne({ + where: configId + ? { id: configId, tenantId } + : { tenantId, branchId: null as any }, + }); + + if (!config) { + res.status(404).json({ + success: false, + error: 'CFDI configuration not found', + }); + return; + } + + // Import PACService for encryption + const { PACService } = await import('../services/pac.service'); + const pacService = new PACService(); + + // TODO: Validate certificate + // - Check it's a valid X.509 certificate + // - Check private key matches + // - Extract certificate number + // - Extract validity dates + + // Encrypt and save + config.certificateEncrypted = pacService.encrypt(certificate); + config.privateKeyEncrypted = pacService.encrypt(privateKey); + config.certificatePasswordEncrypted = pacService.encrypt(password); + config.updatedBy = userId; + + await configRepository.save(config); + + res.json({ + success: true, + message: 'Certificate uploaded successfully', + }); + } catch (error) { + next(error); + } +}; + +/** + * Validate CFDI configuration + * POST /api/invoicing/config/validate + */ +export const validateConfig = async ( + req: Request, + res: Response, + next: NextFunction +): Promise => { + try { + const tenantId = req.headers['x-tenant-id'] as string; + const { testStamp } = req.body; + + const configResult = await cfdiService.getActiveConfig(tenantId); + + if (!configResult.success || !configResult.data) { + res.status(404).json({ + success: false, + error: configResult.error, + }); + return; + } + + const config = configResult.data; + const errors: string[] = []; + + // Validate required fields + if (!config.rfc) errors.push('RFC is required'); + if (!config.razonSocial) errors.push('Razón Social is required'); + if (!config.regimenFiscal) errors.push('Régimen Fiscal is required'); + if (!config.codigoPostal) errors.push('Código Postal is required'); + + // Validate certificate + if (!config.certificateEncrypted) { + errors.push('Certificate (CSD) is not uploaded'); + } + if (!config.privateKeyEncrypted) { + errors.push('Private key is not uploaded'); + } + + // Check certificate validity + if (config.certificateValidUntil && config.certificateValidUntil < new Date()) { + errors.push('Certificate has expired'); + } + + // Validate PAC credentials + if (!config.pacUsername) errors.push('PAC username is required'); + if (!config.pacPasswordEncrypted) errors.push('PAC password is required'); + + if (errors.length > 0) { + res.json({ + success: true, + data: { + valid: false, + errors, + }, + }); + return; + } + + // TODO: If testStamp is true, attempt a test stamp with the PAC + + res.json({ + success: true, + data: { + valid: true, + errors: [], + }, + }); + } catch (error) { + next(error); + } +}; + +// ==================== AUTOFACTURA ENDPOINTS ==================== + +/** + * Lookup order for autofactura + * POST /api/invoicing/autofactura/lookup + */ +export const lookupOrder = async ( + req: Request, + res: Response, + next: NextFunction +): Promise => { + try { + const tenantId = req.headers['x-tenant-id'] as string; + const { orderNumber, orderDate, orderAmount } = req.body as LookupOrderInput; + + // TODO: Implement order lookup + // This would search for the order in pos_orders or ecommerce_orders + + res.json({ + success: true, + data: { + found: false, + message: 'Order lookup not yet implemented', + }, + }); + } catch (error) { + next(error); + } +}; + +/** + * Create CFDI from autofactura request + * POST /api/invoicing/autofactura/create + */ +export const createAutofactura = async ( + req: Request, + res: Response, + next: NextFunction +): Promise => { + try { + const tenantId = req.headers['x-tenant-id'] as string; + const input = req.body as AutofacturaRequest; + + // TODO: Implement autofactura + // 1. Lookup order + // 2. Validate it hasn't been invoiced yet + // 3. Create CFDI from order data + + res.status(501).json({ + success: false, + error: 'Autofactura not yet implemented', + }); + } catch (error) { + next(error); + } +}; diff --git a/backend/src/modules/invoicing/controllers/index.ts b/backend/src/modules/invoicing/controllers/index.ts new file mode 100644 index 0000000..a78f683 --- /dev/null +++ b/backend/src/modules/invoicing/controllers/index.ts @@ -0,0 +1 @@ +export * from './cfdi.controller'; diff --git a/backend/src/modules/invoicing/entities/cfdi-config.entity.ts b/backend/src/modules/invoicing/entities/cfdi-config.entity.ts new file mode 100644 index 0000000..40d8e61 --- /dev/null +++ b/backend/src/modules/invoicing/entities/cfdi-config.entity.ts @@ -0,0 +1,199 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + Index, +} from 'typeorm'; + +export enum CFDIConfigStatus { + ACTIVE = 'active', + INACTIVE = 'inactive', + PENDING_VALIDATION = 'pending_validation', + EXPIRED = 'expired', +} + +export enum PACProvider { + FINKOK = 'finkok', + SOLUCIONES_FISCALES = 'soluciones_fiscales', + FACTURAPI = 'facturapi', + SW_SAPIEN = 'sw_sapien', + DIGISAT = 'digisat', + OTHER = 'other', +} + +@Entity('cfdi_configs', { schema: 'retail' }) +@Index(['tenantId', 'branchId']) +@Index(['tenantId', 'rfc'], { unique: true }) +@Index(['tenantId', 'status']) +export class CFDIConfig { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'tenant_id', type: 'uuid' }) + @Index() + tenantId: string; + + @Column({ name: 'branch_id', type: 'uuid', nullable: true }) + branchId: string; + + @Column({ + type: 'enum', + enum: CFDIConfigStatus, + default: CFDIConfigStatus.PENDING_VALIDATION, + }) + status: CFDIConfigStatus; + + // Emisor (Issuer) Info + @Column({ length: 13 }) + rfc: string; + + @Column({ name: 'razon_social', length: 300 }) + razonSocial: string; + + @Column({ name: 'nombre_comercial', length: 200, nullable: true }) + nombreComercial: string; + + @Column({ name: 'regimen_fiscal', length: 3 }) + regimenFiscal: string; // SAT catalog code + + @Column({ name: 'regimen_fiscal_descripcion', length: 200, nullable: true }) + regimenFiscalDescripcion: string; + + // Address + @Column({ name: 'codigo_postal', length: 5 }) + codigoPostal: string; + + @Column({ type: 'text', nullable: true }) + domicilio: string; + + // PAC Configuration + @Column({ + name: 'pac_provider', + type: 'enum', + enum: PACProvider, + default: PACProvider.FINKOK, + }) + pacProvider: PACProvider; + + @Column({ name: 'pac_username', length: 100, nullable: true }) + pacUsername: string; + + @Column({ name: 'pac_password_encrypted', type: 'text', nullable: true }) + pacPasswordEncrypted: string; + + @Column({ name: 'pac_environment', length: 20, default: 'sandbox' }) + pacEnvironment: 'sandbox' | 'production'; + + @Column({ name: 'pac_api_url', length: 255, nullable: true }) + pacApiUrl: string; + + // Certificate (CSD) + @Column({ name: 'certificate_number', length: 20, nullable: true }) + certificateNumber: string; + + @Column({ name: 'certificate_encrypted', type: 'text', nullable: true }) + certificateEncrypted: string; + + @Column({ name: 'private_key_encrypted', type: 'text', nullable: true }) + privateKeyEncrypted: string; + + @Column({ name: 'certificate_password_encrypted', type: 'text', nullable: true }) + certificatePasswordEncrypted: string; + + @Column({ name: 'certificate_valid_from', type: 'timestamp with time zone', nullable: true }) + certificateValidFrom: Date; + + @Column({ name: 'certificate_valid_until', type: 'timestamp with time zone', nullable: true }) + certificateValidUntil: Date; + + // CFDI defaults + @Column({ name: 'default_serie', length: 10, default: 'A' }) + defaultSerie: string; + + @Column({ name: 'current_folio', type: 'int', default: 1 }) + currentFolio: number; + + @Column({ name: 'default_forma_pago', length: 2, default: '01' }) + defaultFormaPago: string; // 01=Efectivo, 03=Transferencia, etc. + + @Column({ name: 'default_metodo_pago', length: 3, default: 'PUE' }) + defaultMetodoPago: string; // PUE=Pago en Una Exhibición, PPD=Pago en Parcialidades + + @Column({ name: 'default_uso_cfdi', length: 4, default: 'G03' }) + defaultUsoCFDI: string; // G03=Gastos en general + + @Column({ name: 'default_moneda', length: 3, default: 'MXN' }) + defaultMoneda: string; + + // Logo and branding + @Column({ name: 'logo_url', length: 255, nullable: true }) + logoUrl: string; + + @Column({ name: 'pdf_template', length: 50, default: 'standard' }) + pdfTemplate: string; + + @Column({ name: 'footer_text', type: 'text', nullable: true }) + footerText: string; + + // Email settings + @Column({ name: 'send_email_on_issue', type: 'boolean', default: true }) + sendEmailOnIssue: boolean; + + @Column({ name: 'email_from', length: 100, nullable: true }) + emailFrom: string; + + @Column({ name: 'email_subject_template', length: 200, nullable: true }) + emailSubjectTemplate: string; + + @Column({ name: 'email_body_template', type: 'text', nullable: true }) + emailBodyTemplate: string; + + // Notification settings + @Column({ name: 'notify_certificate_expiry', type: 'boolean', default: true }) + notifyCertificateExpiry: boolean; + + @Column({ name: 'notify_days_before_expiry', type: 'int', default: 30 }) + notifyDaysBeforeExpiry: number; + + // Auto-invoicing + @Column({ name: 'auto_invoice_pos', type: 'boolean', default: false }) + autoInvoicePOS: boolean; + + @Column({ name: 'auto_invoice_ecommerce', type: 'boolean', default: true }) + autoInvoiceEcommerce: boolean; + + // Rate limiting + @Column({ name: 'max_invoices_per_day', type: 'int', nullable: true }) + maxInvoicesPerDay: number; + + @Column({ name: 'invoices_issued_today', type: 'int', default: 0 }) + invoicesIssuedToday: number; + + @Column({ name: 'last_invoice_reset', type: 'date', nullable: true }) + lastInvoiceReset: Date; + + // Validation + @Column({ name: 'last_validated_at', type: 'timestamp with time zone', nullable: true }) + lastValidatedAt: Date; + + @Column({ name: 'validation_errors', type: 'jsonb', nullable: true }) + validationErrors: string[]; + + // Metadata + @Column({ type: 'jsonb', nullable: true }) + metadata: Record; + + @CreateDateColumn({ name: 'created_at' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at' }) + updatedAt: Date; + + @Column({ name: 'created_by', type: 'uuid', nullable: true }) + createdBy: string; + + @Column({ name: 'updated_by', type: 'uuid', nullable: true }) + updatedBy: string; +} diff --git a/backend/src/modules/invoicing/entities/cfdi.entity.ts b/backend/src/modules/invoicing/entities/cfdi.entity.ts new file mode 100644 index 0000000..356779e --- /dev/null +++ b/backend/src/modules/invoicing/entities/cfdi.entity.ts @@ -0,0 +1,294 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + Index, +} from 'typeorm'; + +export enum CFDIStatus { + DRAFT = 'draft', + PENDING = 'pending', + STAMPED = 'stamped', + CANCELLED = 'cancelled', + CANCELLATION_PENDING = 'cancellation_pending', + ERROR = 'error', +} + +export enum CFDIType { + INGRESO = 'I', // Income (sale invoice) + EGRESO = 'E', // Expense (credit note) + TRASLADO = 'T', // Transfer + NOMINA = 'N', // Payroll + PAGO = 'P', // Payment receipt +} + +@Entity('cfdis', { schema: 'retail' }) +@Index(['tenantId', 'status', 'fechaEmision']) +@Index(['tenantId', 'uuid'], { unique: true }) +@Index(['tenantId', 'serie', 'folio'], { unique: true }) +@Index(['tenantId', 'orderId']) +@Index(['tenantId', 'receptorRfc']) +export class CFDI { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'tenant_id', type: 'uuid' }) + @Index() + tenantId: string; + + @Column({ name: 'config_id', type: 'uuid' }) + configId: string; + + @Column({ name: 'branch_id', type: 'uuid', nullable: true }) + branchId: string; + + @Column({ + type: 'enum', + enum: CFDIStatus, + default: CFDIStatus.DRAFT, + }) + status: CFDIStatus; + + @Column({ + name: 'tipo_comprobante', + type: 'enum', + enum: CFDIType, + default: CFDIType.INGRESO, + }) + tipoComprobante: CFDIType; + + // CFDI identification + @Column({ type: 'uuid', nullable: true }) + uuid: string; // UUID assigned by SAT after stamping + + @Column({ length: 10 }) + serie: string; + + @Column({ type: 'int' }) + folio: number; + + @Column({ name: 'fecha_emision', type: 'timestamp with time zone' }) + fechaEmision: Date; + + @Column({ name: 'fecha_timbrado', type: 'timestamp with time zone', nullable: true }) + fechaTimbrado: Date; + + // Emisor (Issuer) + @Column({ name: 'emisor_rfc', length: 13 }) + emisorRfc: string; + + @Column({ name: 'emisor_nombre', length: 300 }) + emisorNombre: string; + + @Column({ name: 'emisor_regimen_fiscal', length: 3 }) + emisorRegimenFiscal: string; + + // Receptor (Receiver) + @Column({ name: 'receptor_rfc', length: 13 }) + receptorRfc: string; + + @Column({ name: 'receptor_nombre', length: 300 }) + receptorNombre: string; + + @Column({ name: 'receptor_uso_cfdi', length: 4 }) + receptorUsoCFDI: string; + + @Column({ name: 'receptor_regimen_fiscal', length: 3, nullable: true }) + receptorRegimenFiscal: string; + + @Column({ name: 'receptor_domicilio_fiscal', length: 5, nullable: true }) + receptorDomicilioFiscal: string; + + @Column({ name: 'receptor_email', length: 100, nullable: true }) + receptorEmail: string; + + // Amounts + @Column({ type: 'decimal', precision: 15, scale: 2, default: 0 }) + subtotal: number; + + @Column({ type: 'decimal', precision: 15, scale: 2, default: 0 }) + descuento: number; + + @Column({ name: 'total_impuestos_trasladados', type: 'decimal', precision: 15, scale: 2, default: 0 }) + totalImpuestosTrasladados: number; + + @Column({ name: 'total_impuestos_retenidos', type: 'decimal', precision: 15, scale: 2, default: 0 }) + totalImpuestosRetenidos: number; + + @Column({ type: 'decimal', precision: 15, scale: 2, default: 0 }) + total: number; + + // Payment info + @Column({ name: 'forma_pago', length: 2 }) + formaPago: string; + + @Column({ name: 'metodo_pago', length: 3 }) + metodoPago: string; + + @Column({ length: 3, default: 'MXN' }) + moneda: string; + + @Column({ name: 'tipo_cambio', type: 'decimal', precision: 10, scale: 6, default: 1 }) + tipoCambio: number; + + @Column({ name: 'condiciones_pago', length: 100, nullable: true }) + condicionesPago: string; + + // Concepts (line items) + @Column({ type: 'jsonb' }) + conceptos: { + claveProdServ: string; + claveUnidad: string; + cantidad: number; + unidad: string; + descripcion: string; + valorUnitario: number; + importe: number; + descuento?: number; + objetoImp: string; + impuestos?: { + traslados?: { + base: number; + impuesto: string; + tipoFactor: string; + tasaOCuota: number; + importe: number; + }[]; + retenciones?: { + base: number; + impuesto: string; + tipoFactor: string; + tasaOCuota: number; + importe: number; + }[]; + }; + // Retail-specific + productId?: string; + orderLineId?: string; + }[]; + + // Tax summary + @Column({ name: 'impuestos', type: 'jsonb', nullable: true }) + impuestos: { + traslados?: { + impuesto: string; + tipoFactor: string; + tasaOCuota: number; + importe: number; + base: number; + }[]; + retenciones?: { + impuesto: string; + importe: number; + }[]; + }; + + // Related CFDIs + @Column({ name: 'cfdi_relacionados', type: 'jsonb', nullable: true }) + cfdiRelacionados: { + tipoRelacion: string; + uuids: string[]; + }; + + // Seal and stamps + @Column({ name: 'sello_cfdi', type: 'text', nullable: true }) + selloCFDI: string; + + @Column({ name: 'sello_sat', type: 'text', nullable: true }) + selloSAT: string; + + @Column({ name: 'certificado_sat', length: 20, nullable: true }) + certificadoSAT: string; + + @Column({ name: 'cadena_original_sat', type: 'text', nullable: true }) + cadenaOriginalSAT: string; + + // XML content + @Column({ name: 'xml_content', type: 'text', nullable: true }) + xmlContent: string; + + // PDF + @Column({ name: 'pdf_url', length: 255, nullable: true }) + pdfUrl: string; + + @Column({ name: 'pdf_generated_at', type: 'timestamp with time zone', nullable: true }) + pdfGeneratedAt: Date; + + // QR code data + @Column({ name: 'qr_data', type: 'text', nullable: true }) + qrData: string; + + // Reference to original order + @Column({ name: 'order_id', type: 'uuid', nullable: true }) + orderId: string; + + @Column({ name: 'order_number', length: 30, nullable: true }) + orderNumber: string; + + @Column({ name: 'order_type', length: 20, nullable: true }) + orderType: 'pos' | 'ecommerce'; + + // Customer reference + @Column({ name: 'customer_id', type: 'uuid', nullable: true }) + customerId: string; + + // Cancellation + @Column({ name: 'cancelled_at', type: 'timestamp with time zone', nullable: true }) + cancelledAt: Date; + + @Column({ name: 'cancelled_by', type: 'uuid', nullable: true }) + cancelledBy: string; + + @Column({ name: 'cancellation_reason', length: 2, nullable: true }) + cancellationReason: string; // SAT cancellation code + + @Column({ name: 'cancellation_uuid', type: 'uuid', nullable: true }) + cancellationUuid: string; + + @Column({ name: 'cancellation_status', length: 50, nullable: true }) + cancellationStatus: string; + + @Column({ name: 'substitute_uuid', type: 'uuid', nullable: true }) + substituteUuid: string; + + // Email + @Column({ name: 'email_sent', type: 'boolean', default: false }) + emailSent: boolean; + + @Column({ name: 'email_sent_at', type: 'timestamp with time zone', nullable: true }) + emailSentAt: Date; + + @Column({ name: 'email_sent_to', length: 100, nullable: true }) + emailSentTo: string; + + // PAC response + @Column({ name: 'pac_response', type: 'jsonb', nullable: true }) + pacResponse: Record; + + // Error info + @Column({ name: 'error_code', length: 20, nullable: true }) + errorCode: string; + + @Column({ name: 'error_message', type: 'text', nullable: true }) + errorMessage: string; + + // User + @Column({ name: 'created_by', type: 'uuid', nullable: true }) + createdBy: string; + + // Notes + @Column({ type: 'text', nullable: true }) + notes: string; + + // Metadata + @Column({ type: 'jsonb', nullable: true }) + metadata: Record; + + @CreateDateColumn({ name: 'created_at' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at' }) + updatedAt: Date; +} diff --git a/backend/src/modules/invoicing/entities/index.ts b/backend/src/modules/invoicing/entities/index.ts new file mode 100644 index 0000000..c28478c --- /dev/null +++ b/backend/src/modules/invoicing/entities/index.ts @@ -0,0 +1,2 @@ +export * from './cfdi-config.entity'; +export * from './cfdi.entity'; diff --git a/backend/src/modules/invoicing/index.ts b/backend/src/modules/invoicing/index.ts new file mode 100644 index 0000000..fd9152d --- /dev/null +++ b/backend/src/modules/invoicing/index.ts @@ -0,0 +1,14 @@ +// Services +export * from './services'; + +// Controllers +export * from './controllers'; + +// Routes +export * from './routes'; + +// Validation +export * from './validation'; + +// Entities +export * from './entities'; diff --git a/backend/src/modules/invoicing/routes/cfdi.routes.ts b/backend/src/modules/invoicing/routes/cfdi.routes.ts new file mode 100644 index 0000000..45302e0 --- /dev/null +++ b/backend/src/modules/invoicing/routes/cfdi.routes.ts @@ -0,0 +1,222 @@ +import { Router } from 'express'; +import { authMiddleware } from '../../auth/middleware/auth.middleware'; +import { branchMiddleware } from '../../branches/middleware/branch.middleware'; +import { validateRequest } from '../../../shared/middleware/validation.middleware'; +import { requirePermissions } from '../../../shared/middleware/permissions.middleware'; +import { + createCFDISchema, + cancelCFDISchema, + listCFDIsQuerySchema, + resendEmailSchema, + createCreditNoteSchema, + createCFDIConfigSchema, + updateCFDIConfigSchema, + validateCFDIConfigSchema, + autofacturaRequestSchema, + lookupOrderSchema, + cfdiStatsQuerySchema, +} from '../validation/cfdi.schema'; +import * as cfdiController from '../controllers/cfdi.controller'; + +const router = Router(); + +// ==================== PUBLIC ROUTES (Autofactura) ==================== + +/** + * Lookup order for autofactura (public) + */ +router.post( + '/autofactura/lookup', + validateRequest(lookupOrderSchema), + cfdiController.lookupOrder +); + +/** + * Create autofactura (public) + */ +router.post( + '/autofactura/create', + validateRequest(autofacturaRequestSchema), + cfdiController.createAutofactura +); + +/** + * Get UsoCFDI options (public for forms) + */ +router.get('/uso-cfdi-options', cfdiController.getUsoCFDIOptions); + +/** + * Get common SAT product codes (public for forms) + */ +router.get('/claves-prod-serv', cfdiController.getClavesProdServ); + +/** + * Get cancellation reasons (public for forms) + */ +router.get('/cancellation-reasons', cfdiController.getCancellationReasons); + +// ==================== AUTHENTICATED ROUTES ==================== + +// All routes below require authentication +router.use(authMiddleware); + +// ==================== CFDI ROUTES ==================== + +/** + * List CFDIs with filters + */ +router.get( + '/cfdis', + validateRequest(listCFDIsQuerySchema, 'query'), + requirePermissions('cfdi:view'), + cfdiController.listCFDIs +); + +/** + * Get CFDI statistics + */ +router.get( + '/stats', + validateRequest(cfdiStatsQuerySchema, 'query'), + requirePermissions('cfdi:view'), + cfdiController.getCFDIStats +); + +/** + * Get CFDI by ID + */ +router.get( + '/cfdis/:id', + requirePermissions('cfdi:view'), + cfdiController.getCFDI +); + +/** + * Get CFDI by UUID + */ +router.get( + '/cfdis/uuid/:uuid', + requirePermissions('cfdi:view'), + cfdiController.getCFDIByUUID +); + +/** + * Create a new CFDI + */ +router.post( + '/cfdis', + branchMiddleware, + validateRequest(createCFDISchema), + requirePermissions('cfdi:create'), + cfdiController.createCFDI +); + +/** + * Stamp a draft CFDI + */ +router.post( + '/cfdis/:id/stamp', + requirePermissions('cfdi:stamp'), + cfdiController.stampCFDI +); + +/** + * Cancel a CFDI + */ +router.post( + '/cfdis/:id/cancel', + validateRequest(cancelCFDISchema), + requirePermissions('cfdi:cancel'), + cfdiController.cancelCFDI +); + +/** + * Verify CFDI status with SAT + */ +router.get( + '/cfdis/:id/verify', + requirePermissions('cfdi:view'), + cfdiController.verifyCFDI +); + +/** + * Download CFDI XML + */ +router.get( + '/cfdis/:id/xml', + requirePermissions('cfdi:view'), + cfdiController.downloadXML +); + +/** + * Resend CFDI via email + */ +router.post( + '/cfdis/:id/resend-email', + validateRequest(resendEmailSchema), + requirePermissions('cfdi:send'), + cfdiController.resendEmail +); + +/** + * Create credit note for a CFDI + */ +router.post( + '/cfdis/:id/credit-note', + branchMiddleware, + validateRequest(createCreditNoteSchema.omit({ originalCfdiId: true })), + requirePermissions('cfdi:create'), + cfdiController.createCreditNote +); + +// ==================== CONFIG ROUTES ==================== + +/** + * Get active CFDI configuration + */ +router.get( + '/config', + requirePermissions('cfdi:config:view'), + cfdiController.getConfig +); + +/** + * Create or update CFDI configuration + */ +router.post( + '/config', + validateRequest(createCFDIConfigSchema), + requirePermissions('cfdi:config:manage'), + cfdiController.upsertConfig +); + +/** + * Update CFDI configuration + */ +router.put( + '/config', + validateRequest(updateCFDIConfigSchema), + requirePermissions('cfdi:config:manage'), + cfdiController.upsertConfig +); + +/** + * Upload certificate (CSD) + */ +router.post( + '/config/certificate', + requirePermissions('cfdi:config:manage'), + cfdiController.uploadCertificate +); + +/** + * Validate CFDI configuration + */ +router.post( + '/config/validate', + validateRequest(validateCFDIConfigSchema), + requirePermissions('cfdi:config:view'), + cfdiController.validateConfig +); + +export default router; diff --git a/backend/src/modules/invoicing/routes/index.ts b/backend/src/modules/invoicing/routes/index.ts new file mode 100644 index 0000000..240ad01 --- /dev/null +++ b/backend/src/modules/invoicing/routes/index.ts @@ -0,0 +1 @@ +export { default as cfdiRoutes } from './cfdi.routes'; diff --git a/backend/src/modules/invoicing/services/cfdi-builder.service.ts b/backend/src/modules/invoicing/services/cfdi-builder.service.ts new file mode 100644 index 0000000..d83f3ef --- /dev/null +++ b/backend/src/modules/invoicing/services/cfdi-builder.service.ts @@ -0,0 +1,507 @@ +import { CFDIConfig } from '../entities/cfdi-config.entity'; +import { CFDIType } from '../entities/cfdi.entity'; +import { CFDIData, CFDIConcepto, CFDIImpuestos, XMLService } from './xml.service'; + +export interface OrderLineData { + productId?: string; + productCode: string; + productName: string; + claveProdServ: string; // SAT product/service code + claveUnidad: string; // SAT unit code + unidad: string; // Unit description + quantity: number; + unitPrice: number; + discount?: number; + taxRate: number; // e.g., 0.16 for 16% IVA + taxIncluded?: boolean; // Whether price includes tax + retentionIVA?: number; // IVA retention rate (e.g., 0.106667) + retentionISR?: number; // ISR retention rate + objetoImp?: string; // Tax object code (01, 02, 03) +} + +export interface ReceptorData { + rfc: string; + nombre: string; + usoCFDI: string; + regimenFiscal?: string; + domicilioFiscal?: string; + email?: string; +} + +export interface CFDIBuildInput { + config: CFDIConfig; + receptor: ReceptorData; + lines: OrderLineData[]; + formaPago: string; + metodoPago: string; + moneda?: string; + tipoCambio?: number; + condicionesPago?: string; + tipoComprobante?: CFDIType; + cfdiRelacionados?: { + tipoRelacion: string; + uuids: string[]; + }; + orderId?: string; + orderNumber?: string; + orderType?: 'pos' | 'ecommerce'; + notes?: string; +} + +export interface CFDIBuildResult { + cfdiData: CFDIData; + subtotal: number; + descuento: number; + totalImpuestosTrasladados: number; + totalImpuestosRetenidos: number; + total: number; + conceptos: CFDIConcepto[]; +} + +// SAT Tax Types +const TAX_IVA = '002'; +const TAX_ISR = '001'; +const TAX_IEPS = '003'; + +// SAT Tax Factor Types +const FACTOR_TASA = 'Tasa'; +const FACTOR_CUOTA = 'Cuota'; +const FACTOR_EXENTO = 'Exento'; + +// SAT Object of Tax Codes +const OBJETO_IMP = { + NO_OBJETO: '01', // Not subject to tax + SI_OBJETO: '02', // Subject to tax + SI_NO_DESGLOSE: '03', // Subject to tax but no breakdown +}; + +export class CFDIBuilderService { + private xmlService: XMLService; + + constructor() { + this.xmlService = new XMLService(); + } + + /** + * Build complete CFDI data structure from order data + */ + buildFromOrder(input: CFDIBuildInput): CFDIBuildResult { + const { config, receptor, lines, tipoComprobante = CFDIType.INGRESO } = input; + + // Process each line to calculate taxes + const processedLines = lines.map(line => this.processLine(line)); + + // Calculate totals + const subtotal = processedLines.reduce((sum, l) => sum + l.importe, 0); + const descuento = processedLines.reduce((sum, l) => sum + (l.descuento || 0), 0); + + // Aggregate taxes + const aggregatedTaxes = this.aggregateTaxes(processedLines); + const totalImpuestosTrasladados = aggregatedTaxes.traslados.reduce((sum, t) => sum + t.importe, 0); + const totalImpuestosRetenidos = aggregatedTaxes.retenciones.reduce((sum, r) => sum + r.importe, 0); + + const total = subtotal - descuento + totalImpuestosTrasladados - totalImpuestosRetenidos; + + // Build CFDI data structure + const cfdiData: CFDIData = { + version: '4.0', + serie: config.defaultSerie, + folio: config.currentFolio, + fecha: this.xmlService.formatCFDIDate(new Date()), + formaPago: input.formaPago || config.defaultFormaPago, + metodoPago: input.metodoPago || config.defaultMetodoPago, + tipoDeComprobante: tipoComprobante, + exportacion: '01', // No exportación + lugarExpedicion: config.codigoPostal, + moneda: input.moneda || config.defaultMoneda, + tipoCambio: input.tipoCambio, + subTotal: this.round(subtotal), + descuento: descuento > 0 ? this.round(descuento) : undefined, + total: this.round(total), + condicionesDePago: input.condicionesPago, + emisor: { + rfc: config.rfc, + nombre: config.razonSocial, + regimenFiscal: config.regimenFiscal, + }, + receptor: { + rfc: receptor.rfc, + nombre: receptor.nombre, + usoCFDI: receptor.usoCFDI || config.defaultUsoCFDI, + domicilioFiscalReceptor: receptor.domicilioFiscal, + regimenFiscalReceptor: receptor.regimenFiscal, + }, + conceptos: processedLines, + impuestos: aggregatedTaxes.traslados.length > 0 || aggregatedTaxes.retenciones.length > 0 + ? { + traslados: aggregatedTaxes.traslados.length > 0 ? aggregatedTaxes.traslados : undefined, + retenciones: aggregatedTaxes.retenciones.length > 0 ? aggregatedTaxes.retenciones : undefined, + } + : undefined, + cfdiRelacionados: input.cfdiRelacionados, + }; + + return { + cfdiData, + subtotal: this.round(subtotal), + descuento: this.round(descuento), + totalImpuestosTrasladados: this.round(totalImpuestosTrasladados), + totalImpuestosRetenidos: this.round(totalImpuestosRetenidos), + total: this.round(total), + conceptos: processedLines, + }; + } + + /** + * Build credit note (Nota de Crédito) CFDI + */ + buildCreditNote( + config: CFDIConfig, + receptor: ReceptorData, + originalUuid: string, + lines: OrderLineData[], + reason: string + ): CFDIBuildResult { + const result = this.buildFromOrder({ + config, + receptor, + lines, + formaPago: '99', // Por definir + metodoPago: 'PUE', + tipoComprobante: CFDIType.EGRESO, + cfdiRelacionados: { + tipoRelacion: '01', // Nota de crédito de los documentos relacionados + uuids: [originalUuid], + }, + }); + + return result; + } + + /** + * Build payment complement CFDI (Complemento de Pago) + */ + buildPaymentComplement( + config: CFDIConfig, + receptor: ReceptorData, + relatedCfdis: Array<{ + uuid: string; + serie: string; + folio: string; + total: number; + saldoAnterior: number; + importePagado: number; + saldoInsoluto: number; + }>, + paymentData: { + fecha: Date; + formaPago: string; + moneda: string; + tipoCambio?: number; + monto: number; + numOperacion?: string; + rfcBancoEmisor?: string; + ctaOrdenante?: string; + rfcBancoBeneficiario?: string; + ctaBeneficiario?: string; + } + ): CFDIData { + // Payment CFDIs have special structure + const cfdiData: CFDIData = { + version: '4.0', + serie: config.defaultSerie, + folio: config.currentFolio, + fecha: this.xmlService.formatCFDIDate(new Date()), + formaPago: '', // Not used in payment CFDI + metodoPago: '', // Not used + tipoDeComprobante: CFDIType.PAGO, + exportacion: '01', + lugarExpedicion: config.codigoPostal, + moneda: 'XXX', // Required for payment CFDIs + subTotal: 0, + total: 0, + emisor: { + rfc: config.rfc, + nombre: config.razonSocial, + regimenFiscal: config.regimenFiscal, + }, + receptor: { + rfc: receptor.rfc, + nombre: receptor.nombre, + usoCFDI: 'CP01', // Por definir - mandatory for payments + domicilioFiscalReceptor: receptor.domicilioFiscal, + regimenFiscalReceptor: receptor.regimenFiscal, + }, + conceptos: [{ + claveProdServ: '84111506', // Servicios de facturación + claveUnidad: 'ACT', // Actividad + cantidad: 1, + unidad: 'Actividad', + descripcion: 'Pago', + valorUnitario: 0, + importe: 0, + objetoImp: '01', + }], + }; + + // Note: The actual payment complement (Pagos 2.0) would be added as a Complemento + // This requires additional XML structure beyond basic CFDI + + return cfdiData; + } + + /** + * Process a single line item to CFDI concepto + */ + private processLine(line: OrderLineData): CFDIConcepto { + let valorUnitario: number; + let importe: number; + let taxBase: number; + + if (line.taxIncluded) { + // If price includes tax, we need to extract the base price + const taxDivisor = 1 + line.taxRate; + valorUnitario = line.unitPrice / taxDivisor; + importe = valorUnitario * line.quantity; + } else { + valorUnitario = line.unitPrice; + importe = valorUnitario * line.quantity; + } + + // Apply discount if any + const descuento = line.discount || 0; + taxBase = importe - descuento; + + // Build tax structure + const traslados: CFDIConcepto['impuestos']['traslados'] = []; + const retenciones: CFDIConcepto['impuestos']['retenciones'] = []; + + // Determine object of tax + const objetoImp = line.objetoImp || (line.taxRate > 0 ? OBJETO_IMP.SI_OBJETO : OBJETO_IMP.NO_OBJETO); + + // Only add taxes if subject to tax + if (objetoImp === OBJETO_IMP.SI_OBJETO) { + // IVA traslado + if (line.taxRate > 0) { + traslados.push({ + base: this.round(taxBase), + impuesto: TAX_IVA, + tipoFactor: FACTOR_TASA, + tasaOCuota: line.taxRate, + importe: this.round(taxBase * line.taxRate), + }); + } else if (line.taxRate === 0) { + // Tasa 0% + traslados.push({ + base: this.round(taxBase), + impuesto: TAX_IVA, + tipoFactor: FACTOR_TASA, + tasaOCuota: 0, + importe: 0, + }); + } + + // IVA retention + if (line.retentionIVA && line.retentionIVA > 0) { + retenciones.push({ + base: this.round(taxBase), + impuesto: TAX_IVA, + tipoFactor: FACTOR_TASA, + tasaOCuota: line.retentionIVA, + importe: this.round(taxBase * line.retentionIVA), + }); + } + + // ISR retention + if (line.retentionISR && line.retentionISR > 0) { + retenciones.push({ + base: this.round(taxBase), + impuesto: TAX_ISR, + tipoFactor: FACTOR_TASA, + tasaOCuota: line.retentionISR, + importe: this.round(taxBase * line.retentionISR), + }); + } + } + + return { + claveProdServ: line.claveProdServ, + claveUnidad: line.claveUnidad, + cantidad: line.quantity, + unidad: line.unidad, + descripcion: line.productName, + valorUnitario: this.round(valorUnitario), + importe: this.round(importe), + descuento: descuento > 0 ? this.round(descuento) : undefined, + objetoImp, + impuestos: (traslados.length > 0 || retenciones.length > 0) ? { + traslados: traslados.length > 0 ? traslados : undefined, + retenciones: retenciones.length > 0 ? retenciones : undefined, + } : undefined, + }; + } + + /** + * Aggregate taxes from all conceptos + */ + private aggregateTaxes(conceptos: CFDIConcepto[]): CFDIImpuestos { + const trasladosMap = new Map(); + + const retencionesMap = new Map(); + + for (const concepto of conceptos) { + if (!concepto.impuestos) continue; + + // Aggregate traslados + if (concepto.impuestos.traslados) { + for (const t of concepto.impuestos.traslados) { + const key = `${t.impuesto}-${t.tipoFactor}-${t.tasaOCuota}`; + const existing = trasladosMap.get(key); + if (existing) { + existing.importe += t.importe; + existing.base += t.base; + } else { + trasladosMap.set(key, { + impuesto: t.impuesto, + tipoFactor: t.tipoFactor, + tasaOCuota: t.tasaOCuota, + importe: t.importe, + base: t.base, + }); + } + } + } + + // Aggregate retenciones + if (concepto.impuestos.retenciones) { + for (const r of concepto.impuestos.retenciones) { + const key = r.impuesto; + const existing = retencionesMap.get(key); + if (existing) { + existing.importe += r.importe; + } else { + retencionesMap.set(key, { + impuesto: r.impuesto, + importe: r.importe, + }); + } + } + } + } + + return { + traslados: Array.from(trasladosMap.values()).map(t => ({ + ...t, + importe: this.round(t.importe), + base: this.round(t.base), + })), + retenciones: Array.from(retencionesMap.values()).map(r => ({ + ...r, + importe: this.round(r.importe), + })), + }; + } + + /** + * Validate receptor data for CFDI 4.0 requirements + */ + validateReceptor(receptor: ReceptorData): { valid: boolean; errors: string[] } { + const errors: string[] = []; + + // RFC validation + if (!receptor.rfc || receptor.rfc.length < 12 || receptor.rfc.length > 13) { + errors.push('RFC must be 12 characters (persona moral) or 13 characters (persona física)'); + } + + // For CFDI 4.0, if not generic RFC, need additional fields + if (receptor.rfc !== 'XAXX010101000' && receptor.rfc !== 'XEXX010101000') { + if (!receptor.regimenFiscal) { + errors.push('Régimen fiscal is required for non-generic RFC'); + } + if (!receptor.domicilioFiscal) { + errors.push('Domicilio fiscal (código postal) is required for non-generic RFC'); + } + } + + if (!receptor.nombre) { + errors.push('Receptor name is required'); + } + + if (!receptor.usoCFDI) { + errors.push('Uso de CFDI is required'); + } + + return { + valid: errors.length === 0, + errors, + }; + } + + /** + * Get valid UsoCFDI options based on receptor type + */ + getUsoCFDIOptions(isPersonaFisica: boolean): Array<{ code: string; description: string }> { + const common = [ + { code: 'G01', description: 'Adquisición de mercancías' }, + { code: 'G02', description: 'Devoluciones, descuentos o bonificaciones' }, + { code: 'G03', description: 'Gastos en general' }, + ]; + + const personaFisica = [ + { code: 'D01', description: 'Honorarios médicos, dentales y gastos hospitalarios' }, + { code: 'D02', description: 'Gastos médicos por incapacidad o discapacidad' }, + { code: 'D03', description: 'Gastos funerales' }, + { code: 'D04', description: 'Donativos' }, + { code: 'D05', description: 'Intereses reales efectivamente pagados por créditos hipotecarios' }, + { code: 'D06', description: 'Aportaciones voluntarias al SAR' }, + { code: 'D07', description: 'Primas por seguros de gastos médicos' }, + { code: 'D08', description: 'Gastos de transportación escolar obligatoria' }, + { code: 'D09', description: 'Depósitos en cuentas para el ahorro, primas de pensiones' }, + { code: 'D10', description: 'Pagos por servicios educativos (colegiaturas)' }, + ]; + + const publicoGeneral = [ + { code: 'S01', description: 'Sin efectos fiscales' }, + ]; + + return isPersonaFisica + ? [...common, ...personaFisica, ...publicoGeneral] + : [...common, ...publicoGeneral]; + } + + /** + * Get common SAT product/service codes for retail + */ + getCommonClavesProdServ(): Array<{ code: string; description: string }> { + return [ + { code: '01010101', description: 'No existe en el catálogo' }, + { code: '43231500', description: 'Software funcional específico de la empresa' }, + { code: '43232600', description: 'Servicios de datos' }, + { code: '50000000', description: 'Alimentos, bebidas y tabaco' }, + { code: '53000000', description: 'Prendas de vestir, calzado y accesorios' }, + { code: '42000000', description: 'Equipos y suministros médicos' }, + { code: '44000000', description: 'Equipos de oficina, accesorios y suministros' }, + { code: '52000000', description: 'Muebles y accesorios' }, + { code: '78180000', description: 'Servicios de mantenimiento y reparación de equipos' }, + { code: '80000000', description: 'Servicios de gestión, servicios profesionales de empresa' }, + { code: '84110000', description: 'Servicios contables' }, + { code: '92000000', description: 'Servicios de defensa y orden público' }, + ]; + } + + /** + * Round to 2 decimal places for money values + */ + private round(value: number): number { + return Math.round(value * 100) / 100; + } +} diff --git a/backend/src/modules/invoicing/services/cfdi.service.ts b/backend/src/modules/invoicing/services/cfdi.service.ts new file mode 100644 index 0000000..f64878a --- /dev/null +++ b/backend/src/modules/invoicing/services/cfdi.service.ts @@ -0,0 +1,815 @@ +import { DataSource, Repository, QueryRunner } from 'typeorm'; +import { BaseService } from '../../../shared/services/base.service'; +import { CFDI, CFDIStatus, CFDIType } from '../entities/cfdi.entity'; +import { CFDIConfig, CFDIConfigStatus } from '../entities/cfdi-config.entity'; +import { XMLService } from './xml.service'; +import { PACService, CANCELLATION_REASONS } from './pac.service'; +import { CFDIBuilderService, OrderLineData, ReceptorData } from './cfdi-builder.service'; + +export interface ServiceResult { + success: boolean; + data?: T; + error?: string; + errors?: string[]; +} + +export interface CreateCFDIInput { + configId?: string; + branchId?: string; + receptor: ReceptorData; + lines: OrderLineData[]; + formaPago: string; + metodoPago: string; + moneda?: string; + tipoCambio?: number; + condicionesPago?: string; + orderId?: string; + orderNumber?: string; + orderType?: 'pos' | 'ecommerce'; + notes?: string; + stampImmediately?: boolean; +} + +export interface CFDIListQuery { + page?: number; + limit?: number; + status?: CFDIStatus; + startDate?: Date; + endDate?: Date; + receptorRfc?: string; + branchId?: string; + search?: string; +} + +export class CFDIService extends BaseService { + private xmlService: XMLService; + private pacService: PACService; + private builderService: CFDIBuilderService; + private configRepository: Repository; + + constructor( + dataSource: DataSource, + repository: Repository + ) { + super(dataSource, repository); + this.xmlService = new XMLService(); + this.pacService = new PACService(); + this.builderService = new CFDIBuilderService(); + this.configRepository = dataSource.getRepository(CFDIConfig); + } + + /** + * Get active CFDI configuration for tenant + */ + async getActiveConfig(tenantId: string, branchId?: string): Promise> { + try { + const whereClause: any = { + tenantId, + status: CFDIConfigStatus.ACTIVE, + }; + + // Try branch-specific config first + if (branchId) { + const branchConfig = await this.configRepository.findOne({ + where: { ...whereClause, branchId }, + }); + if (branchConfig) { + return { success: true, data: branchConfig }; + } + } + + // Fall back to tenant-level config + const tenantConfig = await this.configRepository.findOne({ + where: { ...whereClause, branchId: null as any }, + }); + + if (!tenantConfig) { + return { + success: false, + error: 'No active CFDI configuration found. Please configure invoicing first.', + }; + } + + return { success: true, data: tenantConfig }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to get CFDI config', + }; + } + } + + /** + * Create and optionally stamp a CFDI + */ + async createCFDI( + tenantId: string, + input: CreateCFDIInput, + userId: string + ): Promise> { + const queryRunner = this.dataSource.createQueryRunner(); + await queryRunner.connect(); + await queryRunner.startTransaction(); + + try { + // Get config + const configResult = await this.getActiveConfig(tenantId, input.branchId); + if (!configResult.success || !configResult.data) { + throw new Error(configResult.error || 'No CFDI config'); + } + const config = configResult.data; + + // Validate receptor + const validation = this.builderService.validateReceptor(input.receptor); + if (!validation.valid) { + return { + success: false, + error: 'Invalid receptor data', + errors: validation.errors, + }; + } + + // Build CFDI data + const buildResult = this.builderService.buildFromOrder({ + config, + receptor: input.receptor, + lines: input.lines, + formaPago: input.formaPago, + metodoPago: input.metodoPago, + moneda: input.moneda, + tipoCambio: input.tipoCambio, + condicionesPago: input.condicionesPago, + orderId: input.orderId, + orderNumber: input.orderNumber, + orderType: input.orderType, + notes: input.notes, + }); + + // Create CFDI record + const cfdi = queryRunner.manager.create(CFDI, { + tenantId, + configId: config.id, + branchId: input.branchId, + status: CFDIStatus.DRAFT, + tipoComprobante: CFDIType.INGRESO, + serie: config.defaultSerie, + folio: config.currentFolio, + fechaEmision: new Date(), + emisorRfc: config.rfc, + emisorNombre: config.razonSocial, + emisorRegimenFiscal: config.regimenFiscal, + receptorRfc: input.receptor.rfc, + receptorNombre: input.receptor.nombre, + receptorUsoCFDI: input.receptor.usoCFDI, + receptorRegimenFiscal: input.receptor.regimenFiscal, + receptorDomicilioFiscal: input.receptor.domicilioFiscal, + receptorEmail: input.receptor.email, + subtotal: buildResult.subtotal, + descuento: buildResult.descuento, + totalImpuestosTrasladados: buildResult.totalImpuestosTrasladados, + totalImpuestosRetenidos: buildResult.totalImpuestosRetenidos, + total: buildResult.total, + formaPago: input.formaPago, + metodoPago: input.metodoPago, + moneda: input.moneda || config.defaultMoneda, + tipoCambio: input.tipoCambio || 1, + condicionesPago: input.condicionesPago, + conceptos: buildResult.conceptos, + impuestos: buildResult.cfdiData.impuestos, + orderId: input.orderId, + orderNumber: input.orderNumber, + orderType: input.orderType, + notes: input.notes, + createdBy: userId, + }); + + await queryRunner.manager.save(cfdi); + + // Increment folio + await queryRunner.manager.increment( + CFDIConfig, + { id: config.id }, + 'currentFolio', + 1 + ); + + await queryRunner.commitTransaction(); + + // Stamp if requested + if (input.stampImmediately) { + const stampResult = await this.stampCFDI(tenantId, cfdi.id, userId); + if (stampResult.success && stampResult.data) { + return stampResult; + } + // If stamp failed, still return the draft CFDI + return { + success: true, + data: cfdi, + error: stampResult.error, + }; + } + + return { success: true, data: cfdi }; + } catch (error) { + await queryRunner.rollbackTransaction(); + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to create CFDI', + }; + } finally { + await queryRunner.release(); + } + } + + /** + * Stamp a draft CFDI with the PAC + */ + async stampCFDI( + tenantId: string, + cfdiId: string, + userId: string + ): Promise> { + const queryRunner = this.dataSource.createQueryRunner(); + await queryRunner.connect(); + await queryRunner.startTransaction(); + + try { + // Get CFDI + const cfdi = await queryRunner.manager.findOne(CFDI, { + where: { id: cfdiId, tenantId }, + }); + + if (!cfdi) { + throw new Error('CFDI not found'); + } + + if (cfdi.status !== CFDIStatus.DRAFT) { + throw new Error(`Cannot stamp CFDI in ${cfdi.status} status`); + } + + // Get config + const config = await queryRunner.manager.findOne(CFDIConfig, { + where: { id: cfdi.configId }, + }); + + if (!config) { + throw new Error('CFDI config not found'); + } + + // Update status to pending + cfdi.status = CFDIStatus.PENDING; + await queryRunner.manager.save(cfdi); + + // Build XML + const cfdiData = { + version: '4.0', + serie: cfdi.serie, + folio: cfdi.folio, + fecha: this.xmlService.formatCFDIDate(cfdi.fechaEmision), + formaPago: cfdi.formaPago, + metodoPago: cfdi.metodoPago, + tipoDeComprobante: cfdi.tipoComprobante, + exportacion: '01', + lugarExpedicion: config.codigoPostal, + moneda: cfdi.moneda, + tipoCambio: cfdi.tipoCambio !== 1 ? cfdi.tipoCambio : undefined, + subTotal: cfdi.subtotal, + descuento: cfdi.descuento > 0 ? cfdi.descuento : undefined, + total: cfdi.total, + condicionesDePago: cfdi.condicionesPago, + emisor: { + rfc: cfdi.emisorRfc, + nombre: cfdi.emisorNombre, + regimenFiscal: cfdi.emisorRegimenFiscal, + }, + receptor: { + rfc: cfdi.receptorRfc, + nombre: cfdi.receptorNombre, + usoCFDI: cfdi.receptorUsoCFDI, + domicilioFiscalReceptor: cfdi.receptorDomicilioFiscal, + regimenFiscalReceptor: cfdi.receptorRegimenFiscal, + }, + conceptos: cfdi.conceptos, + impuestos: cfdi.impuestos, + cfdiRelacionados: cfdi.cfdiRelacionados, + }; + + // Build unsigned XML + const unsignedXml = this.xmlService.buildCFDIXML(cfdiData, config.certificateNumber || ''); + + // Build cadena original + const cadenaOriginal = this.xmlService.buildCadenaOriginal(cfdiData, config.certificateNumber || ''); + + // Load certificate and sign + const certData = await this.pacService.loadCertificate(config); + const sello = await this.pacService.signData(cadenaOriginal, certData.privateKey, certData.password); + + // Add seal and certificate to XML + let signedXml = this.xmlService.addSealToXML(unsignedXml, sello); + signedXml = this.xmlService.addCertificateToXML(signedXml, this.pacService.getCertificateBase64(certData.certificate)); + + // Stamp with PAC + const stampResult = await this.pacService.stampCFDI(config, signedXml); + + if (!stampResult.success) { + cfdi.status = CFDIStatus.ERROR; + cfdi.errorCode = stampResult.errorCode; + cfdi.errorMessage = stampResult.errorMessage; + await queryRunner.manager.save(cfdi); + await queryRunner.commitTransaction(); + + return { + success: false, + data: cfdi, + error: `Stamping failed: ${stampResult.errorMessage}`, + }; + } + + // Parse stamped XML + const stampedData = this.xmlService.parseStampedXML(stampResult.xml!); + + // Update CFDI with stamp data + cfdi.status = CFDIStatus.STAMPED; + cfdi.uuid = stampedData.uuid; + cfdi.fechaTimbrado = new Date(stampedData.fechaTimbrado); + cfdi.selloCFDI = sello; + cfdi.selloSAT = stampedData.selloSAT; + cfdi.certificadoSAT = stampedData.noCertificadoSAT; + cfdi.cadenaOriginalSAT = stampedData.cadenaOriginalSAT; + cfdi.xmlContent = stampResult.xml; + cfdi.qrData = this.xmlService.generateQRData(cfdi); + cfdi.pacResponse = { + uuid: stampedData.uuid, + fechaTimbrado: stampedData.fechaTimbrado, + rfcProvCertif: stampedData.rfcProvCertif, + acuse: stampResult.acuseRecepcion, + }; + + await queryRunner.manager.save(cfdi); + await queryRunner.commitTransaction(); + + // Send email if configured + if (config.sendEmailOnIssue && cfdi.receptorEmail) { + // Queue email sending (fire and forget) + this.sendCFDIEmail(cfdi, config).catch(err => { + console.error('Failed to send CFDI email:', err); + }); + } + + return { success: true, data: cfdi }; + } catch (error) { + await queryRunner.rollbackTransaction(); + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to stamp CFDI', + }; + } finally { + await queryRunner.release(); + } + } + + /** + * Cancel a stamped CFDI + */ + async cancelCFDI( + tenantId: string, + cfdiId: string, + reason: keyof typeof CANCELLATION_REASONS, + substituteUuid: string | undefined, + userId: string + ): Promise> { + const queryRunner = this.dataSource.createQueryRunner(); + await queryRunner.connect(); + await queryRunner.startTransaction(); + + try { + const cfdi = await queryRunner.manager.findOne(CFDI, { + where: { id: cfdiId, tenantId }, + }); + + if (!cfdi) { + throw new Error('CFDI not found'); + } + + if (cfdi.status !== CFDIStatus.STAMPED) { + throw new Error(`Cannot cancel CFDI in ${cfdi.status} status`); + } + + // Reason 01 requires substitute UUID + if (reason === '01' && !substituteUuid) { + throw new Error('Substitute UUID is required for cancellation reason 01'); + } + + const config = await queryRunner.manager.findOne(CFDIConfig, { + where: { id: cfdi.configId }, + }); + + if (!config) { + throw new Error('CFDI config not found'); + } + + // Update status to pending cancellation + cfdi.status = CFDIStatus.CANCELLATION_PENDING; + cfdi.cancellationReason = reason; + cfdi.substituteUuid = substituteUuid; + cfdi.cancelledBy = userId; + await queryRunner.manager.save(cfdi); + + // Request cancellation from PAC + const cancelResult = await this.pacService.cancelCFDI( + config, + cfdi.uuid, + reason, + substituteUuid + ); + + if (!cancelResult.success) { + cfdi.status = CFDIStatus.STAMPED; // Revert + cfdi.errorCode = cancelResult.errorCode; + cfdi.errorMessage = cancelResult.errorMessage; + await queryRunner.manager.save(cfdi); + await queryRunner.commitTransaction(); + + return { + success: false, + data: cfdi, + error: `Cancellation failed: ${cancelResult.errorMessage}`, + }; + } + + // Update with cancellation result + if (cancelResult.status === 'cancelled') { + cfdi.status = CFDIStatus.CANCELLED; + cfdi.cancelledAt = new Date(); + } + cfdi.cancellationStatus = cancelResult.status; + cfdi.pacResponse = { + ...cfdi.pacResponse, + cancellation: { + status: cancelResult.status, + acuse: cancelResult.acuse, + }, + }; + + await queryRunner.manager.save(cfdi); + await queryRunner.commitTransaction(); + + return { success: true, data: cfdi }; + } catch (error) { + await queryRunner.rollbackTransaction(); + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to cancel CFDI', + }; + } finally { + await queryRunner.release(); + } + } + + /** + * Verify CFDI status with SAT + */ + async verifyCFDIStatus( + tenantId: string, + cfdiId: string + ): Promise> { + try { + const cfdi = await this.repository.findOne({ + where: { id: cfdiId, tenantId }, + }); + + if (!cfdi) { + throw new Error('CFDI not found'); + } + + if (!cfdi.uuid) { + throw new Error('CFDI has not been stamped'); + } + + const config = await this.configRepository.findOne({ + where: { id: cfdi.configId }, + }); + + if (!config) { + throw new Error('CFDI config not found'); + } + + const result = await this.pacService.verifyCFDIStatus( + config, + cfdi.emisorRfc, + cfdi.receptorRfc, + cfdi.total, + cfdi.uuid + ); + + if (!result.success) { + return { + success: false, + error: result.errorMessage, + }; + } + + return { + success: true, + data: { + status: result.status, + cancellable: result.status === 'valid', + }, + }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to verify CFDI', + }; + } + } + + /** + * List CFDIs with filters + */ + async listCFDIs( + tenantId: string, + query: CFDIListQuery + ): Promise<{ data: CFDI[]; meta: { total: number; page: number; limit: number } }> { + const page = query.page || 1; + const limit = query.limit || 20; + const skip = (page - 1) * limit; + + const qb = this.repository.createQueryBuilder('cfdi') + .where('cfdi.tenantId = :tenantId', { tenantId }); + + if (query.status) { + qb.andWhere('cfdi.status = :status', { status: query.status }); + } + + if (query.branchId) { + qb.andWhere('cfdi.branchId = :branchId', { branchId: query.branchId }); + } + + if (query.startDate) { + qb.andWhere('cfdi.fechaEmision >= :startDate', { startDate: query.startDate }); + } + + if (query.endDate) { + qb.andWhere('cfdi.fechaEmision <= :endDate', { endDate: query.endDate }); + } + + if (query.receptorRfc) { + qb.andWhere('cfdi.receptorRfc = :receptorRfc', { receptorRfc: query.receptorRfc }); + } + + if (query.search) { + qb.andWhere( + '(cfdi.receptorNombre ILIKE :search OR cfdi.uuid::text ILIKE :search OR cfdi.serie || cfdi.folio::text ILIKE :search)', + { search: `%${query.search}%` } + ); + } + + qb.orderBy('cfdi.fechaEmision', 'DESC') + .skip(skip) + .take(limit); + + const [data, total] = await qb.getManyAndCount(); + + return { + data, + meta: { total, page, limit }, + }; + } + + /** + * Get CFDI by ID with full details + */ + async getCFDIById(tenantId: string, cfdiId: string): Promise> { + const cfdi = await this.repository.findOne({ + where: { id: cfdiId, tenantId }, + }); + + if (!cfdi) { + return { success: false, error: 'CFDI not found' }; + } + + return { success: true, data: cfdi }; + } + + /** + * Get CFDI by UUID + */ + async getCFDIByUUID(tenantId: string, uuid: string): Promise> { + const cfdi = await this.repository.findOne({ + where: { uuid, tenantId }, + }); + + if (!cfdi) { + return { success: false, error: 'CFDI not found' }; + } + + return { success: true, data: cfdi }; + } + + /** + * Download CFDI XML + */ + async downloadXML(tenantId: string, cfdiId: string): Promise> { + const cfdi = await this.repository.findOne({ + where: { id: cfdiId, tenantId }, + }); + + if (!cfdi) { + return { success: false, error: 'CFDI not found' }; + } + + if (!cfdi.xmlContent) { + return { success: false, error: 'CFDI XML not available' }; + } + + const filename = `${cfdi.uuid || cfdi.serie + cfdi.folio}.xml`; + + return { + success: true, + data: { xml: cfdi.xmlContent, filename }, + }; + } + + /** + * Send CFDI via email + */ + async sendCFDIEmail(cfdi: CFDI, config: CFDIConfig): Promise { + if (!cfdi.receptorEmail) { + throw new Error('No recipient email'); + } + + // TODO: Implement email sending + // This would use your email service + + // Update email sent status + await this.repository.update(cfdi.id, { + emailSent: true, + emailSentAt: new Date(), + emailSentTo: cfdi.receptorEmail, + }); + } + + /** + * Resend CFDI email + */ + async resendEmail( + tenantId: string, + cfdiId: string, + email?: string + ): Promise> { + const cfdi = await this.repository.findOne({ + where: { id: cfdiId, tenantId }, + }); + + if (!cfdi) { + return { success: false, error: 'CFDI not found' }; + } + + const config = await this.configRepository.findOne({ + where: { id: cfdi.configId }, + }); + + if (!config) { + return { success: false, error: 'CFDI config not found' }; + } + + const recipientEmail = email || cfdi.receptorEmail; + if (!recipientEmail) { + return { success: false, error: 'No recipient email provided' }; + } + + // Update email if different + if (email && email !== cfdi.receptorEmail) { + cfdi.receptorEmail = email; + } + + await this.sendCFDIEmail(cfdi, config); + + return { success: true }; + } + + /** + * Create credit note for a CFDI + */ + async createCreditNote( + tenantId: string, + originalCfdiId: string, + lines: OrderLineData[], + reason: string, + userId: string + ): Promise> { + const originalCfdi = await this.repository.findOne({ + where: { id: originalCfdiId, tenantId }, + }); + + if (!originalCfdi) { + return { success: false, error: 'Original CFDI not found' }; + } + + if (originalCfdi.status !== CFDIStatus.STAMPED) { + return { success: false, error: 'Original CFDI must be stamped' }; + } + + const configResult = await this.getActiveConfig(tenantId, originalCfdi.branchId); + if (!configResult.success || !configResult.data) { + return { success: false, error: configResult.error }; + } + + const buildResult = this.builderService.buildCreditNote( + configResult.data, + { + rfc: originalCfdi.receptorRfc, + nombre: originalCfdi.receptorNombre, + usoCFDI: originalCfdi.receptorUsoCFDI, + regimenFiscal: originalCfdi.receptorRegimenFiscal, + domicilioFiscal: originalCfdi.receptorDomicilioFiscal, + }, + originalCfdi.uuid, + lines, + reason + ); + + return this.createCFDI(tenantId, { + configId: configResult.data.id, + branchId: originalCfdi.branchId, + receptor: { + rfc: originalCfdi.receptorRfc, + nombre: originalCfdi.receptorNombre, + usoCFDI: originalCfdi.receptorUsoCFDI, + regimenFiscal: originalCfdi.receptorRegimenFiscal, + domicilioFiscal: originalCfdi.receptorDomicilioFiscal, + email: originalCfdi.receptorEmail, + }, + lines, + formaPago: '99', + metodoPago: 'PUE', + notes: reason, + stampImmediately: true, + }, userId); + } + + /** + * Get CFDI statistics for dashboard + */ + async getStats( + tenantId: string, + startDate: Date, + endDate: Date, + branchId?: string + ): Promise<{ + totalIssued: number; + totalAmount: number; + totalCancelled: number; + byStatus: Record; + }> { + const qb = this.repository.createQueryBuilder('cfdi') + .where('cfdi.tenantId = :tenantId', { tenantId }) + .andWhere('cfdi.fechaEmision >= :startDate', { startDate }) + .andWhere('cfdi.fechaEmision <= :endDate', { endDate }); + + if (branchId) { + qb.andWhere('cfdi.branchId = :branchId', { branchId }); + } + + const [issued, amount, cancelled, statusCounts] = await Promise.all([ + // Total issued (stamped) + qb.clone() + .andWhere('cfdi.status = :status', { status: CFDIStatus.STAMPED }) + .getCount(), + + // Total amount + qb.clone() + .andWhere('cfdi.status = :status', { status: CFDIStatus.STAMPED }) + .select('COALESCE(SUM(cfdi.total), 0)', 'total') + .getRawOne(), + + // Total cancelled + qb.clone() + .andWhere('cfdi.status = :status', { status: CFDIStatus.CANCELLED }) + .getCount(), + + // By status + qb.clone() + .select('cfdi.status', 'status') + .addSelect('COUNT(*)', 'count') + .groupBy('cfdi.status') + .getRawMany(), + ]); + + const byStatus = {} as Record; + for (const status of Object.values(CFDIStatus)) { + byStatus[status] = 0; + } + for (const row of statusCounts) { + byStatus[row.status as CFDIStatus] = parseInt(row.count); + } + + return { + totalIssued: issued, + totalAmount: parseFloat(amount?.total || '0'), + totalCancelled: cancelled, + byStatus, + }; + } +} diff --git a/backend/src/modules/invoicing/services/index.ts b/backend/src/modules/invoicing/services/index.ts new file mode 100644 index 0000000..aa74663 --- /dev/null +++ b/backend/src/modules/invoicing/services/index.ts @@ -0,0 +1,4 @@ +export * from './cfdi.service'; +export * from './cfdi-builder.service'; +export * from './xml.service'; +export * from './pac.service'; diff --git a/backend/src/modules/invoicing/services/pac.service.ts b/backend/src/modules/invoicing/services/pac.service.ts new file mode 100644 index 0000000..fcbbede --- /dev/null +++ b/backend/src/modules/invoicing/services/pac.service.ts @@ -0,0 +1,755 @@ +import crypto from 'crypto'; +import { CFDIConfig, PACProvider } from '../entities/cfdi-config.entity'; + +export interface StampResult { + success: boolean; + uuid?: string; + fechaTimbrado?: string; + xml?: string; + acuseRecepcion?: string; + errorCode?: string; + errorMessage?: string; +} + +export interface CancelResult { + success: boolean; + acuse?: string; + status?: string; + errorCode?: string; + errorMessage?: string; +} + +export interface VerifyStatusResult { + success: boolean; + status: 'valid' | 'cancelled' | 'not_found' | 'error'; + cancellationStatus?: string; + cancellationDate?: string; + errorCode?: string; + errorMessage?: string; +} + +interface PACEndpoints { + stamp: string; + cancel: string; + status: string; +} + +const PAC_ENDPOINTS: Record = { + [PACProvider.FINKOK]: { + sandbox: { + stamp: 'https://demo-facturacion.finkok.com/servicios/soap/stamp.wsdl', + cancel: 'https://demo-facturacion.finkok.com/servicios/soap/cancel.wsdl', + status: 'https://demo-facturacion.finkok.com/servicios/soap/utilities.wsdl', + }, + production: { + stamp: 'https://facturacion.finkok.com/servicios/soap/stamp.wsdl', + cancel: 'https://facturacion.finkok.com/servicios/soap/cancel.wsdl', + status: 'https://facturacion.finkok.com/servicios/soap/utilities.wsdl', + }, + }, + [PACProvider.FACTURAPI]: { + sandbox: { + stamp: 'https://www.facturapi.io/v2/invoices', + cancel: 'https://www.facturapi.io/v2/invoices', + status: 'https://www.facturapi.io/v2/invoices', + }, + production: { + stamp: 'https://www.facturapi.io/v2/invoices', + cancel: 'https://www.facturapi.io/v2/invoices', + status: 'https://www.facturapi.io/v2/invoices', + }, + }, + [PACProvider.SW_SAPIEN]: { + sandbox: { + stamp: 'https://services.test.sw.com.mx/cfdi33/stamp/v4', + cancel: 'https://services.test.sw.com.mx/cfdi33/cancel', + status: 'https://services.test.sw.com.mx/cfdi/status', + }, + production: { + stamp: 'https://services.sw.com.mx/cfdi33/stamp/v4', + cancel: 'https://services.sw.com.mx/cfdi33/cancel', + status: 'https://services.sw.com.mx/cfdi/status', + }, + }, + [PACProvider.SOLUCIONES_FISCALES]: { + sandbox: { + stamp: 'https://pruebas.solucionesfiscales.com.mx/ws/timbrar', + cancel: 'https://pruebas.solucionesfiscales.com.mx/ws/cancelar', + status: 'https://pruebas.solucionesfiscales.com.mx/ws/estatus', + }, + production: { + stamp: 'https://www.solucionesfiscales.com.mx/ws/timbrar', + cancel: 'https://www.solucionesfiscales.com.mx/ws/cancelar', + status: 'https://www.solucionesfiscales.com.mx/ws/estatus', + }, + }, + [PACProvider.DIGISAT]: { + sandbox: { + stamp: 'https://sandbox.digisat.com/timbrado', + cancel: 'https://sandbox.digisat.com/cancelacion', + status: 'https://sandbox.digisat.com/estatus', + }, + production: { + stamp: 'https://api.digisat.com/timbrado', + cancel: 'https://api.digisat.com/cancelacion', + status: 'https://api.digisat.com/estatus', + }, + }, + [PACProvider.OTHER]: { + sandbox: { + stamp: '', + cancel: '', + status: '', + }, + production: { + stamp: '', + cancel: '', + status: '', + }, + }, +}; + +// SAT cancellation reason codes +export const CANCELLATION_REASONS = { + '01': 'Comprobante emitido con errores con relación', + '02': 'Comprobante emitido con errores sin relación', + '03': 'No se llevó a cabo la operación', + '04': 'Operación nominativa relacionada en una factura global', +} as const; + +export class PACService { + private encryptionKey: Buffer; + + constructor() { + // Use environment variable for encryption key + const key = process.env.PAC_ENCRYPTION_KEY || 'default-key-change-in-production'; + this.encryptionKey = crypto.scryptSync(key, 'salt', 32); + } + + /** + * Stamp (timbrar) a CFDI with the PAC + */ + async stampCFDI(config: CFDIConfig, signedXml: string): Promise { + const provider = config.pacProvider; + const environment = config.pacEnvironment; + + try { + switch (provider) { + case PACProvider.FINKOK: + return await this.stampWithFinkok(config, signedXml); + case PACProvider.FACTURAPI: + return await this.stampWithFacturapi(config, signedXml); + case PACProvider.SW_SAPIEN: + return await this.stampWithSWSapien(config, signedXml); + default: + return await this.stampGeneric(config, signedXml); + } + } catch (error) { + return { + success: false, + errorCode: 'PAC_ERROR', + errorMessage: error instanceof Error ? error.message : 'Unknown PAC error', + }; + } + } + + /** + * Cancel a stamped CFDI + */ + async cancelCFDI( + config: CFDIConfig, + uuid: string, + reason: keyof typeof CANCELLATION_REASONS, + substituteUuid?: string + ): Promise { + const provider = config.pacProvider; + + try { + switch (provider) { + case PACProvider.FINKOK: + return await this.cancelWithFinkok(config, uuid, reason, substituteUuid); + case PACProvider.FACTURAPI: + return await this.cancelWithFacturapi(config, uuid, reason, substituteUuid); + case PACProvider.SW_SAPIEN: + return await this.cancelWithSWSapien(config, uuid, reason, substituteUuid); + default: + return await this.cancelGeneric(config, uuid, reason, substituteUuid); + } + } catch (error) { + return { + success: false, + errorCode: 'PAC_CANCEL_ERROR', + errorMessage: error instanceof Error ? error.message : 'Unknown cancellation error', + }; + } + } + + /** + * Verify CFDI status with SAT + */ + async verifyCFDIStatus( + config: CFDIConfig, + emisorRfc: string, + receptorRfc: string, + total: number, + uuid: string + ): Promise { + const provider = config.pacProvider; + + try { + switch (provider) { + case PACProvider.FINKOK: + return await this.verifyWithFinkok(config, emisorRfc, receptorRfc, total, uuid); + default: + return await this.verifyGeneric(config, emisorRfc, receptorRfc, total, uuid); + } + } catch (error) { + return { + success: false, + status: 'error', + errorCode: 'PAC_VERIFY_ERROR', + errorMessage: error instanceof Error ? error.message : 'Unknown verification error', + }; + } + } + + // ==================== FINKOK Implementation ==================== + + private async stampWithFinkok(config: CFDIConfig, signedXml: string): Promise { + const credentials = this.decryptCredentials(config); + const endpoint = this.getEndpoint(config, 'stamp'); + + // Build SOAP request for Finkok stamp service + const soapRequest = this.buildFinkokStampRequest( + credentials.username, + credentials.password, + signedXml + ); + + const response = await fetch(endpoint, { + method: 'POST', + headers: { + 'Content-Type': 'text/xml; charset=utf-8', + 'SOAPAction': 'stamp', + }, + body: soapRequest, + }); + + if (!response.ok) { + throw new Error(`Finkok API error: ${response.status} ${response.statusText}`); + } + + const responseText = await response.text(); + return this.parseFinkokStampResponse(responseText); + } + + private buildFinkokStampRequest(username: string, password: string, xml: string): string { + const xmlBase64 = Buffer.from(xml).toString('base64'); + + return ` + + + + + ${xmlBase64} + ${username} + ${password} + + +`; + } + + private parseFinkokStampResponse(responseXml: string): StampResult { + // Check for Incidencias (errors) + const incidenciaMatch = responseXml.match(/([^<]+)<\/CodigoError>[\s\S]*?([^<]+)<\/MensajeIncidencia>/); + if (incidenciaMatch) { + return { + success: false, + errorCode: incidenciaMatch[1], + errorMessage: incidenciaMatch[2], + }; + } + + // Extract stamped XML + const xmlMatch = responseXml.match(/([^<]+)<\/xml>/); + const uuidMatch = responseXml.match(/([^<]+)<\/UUID>/); + const fechaMatch = responseXml.match(/([^<]+)<\/Fecha>/); + const acuseMatch = responseXml.match(/([^<]+)<\/Acuse>/); + + if (!xmlMatch || !uuidMatch) { + return { + success: false, + errorCode: 'PARSE_ERROR', + errorMessage: 'Could not parse Finkok response', + }; + } + + const stampedXml = Buffer.from(xmlMatch[1], 'base64').toString('utf-8'); + + return { + success: true, + uuid: uuidMatch[1], + fechaTimbrado: fechaMatch?.[1], + xml: stampedXml, + acuseRecepcion: acuseMatch?.[1], + }; + } + + private async cancelWithFinkok( + config: CFDIConfig, + uuid: string, + reason: string, + substituteUuid?: string + ): Promise { + const credentials = this.decryptCredentials(config); + const endpoint = this.getEndpoint(config, 'cancel'); + + const soapRequest = this.buildFinkokCancelRequest( + credentials.username, + credentials.password, + config.rfc, + uuid, + reason, + substituteUuid + ); + + const response = await fetch(endpoint, { + method: 'POST', + headers: { + 'Content-Type': 'text/xml; charset=utf-8', + 'SOAPAction': 'cancel', + }, + body: soapRequest, + }); + + if (!response.ok) { + throw new Error(`Finkok cancel error: ${response.status}`); + } + + const responseText = await response.text(); + return this.parseFinkokCancelResponse(responseText); + } + + private buildFinkokCancelRequest( + username: string, + password: string, + rfc: string, + uuid: string, + reason: string, + substituteUuid?: string + ): string { + const folioSustitucion = substituteUuid || ''; + + return ` + + + + + + ${uuid} + + ${username} + ${password} + ${rfc} + ${reason} + ${folioSustitucion} + + +`; + } + + private parseFinkokCancelResponse(responseXml: string): CancelResult { + const statusMatch = responseXml.match(/([^<]+)<\/EstatusUUID>/); + const acuseMatch = responseXml.match(/([^<]+)<\/Acuse>/); + const errorMatch = responseXml.match(/([^<]+)<\/CodEstatus>/); + + if (statusMatch?.[1] === '201' || statusMatch?.[1] === '202') { + return { + success: true, + status: statusMatch[1] === '201' ? 'cancelled' : 'pending', + acuse: acuseMatch?.[1], + }; + } + + return { + success: false, + status: statusMatch?.[1], + errorCode: errorMatch?.[1] || 'UNKNOWN', + errorMessage: 'Cancellation failed', + }; + } + + private async verifyWithFinkok( + config: CFDIConfig, + emisorRfc: string, + receptorRfc: string, + total: number, + uuid: string + ): Promise { + const credentials = this.decryptCredentials(config); + const endpoint = this.getEndpoint(config, 'status'); + + const soapRequest = ` + + + + + ${credentials.username} + ${credentials.password} + ${emisorRfc} + ${receptorRfc} + ${uuid} + ${total.toFixed(2)} + + +`; + + const response = await fetch(endpoint, { + method: 'POST', + headers: { + 'Content-Type': 'text/xml; charset=utf-8', + 'SOAPAction': 'get_sat_status', + }, + body: soapRequest, + }); + + if (!response.ok) { + throw new Error(`Finkok status error: ${response.status}`); + } + + const responseText = await response.text(); + return this.parseFinkokStatusResponse(responseText); + } + + private parseFinkokStatusResponse(responseXml: string): VerifyStatusResult { + const estadoMatch = responseXml.match(/([^<]+)<\/Estado>/); + const cancelableMatch = responseXml.match(/([^<]+)<\/EsCancelable>/); + const estatusCancelacionMatch = responseXml.match(/([^<]+)<\/EstatusCancelacion>/); + + if (!estadoMatch) { + return { + success: false, + status: 'error', + errorCode: 'PARSE_ERROR', + errorMessage: 'Could not parse status response', + }; + } + + let status: VerifyStatusResult['status'] = 'valid'; + if (estadoMatch[1] === 'Cancelado') { + status = 'cancelled'; + } else if (estadoMatch[1] === 'No Encontrado') { + status = 'not_found'; + } + + return { + success: true, + status, + cancellationStatus: estatusCancelacionMatch?.[1], + }; + } + + // ==================== Facturapi Implementation ==================== + + private async stampWithFacturapi(config: CFDIConfig, signedXml: string): Promise { + const credentials = this.decryptCredentials(config); + const endpoint = this.getEndpoint(config, 'stamp'); + + const response = await fetch(`${endpoint}/stamp`, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${credentials.password}`, + 'Content-Type': 'application/xml', + }, + body: signedXml, + }); + + if (!response.ok) { + const error = await response.json(); + return { + success: false, + errorCode: error.code, + errorMessage: error.message, + }; + } + + const result = await response.json(); + return { + success: true, + uuid: result.uuid, + fechaTimbrado: result.stamp_date, + xml: result.xml, + }; + } + + private async cancelWithFacturapi( + config: CFDIConfig, + uuid: string, + reason: string, + substituteUuid?: string + ): Promise { + const credentials = this.decryptCredentials(config); + const endpoint = this.getEndpoint(config, 'cancel'); + + const response = await fetch(`${endpoint}/${uuid}`, { + method: 'DELETE', + headers: { + 'Authorization': `Bearer ${credentials.password}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + motive: reason, + substitute: substituteUuid, + }), + }); + + if (!response.ok) { + const error = await response.json(); + return { + success: false, + errorCode: error.code, + errorMessage: error.message, + }; + } + + const result = await response.json(); + return { + success: true, + status: result.status, + acuse: result.acuse, + }; + } + + // ==================== SW Sapien Implementation ==================== + + private async stampWithSWSapien(config: CFDIConfig, signedXml: string): Promise { + const credentials = this.decryptCredentials(config); + const endpoint = this.getEndpoint(config, 'stamp'); + + // First get auth token + const authResponse = await fetch('https://services.sw.com.mx/security/authenticate', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + user: credentials.username, + password: credentials.password, + }), + }); + + if (!authResponse.ok) { + throw new Error('SW Sapien authentication failed'); + } + + const authResult = await authResponse.json(); + const token = authResult.data.token; + + // Stamp with token + const xmlBase64 = Buffer.from(signedXml).toString('base64'); + const stampResponse = await fetch(endpoint, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ xml: xmlBase64 }), + }); + + if (!stampResponse.ok) { + const error = await stampResponse.json(); + return { + success: false, + errorCode: error.messageDetail, + errorMessage: error.message, + }; + } + + const result = await stampResponse.json(); + return { + success: true, + uuid: result.data.uuid, + fechaTimbrado: result.data.fechaTimbrado, + xml: Buffer.from(result.data.cfdi, 'base64').toString('utf-8'), + }; + } + + private async cancelWithSWSapien( + config: CFDIConfig, + uuid: string, + reason: string, + substituteUuid?: string + ): Promise { + // Similar implementation with SW Sapien API + return { + success: false, + errorCode: 'NOT_IMPLEMENTED', + errorMessage: 'SW Sapien cancellation not yet implemented', + }; + } + + // ==================== Generic Implementations ==================== + + private async stampGeneric(config: CFDIConfig, signedXml: string): Promise { + if (!config.pacApiUrl) { + return { + success: false, + errorCode: 'NO_API_URL', + errorMessage: 'No PAC API URL configured', + }; + } + + const credentials = this.decryptCredentials(config); + + const response = await fetch(config.pacApiUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/xml', + 'Authorization': `Basic ${Buffer.from(`${credentials.username}:${credentials.password}`).toString('base64')}`, + }, + body: signedXml, + }); + + if (!response.ok) { + return { + success: false, + errorCode: 'API_ERROR', + errorMessage: `API returned ${response.status}`, + }; + } + + const xml = await response.text(); + return { + success: true, + xml, + }; + } + + private async cancelGeneric( + config: CFDIConfig, + uuid: string, + reason: string, + substituteUuid?: string + ): Promise { + return { + success: false, + errorCode: 'NOT_IMPLEMENTED', + errorMessage: 'Generic cancellation not implemented. Configure a specific PAC provider.', + }; + } + + private async verifyGeneric( + config: CFDIConfig, + emisorRfc: string, + receptorRfc: string, + total: number, + uuid: string + ): Promise { + return { + success: false, + status: 'error', + errorCode: 'NOT_IMPLEMENTED', + errorMessage: 'Generic verification not implemented. Configure a specific PAC provider.', + }; + } + + // ==================== Helpers ==================== + + private getEndpoint(config: CFDIConfig, type: keyof PACEndpoints): string { + const provider = config.pacProvider; + const environment = config.pacEnvironment; + const endpoints = PAC_ENDPOINTS[provider]; + + if (!endpoints) { + throw new Error(`Unknown PAC provider: ${provider}`); + } + + return endpoints[environment][type]; + } + + private decryptCredentials(config: CFDIConfig): { username: string; password: string } { + return { + username: config.pacUsername || '', + password: config.pacPasswordEncrypted + ? this.decrypt(config.pacPasswordEncrypted) + : '', + }; + } + + /** + * Encrypt sensitive data + */ + encrypt(text: string): string { + const iv = crypto.randomBytes(16); + const cipher = crypto.createCipheriv('aes-256-cbc', this.encryptionKey, iv); + let encrypted = cipher.update(text, 'utf8', 'hex'); + encrypted += cipher.final('hex'); + return iv.toString('hex') + ':' + encrypted; + } + + /** + * Decrypt sensitive data + */ + decrypt(encryptedText: string): string { + const [ivHex, encrypted] = encryptedText.split(':'); + const iv = Buffer.from(ivHex, 'hex'); + const decipher = crypto.createDecipheriv('aes-256-cbc', this.encryptionKey, iv); + let decrypted = decipher.update(encrypted, 'hex', 'utf8'); + decrypted += decipher.final('utf8'); + return decrypted; + } + + /** + * Decrypt and load certificate + */ + async loadCertificate(config: CFDIConfig): Promise<{ + certificate: Buffer; + privateKey: Buffer; + password: string; + }> { + if (!config.certificateEncrypted || !config.privateKeyEncrypted) { + throw new Error('Certificate not configured'); + } + + return { + certificate: Buffer.from(this.decrypt(config.certificateEncrypted), 'base64'), + privateKey: Buffer.from(this.decrypt(config.privateKeyEncrypted), 'base64'), + password: config.certificatePasswordEncrypted + ? this.decrypt(config.certificatePasswordEncrypted) + : '', + }; + } + + /** + * Sign data with private key (for CFDI sello) + */ + async signData(data: string, privateKey: Buffer, password: string): Promise { + const sign = crypto.createSign('RSA-SHA256'); + sign.update(data, 'utf8'); + const signature = sign.sign({ + key: privateKey, + passphrase: password, + }); + return signature.toString('base64'); + } + + /** + * Get certificate as base64 + */ + getCertificateBase64(certificate: Buffer): string { + // Convert DER to PEM format if needed, then extract base64 + const pemContent = certificate.toString('utf8'); + if (pemContent.includes('-----BEGIN CERTIFICATE-----')) { + // Already PEM, extract base64 content + return pemContent + .replace('-----BEGIN CERTIFICATE-----', '') + .replace('-----END CERTIFICATE-----', '') + .replace(/\s+/g, ''); + } + // Already raw/DER format + return certificate.toString('base64'); + } +} diff --git a/backend/src/modules/invoicing/services/xml.service.ts b/backend/src/modules/invoicing/services/xml.service.ts new file mode 100644 index 0000000..2321136 --- /dev/null +++ b/backend/src/modules/invoicing/services/xml.service.ts @@ -0,0 +1,534 @@ +import { create, fragment } from 'xmlbuilder2'; +import { CFDI, CFDIType } from '../entities/cfdi.entity'; +import { CFDIConfig } from '../entities/cfdi-config.entity'; + +export interface CFDIConcepto { + claveProdServ: string; + claveUnidad: string; + cantidad: number; + unidad: string; + descripcion: string; + valorUnitario: number; + importe: number; + descuento?: number; + objetoImp: string; + impuestos?: { + traslados?: { + base: number; + impuesto: string; + tipoFactor: string; + tasaOCuota: number; + importe: number; + }[]; + retenciones?: { + base: number; + impuesto: string; + tipoFactor: string; + tasaOCuota: number; + importe: number; + }[]; + }; +} + +export interface CFDIImpuestos { + traslados?: { + impuesto: string; + tipoFactor: string; + tasaOCuota: number; + importe: number; + base: number; + }[]; + retenciones?: { + impuesto: string; + importe: number; + }[]; +} + +export interface CFDIData { + version: string; + serie: string; + folio: number; + fecha: string; + formaPago: string; + metodoPago: string; + tipoDeComprobante: string; + exportacion: string; + lugarExpedicion: string; + moneda: string; + tipoCambio?: number; + subTotal: number; + descuento?: number; + total: number; + condicionesDePago?: string; + emisor: { + rfc: string; + nombre: string; + regimenFiscal: string; + }; + receptor: { + rfc: string; + nombre: string; + usoCFDI: string; + domicilioFiscalReceptor?: string; + regimenFiscalReceptor?: string; + }; + conceptos: CFDIConcepto[]; + impuestos?: CFDIImpuestos; + cfdiRelacionados?: { + tipoRelacion: string; + uuids: string[]; + }; +} + +export interface TimbreData { + version: string; + uuid: string; + fechaTimbrado: string; + rfcProvCertif: string; + selloCFD: string; + noCertificadoSAT: string; + selloSAT: string; +} + +const CFDI_NAMESPACE = 'http://www.sat.gob.mx/cfd/4'; +const TIMBRE_NAMESPACE = 'http://www.sat.gob.mx/TimbreFiscalDigital'; +const XSI_NAMESPACE = 'http://www.w3.org/2001/XMLSchema-instance'; + +export class XMLService { + /** + * Build CFDI XML without stamps (for signing) + */ + buildCFDIXML(data: CFDIData, certificateNumber: string): string { + const schemaLocation = `${CFDI_NAMESPACE} http://www.sat.gob.mx/sitio_internet/cfd/4/cfdv40.xsd`; + + const doc = create({ version: '1.0', encoding: 'UTF-8' }) + .ele(CFDI_NAMESPACE, 'cfdi:Comprobante') + .att('xmlns:cfdi', CFDI_NAMESPACE) + .att('xmlns:xsi', XSI_NAMESPACE) + .att('xsi:schemaLocation', schemaLocation) + .att('Version', data.version) + .att('Serie', data.serie) + .att('Folio', data.folio.toString()) + .att('Fecha', data.fecha) + .att('FormaPago', data.formaPago) + .att('SubTotal', this.formatMoney(data.subTotal)) + .att('Moneda', data.moneda) + .att('Total', this.formatMoney(data.total)) + .att('TipoDeComprobante', data.tipoDeComprobante) + .att('Exportacion', data.exportacion) + .att('MetodoPago', data.metodoPago) + .att('LugarExpedicion', data.lugarExpedicion) + .att('NoCertificado', certificateNumber); + + if (data.tipoCambio && data.tipoCambio !== 1) { + doc.att('TipoCambio', data.tipoCambio.toString()); + } + + if (data.descuento && data.descuento > 0) { + doc.att('Descuento', this.formatMoney(data.descuento)); + } + + if (data.condicionesDePago) { + doc.att('CondicionesDePago', data.condicionesDePago); + } + + // CFDI Relacionados + if (data.cfdiRelacionados && data.cfdiRelacionados.uuids.length > 0) { + const relacionados = doc.ele('cfdi:CfdiRelacionados') + .att('TipoRelacion', data.cfdiRelacionados.tipoRelacion); + + for (const uuid of data.cfdiRelacionados.uuids) { + relacionados.ele('cfdi:CfdiRelacionado').att('UUID', uuid); + } + } + + // Emisor + doc.ele('cfdi:Emisor') + .att('Rfc', data.emisor.rfc) + .att('Nombre', data.emisor.nombre) + .att('RegimenFiscal', data.emisor.regimenFiscal); + + // Receptor + const receptor = doc.ele('cfdi:Receptor') + .att('Rfc', data.receptor.rfc) + .att('Nombre', data.receptor.nombre) + .att('UsoCFDI', data.receptor.usoCFDI); + + if (data.receptor.domicilioFiscalReceptor) { + receptor.att('DomicilioFiscalReceptor', data.receptor.domicilioFiscalReceptor); + } + if (data.receptor.regimenFiscalReceptor) { + receptor.att('RegimenFiscalReceptor', data.receptor.regimenFiscalReceptor); + } + + // Conceptos + const conceptos = doc.ele('cfdi:Conceptos'); + for (const concepto of data.conceptos) { + const conceptoNode = conceptos.ele('cfdi:Concepto') + .att('ClaveProdServ', concepto.claveProdServ) + .att('Cantidad', concepto.cantidad.toString()) + .att('ClaveUnidad', concepto.claveUnidad) + .att('Unidad', concepto.unidad) + .att('Descripcion', concepto.descripcion) + .att('ValorUnitario', this.formatMoney(concepto.valorUnitario)) + .att('Importe', this.formatMoney(concepto.importe)) + .att('ObjetoImp', concepto.objetoImp); + + if (concepto.descuento && concepto.descuento > 0) { + conceptoNode.att('Descuento', this.formatMoney(concepto.descuento)); + } + + // Impuestos del concepto + if (concepto.impuestos) { + const impuestosNode = conceptoNode.ele('cfdi:Impuestos'); + + if (concepto.impuestos.traslados && concepto.impuestos.traslados.length > 0) { + const trasladosNode = impuestosNode.ele('cfdi:Traslados'); + for (const traslado of concepto.impuestos.traslados) { + trasladosNode.ele('cfdi:Traslado') + .att('Base', this.formatMoney(traslado.base)) + .att('Impuesto', traslado.impuesto) + .att('TipoFactor', traslado.tipoFactor) + .att('TasaOCuota', this.formatRate(traslado.tasaOCuota)) + .att('Importe', this.formatMoney(traslado.importe)); + } + } + + if (concepto.impuestos.retenciones && concepto.impuestos.retenciones.length > 0) { + const retencionesNode = impuestosNode.ele('cfdi:Retenciones'); + for (const retencion of concepto.impuestos.retenciones) { + retencionesNode.ele('cfdi:Retencion') + .att('Base', this.formatMoney(retencion.base)) + .att('Impuesto', retencion.impuesto) + .att('TipoFactor', retencion.tipoFactor) + .att('TasaOCuota', this.formatRate(retencion.tasaOCuota)) + .att('Importe', this.formatMoney(retencion.importe)); + } + } + } + } + + // Impuestos generales + if (data.impuestos) { + const impuestosNode = doc.ele('cfdi:Impuestos'); + + if (data.impuestos.retenciones && data.impuestos.retenciones.length > 0) { + const totalRetenido = data.impuestos.retenciones.reduce((sum, r) => sum + r.importe, 0); + impuestosNode.att('TotalImpuestosRetenidos', this.formatMoney(totalRetenido)); + + const retencionesNode = impuestosNode.ele('cfdi:Retenciones'); + for (const retencion of data.impuestos.retenciones) { + retencionesNode.ele('cfdi:Retencion') + .att('Impuesto', retencion.impuesto) + .att('Importe', this.formatMoney(retencion.importe)); + } + } + + if (data.impuestos.traslados && data.impuestos.traslados.length > 0) { + const totalTrasladado = data.impuestos.traslados.reduce((sum, t) => sum + t.importe, 0); + impuestosNode.att('TotalImpuestosTrasladados', this.formatMoney(totalTrasladado)); + + const trasladosNode = impuestosNode.ele('cfdi:Traslados'); + for (const traslado of data.impuestos.traslados) { + trasladosNode.ele('cfdi:Traslado') + .att('Base', this.formatMoney(traslado.base)) + .att('Impuesto', traslado.impuesto) + .att('TipoFactor', traslado.tipoFactor) + .att('TasaOCuota', this.formatRate(traslado.tasaOCuota)) + .att('Importe', this.formatMoney(traslado.importe)); + } + } + } + + return doc.end({ prettyPrint: false }); + } + + /** + * Add seal (sello) to XML + */ + addSealToXML(xml: string, sello: string): string { + // Insert Sello attribute after NoCertificado + return xml.replace( + /NoCertificado="([^"]+)"/, + `NoCertificado="$1" Sello="${sello}"` + ); + } + + /** + * Add certificate to XML + */ + addCertificateToXML(xml: string, certificateBase64: string): string { + // Insert Certificado attribute after Sello + return xml.replace( + /Sello="([^"]+)"/, + `Sello="$1" Certificado="${certificateBase64}"` + ); + } + + /** + * Add TimbreFiscalDigital complement to stamped XML + */ + addTimbreToXML(xml: string, timbre: TimbreData): string { + const timbreFragment = fragment() + .ele(TIMBRE_NAMESPACE, 'tfd:TimbreFiscalDigital') + .att('xmlns:tfd', TIMBRE_NAMESPACE) + .att('xmlns:xsi', XSI_NAMESPACE) + .att('xsi:schemaLocation', `${TIMBRE_NAMESPACE} http://www.sat.gob.mx/sitio_internet/cfd/TimbreFiscalDigital/TimbreFiscalDigitalv11.xsd`) + .att('Version', timbre.version) + .att('UUID', timbre.uuid) + .att('FechaTimbrado', timbre.fechaTimbrado) + .att('RfcProvCertif', timbre.rfcProvCertif) + .att('SelloCFD', timbre.selloCFD) + .att('NoCertificadoSAT', timbre.noCertificadoSAT) + .att('SelloSAT', timbre.selloSAT) + .end(); + + // Find closing tag and insert Complemento before it + const insertPoint = xml.indexOf(''); + if (insertPoint === -1) { + throw new Error('Invalid CFDI XML: missing closing tag'); + } + + return xml.slice(0, insertPoint) + + '' + timbreFragment + '' + + xml.slice(insertPoint); + } + + /** + * Build original chain for signing (cadena original) + */ + buildCadenaOriginal(data: CFDIData, certificateNumber: string): string { + const parts: string[] = []; + + // Version and main attributes + parts.push(data.version); + parts.push(data.serie); + parts.push(data.folio.toString()); + parts.push(data.fecha); + parts.push(data.formaPago); + parts.push(certificateNumber); + if (data.condicionesDePago) parts.push(data.condicionesDePago); + parts.push(this.formatMoney(data.subTotal)); + if (data.descuento && data.descuento > 0) { + parts.push(this.formatMoney(data.descuento)); + } + parts.push(data.moneda); + if (data.tipoCambio && data.tipoCambio !== 1) { + parts.push(data.tipoCambio.toString()); + } + parts.push(this.formatMoney(data.total)); + parts.push(data.tipoDeComprobante); + parts.push(data.exportacion); + parts.push(data.metodoPago); + parts.push(data.lugarExpedicion); + + // CFDI Relacionados + if (data.cfdiRelacionados && data.cfdiRelacionados.uuids.length > 0) { + parts.push(data.cfdiRelacionados.tipoRelacion); + for (const uuid of data.cfdiRelacionados.uuids) { + parts.push(uuid); + } + } + + // Emisor + parts.push(data.emisor.rfc); + parts.push(data.emisor.nombre); + parts.push(data.emisor.regimenFiscal); + + // Receptor + parts.push(data.receptor.rfc); + parts.push(data.receptor.nombre); + if (data.receptor.domicilioFiscalReceptor) { + parts.push(data.receptor.domicilioFiscalReceptor); + } + if (data.receptor.regimenFiscalReceptor) { + parts.push(data.receptor.regimenFiscalReceptor); + } + parts.push(data.receptor.usoCFDI); + + // Conceptos + for (const concepto of data.conceptos) { + parts.push(concepto.claveProdServ); + parts.push(concepto.cantidad.toString()); + parts.push(concepto.claveUnidad); + parts.push(concepto.unidad); + parts.push(concepto.descripcion); + parts.push(this.formatMoney(concepto.valorUnitario)); + parts.push(this.formatMoney(concepto.importe)); + if (concepto.descuento && concepto.descuento > 0) { + parts.push(this.formatMoney(concepto.descuento)); + } + parts.push(concepto.objetoImp); + + // Impuestos del concepto + if (concepto.impuestos) { + if (concepto.impuestos.traslados) { + for (const traslado of concepto.impuestos.traslados) { + parts.push(this.formatMoney(traslado.base)); + parts.push(traslado.impuesto); + parts.push(traslado.tipoFactor); + parts.push(this.formatRate(traslado.tasaOCuota)); + parts.push(this.formatMoney(traslado.importe)); + } + } + if (concepto.impuestos.retenciones) { + for (const retencion of concepto.impuestos.retenciones) { + parts.push(this.formatMoney(retencion.base)); + parts.push(retencion.impuesto); + parts.push(retencion.tipoFactor); + parts.push(this.formatRate(retencion.tasaOCuota)); + parts.push(this.formatMoney(retencion.importe)); + } + } + } + } + + // Impuestos generales + if (data.impuestos) { + if (data.impuestos.retenciones) { + const totalRetenido = data.impuestos.retenciones.reduce((sum, r) => sum + r.importe, 0); + parts.push(this.formatMoney(totalRetenido)); + for (const retencion of data.impuestos.retenciones) { + parts.push(retencion.impuesto); + parts.push(this.formatMoney(retencion.importe)); + } + } + if (data.impuestos.traslados) { + const totalTrasladado = data.impuestos.traslados.reduce((sum, t) => sum + t.importe, 0); + parts.push(this.formatMoney(totalTrasladado)); + for (const traslado of data.impuestos.traslados) { + parts.push(this.formatMoney(traslado.base)); + parts.push(traslado.impuesto); + parts.push(traslado.tipoFactor); + parts.push(this.formatRate(traslado.tasaOCuota)); + parts.push(this.formatMoney(traslado.importe)); + } + } + } + + return '||' + parts.join('|') + '||'; + } + + /** + * Parse stamped XML response from PAC + */ + parseStampedXML(xml: string): { + uuid: string; + fechaTimbrado: string; + selloCFD: string; + noCertificadoSAT: string; + selloSAT: string; + rfcProvCertif: string; + cadenaOriginalSAT: string; + } { + // Extract TimbreFiscalDigital attributes using regex (faster than full XML parse) + const uuidMatch = xml.match(/UUID="([^"]+)"/); + const fechaMatch = xml.match(/FechaTimbrado="([^"]+)"/); + const selloCFDMatch = xml.match(/SelloCFD="([^"]+)"/); + const noCertMatch = xml.match(/NoCertificadoSAT="([^"]+)"/); + const selloSATMatch = xml.match(/SelloSAT="([^"]+)"/); + const rfcProvMatch = xml.match(/RfcProvCertif="([^"]+)"/); + + if (!uuidMatch || !fechaMatch || !selloSATMatch || !noCertMatch) { + throw new Error('Invalid stamped XML: missing TimbreFiscalDigital attributes'); + } + + // Build SAT cadena original from timbre + const cadenaOriginalSAT = this.buildCadenaOriginalSAT({ + version: '1.1', + uuid: uuidMatch[1], + fechaTimbrado: fechaMatch[1], + rfcProvCertif: rfcProvMatch?.[1] || '', + selloCFD: selloCFDMatch?.[1] || '', + noCertificadoSAT: noCertMatch[1], + selloSAT: selloSATMatch[1], + }); + + return { + uuid: uuidMatch[1], + fechaTimbrado: fechaMatch[1], + selloCFD: selloCFDMatch?.[1] || '', + noCertificadoSAT: noCertMatch[1], + selloSAT: selloSATMatch[1], + rfcProvCertif: rfcProvMatch?.[1] || '', + cadenaOriginalSAT, + }; + } + + /** + * Build SAT cadena original from timbre + */ + private buildCadenaOriginalSAT(timbre: TimbreData): string { + const parts: string[] = []; + parts.push(timbre.version); + parts.push(timbre.uuid); + parts.push(timbre.fechaTimbrado); + parts.push(timbre.rfcProvCertif); + parts.push(timbre.selloCFD); + parts.push(timbre.noCertificadoSAT); + return '||' + parts.join('|') + '||'; + } + + /** + * Generate QR code data string + */ + generateQRData(cfdi: CFDI): string { + // Format: https://verificacfdi.facturaelectronica.sat.gob.mx/default.aspx?id=uuid&re=rfcEmisor&rr=rfcReceptor&tt=total&fe=ultimos8SelloCFD + const total = cfdi.total.toFixed(2).padStart(17, '0'); + const selloLast8 = cfdi.selloCFDI?.slice(-8) || '00000000'; + + return `https://verificacfdi.facturaelectronica.sat.gob.mx/default.aspx?id=${cfdi.uuid}&re=${cfdi.emisorRfc}&rr=${cfdi.receptorRfc}&tt=${total}&fe=${selloLast8}`; + } + + /** + * Format money value for CFDI (2 decimal places) + */ + private formatMoney(value: number): string { + return value.toFixed(2); + } + + /** + * Format rate/percentage for CFDI (6 decimal places) + */ + private formatRate(value: number): string { + return value.toFixed(6); + } + + /** + * Format date for CFDI (ISO 8601 without timezone) + */ + formatCFDIDate(date: Date): string { + return date.toISOString().replace('Z', '').slice(0, 19); + } + + /** + * Validate XML structure + */ + validateXMLStructure(xml: string): { valid: boolean; errors: string[] } { + const errors: string[] = []; + + // Check required elements + if (!xml.includes('cfdi:Comprobante')) { + errors.push('Missing Comprobante element'); + } + if (!xml.includes('cfdi:Emisor')) { + errors.push('Missing Emisor element'); + } + if (!xml.includes('cfdi:Receptor')) { + errors.push('Missing Receptor element'); + } + if (!xml.includes('cfdi:Conceptos')) { + errors.push('Missing Conceptos element'); + } + + // Check version + if (!xml.includes('Version="4.0"')) { + errors.push('Invalid or missing CFDI version (expected 4.0)'); + } + + return { + valid: errors.length === 0, + errors, + }; + } +} diff --git a/backend/src/modules/invoicing/validation/cfdi.schema.ts b/backend/src/modules/invoicing/validation/cfdi.schema.ts new file mode 100644 index 0000000..94f14c6 --- /dev/null +++ b/backend/src/modules/invoicing/validation/cfdi.schema.ts @@ -0,0 +1,245 @@ +import { z } from 'zod'; +import { + uuidSchema, + moneySchema, + paginationSchema, +} from '../../../shared/validation/common.schema'; + +// RFC validation (12-13 characters) +const rfcSchema = z.string() + .min(12, 'RFC must be at least 12 characters') + .max(13, 'RFC must be at most 13 characters') + .regex(/^[A-Z&Ñ]{3,4}[0-9]{6}[A-Z0-9]{3}$/i, 'Invalid RFC format'); + +// SAT catalog codes +const claveProdServSchema = z.string().length(8, 'ClaveProdServ must be 8 digits').regex(/^\d{8}$/); +const claveUnidadSchema = z.string().min(1).max(4); +const regimenFiscalSchema = z.string().length(3, 'RegimenFiscal must be 3 characters'); +const usoCFDISchema = z.string().length(3).or(z.string().length(4)); +const codigoPostalSchema = z.string().length(5, 'Código postal must be 5 digits').regex(/^\d{5}$/); +const formaPagoSchema = z.string().length(2, 'FormaPago must be 2 characters'); +const metodoPagoSchema = z.enum(['PUE', 'PPD']); +const monedaSchema = z.string().length(3, 'Moneda must be 3 characters').default('MXN'); + +// CFDI Status enum +export const cfdiStatusEnum = z.enum([ + 'draft', + 'pending', + 'stamped', + 'cancelled', + 'cancellation_pending', + 'error', +]); + +// CFDI Type enum +export const cfdiTypeEnum = z.enum(['I', 'E', 'T', 'N', 'P']); + +// Cancellation reason enum +export const cancellationReasonEnum = z.enum(['01', '02', '03', '04']); + +// ==================== RECEPTOR SCHEMAS ==================== + +export const receptorSchema = z.object({ + rfc: rfcSchema, + nombre: z.string().min(1).max(300), + usoCFDI: usoCFDISchema, + regimenFiscal: regimenFiscalSchema.optional(), + domicilioFiscal: codigoPostalSchema.optional(), + email: z.string().email().max(100).optional(), +}); + +// ==================== LINE ITEM SCHEMAS ==================== + +export const orderLineSchema = z.object({ + productId: uuidSchema.optional(), + productCode: z.string().min(1).max(50), + productName: z.string().min(1).max(1000), + claveProdServ: claveProdServSchema, + claveUnidad: claveUnidadSchema, + unidad: z.string().min(1).max(20), + quantity: z.number().positive(), + unitPrice: moneySchema.positive(), + discount: moneySchema.optional(), + taxRate: z.number().min(0).max(1).default(0.16), + taxIncluded: z.boolean().optional(), + retentionIVA: z.number().min(0).max(1).optional(), + retentionISR: z.number().min(0).max(1).optional(), + objetoImp: z.enum(['01', '02', '03']).optional(), +}); + +// ==================== CREATE CFDI SCHEMA ==================== + +export const createCFDISchema = z.object({ + configId: uuidSchema.optional(), + branchId: uuidSchema.optional(), + receptor: receptorSchema, + lines: z.array(orderLineSchema).min(1, 'At least one line item is required'), + formaPago: formaPagoSchema, + metodoPago: metodoPagoSchema, + moneda: monedaSchema.optional(), + tipoCambio: z.number().positive().optional(), + condicionesPago: z.string().max(100).optional(), + orderId: uuidSchema.optional(), + orderNumber: z.string().max(30).optional(), + orderType: z.enum(['pos', 'ecommerce']).optional(), + notes: z.string().max(1000).optional(), + stampImmediately: z.boolean().optional().default(true), +}); + +// ==================== CANCEL CFDI SCHEMA ==================== + +export const cancelCFDISchema = z.object({ + reason: cancellationReasonEnum, + substituteUuid: z.string().uuid().optional(), +}).refine( + (data) => { + // If reason is 01, substituteUuid is required + if (data.reason === '01' && !data.substituteUuid) { + return false; + } + return true; + }, + { + message: 'Substitute UUID is required for cancellation reason 01', + path: ['substituteUuid'], + } +); + +// ==================== LIST CFDI QUERY SCHEMA ==================== + +export const listCFDIsQuerySchema = paginationSchema.extend({ + status: cfdiStatusEnum.optional(), + startDate: z.coerce.date().optional(), + endDate: z.coerce.date().optional(), + receptorRfc: rfcSchema.optional(), + branchId: uuidSchema.optional(), + search: z.string().max(100).optional(), +}); + +// ==================== RESEND EMAIL SCHEMA ==================== + +export const resendEmailSchema = z.object({ + email: z.string().email().max(100).optional(), +}); + +// ==================== CREATE CREDIT NOTE SCHEMA ==================== + +export const createCreditNoteSchema = z.object({ + originalCfdiId: uuidSchema, + lines: z.array(orderLineSchema).min(1), + reason: z.string().min(1).max(500), +}); + +// ==================== CFDI CONFIG SCHEMAS ==================== + +export const pacProviderEnum = z.enum([ + 'finkok', + 'soluciones_fiscales', + 'facturapi', + 'sw_sapien', + 'digisat', + 'other', +]); + +export const cfdiConfigStatusEnum = z.enum([ + 'active', + 'inactive', + 'pending_validation', + 'expired', +]); + +export const createCFDIConfigSchema = z.object({ + branchId: uuidSchema.optional(), + rfc: rfcSchema, + razonSocial: z.string().min(1).max(300), + nombreComercial: z.string().max(200).optional(), + regimenFiscal: regimenFiscalSchema, + regimenFiscalDescripcion: z.string().max(200).optional(), + codigoPostal: codigoPostalSchema, + domicilio: z.string().max(500).optional(), + + // PAC configuration + pacProvider: pacProviderEnum.default('finkok'), + pacUsername: z.string().max(100).optional(), + pacPassword: z.string().max(255).optional(), + pacEnvironment: z.enum(['sandbox', 'production']).default('sandbox'), + pacApiUrl: z.string().url().max(255).optional(), + + // Certificate (base64 encoded) + certificate: z.string().optional(), + privateKey: z.string().optional(), + certificatePassword: z.string().max(255).optional(), + + // Defaults + defaultSerie: z.string().min(1).max(10).default('A'), + defaultFormaPago: formaPagoSchema.default('01'), + defaultMetodoPago: metodoPagoSchema.default('PUE'), + defaultUsoCFDI: usoCFDISchema.default('G03'), + defaultMoneda: monedaSchema.default('MXN'), + + // Branding + logoUrl: z.string().url().max(255).optional(), + pdfTemplate: z.string().max(50).default('standard'), + footerText: z.string().max(500).optional(), + + // Email settings + sendEmailOnIssue: z.boolean().default(true), + emailFrom: z.string().email().max(100).optional(), + emailSubjectTemplate: z.string().max(200).optional(), + emailBodyTemplate: z.string().max(2000).optional(), + + // Notifications + notifyCertificateExpiry: z.boolean().default(true), + notifyDaysBeforeExpiry: z.number().int().min(1).max(90).default(30), + + // Auto-invoicing + autoInvoicePOS: z.boolean().default(false), + autoInvoiceEcommerce: z.boolean().default(true), + + // Rate limiting + maxInvoicesPerDay: z.number().int().positive().optional(), +}); + +export const updateCFDIConfigSchema = createCFDIConfigSchema.partial(); + +export const validateCFDIConfigSchema = z.object({ + testStamp: z.boolean().default(false), +}); + +// ==================== AUTOFACTURA SCHEMAS ==================== + +export const autofacturaRequestSchema = z.object({ + orderNumber: z.string().min(1).max(30), + orderDate: z.coerce.date().optional(), + branchId: uuidSchema.optional(), + receptor: receptorSchema, +}); + +export const lookupOrderSchema = z.object({ + orderNumber: z.string().min(1).max(30), + orderDate: z.coerce.date().optional(), + orderAmount: moneySchema.positive().optional(), +}); + +// ==================== STATS QUERY SCHEMA ==================== + +export const cfdiStatsQuerySchema = z.object({ + startDate: z.coerce.date(), + endDate: z.coerce.date(), + branchId: uuidSchema.optional(), +}); + +// ==================== TYPES ==================== + +export type CreateCFDIInput = z.infer; +export type CancelCFDIInput = z.infer; +export type ListCFDIsQuery = z.infer; +export type ResendEmailInput = z.infer; +export type CreateCreditNoteInput = z.infer; +export type CreateCFDIConfigInput = z.infer; +export type UpdateCFDIConfigInput = z.infer; +export type AutofacturaRequest = z.infer; +export type LookupOrderInput = z.infer; +export type CFDIStatsQuery = z.infer; +export type ReceptorInput = z.infer; +export type OrderLineInput = z.infer; diff --git a/backend/src/modules/invoicing/validation/index.ts b/backend/src/modules/invoicing/validation/index.ts new file mode 100644 index 0000000..390a2e6 --- /dev/null +++ b/backend/src/modules/invoicing/validation/index.ts @@ -0,0 +1 @@ +export * from './cfdi.schema'; diff --git a/backend/src/modules/pos/controllers/pos.controller.ts b/backend/src/modules/pos/controllers/pos.controller.ts new file mode 100644 index 0000000..1f1d5d3 --- /dev/null +++ b/backend/src/modules/pos/controllers/pos.controller.ts @@ -0,0 +1,605 @@ +import { Response, NextFunction } from 'express'; +import { BaseController } from '../../../shared/controllers/base.controller'; +import { AuthenticatedRequest } from '../../../shared/types'; +import { posSessionService } from '../services/pos-session.service'; +import { posOrderService } from '../services/pos-order.service'; + +class POSController extends BaseController { + // ==================== SESSION ENDPOINTS ==================== + + /** + * POST /pos/sessions/open - Open a new session + */ + async openSession(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const tenantId = this.getTenantId(req); + const userId = this.getUserId(req); + const branchId = this.getBranchId(req); + + if (!branchId) { + return this.error(res, 'BRANCH_REQUIRED', 'X-Branch-ID header is required'); + } + + const { registerId, openingCash, openingNotes } = req.body; + + if (!registerId) { + return this.validationError(res, { registerId: 'Required' }); + } + + const result = await posSessionService.openSession(tenantId, { + branchId, + registerId, + userId, + openingCash: openingCash || 0, + openingNotes, + }); + + if (!result.success) { + return this.error(res, result.error.code, result.error.message); + } + + return this.success(res, result.data, 201); + } catch (error) { + next(error); + } + } + + /** + * POST /pos/sessions/:id/close - Close a session + */ + async closeSession(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const tenantId = this.getTenantId(req); + const userId = this.getUserId(req); + const { id } = req.params; + + const { closingCashCounted, closingNotes, cashCountDetail } = req.body; + + if (closingCashCounted === undefined) { + return this.validationError(res, { closingCashCounted: 'Required' }); + } + + const result = await posSessionService.closeSession(tenantId, id, { + closedBy: userId, + closingCashCounted, + closingNotes, + cashCountDetail, + }); + + if (!result.success) { + return this.error(res, result.error.code, result.error.message); + } + + return this.success(res, result.data); + } catch (error) { + next(error); + } + } + + /** + * GET /pos/sessions/active - Get active session for current user + */ + async getActiveSession(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const tenantId = this.getTenantId(req); + const userId = this.getUserId(req); + + const session = await posSessionService.getActiveSession(tenantId, userId); + + if (!session) { + return this.notFound(res, 'Active session'); + } + + return this.success(res, session); + } catch (error) { + next(error); + } + } + + /** + * GET /pos/sessions/:id - Get session by ID + */ + async getSession(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const tenantId = this.getTenantId(req); + const { id } = req.params; + const { withOrders } = req.query; + + const session = withOrders + ? await posSessionService.getSessionWithOrders(tenantId, id) + : await posSessionService.findById(tenantId, id); + + if (!session) { + return this.notFound(res, 'Session'); + } + + return this.success(res, session); + } catch (error) { + next(error); + } + } + + /** + * GET /pos/sessions - List sessions for branch + */ + async listSessions(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const tenantId = this.getTenantId(req); + const branchId = this.getBranchId(req); + + if (!branchId) { + return this.error(res, 'BRANCH_REQUIRED', 'X-Branch-ID header is required'); + } + + const pagination = this.parsePagination(req.query); + const { status, userId, startDate, endDate } = req.query; + + const result = await posSessionService.findAll(tenantId, { + pagination, + filters: [ + { field: 'branchId', operator: 'eq', value: branchId }, + ...(status ? [{ field: 'status', operator: 'eq' as const, value: status }] : []), + ...(userId ? [{ field: 'userId', operator: 'eq' as const, value: userId }] : []), + ], + }); + + return this.paginated(res, result); + } catch (error) { + next(error); + } + } + + /** + * GET /pos/sessions/daily-summary - Get daily session summary + */ + async getDailySummary(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const tenantId = this.getTenantId(req); + const branchId = this.getBranchId(req); + + if (!branchId) { + return this.error(res, 'BRANCH_REQUIRED', 'X-Branch-ID header is required'); + } + + const dateParam = req.query.date as string; + const date = dateParam ? new Date(dateParam) : new Date(); + + const summary = await posSessionService.getDailySummary(tenantId, branchId, date); + return this.success(res, summary); + } catch (error) { + next(error); + } + } + + // ==================== ORDER ENDPOINTS ==================== + + /** + * POST /pos/orders - Create a new order + */ + async createOrder(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const tenantId = this.getTenantId(req); + const userId = this.getUserId(req); + const branchId = this.getBranchId(req); + const branchCode = req.branch?.branchCode || 'UNK'; + + if (!branchId) { + return this.error(res, 'BRANCH_REQUIRED', 'X-Branch-ID header is required'); + } + + const { sessionId, registerId, lines, ...orderData } = req.body; + + if (!sessionId || !registerId || !lines || lines.length === 0) { + return this.validationError(res, { + sessionId: sessionId ? undefined : 'Required', + registerId: registerId ? undefined : 'Required', + lines: lines?.length ? undefined : 'At least one line is required', + }); + } + + const result = await posOrderService.createOrder( + tenantId, + { + sessionId, + branchId, + registerId, + userId, + lines, + ...orderData, + }, + branchCode + ); + + if (!result.success) { + return this.error(res, result.error.code, result.error.message); + } + + return this.success(res, result.data, 201); + } catch (error) { + next(error); + } + } + + /** + * POST /pos/orders/:id/payments - Add payment to order + */ + async addPayment(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const tenantId = this.getTenantId(req); + const userId = this.getUserId(req); + const { id } = req.params; + + const { method, amount, amountReceived, ...paymentData } = req.body; + + if (!method || amount === undefined) { + return this.validationError(res, { + method: method ? undefined : 'Required', + amount: amount !== undefined ? undefined : 'Required', + }); + } + + const result = await posOrderService.addPayment( + tenantId, + id, + { + method, + amount, + amountReceived: amountReceived || amount, + ...paymentData, + }, + userId + ); + + if (!result.success) { + return this.error(res, result.error.code, result.error.message); + } + + return this.success(res, result.data); + } catch (error) { + next(error); + } + } + + /** + * POST /pos/orders/:id/void - Void an order + */ + async voidOrder(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const tenantId = this.getTenantId(req); + const userId = this.getUserId(req); + const { id } = req.params; + const { reason } = req.body; + + if (!reason) { + return this.validationError(res, { reason: 'Required' }); + } + + const result = await posOrderService.voidOrder(tenantId, id, reason, userId); + + if (!result.success) { + return this.error(res, result.error.code, result.error.message); + } + + return this.success(res, result.data); + } catch (error) { + next(error); + } + } + + /** + * GET /pos/orders/:id - Get order by ID + */ + async getOrder(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const tenantId = this.getTenantId(req); + const { id } = req.params; + + const order = await posOrderService.getOrderWithDetails(tenantId, id); + + if (!order) { + return this.notFound(res, 'Order'); + } + + return this.success(res, order); + } catch (error) { + next(error); + } + } + + /** + * GET /pos/orders - List orders + */ + async listOrders(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const tenantId = this.getTenantId(req); + const branchId = this.getBranchId(req); + + if (!branchId) { + return this.error(res, 'BRANCH_REQUIRED', 'X-Branch-ID header is required'); + } + + const pagination = this.parsePagination(req.query); + const { sessionId, status, customerId, startDate, endDate, search } = req.query; + + // If search term provided, use search method + if (search) { + const orders = await posOrderService.searchOrders( + tenantId, + branchId, + search as string, + pagination.limit + ); + return this.success(res, orders); + } + + const result = await posOrderService.findAll(tenantId, { + pagination, + filters: [ + { field: 'branchId', operator: 'eq', value: branchId }, + ...(sessionId ? [{ field: 'sessionId', operator: 'eq' as const, value: sessionId }] : []), + ...(status ? [{ field: 'status', operator: 'eq' as const, value: status }] : []), + ...(customerId ? [{ field: 'customerId', operator: 'eq' as const, value: customerId }] : []), + ], + }); + + return this.paginated(res, result); + } catch (error) { + next(error); + } + } + + /** + * GET /pos/orders/session/:sessionId - Get orders by session + */ + async getOrdersBySession(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const tenantId = this.getTenantId(req); + const { sessionId } = req.params; + + const orders = await posOrderService.getOrdersBySession(tenantId, sessionId); + return this.success(res, orders); + } catch (error) { + next(error); + } + } + + // ==================== REFUND ENDPOINTS ==================== + + /** + * POST /pos/refunds - Create a refund + */ + async createRefund(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const tenantId = this.getTenantId(req); + const userId = this.getUserId(req); + const branchId = this.getBranchId(req); + const branchCode = req.branch?.branchCode || 'UNK'; + + if (!branchId) { + return this.error(res, 'BRANCH_REQUIRED', 'X-Branch-ID header is required'); + } + + const { originalOrderId, sessionId, registerId, lines, refundReason } = req.body; + + if (!originalOrderId || !sessionId || !registerId || !lines || lines.length === 0) { + return this.validationError(res, { + originalOrderId: originalOrderId ? undefined : 'Required', + sessionId: sessionId ? undefined : 'Required', + registerId: registerId ? undefined : 'Required', + lines: lines?.length ? undefined : 'At least one line is required', + }); + } + + const result = await posOrderService.createRefund( + tenantId, + { + originalOrderId, + sessionId, + branchId, + registerId, + userId, + lines, + refundReason: refundReason || 'Customer refund', + }, + branchCode + ); + + if (!result.success) { + return this.error(res, result.error.code, result.error.message); + } + + return this.success(res, result.data, 201); + } catch (error) { + next(error); + } + } + + /** + * POST /pos/refunds/:id/process - Process refund payment + */ + async processRefund(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const tenantId = this.getTenantId(req); + const userId = this.getUserId(req); + const { id } = req.params; + const { method, amount } = req.body; + + if (!method || amount === undefined) { + return this.validationError(res, { + method: method ? undefined : 'Required', + amount: amount !== undefined ? undefined : 'Required', + }); + } + + const result = await posOrderService.processRefundPayment(tenantId, id, { method, amount }, userId); + + if (!result.success) { + return this.error(res, result.error.code, result.error.message); + } + + return this.success(res, result.data); + } catch (error) { + next(error); + } + } + + // ==================== HOLD/RECALL ENDPOINTS ==================== + + /** + * POST /pos/orders/:id/hold - Hold an order + */ + async holdOrder(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const tenantId = this.getTenantId(req); + const { id } = req.params; + const { holdName } = req.body; + + const result = await posOrderService.holdOrder(tenantId, id, holdName || 'Unnamed'); + + if (!result.success) { + return this.error(res, result.error.code, result.error.message); + } + + return this.success(res, result.data); + } catch (error) { + next(error); + } + } + + /** + * GET /pos/orders/held - Get held orders + */ + async getHeldOrders(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const tenantId = this.getTenantId(req); + const branchId = this.getBranchId(req); + + if (!branchId) { + return this.error(res, 'BRANCH_REQUIRED', 'X-Branch-ID header is required'); + } + + const orders = await posOrderService.getHeldOrders(tenantId, branchId); + return this.success(res, orders); + } catch (error) { + next(error); + } + } + + /** + * POST /pos/orders/:id/recall - Recall a held order + */ + async recallOrder(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const tenantId = this.getTenantId(req); + const userId = this.getUserId(req); + const { id } = req.params; + const { sessionId, registerId } = req.body; + + if (!sessionId || !registerId) { + return this.validationError(res, { + sessionId: sessionId ? undefined : 'Required', + registerId: registerId ? undefined : 'Required', + }); + } + + const result = await posOrderService.recallOrder(tenantId, id, sessionId, registerId, userId); + + if (!result.success) { + return this.error(res, result.error.code, result.error.message); + } + + return this.success(res, result.data); + } catch (error) { + next(error); + } + } + + // ==================== ADDITIONAL ENDPOINTS ==================== + + /** + * POST /pos/orders/:id/coupon - Apply coupon to order + */ + async applyCoupon(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const tenantId = this.getTenantId(req); + const branchId = this.getBranchId(req); + const { id } = req.params; + const { couponCode } = req.body; + + if (!branchId) { + return this.error(res, 'BRANCH_REQUIRED', 'X-Branch-ID header is required'); + } + + if (!couponCode) { + return this.validationError(res, { couponCode: 'Required' }); + } + + const result = await posOrderService.applyCouponToOrder(tenantId, id, couponCode, branchId); + + if (!result.success) { + return this.error(res, result.error.code, result.error.message); + } + + return this.success(res, result.data); + } catch (error) { + next(error); + } + } + + /** + * POST /pos/orders/:id/receipt/print - Mark receipt as printed + */ + async markReceiptPrinted(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const tenantId = this.getTenantId(req); + const { id } = req.params; + + await posOrderService.markReceiptPrinted(tenantId, id); + return this.success(res, { message: 'Receipt marked as printed' }); + } catch (error) { + next(error); + } + } + + /** + * POST /pos/orders/:id/receipt/send - Send receipt by email + */ + async sendReceipt(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const tenantId = this.getTenantId(req); + const { id } = req.params; + const { email } = req.body; + + if (!email) { + return this.validationError(res, { email: 'Required' }); + } + + const result = await posOrderService.sendReceiptEmail(tenantId, id, email); + + if (!result.success) { + return this.error(res, result.error.code, result.error.message); + } + + return this.success(res, { message: 'Receipt sent successfully' }); + } catch (error) { + next(error); + } + } + + /** + * GET /pos/sessions/:id/stats - Get session statistics + */ + async getSessionStats(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const tenantId = this.getTenantId(req); + const { id } = req.params; + + const stats = await posOrderService.getSessionStats(tenantId, id); + return this.success(res, stats); + } catch (error) { + next(error); + } + } +} + +export const posController = new POSController(); diff --git a/backend/src/modules/pos/entities/index.ts b/backend/src/modules/pos/entities/index.ts new file mode 100644 index 0000000..d489fed --- /dev/null +++ b/backend/src/modules/pos/entities/index.ts @@ -0,0 +1,4 @@ +export * from './pos-session.entity'; +export * from './pos-order.entity'; +export * from './pos-order-line.entity'; +export * from './pos-payment.entity'; diff --git a/backend/src/modules/pos/entities/pos-order-line.entity.ts b/backend/src/modules/pos/entities/pos-order-line.entity.ts new file mode 100644 index 0000000..9f63e55 --- /dev/null +++ b/backend/src/modules/pos/entities/pos-order-line.entity.ts @@ -0,0 +1,173 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + ManyToOne, + JoinColumn, + Index, +} from 'typeorm'; +import { POSOrder } from './pos-order.entity'; + +export enum LineStatus { + ACTIVE = 'active', + VOIDED = 'voided', + RETURNED = 'returned', +} + +@Entity('pos_order_lines', { schema: 'retail' }) +@Index(['tenantId', 'orderId']) +@Index(['tenantId', 'productId']) +export class POSOrderLine { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'tenant_id', type: 'uuid' }) + @Index() + tenantId: string; + + @Column({ name: 'order_id', type: 'uuid' }) + orderId: string; + + @Column({ name: 'line_number', type: 'int' }) + lineNumber: number; + + // Product info (from erp-core inventory) + @Column({ name: 'product_id', type: 'uuid' }) + productId: string; + + @Column({ name: 'product_code', length: 50 }) + productCode: string; + + @Column({ name: 'product_name', length: 200 }) + productName: string; + + @Column({ name: 'product_barcode', length: 50, nullable: true }) + productBarcode: string; + + // Variant info (if applicable) + @Column({ name: 'variant_id', type: 'uuid', nullable: true }) + variantId: string; + + @Column({ name: 'variant_name', length: 200, nullable: true }) + variantName: string; + + // Quantity + @Column({ type: 'decimal', precision: 15, scale: 4, default: 1 }) + quantity: number; + + @Column({ name: 'uom_id', type: 'uuid', nullable: true }) + uomId: string; + + @Column({ name: 'uom_name', length: 20, default: 'PZA' }) + uomName: string; + + // Pricing + @Column({ name: 'unit_price', type: 'decimal', precision: 15, scale: 4 }) + unitPrice: number; + + @Column({ name: 'original_price', type: 'decimal', precision: 15, scale: 4 }) + originalPrice: number; + + @Column({ name: 'price_list_id', type: 'uuid', nullable: true }) + priceListId: string; + + // Line discounts + @Column({ name: 'discount_percent', type: 'decimal', precision: 5, scale: 2, default: 0 }) + discountPercent: number; + + @Column({ name: 'discount_amount', type: 'decimal', precision: 15, scale: 2, default: 0 }) + discountAmount: number; + + @Column({ name: 'discount_reason', length: 255, nullable: true }) + discountReason: string; + + // Promotion applied + @Column({ name: 'promotion_id', type: 'uuid', nullable: true }) + promotionId: string; + + @Column({ name: 'promotion_name', length: 100, nullable: true }) + promotionName: string; + + @Column({ name: 'promotion_discount', type: 'decimal', precision: 15, scale: 2, default: 0 }) + promotionDiscount: number; + + // Tax + @Column({ name: 'tax_id', type: 'uuid', nullable: true }) + taxId: string; + + @Column({ name: 'tax_rate', type: 'decimal', precision: 5, scale: 4, default: 0 }) + taxRate: number; + + @Column({ name: 'tax_amount', type: 'decimal', precision: 15, scale: 2, default: 0 }) + taxAmount: number; + + @Column({ name: 'tax_included', type: 'boolean', default: true }) + taxIncluded: boolean; + + // Totals + @Column({ type: 'decimal', precision: 15, scale: 2, default: 0 }) + subtotal: number; + + @Column({ type: 'decimal', precision: 15, scale: 2, default: 0 }) + total: number; + + // Cost (for margin calculation) + @Column({ name: 'unit_cost', type: 'decimal', precision: 15, scale: 4, nullable: true }) + unitCost: number; + + @Column({ type: 'decimal', precision: 15, scale: 2, nullable: true }) + margin: number; + + // Status + @Column({ + type: 'enum', + enum: LineStatus, + default: LineStatus.ACTIVE, + }) + status: LineStatus; + + // Lot/Serial tracking + @Column({ name: 'lot_number', length: 50, nullable: true }) + lotNumber: string; + + @Column({ name: 'serial_number', length: 50, nullable: true }) + serialNumber: string; + + @Column({ name: 'expiry_date', type: 'date', nullable: true }) + expiryDate: Date; + + // Warehouse location + @Column({ name: 'warehouse_id', type: 'uuid', nullable: true }) + warehouseId: string; + + @Column({ name: 'location_id', type: 'uuid', nullable: true }) + locationId: string; + + // For returns/exchanges + @Column({ name: 'original_line_id', type: 'uuid', nullable: true }) + originalLineId: string; + + @Column({ name: 'return_reason', length: 255, nullable: true }) + returnReason: string; + + // Salesperson override (if different from order) + @Column({ name: 'salesperson_id', type: 'uuid', nullable: true }) + salespersonId: string; + + // Notes + @Column({ type: 'text', nullable: true }) + notes: string; + + // Metadata + @Column({ type: 'jsonb', nullable: true }) + metadata: Record; + + @CreateDateColumn({ name: 'created_at' }) + createdAt: Date; + + // Relations + @ManyToOne(() => POSOrder, (order) => order.lines) + @JoinColumn({ name: 'order_id' }) + order: POSOrder; +} diff --git a/backend/src/modules/pos/entities/pos-order.entity.ts b/backend/src/modules/pos/entities/pos-order.entity.ts new file mode 100644 index 0000000..c1ad466 --- /dev/null +++ b/backend/src/modules/pos/entities/pos-order.entity.ts @@ -0,0 +1,223 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + ManyToOne, + OneToMany, + JoinColumn, + Index, +} from 'typeorm'; +import { POSSession } from './pos-session.entity'; +import { POSOrderLine } from './pos-order-line.entity'; +import { POSPayment } from './pos-payment.entity'; + +export enum OrderStatus { + DRAFT = 'draft', + CONFIRMED = 'confirmed', + PAID = 'paid', + PARTIALLY_PAID = 'partially_paid', + VOIDED = 'voided', + REFUNDED = 'refunded', +} + +export enum OrderType { + SALE = 'sale', + REFUND = 'refund', + EXCHANGE = 'exchange', +} + +@Entity('pos_orders', { schema: 'retail' }) +@Index(['tenantId', 'branchId', 'createdAt']) +@Index(['tenantId', 'sessionId']) +@Index(['tenantId', 'customerId']) +@Index(['tenantId', 'number'], { unique: true }) +export class POSOrder { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'tenant_id', type: 'uuid' }) + @Index() + tenantId: string; + + @Column({ name: 'branch_id', type: 'uuid' }) + branchId: string; + + @Column({ name: 'session_id', type: 'uuid' }) + sessionId: string; + + @Column({ name: 'register_id', type: 'uuid' }) + registerId: string; + + @Column({ name: 'user_id', type: 'uuid' }) + userId: string; + + @Column({ length: 30, unique: true }) + number: string; + + @Column({ + type: 'enum', + enum: OrderType, + default: OrderType.SALE, + }) + type: OrderType; + + @Column({ + type: 'enum', + enum: OrderStatus, + default: OrderStatus.DRAFT, + }) + status: OrderStatus; + + // Customer (optional, from erp-core partners) + @Column({ name: 'customer_id', type: 'uuid', nullable: true }) + customerId: string; + + @Column({ name: 'customer_name', length: 200, nullable: true }) + customerName: string; + + @Column({ name: 'customer_rfc', length: 13, nullable: true }) + customerRfc: string; + + // Salesperson (for commission tracking) + @Column({ name: 'salesperson_id', type: 'uuid', nullable: true }) + salespersonId: string; + + // Amounts + @Column({ type: 'decimal', precision: 15, scale: 2, default: 0 }) + subtotal: number; + + @Column({ name: 'discount_amount', type: 'decimal', precision: 15, scale: 2, default: 0 }) + discountAmount: number; + + @Column({ name: 'discount_percent', type: 'decimal', precision: 5, scale: 2, default: 0 }) + discountPercent: number; + + @Column({ name: 'discount_reason', length: 255, nullable: true }) + discountReason: string; + + @Column({ name: 'discount_authorized_by', type: 'uuid', nullable: true }) + discountAuthorizedBy: string; + + @Column({ name: 'tax_amount', type: 'decimal', precision: 15, scale: 2, default: 0 }) + taxAmount: number; + + @Column({ type: 'decimal', precision: 15, scale: 2, default: 0 }) + total: number; + + @Column({ name: 'amount_paid', type: 'decimal', precision: 15, scale: 2, default: 0 }) + amountPaid: number; + + @Column({ name: 'change_amount', type: 'decimal', precision: 15, scale: 2, default: 0 }) + changeAmount: number; + + // Tax breakdown + @Column({ name: 'tax_breakdown', type: 'jsonb', nullable: true }) + taxBreakdown: { + taxId: string; + taxName: string; + rate: number; + base: number; + amount: number; + }[]; + + // Currency + @Column({ name: 'currency_code', length: 3, default: 'MXN' }) + currencyCode: string; + + @Column({ name: 'exchange_rate', type: 'decimal', precision: 10, scale: 6, default: 1 }) + exchangeRate: number; + + // Coupon/Promotion + @Column({ name: 'coupon_id', type: 'uuid', nullable: true }) + couponId: string; + + @Column({ name: 'coupon_code', length: 50, nullable: true }) + couponCode: string; + + @Column({ name: 'promotion_ids', type: 'jsonb', nullable: true }) + promotionIds: string[]; + + // Loyalty points + @Column({ name: 'loyalty_points_earned', type: 'int', default: 0 }) + loyaltyPointsEarned: number; + + @Column({ name: 'loyalty_points_redeemed', type: 'int', default: 0 }) + loyaltyPointsRedeemed: number; + + @Column({ name: 'loyalty_points_value', type: 'decimal', precision: 15, scale: 2, default: 0 }) + loyaltyPointsValue: number; + + // Invoicing + @Column({ name: 'requires_invoice', type: 'boolean', default: false }) + requiresInvoice: boolean; + + @Column({ name: 'cfdi_id', type: 'uuid', nullable: true }) + cfdiId: string; + + @Column({ name: 'invoice_status', length: 20, nullable: true }) + invoiceStatus: 'pending' | 'issued' | 'cancelled'; + + // Reference to original order (for refunds/exchanges) + @Column({ name: 'original_order_id', type: 'uuid', nullable: true }) + originalOrderId: string; + + // Void info + @Column({ name: 'voided_at', type: 'timestamp with time zone', nullable: true }) + voidedAt: Date; + + @Column({ name: 'voided_by', type: 'uuid', nullable: true }) + voidedBy: string; + + @Column({ name: 'void_reason', length: 255, nullable: true }) + voidReason: string; + + // Notes + @Column({ type: 'text', nullable: true }) + notes: string; + + // Offline sync + @Column({ name: 'is_offline_order', type: 'boolean', default: false }) + isOfflineOrder: boolean; + + @Column({ name: 'offline_uuid', type: 'uuid', nullable: true }) + offlineUuid: string; + + @Column({ name: 'synced_at', type: 'timestamp with time zone', nullable: true }) + syncedAt: Date; + + // Receipt + @Column({ name: 'receipt_printed', type: 'boolean', default: false }) + receiptPrinted: boolean; + + @Column({ name: 'receipt_sent', type: 'boolean', default: false }) + receiptSent: boolean; + + @Column({ name: 'receipt_email', length: 100, nullable: true }) + receiptEmail: string; + + // Metadata + @Column({ type: 'jsonb', nullable: true }) + metadata: Record; + + @CreateDateColumn({ name: 'created_at' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at' }) + updatedAt: Date; + + @Column({ name: 'completed_at', type: 'timestamp with time zone', nullable: true }) + completedAt: Date; + + // Relations + @ManyToOne(() => POSSession, (session) => session.orders) + @JoinColumn({ name: 'session_id' }) + session: POSSession; + + @OneToMany(() => POSOrderLine, (line) => line.order) + lines: POSOrderLine[]; + + @OneToMany(() => POSPayment, (payment) => payment.order) + payments: POSPayment[]; +} diff --git a/backend/src/modules/pos/entities/pos-payment.entity.ts b/backend/src/modules/pos/entities/pos-payment.entity.ts new file mode 100644 index 0000000..73b6aeb --- /dev/null +++ b/backend/src/modules/pos/entities/pos-payment.entity.ts @@ -0,0 +1,173 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + ManyToOne, + JoinColumn, + Index, +} from 'typeorm'; +import { POSOrder } from './pos-order.entity'; + +export enum PaymentMethod { + CASH = 'cash', + DEBIT_CARD = 'debit_card', + CREDIT_CARD = 'credit_card', + TRANSFER = 'transfer', + CHECK = 'check', + LOYALTY_POINTS = 'loyalty_points', + VOUCHER = 'voucher', + OTHER = 'other', +} + +export enum PaymentStatus { + PENDING = 'pending', + COMPLETED = 'completed', + FAILED = 'failed', + REFUNDED = 'refunded', + VOIDED = 'voided', +} + +@Entity('pos_payments', { schema: 'retail' }) +@Index(['tenantId', 'orderId']) +@Index(['tenantId', 'sessionId']) +@Index(['tenantId', 'createdAt']) +export class POSPayment { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'tenant_id', type: 'uuid' }) + @Index() + tenantId: string; + + @Column({ name: 'order_id', type: 'uuid' }) + orderId: string; + + @Column({ name: 'session_id', type: 'uuid' }) + sessionId: string; + + @Column({ + type: 'enum', + enum: PaymentMethod, + }) + method: PaymentMethod; + + @Column({ + type: 'enum', + enum: PaymentStatus, + default: PaymentStatus.PENDING, + }) + status: PaymentStatus; + + // Amount + @Column({ type: 'decimal', precision: 15, scale: 2 }) + amount: number; + + @Column({ name: 'amount_received', type: 'decimal', precision: 15, scale: 2 }) + amountReceived: number; + + @Column({ name: 'change_given', type: 'decimal', precision: 15, scale: 2, default: 0 }) + changeGiven: number; + + // Currency + @Column({ name: 'currency_code', length: 3, default: 'MXN' }) + currencyCode: string; + + @Column({ name: 'exchange_rate', type: 'decimal', precision: 10, scale: 6, default: 1 }) + exchangeRate: number; + + // Card payment details + @Column({ name: 'card_type', length: 20, nullable: true }) + cardType: string; // visa, mastercard, amex, etc. + + @Column({ name: 'card_last_four', length: 4, nullable: true }) + cardLastFour: string; + + @Column({ name: 'card_holder', length: 100, nullable: true }) + cardHolder: string; + + @Column({ name: 'authorization_code', length: 50, nullable: true }) + authorizationCode: string; + + @Column({ name: 'transaction_reference', length: 100, nullable: true }) + transactionReference: string; + + @Column({ name: 'terminal_id', length: 50, nullable: true }) + terminalId: string; + + // Transfer details + @Column({ name: 'bank_name', length: 100, nullable: true }) + bankName: string; + + @Column({ name: 'transfer_reference', length: 100, nullable: true }) + transferReference: string; + + // Check details + @Column({ name: 'check_number', length: 50, nullable: true }) + checkNumber: string; + + @Column({ name: 'check_bank', length: 100, nullable: true }) + checkBank: string; + + @Column({ name: 'check_date', type: 'date', nullable: true }) + checkDate: Date; + + // Voucher/Gift card + @Column({ name: 'voucher_code', length: 50, nullable: true }) + voucherCode: string; + + @Column({ name: 'voucher_id', type: 'uuid', nullable: true }) + voucherId: string; + + // Loyalty points + @Column({ name: 'loyalty_points_used', type: 'int', nullable: true }) + loyaltyPointsUsed: number; + + // Tips + @Column({ name: 'tip_amount', type: 'decimal', precision: 15, scale: 2, default: 0 }) + tipAmount: number; + + // Financial account (for accounting integration) + @Column({ name: 'account_id', type: 'uuid', nullable: true }) + accountId: string; + + // Refund reference + @Column({ name: 'refund_of_payment_id', type: 'uuid', nullable: true }) + refundOfPaymentId: string; + + @Column({ name: 'refund_reason', length: 255, nullable: true }) + refundReason: string; + + // Processing info + @Column({ name: 'processed_at', type: 'timestamp with time zone', nullable: true }) + processedAt: Date; + + @Column({ name: 'processed_by', type: 'uuid', nullable: true }) + processedBy: string; + + // External payment gateway + @Column({ name: 'gateway_name', length: 50, nullable: true }) + gatewayName: string; + + @Column({ name: 'gateway_transaction_id', length: 100, nullable: true }) + gatewayTransactionId: string; + + @Column({ name: 'gateway_response', type: 'jsonb', nullable: true }) + gatewayResponse: Record; + + // Notes + @Column({ type: 'text', nullable: true }) + notes: string; + + // Metadata + @Column({ type: 'jsonb', nullable: true }) + metadata: Record; + + @CreateDateColumn({ name: 'created_at' }) + createdAt: Date; + + // Relations + @ManyToOne(() => POSOrder, (order) => order.payments) + @JoinColumn({ name: 'order_id' }) + order: POSOrder; +} diff --git a/backend/src/modules/pos/entities/pos-session.entity.ts b/backend/src/modules/pos/entities/pos-session.entity.ts new file mode 100644 index 0000000..ede6eb2 --- /dev/null +++ b/backend/src/modules/pos/entities/pos-session.entity.ts @@ -0,0 +1,146 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + OneToMany, + Index, +} from 'typeorm'; +import { POSOrder } from './pos-order.entity'; + +export enum SessionStatus { + OPEN = 'open', + CLOSING = 'closing', + CLOSED = 'closed', + RECONCILED = 'reconciled', +} + +@Entity('pos_sessions', { schema: 'retail' }) +@Index(['tenantId', 'branchId', 'status']) +@Index(['tenantId', 'registerId', 'status']) +@Index(['tenantId', 'openedAt']) +export class POSSession { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'tenant_id', type: 'uuid' }) + @Index() + tenantId: string; + + @Column({ name: 'branch_id', type: 'uuid' }) + branchId: string; + + @Column({ name: 'register_id', type: 'uuid' }) + registerId: string; + + @Column({ name: 'user_id', type: 'uuid' }) + userId: string; + + @Column({ length: 30, unique: true }) + number: string; + + @Column({ + type: 'enum', + enum: SessionStatus, + default: SessionStatus.OPEN, + }) + status: SessionStatus; + + // Opening + @Column({ name: 'opened_at', type: 'timestamp with time zone' }) + openedAt: Date; + + @Column({ name: 'opening_cash', type: 'decimal', precision: 15, scale: 2, default: 0 }) + openingCash: number; + + @Column({ name: 'opening_notes', type: 'text', nullable: true }) + openingNotes: string; + + // Closing + @Column({ name: 'closed_at', type: 'timestamp with time zone', nullable: true }) + closedAt: Date; + + @Column({ name: 'closing_cash_counted', type: 'decimal', precision: 15, scale: 2, nullable: true }) + closingCashCounted: number; + + @Column({ name: 'closing_cash_expected', type: 'decimal', precision: 15, scale: 2, nullable: true }) + closingCashExpected: number; + + @Column({ name: 'closing_difference', type: 'decimal', precision: 15, scale: 2, nullable: true }) + closingDifference: number; + + @Column({ name: 'closing_notes', type: 'text', nullable: true }) + closingNotes: string; + + @Column({ name: 'closed_by', type: 'uuid', nullable: true }) + closedBy: string; + + // Session totals (denormalized for performance) + @Column({ name: 'total_sales', type: 'decimal', precision: 15, scale: 2, default: 0 }) + totalSales: number; + + @Column({ name: 'total_refunds', type: 'decimal', precision: 15, scale: 2, default: 0 }) + totalRefunds: number; + + @Column({ name: 'total_discounts', type: 'decimal', precision: 15, scale: 2, default: 0 }) + totalDiscounts: number; + + @Column({ name: 'orders_count', type: 'int', default: 0 }) + ordersCount: number; + + @Column({ name: 'items_sold', type: 'int', default: 0 }) + itemsSold: number; + + // Payment totals by method + @Column({ name: 'cash_total', type: 'decimal', precision: 15, scale: 2, default: 0 }) + cashTotal: number; + + @Column({ name: 'card_total', type: 'decimal', precision: 15, scale: 2, default: 0 }) + cardTotal: number; + + @Column({ name: 'transfer_total', type: 'decimal', precision: 15, scale: 2, default: 0 }) + transferTotal: number; + + @Column({ name: 'other_total', type: 'decimal', precision: 15, scale: 2, default: 0 }) + otherTotal: number; + + // Cash movements during session + @Column({ name: 'cash_in_total', type: 'decimal', precision: 15, scale: 2, default: 0 }) + cashInTotal: number; + + @Column({ name: 'cash_out_total', type: 'decimal', precision: 15, scale: 2, default: 0 }) + cashOutTotal: number; + + // Detailed cash count (closing) + @Column({ name: 'cash_count_detail', type: 'jsonb', nullable: true }) + cashCountDetail: { + bills: { denomination: number; count: number; total: number }[]; + coins: { denomination: number; count: number; total: number }[]; + total: number; + }; + + // Offline sync + @Column({ name: 'is_offline_session', type: 'boolean', default: false }) + isOfflineSession: boolean; + + @Column({ name: 'synced_at', type: 'timestamp with time zone', nullable: true }) + syncedAt: Date; + + @Column({ name: 'device_uuid', type: 'uuid', nullable: true }) + deviceUuid: string; + + // Metadata + @Column({ type: 'jsonb', nullable: true }) + metadata: Record; + + @CreateDateColumn({ name: 'created_at' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at' }) + updatedAt: Date; + + // Relations + @OneToMany(() => POSOrder, (order) => order.session) + orders: POSOrder[]; +} diff --git a/backend/src/modules/pos/index.ts b/backend/src/modules/pos/index.ts new file mode 100644 index 0000000..561e3a1 --- /dev/null +++ b/backend/src/modules/pos/index.ts @@ -0,0 +1,3 @@ +export * from './entities'; +export * from './services'; +export { default as posRoutes } from './routes/pos.routes'; diff --git a/backend/src/modules/pos/routes/pos.routes.ts b/backend/src/modules/pos/routes/pos.routes.ts new file mode 100644 index 0000000..76a0fd3 --- /dev/null +++ b/backend/src/modules/pos/routes/pos.routes.ts @@ -0,0 +1,143 @@ +import { Router } from 'express'; +import { posController } from '../controllers/pos.controller'; +import { authMiddleware, requireRoles } from '../../../shared/middleware/auth.middleware'; +import { tenantMiddleware } from '../../../shared/middleware/tenant.middleware'; +import { branchMiddleware, validateBranchMiddleware, requireBranchCapability } from '../../../shared/middleware/branch.middleware'; +import { AuthenticatedRequest } from '../../../shared/types'; + +const router = Router(); + +// All routes require tenant and authentication +router.use(tenantMiddleware); +router.use(authMiddleware); + +// ==================== SESSION ROUTES ==================== + +// Get active session for current user (no branch required) +router.get('/sessions/active', (req, res, next) => + posController.getActiveSession(req as AuthenticatedRequest, res, next) +); + +// Routes that require branch context +router.use('/sessions', branchMiddleware); +router.use('/sessions', validateBranchMiddleware); +router.use('/sessions', requireBranchCapability('pos')); + +// Open session +router.post('/sessions/open', (req, res, next) => + posController.openSession(req as AuthenticatedRequest, res, next) +); + +// Close session +router.post('/sessions/:id/close', (req, res, next) => + posController.closeSession(req as AuthenticatedRequest, res, next) +); + +// Get daily summary +router.get('/sessions/daily-summary', (req, res, next) => + posController.getDailySummary(req as AuthenticatedRequest, res, next) +); + +// List sessions +router.get('/sessions', (req, res, next) => + posController.listSessions(req as AuthenticatedRequest, res, next) +); + +// Get session by ID +router.get('/sessions/:id', (req, res, next) => + posController.getSession(req as AuthenticatedRequest, res, next) +); + +// ==================== ORDER ROUTES ==================== + +// Routes that require branch context +router.use('/orders', branchMiddleware); +router.use('/orders', validateBranchMiddleware); +router.use('/orders', requireBranchCapability('pos')); + +// Create order +router.post('/orders', (req, res, next) => + posController.createOrder(req as AuthenticatedRequest, res, next) +); + +// List orders +router.get('/orders', (req, res, next) => + posController.listOrders(req as AuthenticatedRequest, res, next) +); + +// Get orders by session +router.get('/orders/session/:sessionId', (req, res, next) => + posController.getOrdersBySession(req as AuthenticatedRequest, res, next) +); + +// Get order by ID +router.get('/orders/:id', (req, res, next) => + posController.getOrder(req as AuthenticatedRequest, res, next) +); + +// Add payment +router.post('/orders/:id/payments', (req, res, next) => + posController.addPayment(req as AuthenticatedRequest, res, next) +); + +// Void order (requires supervisor or higher) +router.post( + '/orders/:id/void', + requireRoles('admin', 'manager', 'supervisor'), + (req, res, next) => posController.voidOrder(req as AuthenticatedRequest, res, next) +); + +// Hold order +router.post('/orders/:id/hold', (req, res, next) => + posController.holdOrder(req as AuthenticatedRequest, res, next) +); + +// Get held orders +router.get('/orders/held', (req, res, next) => + posController.getHeldOrders(req as AuthenticatedRequest, res, next) +); + +// Recall held order +router.post('/orders/:id/recall', (req, res, next) => + posController.recallOrder(req as AuthenticatedRequest, res, next) +); + +// Apply coupon to order +router.post('/orders/:id/coupon', (req, res, next) => + posController.applyCoupon(req as AuthenticatedRequest, res, next) +); + +// Mark receipt as printed +router.post('/orders/:id/receipt/print', (req, res, next) => + posController.markReceiptPrinted(req as AuthenticatedRequest, res, next) +); + +// Send receipt by email +router.post('/orders/:id/receipt/send', (req, res, next) => + posController.sendReceipt(req as AuthenticatedRequest, res, next) +); + +// ==================== REFUND ROUTES ==================== + +// Create refund +router.post( + '/refunds', + requireRoles('admin', 'manager', 'supervisor'), + (req, res, next) => posController.createRefund(req as AuthenticatedRequest, res, next) +); + +// Process refund payment +router.post( + '/refunds/:id/process', + requireRoles('admin', 'manager', 'supervisor'), + (req, res, next) => posController.processRefund(req as AuthenticatedRequest, res, next) +); + +// ==================== SESSION STATS ==================== + +// Get session statistics +router.get('/sessions/:id/stats', (req, res, next) => + posController.getSessionStats(req as AuthenticatedRequest, res, next) +); + +export default router; diff --git a/backend/src/modules/pos/services/index.ts b/backend/src/modules/pos/services/index.ts new file mode 100644 index 0000000..8f4d71e --- /dev/null +++ b/backend/src/modules/pos/services/index.ts @@ -0,0 +1,2 @@ +export * from './pos-session.service'; +export * from './pos-order.service'; diff --git a/backend/src/modules/pos/services/pos-order.service.ts b/backend/src/modules/pos/services/pos-order.service.ts new file mode 100644 index 0000000..17fc7f3 --- /dev/null +++ b/backend/src/modules/pos/services/pos-order.service.ts @@ -0,0 +1,914 @@ +import { Repository, DeepPartial } from 'typeorm'; +import { AppDataSource } from '../../../config/typeorm'; +import { BaseService } from '../../../shared/services/base.service'; +import { ServiceResult } from '../../../shared/types'; +import { POSOrder, OrderStatus, OrderType } from '../entities/pos-order.entity'; +import { POSOrderLine, LineStatus } from '../entities/pos-order-line.entity'; +import { POSPayment, PaymentMethod, PaymentStatus } from '../entities/pos-payment.entity'; +import { posSessionService } from './pos-session.service'; + +interface CreateOrderDTO { + sessionId: string; + branchId: string; + registerId: string; + userId: string; + customerId?: string; + customerName?: string; + customerRfc?: string; + lines: { + productId: string; + productCode: string; + productName: string; + productBarcode?: string; + quantity: number; + unitPrice: number; + originalPrice: number; + discountPercent?: number; + discountAmount?: number; + taxRate: number; + taxIncluded?: boolean; + }[]; + discountPercent?: number; + discountAmount?: number; + discountReason?: string; + requiresInvoice?: boolean; + notes?: string; +} + +interface AddPaymentDTO { + method: PaymentMethod; + amount: number; + amountReceived: number; + cardType?: string; + cardLastFour?: string; + authorizationCode?: string; + transactionReference?: string; +} + +export class POSOrderService extends BaseService { + private lineRepository: Repository; + private paymentRepository: Repository; + + constructor() { + super(AppDataSource.getRepository(POSOrder)); + this.lineRepository = AppDataSource.getRepository(POSOrderLine); + this.paymentRepository = AppDataSource.getRepository(POSPayment); + } + + /** + * Generate order number + */ + private generateOrderNumber(branchCode: string): string { + const date = new Date(); + const dateStr = date.toISOString().slice(0, 10).replace(/-/g, ''); + const random = Math.random().toString(36).substring(2, 8).toUpperCase(); + return `ORD-${branchCode}-${dateStr}-${random}`; + } + + /** + * Create a new POS order + */ + async createOrder( + tenantId: string, + data: CreateOrderDTO, + branchCode: string + ): Promise> { + const queryRunner = AppDataSource.createQueryRunner(); + await queryRunner.connect(); + await queryRunner.startTransaction(); + + try { + // Calculate totals + let subtotal = 0; + let totalTax = 0; + const taxBreakdown: POSOrder['taxBreakdown'] = []; + + const linesToCreate: DeepPartial[] = data.lines.map((line, index) => { + const lineSubtotal = line.quantity * line.unitPrice; + const lineDiscount = + line.discountAmount || lineSubtotal * ((line.discountPercent || 0) / 100); + const lineAfterDiscount = lineSubtotal - lineDiscount; + + let lineTax: number; + if (line.taxIncluded !== false) { + // Tax included in price + lineTax = lineAfterDiscount - lineAfterDiscount / (1 + line.taxRate); + } else { + // Tax not included + lineTax = lineAfterDiscount * line.taxRate; + } + + subtotal += lineSubtotal; + totalTax += lineTax; + + return { + tenantId, + lineNumber: index + 1, + productId: line.productId, + productCode: line.productCode, + productName: line.productName, + productBarcode: line.productBarcode, + quantity: line.quantity, + unitPrice: line.unitPrice, + originalPrice: line.originalPrice, + discountPercent: line.discountPercent || 0, + discountAmount: lineDiscount, + taxRate: line.taxRate, + taxAmount: lineTax, + taxIncluded: line.taxIncluded !== false, + subtotal: lineAfterDiscount, + total: line.taxIncluded !== false ? lineAfterDiscount : lineAfterDiscount + lineTax, + status: LineStatus.ACTIVE, + }; + }); + + // Order-level discount + const orderDiscount = + data.discountAmount || subtotal * ((data.discountPercent || 0) / 100); + const total = subtotal - orderDiscount; + + // Create order + const order = this.repository.create({ + tenantId, + branchId: data.branchId, + sessionId: data.sessionId, + registerId: data.registerId, + userId: data.userId, + number: this.generateOrderNumber(branchCode), + type: OrderType.SALE, + status: OrderStatus.DRAFT, + customerId: data.customerId, + customerName: data.customerName, + customerRfc: data.customerRfc, + subtotal, + discountAmount: orderDiscount, + discountPercent: data.discountPercent || 0, + discountReason: data.discountReason, + taxAmount: totalTax, + total, + amountPaid: 0, + changeAmount: 0, + requiresInvoice: data.requiresInvoice || false, + notes: data.notes, + taxBreakdown, + }); + + const savedOrder = await queryRunner.manager.save(order); + + // Create lines + const lines = linesToCreate.map((line) => ({ + ...line, + orderId: savedOrder.id, + })); + await queryRunner.manager.save(POSOrderLine, lines); + + await queryRunner.commitTransaction(); + + // Fetch complete order + const completeOrder = await this.repository.findOne({ + where: { id: savedOrder.id }, + relations: ['lines'], + }); + + return { success: true, data: completeOrder! }; + } catch (error: any) { + await queryRunner.rollbackTransaction(); + return { + success: false, + error: { + code: 'CREATE_ORDER_FAILED', + message: error.message || 'Failed to create order', + details: error, + }, + }; + } finally { + await queryRunner.release(); + } + } + + /** + * Add payment to order + */ + async addPayment( + tenantId: string, + orderId: string, + payment: AddPaymentDTO, + userId: string + ): Promise> { + const queryRunner = AppDataSource.createQueryRunner(); + await queryRunner.connect(); + await queryRunner.startTransaction(); + + try { + const order = await this.repository.findOne({ + where: { id: orderId, tenantId }, + relations: ['payments'], + }); + + if (!order) { + return { + success: false, + error: { + code: 'ORDER_NOT_FOUND', + message: 'Order not found', + }, + }; + } + + if (order.status === OrderStatus.PAID) { + return { + success: false, + error: { + code: 'ORDER_ALREADY_PAID', + message: 'Order is already fully paid', + }, + }; + } + + // Calculate remaining amount + const remaining = order.total - order.amountPaid; + + // Determine actual payment amount + const actualAmount = Math.min(payment.amount, remaining); + const change = + payment.method === PaymentMethod.CASH + ? Math.max(0, payment.amountReceived - remaining) + : 0; + + // Create payment record + const paymentRecord = this.paymentRepository.create({ + tenantId, + orderId: order.id, + sessionId: order.sessionId, + method: payment.method, + status: PaymentStatus.COMPLETED, + amount: actualAmount, + amountReceived: payment.amountReceived, + changeGiven: change, + cardType: payment.cardType, + cardLastFour: payment.cardLastFour, + authorizationCode: payment.authorizationCode, + transactionReference: payment.transactionReference, + processedAt: new Date(), + processedBy: userId, + }); + + await queryRunner.manager.save(paymentRecord); + + // Update order + order.amountPaid += actualAmount; + order.changeAmount += change; + + if (order.amountPaid >= order.total) { + order.status = OrderStatus.PAID; + order.completedAt = new Date(); + } else { + order.status = OrderStatus.PARTIALLY_PAID; + } + + await queryRunner.manager.save(order); + + // Update session totals + await posSessionService.updateSessionTotals(order.sessionId, { + total: actualAmount, + discounts: 0, + cashAmount: payment.method === PaymentMethod.CASH ? actualAmount : 0, + cardAmount: + payment.method === PaymentMethod.CREDIT_CARD || + payment.method === PaymentMethod.DEBIT_CARD + ? actualAmount + : 0, + transferAmount: payment.method === PaymentMethod.TRANSFER ? actualAmount : 0, + otherAmount: + ![ + PaymentMethod.CASH, + PaymentMethod.CREDIT_CARD, + PaymentMethod.DEBIT_CARD, + PaymentMethod.TRANSFER, + ].includes(payment.method) + ? actualAmount + : 0, + itemsCount: order.status === OrderStatus.PAID ? order.lines?.length || 0 : 0, + isRefund: false, + }); + + await queryRunner.commitTransaction(); + + // Fetch updated order + const updatedOrder = await this.repository.findOne({ + where: { id: orderId }, + relations: ['lines', 'payments'], + }); + + return { success: true, data: updatedOrder! }; + } catch (error: any) { + await queryRunner.rollbackTransaction(); + return { + success: false, + error: { + code: 'ADD_PAYMENT_FAILED', + message: error.message || 'Failed to add payment', + details: error, + }, + }; + } finally { + await queryRunner.release(); + } + } + + /** + * Void an order + */ + async voidOrder( + tenantId: string, + orderId: string, + reason: string, + userId: string + ): Promise> { + const order = await this.repository.findOne({ + where: { id: orderId, tenantId }, + }); + + if (!order) { + return { + success: false, + error: { + code: 'ORDER_NOT_FOUND', + message: 'Order not found', + }, + }; + } + + if (order.status === OrderStatus.VOIDED) { + return { + success: false, + error: { + code: 'ORDER_ALREADY_VOIDED', + message: 'Order is already voided', + }, + }; + } + + if (order.cfdiId) { + return { + success: false, + error: { + code: 'ORDER_HAS_INVOICE', + message: 'Cannot void order with issued invoice', + }, + }; + } + + order.status = OrderStatus.VOIDED; + order.voidedAt = new Date(); + order.voidedBy = userId; + order.voidReason = reason; + + const savedOrder = await this.repository.save(order); + return { success: true, data: savedOrder }; + } + + /** + * Get order with all details + */ + async getOrderWithDetails(tenantId: string, orderId: string): Promise { + return this.repository.findOne({ + where: { id: orderId, tenantId }, + relations: ['lines', 'payments'], + }); + } + + /** + * Get orders by session + */ + async getOrdersBySession(tenantId: string, sessionId: string): Promise { + return this.repository.find({ + where: { tenantId, sessionId }, + relations: ['lines', 'payments'], + order: { createdAt: 'DESC' }, + }); + } + + /** + * Search orders by customer or number + */ + async searchOrders( + tenantId: string, + branchId: string, + term: string, + limit: number = 20 + ): Promise { + return this.repository + .createQueryBuilder('order') + .where('order.tenantId = :tenantId', { tenantId }) + .andWhere('order.branchId = :branchId', { branchId }) + .andWhere( + '(order.number ILIKE :term OR order.customerName ILIKE :term OR order.customerRfc ILIKE :term)', + { term: `%${term}%` } + ) + .orderBy('order.createdAt', 'DESC') + .limit(limit) + .getMany(); + } + + /** + * Create a refund order + */ + async createRefund( + tenantId: string, + data: { + originalOrderId: string; + sessionId: string; + branchId: string; + registerId: string; + userId: string; + lines: { + originalLineId: string; + quantity: number; + reason: string; + }[]; + refundReason: string; + }, + branchCode: string + ): Promise> { + const queryRunner = AppDataSource.createQueryRunner(); + await queryRunner.connect(); + await queryRunner.startTransaction(); + + try { + // Get original order + const originalOrder = await this.repository.findOne({ + where: { id: data.originalOrderId, tenantId }, + relations: ['lines', 'payments'], + }); + + if (!originalOrder) { + return { + success: false, + error: { + code: 'ORIGINAL_ORDER_NOT_FOUND', + message: 'Original order not found', + }, + }; + } + + if (originalOrder.status !== OrderStatus.PAID) { + return { + success: false, + error: { + code: 'ORDER_NOT_PAID', + message: 'Can only refund paid orders', + }, + }; + } + + // Build refund lines + let refundTotal = 0; + let refundTax = 0; + const refundLines: DeepPartial[] = []; + + for (const refundLine of data.lines) { + const originalLine = originalOrder.lines?.find(l => l.id === refundLine.originalLineId); + if (!originalLine) { + return { + success: false, + error: { + code: 'LINE_NOT_FOUND', + message: `Original line ${refundLine.originalLineId} not found`, + }, + }; + } + + if (refundLine.quantity > originalLine.quantity) { + return { + success: false, + error: { + code: 'INVALID_QUANTITY', + message: `Cannot refund more than original quantity`, + }, + }; + } + + const lineSubtotal = refundLine.quantity * originalLine.unitPrice; + const lineDiscount = (originalLine.discountAmount / originalLine.quantity) * refundLine.quantity; + const lineTax = (originalLine.taxAmount / originalLine.quantity) * refundLine.quantity; + const lineTotal = lineSubtotal - lineDiscount; + + refundTotal += lineTotal; + refundTax += lineTax; + + refundLines.push({ + tenantId, + lineNumber: refundLines.length + 1, + productId: originalLine.productId, + productCode: originalLine.productCode, + productName: originalLine.productName, + quantity: -refundLine.quantity, + unitPrice: originalLine.unitPrice, + originalPrice: originalLine.originalPrice, + discountPercent: originalLine.discountPercent, + discountAmount: lineDiscount, + taxRate: originalLine.taxRate, + taxAmount: -lineTax, + subtotal: -lineSubtotal, + total: -lineTotal, + status: LineStatus.ACTIVE, + notes: refundLine.reason, + }); + } + + // Create refund order + const refundOrder = this.repository.create({ + tenantId, + branchId: data.branchId, + sessionId: data.sessionId, + registerId: data.registerId, + userId: data.userId, + number: this.generateOrderNumber(branchCode).replace('ORD', 'REF'), + type: OrderType.REFUND, + status: OrderStatus.DRAFT, + customerId: originalOrder.customerId, + customerName: originalOrder.customerName, + customerRfc: originalOrder.customerRfc, + subtotal: -refundTotal, + taxAmount: -refundTax, + total: -refundTotal, + originalOrderId: originalOrder.id, + notes: data.refundReason, + }); + + const savedRefund = await queryRunner.manager.save(refundOrder); + + // Create refund lines + const lines = refundLines.map((line) => ({ + ...line, + orderId: savedRefund.id, + })); + await queryRunner.manager.save(POSOrderLine, lines); + + await queryRunner.commitTransaction(); + + return { + success: true, + data: await this.repository.findOne({ + where: { id: savedRefund.id }, + relations: ['lines'], + }) as POSOrder, + }; + } catch (error: any) { + await queryRunner.rollbackTransaction(); + return { + success: false, + error: { + code: 'CREATE_REFUND_FAILED', + message: error.message || 'Failed to create refund', + details: error, + }, + }; + } finally { + await queryRunner.release(); + } + } + + /** + * Process refund payment (cash out) + */ + async processRefundPayment( + tenantId: string, + refundOrderId: string, + payment: { + method: PaymentMethod; + amount: number; + }, + userId: string + ): Promise> { + const queryRunner = AppDataSource.createQueryRunner(); + await queryRunner.connect(); + await queryRunner.startTransaction(); + + try { + const refundOrder = await this.repository.findOne({ + where: { id: refundOrderId, tenantId, type: OrderType.REFUND }, + relations: ['payments'], + }); + + if (!refundOrder) { + return { + success: false, + error: { + code: 'REFUND_NOT_FOUND', + message: 'Refund order not found', + }, + }; + } + + if (refundOrder.status === OrderStatus.PAID) { + return { + success: false, + error: { + code: 'REFUND_ALREADY_PROCESSED', + message: 'Refund has already been processed', + }, + }; + } + + // Create payment record (negative) + const paymentRecord = this.paymentRepository.create({ + tenantId, + orderId: refundOrder.id, + sessionId: refundOrder.sessionId, + method: payment.method, + status: PaymentStatus.COMPLETED, + amount: -Math.abs(payment.amount), + amountReceived: 0, + changeGiven: Math.abs(payment.amount), + processedAt: new Date(), + processedBy: userId, + }); + + await queryRunner.manager.save(paymentRecord); + + // Update refund order + refundOrder.amountPaid = -Math.abs(payment.amount); + refundOrder.status = OrderStatus.PAID; + refundOrder.completedAt = new Date(); + + await queryRunner.manager.save(refundOrder); + + // Update session totals (negative for refund) + await posSessionService.updateSessionTotals(refundOrder.sessionId, { + total: refundOrder.total, + discounts: 0, + cashAmount: payment.method === PaymentMethod.CASH ? -Math.abs(payment.amount) : 0, + cardAmount: 0, + transferAmount: 0, + otherAmount: 0, + itemsCount: 0, + isRefund: true, + }); + + await queryRunner.commitTransaction(); + + return { + success: true, + data: await this.repository.findOne({ + where: { id: refundOrderId }, + relations: ['lines', 'payments'], + }) as POSOrder, + }; + } catch (error: any) { + await queryRunner.rollbackTransaction(); + return { + success: false, + error: { + code: 'PROCESS_REFUND_FAILED', + message: error.message || 'Failed to process refund', + details: error, + }, + }; + } finally { + await queryRunner.release(); + } + } + + /** + * Hold an order for later (park) + */ + async holdOrder( + tenantId: string, + orderId: string, + holdName: string + ): Promise> { + const order = await this.repository.findOne({ + where: { id: orderId, tenantId }, + }); + + if (!order) { + return { + success: false, + error: { + code: 'ORDER_NOT_FOUND', + message: 'Order not found', + }, + }; + } + + if (order.status !== OrderStatus.DRAFT) { + return { + success: false, + error: { + code: 'CANNOT_HOLD', + message: 'Only draft orders can be held', + }, + }; + } + + order.status = OrderStatus.CONFIRMED; // Using confirmed as "on hold" + order.metadata = { + ...order.metadata, + isHeld: true, + holdName, + heldAt: new Date().toISOString(), + }; + + const savedOrder = await this.repository.save(order); + return { success: true, data: savedOrder }; + } + + /** + * Get held orders + */ + async getHeldOrders(tenantId: string, branchId: string): Promise { + return this.repository + .createQueryBuilder('order') + .where('order.tenantId = :tenantId', { tenantId }) + .andWhere('order.branchId = :branchId', { branchId }) + .andWhere('order.status = :status', { status: OrderStatus.CONFIRMED }) + .andWhere("order.metadata->>'isHeld' = 'true'") + .leftJoinAndSelect('order.lines', 'lines') + .orderBy('order.createdAt', 'DESC') + .getMany(); + } + + /** + * Recall a held order + */ + async recallOrder( + tenantId: string, + orderId: string, + sessionId: string, + registerId: string, + userId: string + ): Promise> { + const order = await this.repository.findOne({ + where: { id: orderId, tenantId }, + relations: ['lines'], + }); + + if (!order) { + return { + success: false, + error: { + code: 'ORDER_NOT_FOUND', + message: 'Order not found', + }, + }; + } + + if (!order.metadata?.isHeld) { + return { + success: false, + error: { + code: 'ORDER_NOT_HELD', + message: 'Order is not on hold', + }, + }; + } + + // Update to current session + order.sessionId = sessionId; + order.registerId = registerId; + order.userId = userId; + order.status = OrderStatus.DRAFT; + order.metadata = { + ...order.metadata, + isHeld: false, + recalledAt: new Date().toISOString(), + }; + + const savedOrder = await this.repository.save(order); + return { success: true, data: savedOrder }; + } + + /** + * Apply coupon to order using pricing engine + */ + async applyCouponToOrder( + tenantId: string, + orderId: string, + couponCode: string, + branchId: string + ): Promise> { + const order = await this.repository.findOne({ + where: { id: orderId, tenantId }, + relations: ['lines'], + }); + + if (!order) { + return { + success: false, + error: { + code: 'ORDER_NOT_FOUND', + message: 'Order not found', + }, + }; + } + + if (order.status !== OrderStatus.DRAFT) { + return { + success: false, + error: { + code: 'ORDER_NOT_DRAFT', + message: 'Can only apply coupons to draft orders', + }, + }; + } + + // TODO: Integrate with PriceEngineService + // For now, just store the coupon code + order.couponCode = couponCode; + order.metadata = { + ...order.metadata, + couponAppliedAt: new Date().toISOString(), + }; + + const savedOrder = await this.repository.save(order); + return { success: true, data: savedOrder }; + } + + /** + * Mark receipt as printed + */ + async markReceiptPrinted(tenantId: string, orderId: string): Promise { + await this.repository.update( + { id: orderId, tenantId }, + { receiptPrinted: true } + ); + } + + /** + * Send receipt by email + */ + async sendReceiptEmail( + tenantId: string, + orderId: string, + email: string + ): Promise> { + const order = await this.repository.findOne({ + where: { id: orderId, tenantId }, + }); + + if (!order) { + return { + success: false, + error: { + code: 'ORDER_NOT_FOUND', + message: 'Order not found', + }, + }; + } + + // TODO: Implement email sending + // Update receipt status + await this.repository.update(orderId, { + receiptSent: true, + receiptEmail: email, + }); + + return { success: true }; + } + + /** + * Get order statistics for session + */ + async getSessionStats(tenantId: string, sessionId: string): Promise<{ + totalOrders: number; + totalSales: number; + totalRefunds: number; + totalDiscounts: number; + averageTicket: number; + paymentBreakdown: Record; + }> { + const orders = await this.repository.find({ + where: { tenantId, sessionId }, + relations: ['payments'], + }); + + const salesOrders = orders.filter(o => o.type === OrderType.SALE && o.status === OrderStatus.PAID); + const refundOrders = orders.filter(o => o.type === OrderType.REFUND && o.status === OrderStatus.PAID); + + const totalSales = salesOrders.reduce((sum, o) => sum + Number(o.total), 0); + const totalRefunds = Math.abs(refundOrders.reduce((sum, o) => sum + Number(o.total), 0)); + const totalDiscounts = salesOrders.reduce((sum, o) => sum + Number(o.discountAmount), 0); + + // Payment breakdown + const paymentBreakdown: Record = {}; + for (const order of salesOrders) { + for (const payment of (order.payments || [])) { + paymentBreakdown[payment.method] = (paymentBreakdown[payment.method] || 0) + Number(payment.amount); + } + } + + return { + totalOrders: salesOrders.length, + totalSales, + totalRefunds, + totalDiscounts, + averageTicket: salesOrders.length > 0 ? totalSales / salesOrders.length : 0, + paymentBreakdown, + }; + } +} + +// Export singleton instance +export const posOrderService = new POSOrderService(); diff --git a/backend/src/modules/pos/services/pos-session.service.ts b/backend/src/modules/pos/services/pos-session.service.ts new file mode 100644 index 0000000..aa3e4cc --- /dev/null +++ b/backend/src/modules/pos/services/pos-session.service.ts @@ -0,0 +1,360 @@ +import { Repository, DeepPartial } from 'typeorm'; +import { AppDataSource } from '../../../config/typeorm'; +import { BaseService } from '../../../shared/services/base.service'; +import { ServiceResult } from '../../../shared/types'; +import { POSSession, SessionStatus } from '../entities/pos-session.entity'; +import { CashRegister, RegisterStatus } from '../../branches/entities/cash-register.entity'; + +export class POSSessionService extends BaseService { + private registerRepository: Repository; + + constructor() { + super(AppDataSource.getRepository(POSSession)); + this.registerRepository = AppDataSource.getRepository(CashRegister); + } + + /** + * Generate session number + */ + private generateSessionNumber(branchCode: string): string { + const date = new Date(); + const dateStr = date.toISOString().slice(0, 10).replace(/-/g, ''); + const timeStr = date.toISOString().slice(11, 19).replace(/:/g, ''); + return `SES-${branchCode}-${dateStr}-${timeStr}`; + } + + /** + * Open a new POS session + */ + async openSession( + tenantId: string, + data: { + branchId: string; + registerId: string; + userId: string; + openingCash: number; + openingNotes?: string; + } + ): Promise> { + const queryRunner = AppDataSource.createQueryRunner(); + await queryRunner.connect(); + await queryRunner.startTransaction(); + + try { + // Check if register exists and is available + const register = await this.registerRepository.findOne({ + where: { id: data.registerId, tenantId, branchId: data.branchId }, + relations: ['branch'], + }); + + if (!register) { + return { + success: false, + error: { + code: 'REGISTER_NOT_FOUND', + message: 'Cash register not found', + }, + }; + } + + if (register.status !== RegisterStatus.AVAILABLE) { + return { + success: false, + error: { + code: 'REGISTER_NOT_AVAILABLE', + message: `Cash register is currently ${register.status}`, + }, + }; + } + + // Check if user already has an open session + const existingSession = await this.repository.findOne({ + where: { + tenantId, + userId: data.userId, + status: SessionStatus.OPEN, + }, + }); + + if (existingSession) { + return { + success: false, + error: { + code: 'SESSION_ALREADY_OPEN', + message: 'User already has an open session', + }, + }; + } + + // Create session + const branchCode = register.branch?.code || 'UNK'; + const session = this.repository.create({ + tenantId, + branchId: data.branchId, + registerId: data.registerId, + userId: data.userId, + number: this.generateSessionNumber(branchCode), + status: SessionStatus.OPEN, + openedAt: new Date(), + openingCash: data.openingCash, + openingNotes: data.openingNotes, + }); + + const savedSession = await queryRunner.manager.save(session); + + // Update register status + await queryRunner.manager.update(CashRegister, register.id, { + status: RegisterStatus.IN_USE, + currentSessionId: savedSession.id, + currentUserId: data.userId, + }); + + await queryRunner.commitTransaction(); + + return { success: true, data: savedSession }; + } catch (error: any) { + await queryRunner.rollbackTransaction(); + return { + success: false, + error: { + code: 'OPEN_SESSION_FAILED', + message: error.message || 'Failed to open session', + details: error, + }, + }; + } finally { + await queryRunner.release(); + } + } + + /** + * Close a POS session + */ + async closeSession( + tenantId: string, + sessionId: string, + data: { + closedBy: string; + closingCashCounted: number; + closingNotes?: string; + cashCountDetail?: POSSession['cashCountDetail']; + } + ): Promise> { + const queryRunner = AppDataSource.createQueryRunner(); + await queryRunner.connect(); + await queryRunner.startTransaction(); + + try { + const session = await this.repository.findOne({ + where: { id: sessionId, tenantId }, + }); + + if (!session) { + return { + success: false, + error: { + code: 'SESSION_NOT_FOUND', + message: 'Session not found', + }, + }; + } + + if (session.status !== SessionStatus.OPEN) { + return { + success: false, + error: { + code: 'SESSION_NOT_OPEN', + message: 'Session is not open', + }, + }; + } + + // Calculate expected cash + const expectedCash = + session.openingCash + + session.cashTotal + + session.cashInTotal - + session.cashOutTotal; + + const difference = data.closingCashCounted - expectedCash; + + // Update session + session.status = SessionStatus.CLOSED; + session.closedAt = new Date(); + session.closedBy = data.closedBy; + session.closingCashCounted = data.closingCashCounted; + session.closingCashExpected = expectedCash; + session.closingDifference = difference; + session.closingNotes = data.closingNotes; + session.cashCountDetail = data.cashCountDetail; + + const savedSession = await queryRunner.manager.save(session); + + // Update register status + await queryRunner.manager.update(CashRegister, session.registerId, { + status: RegisterStatus.AVAILABLE, + currentSessionId: null, + currentUserId: null, + }); + + await queryRunner.commitTransaction(); + + return { success: true, data: savedSession }; + } catch (error: any) { + await queryRunner.rollbackTransaction(); + return { + success: false, + error: { + code: 'CLOSE_SESSION_FAILED', + message: error.message || 'Failed to close session', + details: error, + }, + }; + } finally { + await queryRunner.release(); + } + } + + /** + * Get active session for a user + */ + async getActiveSession(tenantId: string, userId: string): Promise { + return this.repository.findOne({ + where: { + tenantId, + userId, + status: SessionStatus.OPEN, + }, + }); + } + + /** + * Get active session for a register + */ + async getRegisterSession(tenantId: string, registerId: string): Promise { + return this.repository.findOne({ + where: { + tenantId, + registerId, + status: SessionStatus.OPEN, + }, + }); + } + + /** + * Get session with orders + */ + async getSessionWithOrders(tenantId: string, sessionId: string): Promise { + return this.repository.findOne({ + where: { id: sessionId, tenantId }, + relations: ['orders'], + }); + } + + /** + * Update session totals after an order + */ + async updateSessionTotals( + sessionId: string, + orderData: { + total: number; + discounts: number; + cashAmount: number; + cardAmount: number; + transferAmount: number; + otherAmount: number; + itemsCount: number; + isRefund: boolean; + } + ): Promise { + const session = await this.repository.findOne({ where: { id: sessionId } }); + if (!session) return; + + if (orderData.isRefund) { + session.totalRefunds += orderData.total; + } else { + session.totalSales += orderData.total; + session.ordersCount += 1; + } + + session.totalDiscounts += orderData.discounts; + session.cashTotal += orderData.cashAmount; + session.cardTotal += orderData.cardAmount; + session.transferTotal += orderData.transferAmount; + session.otherTotal += orderData.otherAmount; + session.itemsSold += orderData.itemsCount; + + await this.repository.save(session); + } + + /** + * Get sessions for a date range + */ + async getSessionsByDateRange( + tenantId: string, + branchId: string, + startDate: Date, + endDate: Date + ): Promise { + return this.repository + .createQueryBuilder('session') + .where('session.tenantId = :tenantId', { tenantId }) + .andWhere('session.branchId = :branchId', { branchId }) + .andWhere('session.openedAt BETWEEN :startDate AND :endDate', { + startDate, + endDate, + }) + .orderBy('session.openedAt', 'DESC') + .getMany(); + } + + /** + * Get daily session summary + */ + async getDailySummary( + tenantId: string, + branchId: string, + date: Date + ): Promise<{ + sessionsCount: number; + totalSales: number; + totalRefunds: number; + totalDiscounts: number; + netSales: number; + ordersCount: number; + }> { + const startOfDay = new Date(date); + startOfDay.setHours(0, 0, 0, 0); + + const endOfDay = new Date(date); + endOfDay.setHours(23, 59, 59, 999); + + const result = await this.repository + .createQueryBuilder('session') + .select('COUNT(*)', 'sessionsCount') + .addSelect('COALESCE(SUM(session.totalSales), 0)', 'totalSales') + .addSelect('COALESCE(SUM(session.totalRefunds), 0)', 'totalRefunds') + .addSelect('COALESCE(SUM(session.totalDiscounts), 0)', 'totalDiscounts') + .addSelect('COALESCE(SUM(session.ordersCount), 0)', 'ordersCount') + .where('session.tenantId = :tenantId', { tenantId }) + .andWhere('session.branchId = :branchId', { branchId }) + .andWhere('session.openedAt BETWEEN :startDate AND :endDate', { + startDate: startOfDay, + endDate: endOfDay, + }) + .getRawOne(); + + return { + sessionsCount: parseInt(result.sessionsCount) || 0, + totalSales: parseFloat(result.totalSales) || 0, + totalRefunds: parseFloat(result.totalRefunds) || 0, + totalDiscounts: parseFloat(result.totalDiscounts) || 0, + netSales: + (parseFloat(result.totalSales) || 0) - (parseFloat(result.totalRefunds) || 0), + ordersCount: parseInt(result.ordersCount) || 0, + }; + } +} + +// Export singleton instance +export const posSessionService = new POSSessionService(); diff --git a/backend/src/modules/pos/validation/pos.schema.ts b/backend/src/modules/pos/validation/pos.schema.ts new file mode 100644 index 0000000..8f6d67c --- /dev/null +++ b/backend/src/modules/pos/validation/pos.schema.ts @@ -0,0 +1,206 @@ +import { z } from 'zod'; +import { + uuidSchema, + moneySchema, + quantitySchema, + percentSchema, + paginationSchema, + rfcSchema, + emailSchema, +} from '../../../shared/validation/common.schema'; + +// Enums +export const paymentMethodEnum = z.enum([ + 'cash', + 'debit_card', + 'credit_card', + 'transfer', + 'check', + 'loyalty_points', + 'voucher', + 'other', +]); + +export const orderStatusEnum = z.enum([ + 'draft', + 'confirmed', + 'paid', + 'partially_paid', + 'voided', + 'refunded', +]); + +// ==================== SESSION SCHEMAS ==================== + +// Open session schema +export const openSessionSchema = z.object({ + registerId: uuidSchema, + openingCash: moneySchema.default(0), + openingNotes: z.string().max(500).optional(), +}); + +// Close session schema +export const closeSessionSchema = z.object({ + closingCashCounted: moneySchema, + closingNotes: z.string().max(500).optional(), + cashCountDetail: z.object({ + bills: z.array(z.object({ + denomination: z.number(), + count: z.number().int().min(0), + total: z.number(), + })).optional(), + coins: z.array(z.object({ + denomination: z.number(), + count: z.number().int().min(0), + total: z.number(), + })).optional(), + total: z.number(), + }).optional(), +}); + +// List sessions query schema +export const listSessionsQuerySchema = paginationSchema.extend({ + status: z.enum(['open', 'closing', 'closed', 'reconciled']).optional(), + userId: uuidSchema.optional(), + startDate: z.coerce.date().optional(), + endDate: z.coerce.date().optional(), +}); + +// ==================== ORDER SCHEMAS ==================== + +// Order line schema +export const orderLineSchema = z.object({ + productId: uuidSchema, + productCode: z.string().min(1).max(50), + productName: z.string().min(1).max(200), + productBarcode: z.string().max(50).optional(), + variantId: uuidSchema.optional(), + variantName: z.string().max(200).optional(), + quantity: quantitySchema.min(0.0001), + unitPrice: moneySchema, + originalPrice: moneySchema, + discountPercent: percentSchema.optional(), + discountAmount: moneySchema.optional(), + taxRate: z.coerce.number().min(0).max(1).default(0.16), + taxIncluded: z.boolean().default(true), + notes: z.string().max(500).optional(), +}); + +// Create order schema +export const createOrderSchema = z.object({ + sessionId: uuidSchema, + registerId: uuidSchema, + customerId: uuidSchema.optional(), + customerName: z.string().max(200).optional(), + customerRfc: rfcSchema.optional(), + salespersonId: uuidSchema.optional(), + lines: z.array(orderLineSchema).min(1, 'At least one line is required'), + discountPercent: percentSchema.optional(), + discountAmount: moneySchema.optional(), + discountReason: z.string().max(255).optional(), + couponId: uuidSchema.optional(), + couponCode: z.string().max(50).optional(), + requiresInvoice: z.boolean().default(false), + notes: z.string().max(1000).optional(), +}); + +// Add payment schema +export const addPaymentSchema = z.object({ + method: paymentMethodEnum, + amount: moneySchema.positive('Amount must be positive'), + amountReceived: moneySchema.optional(), + cardType: z.string().max(20).optional(), + cardLastFour: z.string().length(4).optional(), + cardHolder: z.string().max(100).optional(), + authorizationCode: z.string().max(50).optional(), + transactionReference: z.string().max(100).optional(), + terminalId: z.string().max(50).optional(), + bankName: z.string().max(100).optional(), + transferReference: z.string().max(100).optional(), + checkNumber: z.string().max(50).optional(), + checkBank: z.string().max(100).optional(), + voucherCode: z.string().max(50).optional(), + loyaltyPointsUsed: z.number().int().min(0).optional(), + tipAmount: moneySchema.optional(), + notes: z.string().max(500).optional(), +}); + +// Void order schema +export const voidOrderSchema = z.object({ + reason: z.string().min(1, 'Reason is required').max(255), +}); + +// List orders query schema +export const listOrdersQuerySchema = paginationSchema.extend({ + sessionId: uuidSchema.optional(), + status: orderStatusEnum.optional(), + customerId: uuidSchema.optional(), + startDate: z.coerce.date().optional(), + endDate: z.coerce.date().optional(), + search: z.string().optional(), +}); + +// ==================== RECEIPT SCHEMAS ==================== + +// Send receipt schema +export const sendReceiptSchema = z.object({ + email: emailSchema, +}); + +// ==================== REFUND SCHEMAS ==================== + +// Refund line schema +export const refundLineSchema = z.object({ + originalLineId: uuidSchema, + quantity: quantitySchema.positive('Quantity must be positive'), + reason: z.string().min(1).max(255), +}); + +// Create refund schema +export const createRefundSchema = z.object({ + originalOrderId: uuidSchema, + sessionId: uuidSchema, + registerId: uuidSchema, + lines: z.array(refundLineSchema).min(1, 'At least one line is required'), + refundReason: z.string().min(1).max(500), +}); + +// Process refund payment schema +export const processRefundPaymentSchema = z.object({ + method: paymentMethodEnum, + amount: moneySchema.positive('Amount must be positive'), +}); + +// ==================== HOLD/RECALL SCHEMAS ==================== + +// Hold order schema +export const holdOrderSchema = z.object({ + holdName: z.string().max(100).optional(), +}); + +// Recall order schema +export const recallOrderSchema = z.object({ + sessionId: uuidSchema, + registerId: uuidSchema, +}); + +// ==================== COUPON SCHEMA ==================== + +// Apply coupon schema +export const applyCouponSchema = z.object({ + couponCode: z.string().min(1).max(50), +}); + +// Types +export type OpenSessionInput = z.infer; +export type CloseSessionInput = z.infer; +export type CreateOrderInput = z.infer; +export type AddPaymentInput = z.infer; +export type VoidOrderInput = z.infer; +export type OrderLineInput = z.infer; +export type RefundLineInput = z.infer; +export type CreateRefundInput = z.infer; +export type ProcessRefundPaymentInput = z.infer; +export type HoldOrderInput = z.infer; +export type RecallOrderInput = z.infer; +export type ApplyCouponInput = z.infer; diff --git a/backend/src/modules/pricing/controllers/index.ts b/backend/src/modules/pricing/controllers/index.ts new file mode 100644 index 0000000..0be2cea --- /dev/null +++ b/backend/src/modules/pricing/controllers/index.ts @@ -0,0 +1 @@ +export * from './pricing.controller'; diff --git a/backend/src/modules/pricing/controllers/pricing.controller.ts b/backend/src/modules/pricing/controllers/pricing.controller.ts new file mode 100644 index 0000000..e3cf6a0 --- /dev/null +++ b/backend/src/modules/pricing/controllers/pricing.controller.ts @@ -0,0 +1,985 @@ +import { Request, Response, NextFunction } from 'express'; +import { PriceEngineService } from '../services/price-engine.service'; +import { PromotionService } from '../services/promotion.service'; +import { CouponService } from '../services/coupon.service'; +import { + CreatePromotionInput, + UpdatePromotionInput, + CreateCouponInput, + UpdateCouponInput, + CalculatePricesInput, + RedeemCouponInput, + ListPromotionsQuery, + ListCouponsQuery, + PromotionProductInput, + GenerateBulkCouponsInput, +} from '../validation/pricing.schema'; +import { AppDataSource } from '../../../config/database'; +import { Promotion } from '../entities/promotion.entity'; +import { Coupon } from '../entities/coupon.entity'; + +const priceEngineService = new PriceEngineService(AppDataSource); +const promotionService = new PromotionService( + AppDataSource, + AppDataSource.getRepository(Promotion) +); +const couponService = new CouponService( + AppDataSource, + AppDataSource.getRepository(Coupon) +); + +// ==================== PRICE ENGINE ==================== + +/** + * Calculate prices for cart/order items + * POST /api/pricing/calculate + */ +export const calculatePrices = async ( + req: Request, + res: Response, + next: NextFunction +): Promise => { + try { + const tenantId = req.headers['x-tenant-id'] as string; + const input = req.body as CalculatePricesInput; + + const context = { + tenantId, + branchId: input.branchId, + customerId: input.customerId, + customerLevelId: input.customerLevelId, + isNewCustomer: input.isNewCustomer || false, + isLoyaltyMember: input.isLoyaltyMember || false, + isFirstOrder: input.isFirstOrder || false, + channel: input.channel, + }; + + const result = await priceEngineService.calculatePrices( + context, + input.lines, + input.couponCode + ); + + res.json({ + success: true, + data: result, + }); + } catch (error) { + next(error); + } +}; + +/** + * Validate a coupon code + * POST /api/pricing/validate-coupon + */ +export const validateCoupon = async ( + req: Request, + res: Response, + next: NextFunction +): Promise => { + try { + const tenantId = req.headers['x-tenant-id'] as string; + const branchId = req.headers['x-branch-id'] as string; + const { code, orderAmount } = req.body; + + const context = { + tenantId, + branchId, + channel: 'pos' as const, + isNewCustomer: false, + isLoyaltyMember: false, + isFirstOrder: false, + }; + + const result = await priceEngineService.validateCoupon( + context, + code, + orderAmount + ); + + res.json({ + success: true, + data: result, + }); + } catch (error) { + next(error); + } +}; + +/** + * Get active promotions for a branch + * GET /api/pricing/active-promotions + */ +export const getActivePromotions = async ( + req: Request, + res: Response, + next: NextFunction +): Promise => { + try { + const tenantId = req.headers['x-tenant-id'] as string; + const branchId = req.headers['x-branch-id'] as string; + const channel = (req.query.channel as 'pos' | 'ecommerce' | 'mobile') || 'pos'; + + const promotions = await priceEngineService.getActivePromotions( + tenantId, + branchId, + channel + ); + + res.json({ + success: true, + data: promotions, + meta: { + count: promotions.length, + }, + }); + } catch (error) { + next(error); + } +}; + +// ==================== PROMOTIONS ==================== + +/** + * List promotions with filters + * GET /api/pricing/promotions + */ +export const listPromotions = async ( + req: Request, + res: Response, + next: NextFunction +): Promise => { + try { + const tenantId = req.headers['x-tenant-id'] as string; + const query = req.query as unknown as ListPromotionsQuery; + + const result = await promotionService.listPromotions(tenantId, query); + + res.json({ + success: true, + data: result.data, + meta: result.meta, + }); + } catch (error) { + next(error); + } +}; + +/** + * Get promotion by ID + * GET /api/pricing/promotions/:id + */ +export const getPromotion = async ( + req: Request, + res: Response, + next: NextFunction +): Promise => { + try { + const tenantId = req.headers['x-tenant-id'] as string; + const { id } = req.params; + + const result = await promotionService.getById(tenantId, id, { + relations: ['products'], + }); + + if (!result.success) { + res.status(404).json({ + success: false, + error: result.error, + }); + return; + } + + res.json({ + success: true, + data: result.data, + }); + } catch (error) { + next(error); + } +}; + +/** + * Create a new promotion + * POST /api/pricing/promotions + */ +export const createPromotion = async ( + req: Request, + res: Response, + next: NextFunction +): Promise => { + try { + const tenantId = req.headers['x-tenant-id'] as string; + const userId = req.user?.id as string; + const input = req.body as CreatePromotionInput; + + const result = await promotionService.createPromotion(tenantId, input, userId); + + if (!result.success) { + res.status(400).json({ + success: false, + error: result.error, + }); + return; + } + + res.status(201).json({ + success: true, + data: result.data, + }); + } catch (error) { + next(error); + } +}; + +/** + * Update a promotion + * PUT /api/pricing/promotions/:id + */ +export const updatePromotion = async ( + req: Request, + res: Response, + next: NextFunction +): Promise => { + try { + const tenantId = req.headers['x-tenant-id'] as string; + const userId = req.user?.id as string; + const { id } = req.params; + const input = req.body as UpdatePromotionInput; + + const result = await promotionService.updatePromotion(tenantId, id, input, userId); + + if (!result.success) { + res.status(400).json({ + success: false, + error: result.error, + }); + return; + } + + res.json({ + success: true, + data: result.data, + }); + } catch (error) { + next(error); + } +}; + +/** + * Activate a promotion + * POST /api/pricing/promotions/:id/activate + */ +export const activatePromotion = async ( + req: Request, + res: Response, + next: NextFunction +): Promise => { + try { + const tenantId = req.headers['x-tenant-id'] as string; + const userId = req.user?.id as string; + const { id } = req.params; + + const result = await promotionService.activatePromotion(tenantId, id, userId); + + if (!result.success) { + res.status(400).json({ + success: false, + error: result.error, + }); + return; + } + + res.json({ + success: true, + data: result.data, + }); + } catch (error) { + next(error); + } +}; + +/** + * Pause a promotion + * POST /api/pricing/promotions/:id/pause + */ +export const pausePromotion = async ( + req: Request, + res: Response, + next: NextFunction +): Promise => { + try { + const tenantId = req.headers['x-tenant-id'] as string; + const userId = req.user?.id as string; + const { id } = req.params; + + const result = await promotionService.pausePromotion(tenantId, id, userId); + + if (!result.success) { + res.status(400).json({ + success: false, + error: result.error, + }); + return; + } + + res.json({ + success: true, + data: result.data, + }); + } catch (error) { + next(error); + } +}; + +/** + * End a promotion + * POST /api/pricing/promotions/:id/end + */ +export const endPromotion = async ( + req: Request, + res: Response, + next: NextFunction +): Promise => { + try { + const tenantId = req.headers['x-tenant-id'] as string; + const userId = req.user?.id as string; + const { id } = req.params; + + const result = await promotionService.endPromotion(tenantId, id, userId); + + if (!result.success) { + res.status(400).json({ + success: false, + error: result.error, + }); + return; + } + + res.json({ + success: true, + data: result.data, + }); + } catch (error) { + next(error); + } +}; + +/** + * Cancel a promotion + * POST /api/pricing/promotions/:id/cancel + */ +export const cancelPromotion = async ( + req: Request, + res: Response, + next: NextFunction +): Promise => { + try { + const tenantId = req.headers['x-tenant-id'] as string; + const userId = req.user?.id as string; + const { id } = req.params; + + const result = await promotionService.cancelPromotion(tenantId, id, userId); + + if (!result.success) { + res.status(400).json({ + success: false, + error: result.error, + }); + return; + } + + res.json({ + success: true, + data: result.data, + }); + } catch (error) { + next(error); + } +}; + +/** + * Add products to a promotion + * POST /api/pricing/promotions/:id/products + */ +export const addPromotionProducts = async ( + req: Request, + res: Response, + next: NextFunction +): Promise => { + try { + const tenantId = req.headers['x-tenant-id'] as string; + const userId = req.user?.id as string; + const { id } = req.params; + const { products } = req.body as { products: PromotionProductInput[] }; + + const result = await promotionService.addProducts(tenantId, id, products, userId); + + if (!result.success) { + res.status(400).json({ + success: false, + error: result.error, + }); + return; + } + + res.status(201).json({ + success: true, + data: result.data, + }); + } catch (error) { + next(error); + } +}; + +/** + * Remove product from a promotion + * DELETE /api/pricing/promotions/:id/products/:productId + */ +export const removePromotionProduct = async ( + req: Request, + res: Response, + next: NextFunction +): Promise => { + try { + const tenantId = req.headers['x-tenant-id'] as string; + const userId = req.user?.id as string; + const { id, productId } = req.params; + + const result = await promotionService.removeProduct(tenantId, id, productId, userId); + + if (!result.success) { + res.status(400).json({ + success: false, + error: result.error, + }); + return; + } + + res.json({ + success: true, + message: 'Product removed from promotion', + }); + } catch (error) { + next(error); + } +}; + +/** + * Delete a promotion + * DELETE /api/pricing/promotions/:id + */ +export const deletePromotion = async ( + req: Request, + res: Response, + next: NextFunction +): Promise => { + try { + const tenantId = req.headers['x-tenant-id'] as string; + const userId = req.user?.id as string; + const { id } = req.params; + + const result = await promotionService.softDelete(tenantId, id, userId); + + if (!result.success) { + res.status(400).json({ + success: false, + error: result.error, + }); + return; + } + + res.json({ + success: true, + message: 'Promotion deleted', + }); + } catch (error) { + next(error); + } +}; + +// ==================== COUPONS ==================== + +/** + * List coupons with filters + * GET /api/pricing/coupons + */ +export const listCoupons = async ( + req: Request, + res: Response, + next: NextFunction +): Promise => { + try { + const tenantId = req.headers['x-tenant-id'] as string; + const query = req.query as unknown as ListCouponsQuery; + + const result = await couponService.listCoupons(tenantId, query); + + res.json({ + success: true, + data: result.data, + meta: result.meta, + }); + } catch (error) { + next(error); + } +}; + +/** + * Get coupon by ID + * GET /api/pricing/coupons/:id + */ +export const getCoupon = async ( + req: Request, + res: Response, + next: NextFunction +): Promise => { + try { + const tenantId = req.headers['x-tenant-id'] as string; + const { id } = req.params; + + const result = await couponService.getById(tenantId, id, { + relations: ['redemptions'], + }); + + if (!result.success) { + res.status(404).json({ + success: false, + error: result.error, + }); + return; + } + + res.json({ + success: true, + data: result.data, + }); + } catch (error) { + next(error); + } +}; + +/** + * Get coupon by code + * GET /api/pricing/coupons/code/:code + */ +export const getCouponByCode = async ( + req: Request, + res: Response, + next: NextFunction +): Promise => { + try { + const tenantId = req.headers['x-tenant-id'] as string; + const { code } = req.params; + + const result = await couponService.getByCode(tenantId, code); + + if (!result.success) { + res.status(404).json({ + success: false, + error: result.error, + }); + return; + } + + res.json({ + success: true, + data: result.data, + }); + } catch (error) { + next(error); + } +}; + +/** + * Create a new coupon + * POST /api/pricing/coupons + */ +export const createCoupon = async ( + req: Request, + res: Response, + next: NextFunction +): Promise => { + try { + const tenantId = req.headers['x-tenant-id'] as string; + const userId = req.user?.id as string; + const input = req.body as CreateCouponInput; + + const result = await couponService.createCoupon(tenantId, input, userId); + + if (!result.success) { + res.status(400).json({ + success: false, + error: result.error, + }); + return; + } + + res.status(201).json({ + success: true, + data: result.data, + }); + } catch (error) { + next(error); + } +}; + +/** + * Generate bulk coupons + * POST /api/pricing/coupons/bulk + */ +export const generateBulkCoupons = async ( + req: Request, + res: Response, + next: NextFunction +): Promise => { + try { + const tenantId = req.headers['x-tenant-id'] as string; + const userId = req.user?.id as string; + const { quantity, prefix, baseInput } = req.body as GenerateBulkCouponsInput; + + const result = await couponService.generateBulkCoupons( + tenantId, + baseInput, + quantity, + prefix, + userId + ); + + if (!result.success) { + res.status(400).json({ + success: false, + error: result.error, + }); + return; + } + + res.status(201).json({ + success: true, + data: result.data, + meta: { + count: result.data?.length || 0, + }, + }); + } catch (error) { + next(error); + } +}; + +/** + * Update a coupon + * PUT /api/pricing/coupons/:id + */ +export const updateCoupon = async ( + req: Request, + res: Response, + next: NextFunction +): Promise => { + try { + const tenantId = req.headers['x-tenant-id'] as string; + const userId = req.user?.id as string; + const { id } = req.params; + const input = req.body as UpdateCouponInput; + + const result = await couponService.updateCoupon(tenantId, id, input, userId); + + if (!result.success) { + res.status(400).json({ + success: false, + error: result.error, + }); + return; + } + + res.json({ + success: true, + data: result.data, + }); + } catch (error) { + next(error); + } +}; + +/** + * Activate a coupon + * POST /api/pricing/coupons/:id/activate + */ +export const activateCoupon = async ( + req: Request, + res: Response, + next: NextFunction +): Promise => { + try { + const tenantId = req.headers['x-tenant-id'] as string; + const userId = req.user?.id as string; + const { id } = req.params; + + const result = await couponService.activateCoupon(tenantId, id, userId); + + if (!result.success) { + res.status(400).json({ + success: false, + error: result.error, + }); + return; + } + + res.json({ + success: true, + data: result.data, + }); + } catch (error) { + next(error); + } +}; + +/** + * Deactivate a coupon + * POST /api/pricing/coupons/:id/deactivate + */ +export const deactivateCoupon = async ( + req: Request, + res: Response, + next: NextFunction +): Promise => { + try { + const tenantId = req.headers['x-tenant-id'] as string; + const userId = req.user?.id as string; + const { id } = req.params; + + const result = await couponService.deactivateCoupon(tenantId, id, userId); + + if (!result.success) { + res.status(400).json({ + success: false, + error: result.error, + }); + return; + } + + res.json({ + success: true, + data: result.data, + }); + } catch (error) { + next(error); + } +}; + +/** + * Redeem a coupon + * POST /api/pricing/coupons/:id/redeem + */ +export const redeemCoupon = async ( + req: Request, + res: Response, + next: NextFunction +): Promise => { + try { + const tenantId = req.headers['x-tenant-id'] as string; + const userId = req.user?.id as string; + const { id } = req.params; + const input = req.body as RedeemCouponInput; + + // Get coupon code from ID + const couponResult = await couponService.getById(tenantId, id); + if (!couponResult.success || !couponResult.data) { + res.status(404).json({ + success: false, + error: 'Coupon not found', + }); + return; + } + + const result = await couponService.redeemCoupon( + tenantId, + couponResult.data.code, + input, + userId + ); + + if (!result.success) { + res.status(400).json({ + success: false, + error: result.error, + }); + return; + } + + res.status(201).json({ + success: true, + data: result.data, + }); + } catch (error) { + next(error); + } +}; + +/** + * Redeem coupon by code + * POST /api/pricing/coupons/redeem + */ +export const redeemCouponByCode = async ( + req: Request, + res: Response, + next: NextFunction +): Promise => { + try { + const tenantId = req.headers['x-tenant-id'] as string; + const userId = req.user?.id as string; + const { code, ...input } = req.body as RedeemCouponInput & { code: string }; + + const result = await couponService.redeemCoupon(tenantId, code, input, userId); + + if (!result.success) { + res.status(400).json({ + success: false, + error: result.error, + }); + return; + } + + res.status(201).json({ + success: true, + data: result.data, + }); + } catch (error) { + next(error); + } +}; + +/** + * Reverse a coupon redemption + * POST /api/pricing/coupons/redemptions/:redemptionId/reverse + */ +export const reverseRedemption = async ( + req: Request, + res: Response, + next: NextFunction +): Promise => { + try { + const tenantId = req.headers['x-tenant-id'] as string; + const userId = req.user?.id as string; + const { redemptionId } = req.params; + const { reason } = req.body as { reason: string }; + + const result = await couponService.reverseRedemption( + tenantId, + redemptionId, + reason, + userId + ); + + if (!result.success) { + res.status(400).json({ + success: false, + error: result.error, + }); + return; + } + + res.json({ + success: true, + data: result.data, + }); + } catch (error) { + next(error); + } +}; + +/** + * Get coupon redemption history + * GET /api/pricing/coupons/:id/redemptions + */ +export const getCouponRedemptions = async ( + req: Request, + res: Response, + next: NextFunction +): Promise => { + try { + const tenantId = req.headers['x-tenant-id'] as string; + const { id } = req.params; + const { page = 1, limit = 20 } = req.query; + + const result = await couponService.getRedemptionHistory( + tenantId, + id, + Number(page), + Number(limit) + ); + + res.json({ + success: true, + data: result.data, + meta: result.meta, + }); + } catch (error) { + next(error); + } +}; + +/** + * Delete a coupon + * DELETE /api/pricing/coupons/:id + */ +export const deleteCoupon = async ( + req: Request, + res: Response, + next: NextFunction +): Promise => { + try { + const tenantId = req.headers['x-tenant-id'] as string; + const userId = req.user?.id as string; + const { id } = req.params; + + const result = await couponService.softDelete(tenantId, id, userId); + + if (!result.success) { + res.status(400).json({ + success: false, + error: result.error, + }); + return; + } + + res.json({ + success: true, + message: 'Coupon deleted', + }); + } catch (error) { + next(error); + } +}; + +/** + * Get coupon statistics + * GET /api/pricing/coupons/:id/stats + */ +export const getCouponStats = async ( + req: Request, + res: Response, + next: NextFunction +): Promise => { + try { + const tenantId = req.headers['x-tenant-id'] as string; + const { id } = req.params; + + const result = await couponService.getCouponStats(tenantId, id); + + if (!result.success) { + res.status(404).json({ + success: false, + error: result.error, + }); + return; + } + + res.json({ + success: true, + data: result.data, + }); + } catch (error) { + next(error); + } +}; diff --git a/backend/src/modules/pricing/entities/coupon-redemption.entity.ts b/backend/src/modules/pricing/entities/coupon-redemption.entity.ts new file mode 100644 index 0000000..9879be1 --- /dev/null +++ b/backend/src/modules/pricing/entities/coupon-redemption.entity.ts @@ -0,0 +1,116 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + ManyToOne, + JoinColumn, + Index, +} from 'typeorm'; +import { Coupon } from './coupon.entity'; + +export enum RedemptionStatus { + APPLIED = 'applied', + COMPLETED = 'completed', + REVERSED = 'reversed', + CANCELLED = 'cancelled', +} + +@Entity('coupon_redemptions', { schema: 'retail' }) +@Index(['tenantId', 'couponId', 'createdAt']) +@Index(['tenantId', 'customerId']) +@Index(['tenantId', 'orderId']) +export class CouponRedemption { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'tenant_id', type: 'uuid' }) + @Index() + tenantId: string; + + @Column({ name: 'coupon_id', type: 'uuid' }) + couponId: string; + + @Column({ name: 'coupon_code', length: 50 }) + couponCode: string; + + @Column({ name: 'order_id', type: 'uuid' }) + orderId: string; + + @Column({ name: 'order_number', length: 30 }) + orderNumber: string; + + @Column({ name: 'customer_id', type: 'uuid', nullable: true }) + customerId: string; + + @Column({ name: 'branch_id', type: 'uuid' }) + branchId: string; + + @Column({ + type: 'enum', + enum: RedemptionStatus, + default: RedemptionStatus.APPLIED, + }) + status: RedemptionStatus; + + // Amounts + @Column({ name: 'order_amount', type: 'decimal', precision: 15, scale: 2 }) + orderAmount: number; + + @Column({ name: 'discount_applied', type: 'decimal', precision: 15, scale: 2 }) + discountApplied: number; + + // Discount breakdown + @Column({ name: 'discount_type', length: 30 }) + discountType: string; + + @Column({ name: 'discount_percent', type: 'decimal', precision: 5, scale: 2, nullable: true }) + discountPercent: number; + + @Column({ name: 'discount_amount', type: 'decimal', precision: 15, scale: 2, nullable: true }) + discountAmount: number; + + // Free product info + @Column({ name: 'free_product_id', type: 'uuid', nullable: true }) + freeProductId: string; + + @Column({ name: 'free_product_quantity', type: 'int', nullable: true }) + freeProductQuantity: number; + + @Column({ name: 'free_product_value', type: 'decimal', precision: 15, scale: 2, nullable: true }) + freeProductValue: number; + + // Channel + @Column({ length: 20, default: 'pos' }) + channel: 'pos' | 'ecommerce' | 'mobile'; + + // Reversal info + @Column({ name: 'reversed_at', type: 'timestamp with time zone', nullable: true }) + reversedAt: Date; + + @Column({ name: 'reversed_by', type: 'uuid', nullable: true }) + reversedBy: string; + + @Column({ name: 'reversal_reason', length: 255, nullable: true }) + reversalReason: string; + + // User who applied the coupon + @Column({ name: 'applied_by', type: 'uuid' }) + appliedBy: string; + + // IP address (for e-commerce fraud prevention) + @Column({ name: 'ip_address', length: 45, nullable: true }) + ipAddress: string; + + // Metadata + @Column({ type: 'jsonb', nullable: true }) + metadata: Record; + + @CreateDateColumn({ name: 'created_at' }) + createdAt: Date; + + // Relations + @ManyToOne(() => Coupon, (coupon) => coupon.redemptions) + @JoinColumn({ name: 'coupon_id' }) + coupon: Coupon; +} diff --git a/backend/src/modules/pricing/entities/coupon.entity.ts b/backend/src/modules/pricing/entities/coupon.entity.ts new file mode 100644 index 0000000..c35e698 --- /dev/null +++ b/backend/src/modules/pricing/entities/coupon.entity.ts @@ -0,0 +1,195 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + OneToMany, + Index, +} from 'typeorm'; +import { CouponRedemption } from './coupon-redemption.entity'; + +export enum CouponStatus { + ACTIVE = 'active', + INACTIVE = 'inactive', + EXPIRED = 'expired', + DEPLETED = 'depleted', +} + +export enum CouponType { + PERCENTAGE = 'percentage', + FIXED_AMOUNT = 'fixed_amount', + FREE_SHIPPING = 'free_shipping', + FREE_PRODUCT = 'free_product', +} + +export enum CouponScope { + ORDER = 'order', + PRODUCT = 'product', + CATEGORY = 'category', + SHIPPING = 'shipping', +} + +@Entity('coupons', { schema: 'retail' }) +@Index(['tenantId', 'code'], { unique: true }) +@Index(['tenantId', 'status', 'validFrom', 'validUntil']) +@Index(['tenantId', 'campaignId']) +export class Coupon { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'tenant_id', type: 'uuid' }) + @Index() + tenantId: string; + + @Column({ length: 50, unique: true }) + code: string; + + @Column({ length: 100 }) + name: string; + + @Column({ type: 'text', nullable: true }) + description: string; + + @Column({ + type: 'enum', + enum: CouponType, + }) + type: CouponType; + + @Column({ + type: 'enum', + enum: CouponScope, + default: CouponScope.ORDER, + }) + scope: CouponScope; + + @Column({ + type: 'enum', + enum: CouponStatus, + default: CouponStatus.ACTIVE, + }) + status: CouponStatus; + + // Discount value + @Column({ name: 'discount_percent', type: 'decimal', precision: 5, scale: 2, nullable: true }) + discountPercent: number; + + @Column({ name: 'discount_amount', type: 'decimal', precision: 15, scale: 2, nullable: true }) + discountAmount: number; + + @Column({ name: 'max_discount', type: 'decimal', precision: 15, scale: 2, nullable: true }) + maxDiscount: number; + + // Free product + @Column({ name: 'free_product_id', type: 'uuid', nullable: true }) + freeProductId: string; + + @Column({ name: 'free_product_quantity', type: 'int', default: 1 }) + freeProductQuantity: number; + + // Validity + @Column({ name: 'valid_from', type: 'timestamp with time zone' }) + validFrom: Date; + + @Column({ name: 'valid_until', type: 'timestamp with time zone', nullable: true }) + validUntil: Date; + + // Usage limits + @Column({ name: 'max_uses', type: 'int', nullable: true }) + maxUses: number; + + @Column({ name: 'max_uses_per_customer', type: 'int', default: 1 }) + maxUsesPerCustomer: number; + + @Column({ name: 'current_uses', type: 'int', default: 0 }) + currentUses: number; + + // Minimum requirements + @Column({ name: 'min_order_amount', type: 'decimal', precision: 15, scale: 2, nullable: true }) + minOrderAmount: number; + + @Column({ name: 'min_items', type: 'int', nullable: true }) + minItems: number; + + // Scope restrictions + @Column({ name: 'applies_to_categories', type: 'jsonb', nullable: true }) + appliesToCategories: string[]; + + @Column({ name: 'applies_to_products', type: 'jsonb', nullable: true }) + appliesToProducts: string[]; + + @Column({ name: 'excluded_products', type: 'jsonb', nullable: true }) + excludedProducts: string[]; + + @Column({ name: 'included_branches', type: 'jsonb', nullable: true }) + includedBranches: string[]; + + // Customer restrictions + @Column({ name: 'customer_specific', type: 'boolean', default: false }) + customerSpecific: boolean; + + @Column({ name: 'allowed_customers', type: 'jsonb', nullable: true }) + allowedCustomers: string[]; + + @Column({ name: 'customer_levels', type: 'jsonb', nullable: true }) + customerLevels: string[]; + + @Column({ name: 'new_customers_only', type: 'boolean', default: false }) + newCustomersOnly: boolean; + + @Column({ name: 'first_order_only', type: 'boolean', default: false }) + firstOrderOnly: boolean; + + // Campaign + @Column({ name: 'campaign_id', type: 'uuid', nullable: true }) + campaignId: string; + + @Column({ name: 'campaign_name', length: 100, nullable: true }) + campaignName: string; + + // Stackability + @Column({ name: 'stackable_with_promotions', type: 'boolean', default: false }) + stackableWithPromotions: boolean; + + @Column({ name: 'stackable_with_coupons', type: 'boolean', default: false }) + stackableWithCoupons: boolean; + + // Channels + @Column({ name: 'pos_enabled', type: 'boolean', default: true }) + posEnabled: boolean; + + @Column({ name: 'ecommerce_enabled', type: 'boolean', default: true }) + ecommerceEnabled: boolean; + + // Distribution + @Column({ name: 'is_single_use', type: 'boolean', default: false }) + isSingleUse: boolean; + + @Column({ name: 'auto_apply', type: 'boolean', default: false }) + autoApply: boolean; + + @Column({ name: 'public_visible', type: 'boolean', default: false }) + publicVisible: boolean; + + // Terms + @Column({ name: 'terms_and_conditions', type: 'text', nullable: true }) + termsAndConditions: string; + + // Metadata + @Column({ type: 'jsonb', nullable: true }) + metadata: Record; + + @CreateDateColumn({ name: 'created_at' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at' }) + updatedAt: Date; + + @Column({ name: 'created_by', type: 'uuid', nullable: true }) + createdBy: string; + + // Relations + @OneToMany(() => CouponRedemption, (redemption) => redemption.coupon) + redemptions: CouponRedemption[]; +} diff --git a/backend/src/modules/pricing/entities/index.ts b/backend/src/modules/pricing/entities/index.ts new file mode 100644 index 0000000..3af0211 --- /dev/null +++ b/backend/src/modules/pricing/entities/index.ts @@ -0,0 +1,4 @@ +export * from './promotion.entity'; +export * from './promotion-product.entity'; +export * from './coupon.entity'; +export * from './coupon-redemption.entity'; diff --git a/backend/src/modules/pricing/entities/promotion-product.entity.ts b/backend/src/modules/pricing/entities/promotion-product.entity.ts new file mode 100644 index 0000000..2ca1ef4 --- /dev/null +++ b/backend/src/modules/pricing/entities/promotion-product.entity.ts @@ -0,0 +1,91 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + ManyToOne, + JoinColumn, + Index, +} from 'typeorm'; +import { Promotion } from './promotion.entity'; + +export enum ProductRole { + TRIGGER = 'trigger', // Must be purchased to activate promotion + TARGET = 'target', // Receives the discount/benefit + BOTH = 'both', // Triggers and receives discount + GIFT = 'gift', // Free gift product +} + +@Entity('promotion_products', { schema: 'retail' }) +@Index(['tenantId', 'promotionId']) +@Index(['tenantId', 'productId']) +@Index(['tenantId', 'promotionId', 'productId'], { unique: true }) +export class PromotionProduct { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'tenant_id', type: 'uuid' }) + @Index() + tenantId: string; + + @Column({ name: 'promotion_id', type: 'uuid' }) + promotionId: string; + + // Product (from erp-core) + @Column({ name: 'product_id', type: 'uuid' }) + productId: string; + + @Column({ name: 'product_code', length: 50 }) + productCode: string; + + @Column({ name: 'product_name', length: 200 }) + productName: string; + + // Variant (optional) + @Column({ name: 'variant_id', type: 'uuid', nullable: true }) + variantId: string; + + @Column({ + type: 'enum', + enum: ProductRole, + default: ProductRole.BOTH, + }) + role: ProductRole; + + // Specific discount for this product (overrides promotion default) + @Column({ name: 'discount_percent', type: 'decimal', precision: 5, scale: 2, nullable: true }) + discountPercent: number; + + @Column({ name: 'discount_amount', type: 'decimal', precision: 15, scale: 2, nullable: true }) + discountAmount: number; + + @Column({ name: 'fixed_price', type: 'decimal', precision: 15, scale: 2, nullable: true }) + fixedPrice: number; + + // Quantity limits for this product + @Column({ name: 'min_quantity', type: 'int', default: 1 }) + minQuantity: number; + + @Column({ name: 'max_quantity', type: 'int', nullable: true }) + maxQuantity: number; + + // For bundles - quantity included in bundle + @Column({ name: 'bundle_quantity', type: 'int', default: 1 }) + bundleQuantity: number; + + // For buy X get Y - quantity to get free/discounted + @Column({ name: 'get_quantity', type: 'int', nullable: true }) + getQuantity: number; + + // Metadata + @Column({ type: 'jsonb', nullable: true }) + metadata: Record; + + @CreateDateColumn({ name: 'created_at' }) + createdAt: Date; + + // Relations + @ManyToOne(() => Promotion, (promotion) => promotion.products) + @JoinColumn({ name: 'promotion_id' }) + promotion: Promotion; +} diff --git a/backend/src/modules/pricing/entities/promotion.entity.ts b/backend/src/modules/pricing/entities/promotion.entity.ts new file mode 100644 index 0000000..6a66132 --- /dev/null +++ b/backend/src/modules/pricing/entities/promotion.entity.ts @@ -0,0 +1,224 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + OneToMany, + Index, +} from 'typeorm'; +import { PromotionProduct } from './promotion-product.entity'; + +export enum PromotionStatus { + DRAFT = 'draft', + SCHEDULED = 'scheduled', + ACTIVE = 'active', + PAUSED = 'paused', + ENDED = 'ended', + CANCELLED = 'cancelled', +} + +export enum PromotionType { + PERCENTAGE_DISCOUNT = 'percentage_discount', + FIXED_DISCOUNT = 'fixed_discount', + BUY_X_GET_Y = 'buy_x_get_y', + BUNDLE = 'bundle', + FLASH_SALE = 'flash_sale', + QUANTITY_DISCOUNT = 'quantity_discount', + FREE_SHIPPING = 'free_shipping', + GIFT_WITH_PURCHASE = 'gift_with_purchase', +} + +export enum DiscountApplication { + ORDER = 'order', + LINE = 'line', + SHIPPING = 'shipping', +} + +@Entity('promotions', { schema: 'retail' }) +@Index(['tenantId', 'status', 'startDate', 'endDate']) +@Index(['tenantId', 'code'], { unique: true }) +@Index(['tenantId', 'type']) +export class Promotion { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'tenant_id', type: 'uuid' }) + @Index() + tenantId: string; + + @Column({ length: 30, unique: true }) + code: string; + + @Column({ length: 100 }) + name: string; + + @Column({ type: 'text', nullable: true }) + description: string; + + @Column({ + type: 'enum', + enum: PromotionType, + }) + type: PromotionType; + + @Column({ + type: 'enum', + enum: PromotionStatus, + default: PromotionStatus.DRAFT, + }) + status: PromotionStatus; + + @Column({ + name: 'discount_application', + type: 'enum', + enum: DiscountApplication, + default: DiscountApplication.LINE, + }) + discountApplication: DiscountApplication; + + // Discount values + @Column({ name: 'discount_percent', type: 'decimal', precision: 5, scale: 2, nullable: true }) + discountPercent: number; + + @Column({ name: 'discount_amount', type: 'decimal', precision: 15, scale: 2, nullable: true }) + discountAmount: number; + + @Column({ name: 'max_discount', type: 'decimal', precision: 15, scale: 2, nullable: true }) + maxDiscount: number; + + // Buy X Get Y config + @Column({ name: 'buy_quantity', type: 'int', nullable: true }) + buyQuantity: number; + + @Column({ name: 'get_quantity', type: 'int', nullable: true }) + getQuantity: number; + + @Column({ name: 'get_discount_percent', type: 'decimal', precision: 5, scale: 2, nullable: true }) + getDiscountPercent: number; + + // Quantity discount config + @Column({ name: 'quantity_tiers', type: 'jsonb', nullable: true }) + quantityTiers: { + minQuantity: number; + discountPercent: number; + }[]; + + // Bundle config + @Column({ name: 'bundle_price', type: 'decimal', precision: 15, scale: 2, nullable: true }) + bundlePrice: number; + + // Validity + @Column({ name: 'start_date', type: 'timestamp with time zone' }) + startDate: Date; + + @Column({ name: 'end_date', type: 'timestamp with time zone', nullable: true }) + endDate: Date; + + // Time restrictions + @Column({ name: 'valid_days', type: 'jsonb', nullable: true }) + validDays: string[]; // ['monday', 'tuesday', ...] + + @Column({ name: 'valid_hours_start', type: 'time', nullable: true }) + validHoursStart: string; + + @Column({ name: 'valid_hours_end', type: 'time', nullable: true }) + validHoursEnd: string; + + // Usage limits + @Column({ name: 'max_uses_total', type: 'int', nullable: true }) + maxUsesTotal: number; + + @Column({ name: 'max_uses_per_customer', type: 'int', nullable: true }) + maxUsesPerCustomer: number; + + @Column({ name: 'current_uses', type: 'int', default: 0 }) + currentUses: number; + + // Minimum requirements + @Column({ name: 'min_order_amount', type: 'decimal', precision: 15, scale: 2, nullable: true }) + minOrderAmount: number; + + @Column({ name: 'min_quantity', type: 'int', nullable: true }) + minQuantity: number; + + // Scope + @Column({ name: 'applies_to_all_products', type: 'boolean', default: false }) + appliesToAllProducts: boolean; + + @Column({ name: 'included_categories', type: 'jsonb', nullable: true }) + includedCategories: string[]; + + @Column({ name: 'excluded_categories', type: 'jsonb', nullable: true }) + excludedCategories: string[]; + + @Column({ name: 'excluded_products', type: 'jsonb', nullable: true }) + excludedProducts: string[]; + + @Column({ name: 'included_branches', type: 'jsonb', nullable: true }) + includedBranches: string[]; + + // Customer restrictions + @Column({ name: 'customer_levels', type: 'jsonb', nullable: true }) + customerLevels: string[]; // membership level IDs + + @Column({ name: 'new_customers_only', type: 'boolean', default: false }) + newCustomersOnly: boolean; + + @Column({ name: 'loyalty_members_only', type: 'boolean', default: false }) + loyaltyMembersOnly: boolean; + + // Stackability + @Column({ name: 'stackable', type: 'boolean', default: false }) + stackable: boolean; + + @Column({ name: 'stackable_with', type: 'jsonb', nullable: true }) + stackableWith: string[]; // promotion IDs that can stack + + @Column({ type: 'int', default: 0 }) + priority: number; // higher = applied first + + // Display + @Column({ name: 'display_name', length: 100, nullable: true }) + displayName: string; + + @Column({ name: 'badge_text', length: 30, nullable: true }) + badgeText: string; + + @Column({ name: 'badge_color', length: 7, default: '#ff0000' }) + badgeColor: string; + + @Column({ name: 'image_url', length: 255, nullable: true }) + imageUrl: string; + + // Channels + @Column({ name: 'pos_enabled', type: 'boolean', default: true }) + posEnabled: boolean; + + @Column({ name: 'ecommerce_enabled', type: 'boolean', default: true }) + ecommerceEnabled: boolean; + + // Terms + @Column({ name: 'terms_and_conditions', type: 'text', nullable: true }) + termsAndConditions: string; + + // Metadata + @Column({ type: 'jsonb', nullable: true }) + metadata: Record; + + @CreateDateColumn({ name: 'created_at' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at' }) + updatedAt: Date; + + @Column({ name: 'created_by', type: 'uuid', nullable: true }) + createdBy: string; + + @Column({ name: 'updated_by', type: 'uuid', nullable: true }) + updatedBy: string; + + // Relations + @OneToMany(() => PromotionProduct, (pp) => pp.promotion) + products: PromotionProduct[]; +} diff --git a/backend/src/modules/pricing/index.ts b/backend/src/modules/pricing/index.ts new file mode 100644 index 0000000..fd9152d --- /dev/null +++ b/backend/src/modules/pricing/index.ts @@ -0,0 +1,14 @@ +// Services +export * from './services'; + +// Controllers +export * from './controllers'; + +// Routes +export * from './routes'; + +// Validation +export * from './validation'; + +// Entities +export * from './entities'; diff --git a/backend/src/modules/pricing/routes/index.ts b/backend/src/modules/pricing/routes/index.ts new file mode 100644 index 0000000..5aa87c7 --- /dev/null +++ b/backend/src/modules/pricing/routes/index.ts @@ -0,0 +1 @@ +export { default as pricingRoutes } from './pricing.routes'; diff --git a/backend/src/modules/pricing/routes/pricing.routes.ts b/backend/src/modules/pricing/routes/pricing.routes.ts new file mode 100644 index 0000000..3264204 --- /dev/null +++ b/backend/src/modules/pricing/routes/pricing.routes.ts @@ -0,0 +1,306 @@ +import { Router } from 'express'; +import { authMiddleware } from '../../auth/middleware/auth.middleware'; +import { branchMiddleware } from '../../branches/middleware/branch.middleware'; +import { validateRequest } from '../../../shared/middleware/validation.middleware'; +import { requirePermissions } from '../../../shared/middleware/permissions.middleware'; +import { + createPromotionSchema, + updatePromotionSchema, + addPromotionProductsSchema, + listPromotionsQuerySchema, + createCouponSchema, + updateCouponSchema, + generateBulkCouponsSchema, + redeemCouponSchema, + validateCouponSchema, + reverseRedemptionSchema, + listCouponsQuerySchema, + calculatePricesSchema, +} from '../validation/pricing.schema'; +import * as pricingController from '../controllers/pricing.controller'; + +const router = Router(); + +// All routes require authentication +router.use(authMiddleware); + +// ==================== PRICE ENGINE ROUTES ==================== + +/** + * Calculate prices for cart/order items + * Requires branch context + */ +router.post( + '/calculate', + branchMiddleware, + validateRequest(calculatePricesSchema), + requirePermissions('pricing:calculate'), + pricingController.calculatePrices +); + +/** + * Validate a coupon code + * Requires branch context + */ +router.post( + '/validate-coupon', + branchMiddleware, + validateRequest(validateCouponSchema), + requirePermissions('pricing:validate'), + pricingController.validateCoupon +); + +/** + * Get active promotions for current branch + * Requires branch context + */ +router.get( + '/active-promotions', + branchMiddleware, + requirePermissions('pricing:view'), + pricingController.getActivePromotions +); + +// ==================== PROMOTION ROUTES ==================== + +/** + * List promotions with filters + */ +router.get( + '/promotions', + validateRequest(listPromotionsQuerySchema, 'query'), + requirePermissions('promotions:view'), + pricingController.listPromotions +); + +/** + * Get promotion by ID + */ +router.get( + '/promotions/:id', + requirePermissions('promotions:view'), + pricingController.getPromotion +); + +/** + * Create a new promotion + */ +router.post( + '/promotions', + validateRequest(createPromotionSchema), + requirePermissions('promotions:create'), + pricingController.createPromotion +); + +/** + * Update a promotion + */ +router.put( + '/promotions/:id', + validateRequest(updatePromotionSchema), + requirePermissions('promotions:update'), + pricingController.updatePromotion +); + +/** + * Activate a promotion + */ +router.post( + '/promotions/:id/activate', + requirePermissions('promotions:activate'), + pricingController.activatePromotion +); + +/** + * Pause a promotion + */ +router.post( + '/promotions/:id/pause', + requirePermissions('promotions:update'), + pricingController.pausePromotion +); + +/** + * End a promotion + */ +router.post( + '/promotions/:id/end', + requirePermissions('promotions:update'), + pricingController.endPromotion +); + +/** + * Cancel a promotion + */ +router.post( + '/promotions/:id/cancel', + requirePermissions('promotions:delete'), + pricingController.cancelPromotion +); + +/** + * Add products to a promotion + */ +router.post( + '/promotions/:id/products', + validateRequest(addPromotionProductsSchema), + requirePermissions('promotions:update'), + pricingController.addPromotionProducts +); + +/** + * Remove product from a promotion + */ +router.delete( + '/promotions/:id/products/:productId', + requirePermissions('promotions:update'), + pricingController.removePromotionProduct +); + +/** + * Delete a promotion (soft delete) + */ +router.delete( + '/promotions/:id', + requirePermissions('promotions:delete'), + pricingController.deletePromotion +); + +// ==================== COUPON ROUTES ==================== + +/** + * List coupons with filters + */ +router.get( + '/coupons', + validateRequest(listCouponsQuerySchema, 'query'), + requirePermissions('coupons:view'), + pricingController.listCoupons +); + +/** + * Get coupon by ID + */ +router.get( + '/coupons/:id', + requirePermissions('coupons:view'), + pricingController.getCoupon +); + +/** + * Get coupon by code + */ +router.get( + '/coupons/code/:code', + requirePermissions('coupons:view'), + pricingController.getCouponByCode +); + +/** + * Create a new coupon + */ +router.post( + '/coupons', + validateRequest(createCouponSchema), + requirePermissions('coupons:create'), + pricingController.createCoupon +); + +/** + * Generate bulk coupons + */ +router.post( + '/coupons/bulk', + validateRequest(generateBulkCouponsSchema), + requirePermissions('coupons:create'), + pricingController.generateBulkCoupons +); + +/** + * Update a coupon + */ +router.put( + '/coupons/:id', + validateRequest(updateCouponSchema), + requirePermissions('coupons:update'), + pricingController.updateCoupon +); + +/** + * Activate a coupon + */ +router.post( + '/coupons/:id/activate', + requirePermissions('coupons:activate'), + pricingController.activateCoupon +); + +/** + * Deactivate a coupon + */ +router.post( + '/coupons/:id/deactivate', + requirePermissions('coupons:update'), + pricingController.deactivateCoupon +); + +/** + * Redeem a coupon by ID + */ +router.post( + '/coupons/:id/redeem', + branchMiddleware, + validateRequest(redeemCouponSchema), + requirePermissions('coupons:redeem'), + pricingController.redeemCoupon +); + +/** + * Redeem coupon by code + */ +router.post( + '/coupons/redeem', + branchMiddleware, + validateRequest(redeemCouponSchema), + requirePermissions('coupons:redeem'), + pricingController.redeemCouponByCode +); + +/** + * Reverse a coupon redemption + */ +router.post( + '/coupons/redemptions/:redemptionId/reverse', + validateRequest(reverseRedemptionSchema), + requirePermissions('coupons:reverse'), + pricingController.reverseRedemption +); + +/** + * Get coupon redemption history + */ +router.get( + '/coupons/:id/redemptions', + requirePermissions('coupons:view'), + pricingController.getCouponRedemptions +); + +/** + * Get coupon statistics + */ +router.get( + '/coupons/:id/stats', + requirePermissions('coupons:view'), + pricingController.getCouponStats +); + +/** + * Delete a coupon (soft delete) + */ +router.delete( + '/coupons/:id', + requirePermissions('coupons:delete'), + pricingController.deleteCoupon +); + +export default router; diff --git a/backend/src/modules/pricing/services/coupon.service.ts b/backend/src/modules/pricing/services/coupon.service.ts new file mode 100644 index 0000000..ef39e3b --- /dev/null +++ b/backend/src/modules/pricing/services/coupon.service.ts @@ -0,0 +1,643 @@ +import { Repository, DataSource, Between } from 'typeorm'; +import { BaseService } from '../../../shared/services/base.service'; +import { ServiceResult, QueryOptions } from '../../../shared/types'; +import { Coupon, CouponStatus, CouponType, CouponScope } from '../entities/coupon.entity'; +import { CouponRedemption, RedemptionStatus } from '../entities/coupon-redemption.entity'; + +export interface CreateCouponInput { + code: string; + name: string; + description?: string; + type: CouponType; + scope?: CouponScope; + discountPercent?: number; + discountAmount?: number; + maxDiscount?: number; + freeProductId?: string; + freeProductQuantity?: number; + validFrom: Date; + validUntil?: Date; + maxUses?: number; + maxUsesPerCustomer?: number; + minOrderAmount?: number; + minItems?: number; + appliesToCategories?: string[]; + appliesToProducts?: string[]; + excludedProducts?: string[]; + includedBranches?: string[]; + customerSpecific?: boolean; + allowedCustomers?: string[]; + customerLevels?: string[]; + newCustomersOnly?: boolean; + firstOrderOnly?: boolean; + campaignId?: string; + campaignName?: string; + stackableWithPromotions?: boolean; + stackableWithCoupons?: boolean; + posEnabled?: boolean; + ecommerceEnabled?: boolean; + isSingleUse?: boolean; + autoApply?: boolean; + publicVisible?: boolean; + termsAndConditions?: string; +} + +export interface RedeemCouponInput { + orderId: string; + orderNumber: string; + customerId?: string; + branchId: string; + orderAmount: number; + discountApplied: number; + channel: 'pos' | 'ecommerce' | 'mobile'; + ipAddress?: string; +} + +export interface CouponQueryOptions extends QueryOptions { + status?: CouponStatus; + type?: CouponType; + campaignId?: string; + branchId?: string; + activeOnly?: boolean; + publicOnly?: boolean; +} + +export class CouponService extends BaseService { + constructor( + repository: Repository, + private readonly redemptionRepository: Repository, + private readonly dataSource: DataSource + ) { + super(repository); + } + + /** + * Generate unique coupon code + */ + async generateCode(tenantId: string, prefix: string = 'CPN'): Promise { + const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'; + let code: string; + let exists = true; + + while (exists) { + let random = ''; + for (let i = 0; i < 8; i++) { + random += chars.charAt(Math.floor(Math.random() * chars.length)); + } + code = `${prefix}-${random}`; + + const existing = await this.repository.findOne({ + where: { tenantId, code }, + }); + exists = !!existing; + } + + return code!; + } + + /** + * Create a new coupon + */ + async createCoupon( + tenantId: string, + input: CreateCouponInput, + userId: string + ): Promise> { + // Check for duplicate code + const existing = await this.repository.findOne({ + where: { tenantId, code: input.code.toUpperCase() }, + }); + + if (existing) { + return { + success: false, + error: { code: 'DUPLICATE_CODE', message: 'Coupon code already exists' }, + }; + } + + const coupon = this.repository.create({ + tenantId, + code: input.code.toUpperCase(), + name: input.name, + description: input.description, + type: input.type, + scope: input.scope ?? CouponScope.ORDER, + status: CouponStatus.ACTIVE, + discountPercent: input.discountPercent, + discountAmount: input.discountAmount, + maxDiscount: input.maxDiscount, + freeProductId: input.freeProductId, + freeProductQuantity: input.freeProductQuantity ?? 1, + validFrom: input.validFrom, + validUntil: input.validUntil, + maxUses: input.maxUses, + maxUsesPerCustomer: input.maxUsesPerCustomer ?? 1, + minOrderAmount: input.minOrderAmount, + minItems: input.minItems, + appliesToCategories: input.appliesToCategories, + appliesToProducts: input.appliesToProducts, + excludedProducts: input.excludedProducts, + includedBranches: input.includedBranches, + customerSpecific: input.customerSpecific ?? false, + allowedCustomers: input.allowedCustomers, + customerLevels: input.customerLevels, + newCustomersOnly: input.newCustomersOnly ?? false, + firstOrderOnly: input.firstOrderOnly ?? false, + campaignId: input.campaignId, + campaignName: input.campaignName, + stackableWithPromotions: input.stackableWithPromotions ?? false, + stackableWithCoupons: input.stackableWithCoupons ?? false, + posEnabled: input.posEnabled ?? true, + ecommerceEnabled: input.ecommerceEnabled ?? true, + isSingleUse: input.isSingleUse ?? false, + autoApply: input.autoApply ?? false, + publicVisible: input.publicVisible ?? false, + termsAndConditions: input.termsAndConditions, + createdBy: userId, + }); + + const saved = await this.repository.save(coupon); + return { success: true, data: saved }; + } + + /** + * Generate bulk coupons for a campaign + */ + async generateBulkCoupons( + tenantId: string, + baseInput: Omit, + quantity: number, + prefix: string, + userId: string + ): Promise> { + const queryRunner = this.dataSource.createQueryRunner(); + await queryRunner.connect(); + await queryRunner.startTransaction(); + + try { + const coupons: Coupon[] = []; + + for (let i = 0; i < quantity; i++) { + const code = await this.generateCode(tenantId, prefix); + + const coupon = queryRunner.manager.create(Coupon, { + tenantId, + code, + ...baseInput, + status: CouponStatus.ACTIVE, + isSingleUse: true, + createdBy: userId, + }); + + const saved = await queryRunner.manager.save(coupon); + coupons.push(saved); + } + + await queryRunner.commitTransaction(); + return { success: true, data: coupons }; + } catch (error) { + await queryRunner.rollbackTransaction(); + return { + success: false, + error: { + code: 'BULK_CREATE_ERROR', + message: error instanceof Error ? error.message : 'Failed to create bulk coupons', + }, + }; + } finally { + await queryRunner.release(); + } + } + + /** + * Update coupon + */ + async updateCoupon( + tenantId: string, + id: string, + input: Partial, + userId: string + ): Promise> { + const coupon = await this.repository.findOne({ + where: { id, tenantId }, + }); + + if (!coupon) { + return { + success: false, + error: { code: 'NOT_FOUND', message: 'Coupon not found' }, + }; + } + + // Don't allow changing code + delete input.code; + + Object.assign(coupon, input); + const saved = await this.repository.save(coupon); + + return { success: true, data: saved }; + } + + /** + * Deactivate coupon + */ + async deactivateCoupon( + tenantId: string, + id: string, + userId: string + ): Promise> { + const coupon = await this.repository.findOne({ + where: { id, tenantId }, + }); + + if (!coupon) { + return { + success: false, + error: { code: 'NOT_FOUND', message: 'Coupon not found' }, + }; + } + + coupon.status = CouponStatus.INACTIVE; + const saved = await this.repository.save(coupon); + + return { success: true, data: saved }; + } + + /** + * Record coupon redemption + */ + async redeemCoupon( + tenantId: string, + couponCode: string, + input: RedeemCouponInput, + userId: string + ): Promise> { + const queryRunner = this.dataSource.createQueryRunner(); + await queryRunner.connect(); + await queryRunner.startTransaction(); + + try { + const coupon = await queryRunner.manager.findOne(Coupon, { + where: { tenantId, code: couponCode.toUpperCase() }, + }); + + if (!coupon) { + return { + success: false, + error: { code: 'NOT_FOUND', message: 'Coupon not found' }, + }; + } + + // Check if already redeemed for this order + const existingRedemption = await queryRunner.manager.findOne(CouponRedemption, { + where: { tenantId, couponId: coupon.id, orderId: input.orderId }, + }); + + if (existingRedemption) { + return { + success: false, + error: { code: 'ALREADY_REDEEMED', message: 'Coupon already redeemed for this order' }, + }; + } + + // Check customer usage limit + if (input.customerId && coupon.maxUsesPerCustomer) { + const customerUses = await queryRunner.manager.count(CouponRedemption, { + where: { + tenantId, + couponId: coupon.id, + customerId: input.customerId, + status: RedemptionStatus.COMPLETED, + }, + }); + + if (customerUses >= coupon.maxUsesPerCustomer) { + return { + success: false, + error: { code: 'MAX_USES_REACHED', message: 'Customer usage limit reached' }, + }; + } + } + + // Create redemption + const redemption = queryRunner.manager.create(CouponRedemption, { + tenantId, + couponId: coupon.id, + couponCode: coupon.code, + orderId: input.orderId, + orderNumber: input.orderNumber, + customerId: input.customerId, + branchId: input.branchId, + status: RedemptionStatus.APPLIED, + orderAmount: input.orderAmount, + discountApplied: input.discountApplied, + discountType: coupon.type, + discountPercent: coupon.discountPercent, + discountAmount: coupon.discountAmount, + freeProductId: coupon.freeProductId, + freeProductQuantity: coupon.freeProductQuantity, + channel: input.channel, + ipAddress: input.ipAddress, + appliedBy: userId, + }); + + const savedRedemption = await queryRunner.manager.save(redemption); + + // Increment coupon usage + coupon.currentUses += 1; + + // Check if depleted + if (coupon.maxUses && coupon.currentUses >= coupon.maxUses) { + coupon.status = CouponStatus.DEPLETED; + } + + // Mark as inactive if single use + if (coupon.isSingleUse) { + coupon.status = CouponStatus.DEPLETED; + } + + await queryRunner.manager.save(coupon); + await queryRunner.commitTransaction(); + + return { success: true, data: savedRedemption }; + } catch (error) { + await queryRunner.rollbackTransaction(); + return { + success: false, + error: { + code: 'REDEEM_ERROR', + message: error instanceof Error ? error.message : 'Failed to redeem coupon', + }, + }; + } finally { + await queryRunner.release(); + } + } + + /** + * Complete redemption (after order is finalized) + */ + async completeRedemption( + tenantId: string, + redemptionId: string + ): Promise> { + const redemption = await this.redemptionRepository.findOne({ + where: { id: redemptionId, tenantId }, + }); + + if (!redemption) { + return { + success: false, + error: { code: 'NOT_FOUND', message: 'Redemption not found' }, + }; + } + + redemption.status = RedemptionStatus.COMPLETED; + const saved = await this.redemptionRepository.save(redemption); + + return { success: true, data: saved }; + } + + /** + * Reverse redemption (e.g., order cancelled) + */ + async reverseRedemption( + tenantId: string, + redemptionId: string, + reason: string, + userId: string + ): Promise> { + const queryRunner = this.dataSource.createQueryRunner(); + await queryRunner.connect(); + await queryRunner.startTransaction(); + + try { + const redemption = await queryRunner.manager.findOne(CouponRedemption, { + where: { id: redemptionId, tenantId }, + }); + + if (!redemption) { + return { + success: false, + error: { code: 'NOT_FOUND', message: 'Redemption not found' }, + }; + } + + if (redemption.status === RedemptionStatus.REVERSED) { + return { + success: false, + error: { code: 'ALREADY_REVERSED', message: 'Redemption is already reversed' }, + }; + } + + redemption.status = RedemptionStatus.REVERSED; + redemption.reversedAt = new Date(); + redemption.reversedBy = userId; + redemption.reversalReason = reason; + + await queryRunner.manager.save(redemption); + + // Restore coupon usage + const coupon = await queryRunner.manager.findOne(Coupon, { + where: { id: redemption.couponId, tenantId }, + }); + + if (coupon) { + coupon.currentUses = Math.max(0, coupon.currentUses - 1); + + // Reactivate if was depleted + if (coupon.status === CouponStatus.DEPLETED && !coupon.isSingleUse) { + coupon.status = CouponStatus.ACTIVE; + } + + await queryRunner.manager.save(coupon); + } + + await queryRunner.commitTransaction(); + return { success: true, data: redemption }; + } catch (error) { + await queryRunner.rollbackTransaction(); + return { + success: false, + error: { + code: 'REVERSE_ERROR', + message: error instanceof Error ? error.message : 'Failed to reverse redemption', + }, + }; + } finally { + await queryRunner.release(); + } + } + + /** + * Find coupons with filters + */ + async findCoupons( + tenantId: string, + options: CouponQueryOptions + ): Promise<{ data: Coupon[]; total: number }> { + const qb = this.repository.createQueryBuilder('coupon') + .where('coupon.tenantId = :tenantId', { tenantId }); + + if (options.status) { + qb.andWhere('coupon.status = :status', { status: options.status }); + } + + if (options.type) { + qb.andWhere('coupon.type = :type', { type: options.type }); + } + + if (options.campaignId) { + qb.andWhere('coupon.campaignId = :campaignId', { campaignId: options.campaignId }); + } + + if (options.branchId) { + qb.andWhere('(coupon.includedBranches IS NULL OR coupon.includedBranches @> :branchArray)', { + branchArray: JSON.stringify([options.branchId]), + }); + } + + if (options.activeOnly) { + const now = new Date(); + qb.andWhere('coupon.status = :activeStatus', { activeStatus: CouponStatus.ACTIVE }) + .andWhere('coupon.validFrom <= :now', { now }) + .andWhere('(coupon.validUntil IS NULL OR coupon.validUntil >= :now)', { now }); + } + + if (options.publicOnly) { + qb.andWhere('coupon.publicVisible = true'); + } + + const page = options.page ?? 1; + const limit = options.limit ?? 20; + qb.skip((page - 1) * limit).take(limit); + qb.orderBy('coupon.createdAt', 'DESC'); + + const [data, total] = await qb.getManyAndCount(); + return { data, total }; + } + + /** + * Get coupon by code + */ + async getCouponByCode( + tenantId: string, + code: string + ): Promise { + return this.repository.findOne({ + where: { tenantId, code: code.toUpperCase() }, + }); + } + + /** + * Get redemption history for a coupon + */ + async getRedemptionHistory( + tenantId: string, + couponId: string, + options?: { page?: number; limit?: number } + ): Promise<{ data: CouponRedemption[]; total: number }> { + const page = options?.page ?? 1; + const limit = options?.limit ?? 20; + + const [data, total] = await this.redemptionRepository.findAndCount({ + where: { tenantId, couponId }, + order: { createdAt: 'DESC' }, + skip: (page - 1) * limit, + take: limit, + }); + + return { data, total }; + } + + /** + * Get customer redemption history + */ + async getCustomerRedemptions( + tenantId: string, + customerId: string, + options?: { page?: number; limit?: number } + ): Promise<{ data: CouponRedemption[]; total: number }> { + const page = options?.page ?? 1; + const limit = options?.limit ?? 20; + + const [data, total] = await this.redemptionRepository.findAndCount({ + where: { tenantId, customerId }, + relations: ['coupon'], + order: { createdAt: 'DESC' }, + skip: (page - 1) * limit, + take: limit, + }); + + return { data, total }; + } + + /** + * Get coupon statistics + */ + async getCouponStats( + tenantId: string, + couponId: string + ): Promise<{ + totalRedemptions: number; + completedRedemptions: number; + reversedRedemptions: number; + totalDiscountGiven: number; + remainingUses: number | null; + }> { + const coupon = await this.repository.findOne({ + where: { id: couponId, tenantId }, + }); + + if (!coupon) { + return { + totalRedemptions: 0, + completedRedemptions: 0, + reversedRedemptions: 0, + totalDiscountGiven: 0, + remainingUses: null, + }; + } + + const redemptions = await this.redemptionRepository.find({ + where: { tenantId, couponId }, + }); + + const completedRedemptions = redemptions.filter( + r => r.status === RedemptionStatus.COMPLETED + ); + + const totalDiscountGiven = completedRedemptions.reduce( + (sum, r) => sum + Number(r.discountApplied), + 0 + ); + + return { + totalRedemptions: redemptions.length, + completedRedemptions: completedRedemptions.length, + reversedRedemptions: redemptions.filter(r => r.status === RedemptionStatus.REVERSED).length, + totalDiscountGiven, + remainingUses: coupon.maxUses ? coupon.maxUses - coupon.currentUses : null, + }; + } + + /** + * Expire outdated coupons + */ + async expireOutdatedCoupons(tenantId: string): Promise { + const now = new Date(); + + const result = await this.repository.update( + { + tenantId, + status: CouponStatus.ACTIVE, + validUntil: now, + }, + { + status: CouponStatus.EXPIRED, + } + ); + + return result.affected ?? 0; + } +} diff --git a/backend/src/modules/pricing/services/index.ts b/backend/src/modules/pricing/services/index.ts new file mode 100644 index 0000000..f3820b2 --- /dev/null +++ b/backend/src/modules/pricing/services/index.ts @@ -0,0 +1,3 @@ +export * from './price-engine.service'; +export * from './promotion.service'; +export * from './coupon.service'; diff --git a/backend/src/modules/pricing/services/price-engine.service.ts b/backend/src/modules/pricing/services/price-engine.service.ts new file mode 100644 index 0000000..b9b3a18 --- /dev/null +++ b/backend/src/modules/pricing/services/price-engine.service.ts @@ -0,0 +1,725 @@ +import { Repository, LessThanOrEqual, MoreThanOrEqual, In } from 'typeorm'; +import { + Promotion, + PromotionStatus, + PromotionType, + DiscountApplication, +} from '../entities/promotion.entity'; +import { PromotionProduct, ProductRole } from '../entities/promotion-product.entity'; +import { Coupon, CouponStatus, CouponType, CouponScope } from '../entities/coupon.entity'; + +export interface LineItem { + productId: string; + productCode: string; + productName: string; + variantId?: string; + categoryId?: string; + quantity: number; + unitPrice: number; + originalPrice: number; +} + +export interface PriceContext { + tenantId: string; + branchId: string; + customerId?: string; + customerLevelId?: string; + isNewCustomer?: boolean; + isLoyaltyMember?: boolean; + isFirstOrder?: boolean; + channel: 'pos' | 'ecommerce' | 'mobile'; +} + +export interface AppliedDiscount { + type: 'promotion' | 'coupon'; + id: string; + code: string; + name: string; + discountType: PromotionType | CouponType; + discountPercent?: number; + discountAmount?: number; + appliedToLineIndex?: number; + lineDiscount?: number; + orderDiscount?: number; + freeProductId?: string; + freeProductQuantity?: number; +} + +export interface PricedLine extends LineItem { + lineIndex: number; + subtotal: number; + discountAmount: number; + discountPercent: number; + finalPrice: number; + appliedPromotions: string[]; +} + +export interface PricingResult { + lines: PricedLine[]; + subtotal: number; + totalLineDiscounts: number; + orderDiscount: number; + totalDiscount: number; + total: number; + appliedDiscounts: AppliedDiscount[]; + freeProducts: { + productId: string; + quantity: number; + sourcePromotion?: string; + sourceCoupon?: string; + }[]; + errors: string[]; +} + +export class PriceEngineService { + constructor( + private readonly promotionRepository: Repository, + private readonly promotionProductRepository: Repository, + private readonly couponRepository: Repository + ) {} + + /** + * Get active promotions for a tenant/branch + */ + async getActivePromotions( + tenantId: string, + branchId: string, + channel: 'pos' | 'ecommerce' | 'mobile' + ): Promise { + const now = new Date(); + const today = now.toLocaleDateString('en-US', { weekday: 'lowercase' }); + const currentTime = now.toTimeString().slice(0, 5); + + const qb = this.promotionRepository.createQueryBuilder('p') + .leftJoinAndSelect('p.products', 'products') + .where('p.tenantId = :tenantId', { tenantId }) + .andWhere('p.status = :status', { status: PromotionStatus.ACTIVE }) + .andWhere('p.startDate <= :now', { now }) + .andWhere('(p.endDate IS NULL OR p.endDate >= :now)', { now }); + + // Channel filter + if (channel === 'pos') { + qb.andWhere('p.posEnabled = true'); + } else { + qb.andWhere('p.ecommerceEnabled = true'); + } + + // Branch filter + qb.andWhere('(p.includedBranches IS NULL OR p.includedBranches @> :branchArray)', { + branchArray: JSON.stringify([branchId]), + }); + + qb.orderBy('p.priority', 'DESC'); + + const promotions = await qb.getMany(); + + // Filter by day and time restrictions + return promotions.filter(p => { + // Check day restrictions + if (p.validDays && p.validDays.length > 0) { + if (!p.validDays.includes(today)) return false; + } + + // Check time restrictions + if (p.validHoursStart && p.validHoursEnd) { + if (currentTime < p.validHoursStart || currentTime > p.validHoursEnd) { + return false; + } + } + + // Check usage limits + if (p.maxUsesTotal && p.currentUses >= p.maxUsesTotal) { + return false; + } + + return true; + }); + } + + /** + * Check if promotion applies to a line item + */ + private promotionAppliesToProduct( + promotion: Promotion, + line: LineItem + ): boolean { + // Check if all products + if (promotion.appliesToAllProducts) { + // Check excluded categories + if (promotion.excludedCategories?.includes(line.categoryId!)) { + return false; + } + // Check excluded products + if (promotion.excludedProducts?.includes(line.productId)) { + return false; + } + return true; + } + + // Check included categories + if (promotion.includedCategories?.includes(line.categoryId!)) { + return !promotion.excludedProducts?.includes(line.productId); + } + + // Check promotion products + const promotionProduct = promotion.products?.find( + pp => pp.productId === line.productId && + (!pp.variantId || pp.variantId === line.variantId) + ); + + return !!promotionProduct; + } + + /** + * Check if customer meets promotion requirements + */ + private customerMeetsRequirements( + promotion: Promotion, + context: PriceContext + ): boolean { + if (promotion.newCustomersOnly && !context.isNewCustomer) { + return false; + } + + if (promotion.loyaltyMembersOnly && !context.isLoyaltyMember) { + return false; + } + + if (promotion.customerLevels && promotion.customerLevels.length > 0) { + if (!context.customerLevelId || !promotion.customerLevels.includes(context.customerLevelId)) { + return false; + } + } + + return true; + } + + /** + * Calculate line discount from promotion + */ + private calculateLineDiscount( + promotion: Promotion, + line: LineItem, + promotionProduct?: PromotionProduct + ): number { + const lineSubtotal = line.unitPrice * line.quantity; + + // Use product-specific discount if available + const discountPercent = promotionProduct?.discountPercent ?? promotion.discountPercent; + const discountAmount = promotionProduct?.discountAmount ?? promotion.discountAmount; + const fixedPrice = promotionProduct?.fixedPrice; + + if (fixedPrice !== undefined && fixedPrice !== null) { + return Math.max(0, lineSubtotal - (fixedPrice * line.quantity)); + } + + if (discountPercent) { + let discount = lineSubtotal * (Number(discountPercent) / 100); + if (promotion.maxDiscount) { + discount = Math.min(discount, Number(promotion.maxDiscount)); + } + return discount; + } + + if (discountAmount) { + return Math.min(Number(discountAmount) * line.quantity, lineSubtotal); + } + + return 0; + } + + /** + * Apply Buy X Get Y promotion + */ + private applyBuyXGetY( + promotion: Promotion, + lines: LineItem[] + ): { discounts: Map; freeProducts: { productId: string; quantity: number }[] } { + const discounts = new Map(); + const freeProducts: { productId: string; quantity: number }[] = []; + + const triggerProducts = promotion.products?.filter( + pp => pp.role === ProductRole.TRIGGER || pp.role === ProductRole.BOTH + ) || []; + + const targetProducts = promotion.products?.filter( + pp => pp.role === ProductRole.TARGET || pp.role === ProductRole.BOTH + ) || []; + + // Count trigger quantity + let triggerCount = 0; + for (const line of lines) { + const isTrigger = triggerProducts.some(tp => + tp.productId === line.productId && + (!tp.variantId || tp.variantId === line.variantId) + ); + if (isTrigger) { + triggerCount += line.quantity; + } + } + + // Calculate how many times promotion can be applied + const timesApplicable = Math.floor(triggerCount / (promotion.buyQuantity ?? 1)); + const getFreeQuantity = timesApplicable * (promotion.getQuantity ?? 1); + + if (getFreeQuantity <= 0) return { discounts, freeProducts }; + + // Apply discount to target products + let remainingFree = getFreeQuantity; + const getDiscountPercent = promotion.getDiscountPercent ?? 100; + + for (let i = 0; i < lines.length && remainingFree > 0; i++) { + const line = lines[i]; + const isTarget = targetProducts.some(tp => + tp.productId === line.productId && + (!tp.variantId || tp.variantId === line.variantId) + ); + + if (isTarget) { + const qtyToDiscount = Math.min(remainingFree, line.quantity); + const discountAmount = line.unitPrice * qtyToDiscount * (getDiscountPercent / 100); + discounts.set(i, (discounts.get(i) ?? 0) + discountAmount); + remainingFree -= qtyToDiscount; + } + } + + return { discounts, freeProducts }; + } + + /** + * Apply quantity tier discount + */ + private applyQuantityTierDiscount( + promotion: Promotion, + line: LineItem + ): number { + if (!promotion.quantityTiers || promotion.quantityTiers.length === 0) { + return 0; + } + + // Find applicable tier + const sortedTiers = [...promotion.quantityTiers].sort( + (a, b) => b.minQuantity - a.minQuantity + ); + + const applicableTier = sortedTiers.find(tier => line.quantity >= tier.minQuantity); + + if (!applicableTier) return 0; + + const lineSubtotal = line.unitPrice * line.quantity; + return lineSubtotal * (applicableTier.discountPercent / 100); + } + + /** + * Calculate prices with promotions and optional coupon + */ + async calculatePrices( + context: PriceContext, + lines: LineItem[], + couponCode?: string + ): Promise { + const result: PricingResult = { + lines: [], + subtotal: 0, + totalLineDiscounts: 0, + orderDiscount: 0, + totalDiscount: 0, + total: 0, + appliedDiscounts: [], + freeProducts: [], + errors: [], + }; + + // Initialize priced lines + let lineIndex = 0; + for (const line of lines) { + const subtotal = line.unitPrice * line.quantity; + result.subtotal += subtotal; + result.lines.push({ + ...line, + lineIndex, + subtotal, + discountAmount: 0, + discountPercent: 0, + finalPrice: subtotal, + appliedPromotions: [], + }); + lineIndex++; + } + + // Get active promotions + const promotions = await this.getActivePromotions( + context.tenantId, + context.branchId, + context.channel + ); + + // Track which promotions can stack + const appliedNonStackable: string[] = []; + + // Apply promotions + for (const promotion of promotions) { + // Check stackability + if (!promotion.stackable && appliedNonStackable.length > 0) { + continue; + } + + // Check customer requirements + if (!this.customerMeetsRequirements(promotion, context)) { + continue; + } + + // Check minimum order amount + if (promotion.minOrderAmount && result.subtotal < Number(promotion.minOrderAmount)) { + continue; + } + + // Apply based on type + switch (promotion.type) { + case PromotionType.PERCENTAGE_DISCOUNT: + case PromotionType.FIXED_DISCOUNT: + this.applyLinePromotions(promotion, result, context); + break; + + case PromotionType.BUY_X_GET_Y: + const buyXGetY = this.applyBuyXGetY(promotion, lines); + for (const [idx, discount] of buyXGetY.discounts) { + result.lines[idx].discountAmount += discount; + result.lines[idx].appliedPromotions.push(promotion.code); + } + result.freeProducts.push(...buyXGetY.freeProducts.map(fp => ({ + ...fp, + sourcePromotion: promotion.code, + }))); + break; + + case PromotionType.QUANTITY_DISCOUNT: + for (const pricedLine of result.lines) { + if (this.promotionAppliesToProduct(promotion, pricedLine)) { + const discount = this.applyQuantityTierDiscount(promotion, pricedLine); + if (discount > 0) { + pricedLine.discountAmount += discount; + pricedLine.appliedPromotions.push(promotion.code); + } + } + } + break; + + case PromotionType.FLASH_SALE: + // Same as percentage/fixed but time-limited (already filtered) + this.applyLinePromotions(promotion, result, context); + break; + } + + // Track non-stackable + if (!promotion.stackable) { + appliedNonStackable.push(promotion.id); + } + + // Record applied discount + if (result.lines.some(l => l.appliedPromotions.includes(promotion.code))) { + result.appliedDiscounts.push({ + type: 'promotion', + id: promotion.id, + code: promotion.code, + name: promotion.name, + discountType: promotion.type, + discountPercent: promotion.discountPercent ? Number(promotion.discountPercent) : undefined, + discountAmount: promotion.discountAmount ? Number(promotion.discountAmount) : undefined, + }); + } + } + + // Apply coupon if provided + if (couponCode) { + const couponResult = await this.applyCoupon(context, result, couponCode); + if (couponResult.error) { + result.errors.push(couponResult.error); + } + } + + // Calculate totals + for (const line of result.lines) { + line.discountPercent = line.subtotal > 0 + ? (line.discountAmount / line.subtotal) * 100 + : 0; + line.finalPrice = line.subtotal - line.discountAmount; + result.totalLineDiscounts += line.discountAmount; + } + + result.totalDiscount = result.totalLineDiscounts + result.orderDiscount; + result.total = result.subtotal - result.totalDiscount; + + return result; + } + + /** + * Apply line-level promotions + */ + private applyLinePromotions( + promotion: Promotion, + result: PricingResult, + context: PriceContext + ): void { + for (const pricedLine of result.lines) { + if (!this.promotionAppliesToProduct(promotion, pricedLine)) { + continue; + } + + // Check minimum quantity + if (promotion.minQuantity && pricedLine.quantity < promotion.minQuantity) { + continue; + } + + const promotionProduct = promotion.products?.find( + pp => pp.productId === pricedLine.productId + ); + + const discount = this.calculateLineDiscount(promotion, pricedLine, promotionProduct); + + if (discount > 0) { + pricedLine.discountAmount += discount; + pricedLine.appliedPromotions.push(promotion.code); + } + } + } + + /** + * Validate and apply coupon + */ + async applyCoupon( + context: PriceContext, + result: PricingResult, + couponCode: string + ): Promise<{ success: boolean; error?: string }> { + const coupon = await this.couponRepository.findOne({ + where: { + tenantId: context.tenantId, + code: couponCode.toUpperCase(), + }, + }); + + if (!coupon) { + return { success: false, error: 'Coupon not found' }; + } + + // Validate status + if (coupon.status !== CouponStatus.ACTIVE) { + return { success: false, error: `Coupon is ${coupon.status}` }; + } + + // Validate dates + const now = new Date(); + if (coupon.validFrom > now) { + return { success: false, error: 'Coupon is not yet valid' }; + } + if (coupon.validUntil && coupon.validUntil < now) { + return { success: false, error: 'Coupon has expired' }; + } + + // Validate usage + if (coupon.maxUses && coupon.currentUses >= coupon.maxUses) { + return { success: false, error: 'Coupon usage limit reached' }; + } + + // Validate channel + if (context.channel === 'pos' && !coupon.posEnabled) { + return { success: false, error: 'Coupon not valid for POS' }; + } + if (context.channel !== 'pos' && !coupon.ecommerceEnabled) { + return { success: false, error: 'Coupon not valid for e-commerce' }; + } + + // Validate branch + if (coupon.includedBranches && !coupon.includedBranches.includes(context.branchId)) { + return { success: false, error: 'Coupon not valid for this branch' }; + } + + // Validate customer restrictions + if (coupon.customerSpecific) { + if (!context.customerId || !coupon.allowedCustomers?.includes(context.customerId)) { + return { success: false, error: 'Coupon not valid for this customer' }; + } + } + + if (coupon.newCustomersOnly && !context.isNewCustomer) { + return { success: false, error: 'Coupon is for new customers only' }; + } + + if (coupon.firstOrderOnly && !context.isFirstOrder) { + return { success: false, error: 'Coupon is for first order only' }; + } + + if (coupon.customerLevels && coupon.customerLevels.length > 0) { + if (!context.customerLevelId || !coupon.customerLevels.includes(context.customerLevelId)) { + return { success: false, error: 'Coupon not valid for your membership level' }; + } + } + + // Validate minimum order + if (coupon.minOrderAmount && result.subtotal < Number(coupon.minOrderAmount)) { + return { + success: false, + error: `Minimum order amount is ${coupon.minOrderAmount}`, + }; + } + + // Validate minimum items + const totalItems = result.lines.reduce((sum, l) => sum + l.quantity, 0); + if (coupon.minItems && totalItems < coupon.minItems) { + return { success: false, error: `Minimum ${coupon.minItems} items required` }; + } + + // Check stackability + if (!coupon.stackableWithPromotions && result.appliedDiscounts.some(d => d.type === 'promotion')) { + return { success: false, error: 'Coupon cannot be combined with promotions' }; + } + + // Calculate discount + let discountApplied = 0; + + switch (coupon.type) { + case CouponType.PERCENTAGE: + if (coupon.scope === CouponScope.ORDER) { + discountApplied = result.subtotal * (Number(coupon.discountPercent) / 100); + } else { + // Apply to applicable products only + for (const line of result.lines) { + if (this.couponAppliesToLine(coupon, line)) { + const lineDiscount = line.subtotal * (Number(coupon.discountPercent) / 100); + line.discountAmount += lineDiscount; + discountApplied += lineDiscount; + } + } + } + break; + + case CouponType.FIXED_AMOUNT: + discountApplied = Math.min(Number(coupon.discountAmount), result.subtotal); + break; + + case CouponType.FREE_SHIPPING: + // Would be handled in shipping calculation + break; + + case CouponType.FREE_PRODUCT: + if (coupon.freeProductId) { + result.freeProducts.push({ + productId: coupon.freeProductId, + quantity: coupon.freeProductQuantity ?? 1, + sourceCoupon: coupon.code, + }); + } + break; + } + + // Apply max discount cap + if (coupon.maxDiscount && discountApplied > Number(coupon.maxDiscount)) { + discountApplied = Number(coupon.maxDiscount); + } + + // Add to order discount or line discounts + if (coupon.scope === CouponScope.ORDER) { + result.orderDiscount += discountApplied; + } + + // Record applied coupon + result.appliedDiscounts.push({ + type: 'coupon', + id: coupon.id, + code: coupon.code, + name: coupon.name, + discountType: coupon.type, + discountPercent: coupon.discountPercent ? Number(coupon.discountPercent) : undefined, + discountAmount: coupon.discountAmount ? Number(coupon.discountAmount) : undefined, + orderDiscount: coupon.scope === CouponScope.ORDER ? discountApplied : undefined, + freeProductId: coupon.freeProductId, + freeProductQuantity: coupon.freeProductQuantity, + }); + + return { success: true }; + } + + /** + * Check if coupon applies to a line item + */ + private couponAppliesToLine(coupon: Coupon, line: LineItem): boolean { + // Check excluded products + if (coupon.excludedProducts?.includes(line.productId)) { + return false; + } + + // Check specific products + if (coupon.appliesToProducts && coupon.appliesToProducts.length > 0) { + return coupon.appliesToProducts.includes(line.productId); + } + + // Check categories + if (coupon.appliesToCategories && coupon.appliesToCategories.length > 0) { + return line.categoryId ? coupon.appliesToCategories.includes(line.categoryId) : false; + } + + // Applies to all + return true; + } + + /** + * Validate coupon code without applying + */ + async validateCoupon( + context: PriceContext, + couponCode: string, + orderAmount: number + ): Promise<{ + valid: boolean; + coupon?: Coupon; + error?: string; + potentialDiscount?: number; + }> { + const coupon = await this.couponRepository.findOne({ + where: { + tenantId: context.tenantId, + code: couponCode.toUpperCase(), + }, + }); + + if (!coupon) { + return { valid: false, error: 'Coupon not found' }; + } + + // Run basic validations + if (coupon.status !== CouponStatus.ACTIVE) { + return { valid: false, error: `Coupon is ${coupon.status}`, coupon }; + } + + const now = new Date(); + if (coupon.validFrom > now) { + return { valid: false, error: 'Coupon is not yet valid', coupon }; + } + if (coupon.validUntil && coupon.validUntil < now) { + return { valid: false, error: 'Coupon has expired', coupon }; + } + + if (coupon.minOrderAmount && orderAmount < Number(coupon.minOrderAmount)) { + return { + valid: false, + error: `Minimum order amount is ${coupon.minOrderAmount}`, + coupon, + }; + } + + // Calculate potential discount + let potentialDiscount = 0; + if (coupon.type === CouponType.PERCENTAGE) { + potentialDiscount = orderAmount * (Number(coupon.discountPercent) / 100); + } else if (coupon.type === CouponType.FIXED_AMOUNT) { + potentialDiscount = Math.min(Number(coupon.discountAmount), orderAmount); + } + + if (coupon.maxDiscount) { + potentialDiscount = Math.min(potentialDiscount, Number(coupon.maxDiscount)); + } + + return { valid: true, coupon, potentialDiscount }; + } +} diff --git a/backend/src/modules/pricing/services/promotion.service.ts b/backend/src/modules/pricing/services/promotion.service.ts new file mode 100644 index 0000000..c8f512c --- /dev/null +++ b/backend/src/modules/pricing/services/promotion.service.ts @@ -0,0 +1,607 @@ +import { Repository, DataSource, Between, In, LessThanOrEqual, MoreThanOrEqual } from 'typeorm'; +import { BaseService } from '../../../shared/services/base.service'; +import { ServiceResult, QueryOptions } from '../../../shared/types'; +import { + Promotion, + PromotionStatus, + PromotionType, + DiscountApplication, +} from '../entities/promotion.entity'; +import { PromotionProduct, ProductRole } from '../entities/promotion-product.entity'; + +export interface PromotionProductInput { + productId: string; + productCode: string; + productName: string; + variantId?: string; + role?: ProductRole; + discountPercent?: number; + discountAmount?: number; + fixedPrice?: number; + minQuantity?: number; + maxQuantity?: number; + bundleQuantity?: number; + getQuantity?: number; +} + +export interface CreatePromotionInput { + code: string; + name: string; + description?: string; + type: PromotionType; + discountApplication?: DiscountApplication; + discountPercent?: number; + discountAmount?: number; + maxDiscount?: number; + buyQuantity?: number; + getQuantity?: number; + getDiscountPercent?: number; + quantityTiers?: { minQuantity: number; discountPercent: number }[]; + bundlePrice?: number; + startDate: Date; + endDate?: Date; + validDays?: string[]; + validHoursStart?: string; + validHoursEnd?: string; + maxUsesTotal?: number; + maxUsesPerCustomer?: number; + minOrderAmount?: number; + minQuantity?: number; + appliesToAllProducts?: boolean; + includedCategories?: string[]; + excludedCategories?: string[]; + excludedProducts?: string[]; + includedBranches?: string[]; + customerLevels?: string[]; + newCustomersOnly?: boolean; + loyaltyMembersOnly?: boolean; + stackable?: boolean; + stackableWith?: string[]; + priority?: number; + displayName?: string; + badgeText?: string; + badgeColor?: string; + imageUrl?: string; + posEnabled?: boolean; + ecommerceEnabled?: boolean; + termsAndConditions?: string; + products?: PromotionProductInput[]; +} + +export interface PromotionQueryOptions extends QueryOptions { + status?: PromotionStatus; + type?: PromotionType; + branchId?: string; + startDate?: Date; + endDate?: Date; + activeOnly?: boolean; +} + +export class PromotionService extends BaseService { + constructor( + repository: Repository, + private readonly productRepository: Repository, + private readonly dataSource: DataSource + ) { + super(repository); + } + + /** + * Create a new promotion with products + */ + async createPromotion( + tenantId: string, + input: CreatePromotionInput, + userId: string + ): Promise> { + const queryRunner = this.dataSource.createQueryRunner(); + await queryRunner.connect(); + await queryRunner.startTransaction(); + + try { + // Check for duplicate code + const existing = await queryRunner.manager.findOne(Promotion, { + where: { tenantId, code: input.code.toUpperCase() }, + }); + + if (existing) { + return { + success: false, + error: { code: 'DUPLICATE_CODE', message: 'Promotion code already exists' }, + }; + } + + const promotion = queryRunner.manager.create(Promotion, { + tenantId, + code: input.code.toUpperCase(), + name: input.name, + description: input.description, + type: input.type, + status: PromotionStatus.DRAFT, + discountApplication: input.discountApplication ?? DiscountApplication.LINE, + discountPercent: input.discountPercent, + discountAmount: input.discountAmount, + maxDiscount: input.maxDiscount, + buyQuantity: input.buyQuantity, + getQuantity: input.getQuantity, + getDiscountPercent: input.getDiscountPercent, + quantityTiers: input.quantityTiers, + bundlePrice: input.bundlePrice, + startDate: input.startDate, + endDate: input.endDate, + validDays: input.validDays, + validHoursStart: input.validHoursStart, + validHoursEnd: input.validHoursEnd, + maxUsesTotal: input.maxUsesTotal, + maxUsesPerCustomer: input.maxUsesPerCustomer, + minOrderAmount: input.minOrderAmount, + minQuantity: input.minQuantity, + appliesToAllProducts: input.appliesToAllProducts ?? false, + includedCategories: input.includedCategories, + excludedCategories: input.excludedCategories, + excludedProducts: input.excludedProducts, + includedBranches: input.includedBranches, + customerLevels: input.customerLevels, + newCustomersOnly: input.newCustomersOnly ?? false, + loyaltyMembersOnly: input.loyaltyMembersOnly ?? false, + stackable: input.stackable ?? false, + stackableWith: input.stackableWith, + priority: input.priority ?? 0, + displayName: input.displayName, + badgeText: input.badgeText, + badgeColor: input.badgeColor ?? '#ff0000', + imageUrl: input.imageUrl, + posEnabled: input.posEnabled ?? true, + ecommerceEnabled: input.ecommerceEnabled ?? true, + termsAndConditions: input.termsAndConditions, + createdBy: userId, + }); + + const savedPromotion = await queryRunner.manager.save(promotion); + + // Create promotion products + if (input.products && input.products.length > 0) { + for (const productInput of input.products) { + const promotionProduct = queryRunner.manager.create(PromotionProduct, { + tenantId, + promotionId: savedPromotion.id, + productId: productInput.productId, + productCode: productInput.productCode, + productName: productInput.productName, + variantId: productInput.variantId, + role: productInput.role ?? ProductRole.BOTH, + discountPercent: productInput.discountPercent, + discountAmount: productInput.discountAmount, + fixedPrice: productInput.fixedPrice, + minQuantity: productInput.minQuantity ?? 1, + maxQuantity: productInput.maxQuantity, + bundleQuantity: productInput.bundleQuantity ?? 1, + getQuantity: productInput.getQuantity, + }); + await queryRunner.manager.save(promotionProduct); + } + } + + await queryRunner.commitTransaction(); + + // Reload with products + const result = await this.repository.findOne({ + where: { id: savedPromotion.id, tenantId }, + relations: ['products'], + }); + + return { success: true, data: result! }; + } catch (error) { + await queryRunner.rollbackTransaction(); + return { + success: false, + error: { + code: 'CREATE_PROMOTION_ERROR', + message: error instanceof Error ? error.message : 'Failed to create promotion', + }, + }; + } finally { + await queryRunner.release(); + } + } + + /** + * Update promotion + */ + async updatePromotion( + tenantId: string, + id: string, + input: Partial, + userId: string + ): Promise> { + const promotion = await this.repository.findOne({ + where: { id, tenantId }, + }); + + if (!promotion) { + return { + success: false, + error: { code: 'NOT_FOUND', message: 'Promotion not found' }, + }; + } + + // Don't allow editing active promotions (only some fields) + if (promotion.status === PromotionStatus.ACTIVE) { + const allowedFields = ['endDate', 'maxUsesTotal', 'priority', 'status']; + const inputKeys = Object.keys(input); + const hasDisallowedFields = inputKeys.some(k => !allowedFields.includes(k)); + + if (hasDisallowedFields) { + return { + success: false, + error: { + code: 'PROMOTION_ACTIVE', + message: 'Cannot modify active promotion. Only endDate, maxUsesTotal, priority, and status can be changed.', + }, + }; + } + } + + // Update allowed fields + const { products, ...updateData } = input; + Object.assign(promotion, updateData, { updatedBy: userId }); + + const saved = await this.repository.save(promotion); + return { success: true, data: saved }; + } + + /** + * Activate promotion + */ + async activatePromotion( + tenantId: string, + id: string, + userId: string + ): Promise> { + const promotion = await this.repository.findOne({ + where: { id, tenantId }, + relations: ['products'], + }); + + if (!promotion) { + return { + success: false, + error: { code: 'NOT_FOUND', message: 'Promotion not found' }, + }; + } + + if (promotion.status !== PromotionStatus.DRAFT && promotion.status !== PromotionStatus.SCHEDULED) { + return { + success: false, + error: { code: 'INVALID_STATUS', message: 'Can only activate draft or scheduled promotions' }, + }; + } + + // Validate promotion has required data + if (!promotion.appliesToAllProducts && (!promotion.products || promotion.products.length === 0)) { + if (!promotion.includedCategories || promotion.includedCategories.length === 0) { + return { + success: false, + error: { code: 'NO_PRODUCTS', message: 'Promotion must have products or categories defined' }, + }; + } + } + + const now = new Date(); + if (promotion.startDate > now) { + promotion.status = PromotionStatus.SCHEDULED; + } else { + promotion.status = PromotionStatus.ACTIVE; + } + + promotion.updatedBy = userId; + const saved = await this.repository.save(promotion); + + return { success: true, data: saved }; + } + + /** + * Pause promotion + */ + async pausePromotion( + tenantId: string, + id: string, + userId: string + ): Promise> { + const promotion = await this.repository.findOne({ + where: { id, tenantId }, + }); + + if (!promotion) { + return { + success: false, + error: { code: 'NOT_FOUND', message: 'Promotion not found' }, + }; + } + + if (promotion.status !== PromotionStatus.ACTIVE) { + return { + success: false, + error: { code: 'INVALID_STATUS', message: 'Can only pause active promotions' }, + }; + } + + promotion.status = PromotionStatus.PAUSED; + promotion.updatedBy = userId; + + const saved = await this.repository.save(promotion); + return { success: true, data: saved }; + } + + /** + * End promotion + */ + async endPromotion( + tenantId: string, + id: string, + userId: string + ): Promise> { + const promotion = await this.repository.findOne({ + where: { id, tenantId }, + }); + + if (!promotion) { + return { + success: false, + error: { code: 'NOT_FOUND', message: 'Promotion not found' }, + }; + } + + if ([PromotionStatus.ENDED, PromotionStatus.CANCELLED].includes(promotion.status)) { + return { + success: false, + error: { code: 'INVALID_STATUS', message: 'Promotion is already ended or cancelled' }, + }; + } + + promotion.status = PromotionStatus.ENDED; + promotion.endDate = new Date(); + promotion.updatedBy = userId; + + const saved = await this.repository.save(promotion); + return { success: true, data: saved }; + } + + /** + * Cancel promotion + */ + async cancelPromotion( + tenantId: string, + id: string, + userId: string + ): Promise> { + const promotion = await this.repository.findOne({ + where: { id, tenantId }, + }); + + if (!promotion) { + return { + success: false, + error: { code: 'NOT_FOUND', message: 'Promotion not found' }, + }; + } + + if (promotion.status === PromotionStatus.CANCELLED) { + return { + success: false, + error: { code: 'ALREADY_CANCELLED', message: 'Promotion is already cancelled' }, + }; + } + + promotion.status = PromotionStatus.CANCELLED; + promotion.updatedBy = userId; + + const saved = await this.repository.save(promotion); + return { success: true, data: saved }; + } + + /** + * Add products to promotion + */ + async addProducts( + tenantId: string, + promotionId: string, + products: PromotionProductInput[], + userId: string + ): Promise> { + const promotion = await this.repository.findOne({ + where: { id: promotionId, tenantId }, + }); + + if (!promotion) { + return { + success: false, + error: { code: 'NOT_FOUND', message: 'Promotion not found' }, + }; + } + + if (promotion.status === PromotionStatus.ACTIVE) { + return { + success: false, + error: { code: 'PROMOTION_ACTIVE', message: 'Cannot add products to active promotion' }, + }; + } + + const created: PromotionProduct[] = []; + + for (const productInput of products) { + // Check if already exists + const existing = await this.productRepository.findOne({ + where: { + tenantId, + promotionId, + productId: productInput.productId, + variantId: productInput.variantId ?? undefined, + }, + }); + + if (existing) continue; + + const promotionProduct = this.productRepository.create({ + tenantId, + promotionId, + productId: productInput.productId, + productCode: productInput.productCode, + productName: productInput.productName, + variantId: productInput.variantId, + role: productInput.role ?? ProductRole.BOTH, + discountPercent: productInput.discountPercent, + discountAmount: productInput.discountAmount, + fixedPrice: productInput.fixedPrice, + minQuantity: productInput.minQuantity ?? 1, + maxQuantity: productInput.maxQuantity, + bundleQuantity: productInput.bundleQuantity ?? 1, + getQuantity: productInput.getQuantity, + }); + + const saved = await this.productRepository.save(promotionProduct); + created.push(saved); + } + + return { success: true, data: created }; + } + + /** + * Remove product from promotion + */ + async removeProduct( + tenantId: string, + promotionId: string, + productId: string + ): Promise> { + const promotion = await this.repository.findOne({ + where: { id: promotionId, tenantId }, + }); + + if (!promotion) { + return { + success: false, + error: { code: 'NOT_FOUND', message: 'Promotion not found' }, + }; + } + + if (promotion.status === PromotionStatus.ACTIVE) { + return { + success: false, + error: { code: 'PROMOTION_ACTIVE', message: 'Cannot remove products from active promotion' }, + }; + } + + await this.productRepository.delete({ + tenantId, + promotionId, + productId, + }); + + return { success: true, data: true }; + } + + /** + * Find promotions with filters + */ + async findPromotions( + tenantId: string, + options: PromotionQueryOptions + ): Promise<{ data: Promotion[]; total: number }> { + const qb = this.repository.createQueryBuilder('promotion') + .leftJoinAndSelect('promotion.products', 'products') + .where('promotion.tenantId = :tenantId', { tenantId }); + + if (options.status) { + qb.andWhere('promotion.status = :status', { status: options.status }); + } + + if (options.type) { + qb.andWhere('promotion.type = :type', { type: options.type }); + } + + if (options.branchId) { + qb.andWhere('(promotion.includedBranches IS NULL OR promotion.includedBranches @> :branchArray)', { + branchArray: JSON.stringify([options.branchId]), + }); + } + + if (options.activeOnly) { + const now = new Date(); + qb.andWhere('promotion.status = :activeStatus', { activeStatus: PromotionStatus.ACTIVE }) + .andWhere('promotion.startDate <= :now', { now }) + .andWhere('(promotion.endDate IS NULL OR promotion.endDate >= :now)', { now }); + } + + if (options.startDate && options.endDate) { + qb.andWhere('promotion.startDate BETWEEN :startDate AND :endDate', { + startDate: options.startDate, + endDate: options.endDate, + }); + } + + const page = options.page ?? 1; + const limit = options.limit ?? 20; + qb.skip((page - 1) * limit).take(limit); + qb.orderBy('promotion.priority', 'DESC') + .addOrderBy('promotion.createdAt', 'DESC'); + + const [data, total] = await qb.getManyAndCount(); + return { data, total }; + } + + /** + * Get promotion with products + */ + async getPromotionWithProducts( + tenantId: string, + id: string + ): Promise { + return this.repository.findOne({ + where: { id, tenantId }, + relations: ['products'], + }); + } + + /** + * Increment usage count + */ + async incrementUsage( + tenantId: string, + id: string + ): Promise { + await this.repository.increment( + { id, tenantId }, + 'currentUses', + 1 + ); + } + + /** + * Get promotion statistics + */ + async getPromotionStats( + tenantId: string, + id: string + ): Promise<{ + totalUses: number; + totalDiscount: number; + averageDiscount: number; + }> { + const promotion = await this.repository.findOne({ + where: { id, tenantId }, + }); + + if (!promotion) { + return { totalUses: 0, totalDiscount: 0, averageDiscount: 0 }; + } + + // Would need to query order data for actual discount amounts + // For now, return basic usage stats + return { + totalUses: promotion.currentUses, + totalDiscount: 0, // Would calculate from orders + averageDiscount: 0, + }; + } +} diff --git a/backend/src/modules/pricing/validation/index.ts b/backend/src/modules/pricing/validation/index.ts new file mode 100644 index 0000000..915fd2c --- /dev/null +++ b/backend/src/modules/pricing/validation/index.ts @@ -0,0 +1 @@ +export * from './pricing.schema'; diff --git a/backend/src/modules/pricing/validation/pricing.schema.ts b/backend/src/modules/pricing/validation/pricing.schema.ts new file mode 100644 index 0000000..1dcbefe --- /dev/null +++ b/backend/src/modules/pricing/validation/pricing.schema.ts @@ -0,0 +1,252 @@ +import { z } from 'zod'; +import { + uuidSchema, + moneySchema, + percentSchema, + paginationSchema, +} from '../../../shared/validation/common.schema'; + +// Enums +export const promotionTypeEnum = z.enum([ + 'percentage_discount', + 'fixed_discount', + 'buy_x_get_y', + 'bundle', + 'flash_sale', + 'quantity_discount', + 'free_shipping', + 'gift_with_purchase', +]); + +export const promotionStatusEnum = z.enum([ + 'draft', + 'scheduled', + 'active', + 'paused', + 'ended', + 'cancelled', +]); + +export const discountApplicationEnum = z.enum(['order', 'line', 'shipping']); + +export const productRoleEnum = z.enum(['trigger', 'target', 'both', 'gift']); + +export const couponTypeEnum = z.enum([ + 'percentage', + 'fixed_amount', + 'free_shipping', + 'free_product', +]); + +export const couponScopeEnum = z.enum(['order', 'product', 'category', 'shipping']); +export const couponStatusEnum = z.enum(['active', 'inactive', 'expired', 'depleted']); + +// ==================== PROMOTION SCHEMAS ==================== + +// Quantity tier schema +export const quantityTierSchema = z.object({ + minQuantity: z.number().int().positive(), + discountPercent: percentSchema, +}); + +// Promotion product schema +export const promotionProductSchema = z.object({ + productId: uuidSchema, + productCode: z.string().min(1).max(50), + productName: z.string().min(1).max(200), + variantId: uuidSchema.optional(), + role: productRoleEnum.optional(), + discountPercent: percentSchema.optional(), + discountAmount: moneySchema.optional(), + fixedPrice: moneySchema.optional(), + minQuantity: z.number().int().positive().optional(), + maxQuantity: z.number().int().positive().optional(), + bundleQuantity: z.number().int().positive().optional(), + getQuantity: z.number().int().positive().optional(), +}); + +// Create promotion schema +export const createPromotionSchema = z.object({ + code: z.string().min(1).max(30).regex(/^[A-Z0-9_-]+$/i, 'Code must be alphanumeric'), + name: z.string().min(1).max(100), + description: z.string().max(1000).optional(), + type: promotionTypeEnum, + discountApplication: discountApplicationEnum.optional(), + discountPercent: percentSchema.optional(), + discountAmount: moneySchema.optional(), + maxDiscount: moneySchema.optional(), + buyQuantity: z.number().int().positive().optional(), + getQuantity: z.number().int().positive().optional(), + getDiscountPercent: percentSchema.optional(), + quantityTiers: z.array(quantityTierSchema).optional(), + bundlePrice: moneySchema.optional(), + startDate: z.coerce.date(), + endDate: z.coerce.date().optional(), + validDays: z.array(z.enum(['monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday'])).optional(), + validHoursStart: z.string().regex(/^([01]\d|2[0-3]):([0-5]\d)$/, 'Invalid time format').optional(), + validHoursEnd: z.string().regex(/^([01]\d|2[0-3]):([0-5]\d)$/, 'Invalid time format').optional(), + maxUsesTotal: z.number().int().positive().optional(), + maxUsesPerCustomer: z.number().int().positive().optional(), + minOrderAmount: moneySchema.optional(), + minQuantity: z.number().int().positive().optional(), + appliesToAllProducts: z.boolean().optional(), + includedCategories: z.array(uuidSchema).optional(), + excludedCategories: z.array(uuidSchema).optional(), + excludedProducts: z.array(uuidSchema).optional(), + includedBranches: z.array(uuidSchema).optional(), + customerLevels: z.array(uuidSchema).optional(), + newCustomersOnly: z.boolean().optional(), + loyaltyMembersOnly: z.boolean().optional(), + stackable: z.boolean().optional(), + stackableWith: z.array(uuidSchema).optional(), + priority: z.number().int().min(0).max(100).optional(), + displayName: z.string().max(100).optional(), + badgeText: z.string().max(30).optional(), + badgeColor: z.string().regex(/^#[0-9A-Fa-f]{6}$/, 'Invalid color format').optional(), + imageUrl: z.string().url().optional(), + posEnabled: z.boolean().optional(), + ecommerceEnabled: z.boolean().optional(), + termsAndConditions: z.string().max(5000).optional(), + products: z.array(promotionProductSchema).optional(), +}); + +// Update promotion schema +export const updatePromotionSchema = createPromotionSchema.partial().omit({ code: true }); + +// Add products schema +export const addPromotionProductsSchema = z.object({ + products: z.array(promotionProductSchema).min(1), +}); + +// List promotions query schema +export const listPromotionsQuerySchema = paginationSchema.extend({ + status: promotionStatusEnum.optional(), + type: promotionTypeEnum.optional(), + branchId: uuidSchema.optional(), + activeOnly: z.coerce.boolean().optional(), + startDate: z.coerce.date().optional(), + endDate: z.coerce.date().optional(), +}); + +// ==================== COUPON SCHEMAS ==================== + +// Create coupon schema +export const createCouponSchema = z.object({ + code: z.string().min(1).max(50).regex(/^[A-Z0-9_-]+$/i, 'Code must be alphanumeric'), + name: z.string().min(1).max(100), + description: z.string().max(1000).optional(), + type: couponTypeEnum, + scope: couponScopeEnum.optional(), + discountPercent: percentSchema.optional(), + discountAmount: moneySchema.optional(), + maxDiscount: moneySchema.optional(), + freeProductId: uuidSchema.optional(), + freeProductQuantity: z.number().int().positive().optional(), + validFrom: z.coerce.date(), + validUntil: z.coerce.date().optional(), + maxUses: z.number().int().positive().optional(), + maxUsesPerCustomer: z.number().int().positive().optional(), + minOrderAmount: moneySchema.optional(), + minItems: z.number().int().positive().optional(), + appliesToCategories: z.array(uuidSchema).optional(), + appliesToProducts: z.array(uuidSchema).optional(), + excludedProducts: z.array(uuidSchema).optional(), + includedBranches: z.array(uuidSchema).optional(), + customerSpecific: z.boolean().optional(), + allowedCustomers: z.array(uuidSchema).optional(), + customerLevels: z.array(uuidSchema).optional(), + newCustomersOnly: z.boolean().optional(), + firstOrderOnly: z.boolean().optional(), + campaignId: uuidSchema.optional(), + campaignName: z.string().max(100).optional(), + stackableWithPromotions: z.boolean().optional(), + stackableWithCoupons: z.boolean().optional(), + posEnabled: z.boolean().optional(), + ecommerceEnabled: z.boolean().optional(), + isSingleUse: z.boolean().optional(), + autoApply: z.boolean().optional(), + publicVisible: z.boolean().optional(), + termsAndConditions: z.string().max(5000).optional(), +}); + +// Update coupon schema +export const updateCouponSchema = createCouponSchema.partial().omit({ code: true }); + +// Generate bulk coupons schema +export const generateBulkCouponsSchema = z.object({ + quantity: z.number().int().min(1).max(1000), + prefix: z.string().min(1).max(10).regex(/^[A-Z0-9]+$/i).default('CPN'), + baseInput: createCouponSchema.omit({ code: true }), +}); + +// Redeem coupon schema +export const redeemCouponSchema = z.object({ + orderId: uuidSchema, + orderNumber: z.string().min(1).max(30), + customerId: uuidSchema.optional(), + branchId: uuidSchema, + orderAmount: moneySchema.positive(), + discountApplied: moneySchema, + channel: z.enum(['pos', 'ecommerce', 'mobile']), + ipAddress: z.string().ip().optional(), +}); + +// Validate coupon schema +export const validateCouponSchema = z.object({ + code: z.string().min(1).max(50), + orderAmount: moneySchema.positive(), +}); + +// Reverse redemption schema +export const reverseRedemptionSchema = z.object({ + reason: z.string().min(1, 'Reason is required').max(255), +}); + +// List coupons query schema +export const listCouponsQuerySchema = paginationSchema.extend({ + status: couponStatusEnum.optional(), + type: couponTypeEnum.optional(), + campaignId: uuidSchema.optional(), + branchId: uuidSchema.optional(), + activeOnly: z.coerce.boolean().optional(), + publicOnly: z.coerce.boolean().optional(), +}); + +// ==================== PRICE ENGINE SCHEMAS ==================== + +// Line item schema for price calculation +export const lineItemSchema = z.object({ + productId: uuidSchema, + productCode: z.string().min(1).max(50), + productName: z.string().min(1).max(200), + variantId: uuidSchema.optional(), + categoryId: uuidSchema.optional(), + quantity: z.number().positive(), + unitPrice: moneySchema.positive(), + originalPrice: moneySchema.positive(), +}); + +// Calculate prices schema +export const calculatePricesSchema = z.object({ + branchId: uuidSchema, + customerId: uuidSchema.optional(), + customerLevelId: uuidSchema.optional(), + isNewCustomer: z.boolean().optional(), + isLoyaltyMember: z.boolean().optional(), + isFirstOrder: z.boolean().optional(), + channel: z.enum(['pos', 'ecommerce', 'mobile']), + lines: z.array(lineItemSchema).min(1), + couponCode: z.string().max(50).optional(), +}); + +// Types +export type CreatePromotionInput = z.infer; +export type UpdatePromotionInput = z.infer; +export type PromotionProductInput = z.infer; +export type ListPromotionsQuery = z.infer; +export type CreateCouponInput = z.infer; +export type UpdateCouponInput = z.infer; +export type GenerateBulkCouponsInput = z.infer; +export type RedeemCouponInput = z.infer; +export type ListCouponsQuery = z.infer; +export type CalculatePricesInput = z.infer; diff --git a/backend/src/modules/purchases/controllers/index.ts b/backend/src/modules/purchases/controllers/index.ts new file mode 100644 index 0000000..6e9c295 --- /dev/null +++ b/backend/src/modules/purchases/controllers/index.ts @@ -0,0 +1 @@ +export * from './purchases.controller'; diff --git a/backend/src/modules/purchases/controllers/purchases.controller.ts b/backend/src/modules/purchases/controllers/purchases.controller.ts new file mode 100644 index 0000000..7e82a44 --- /dev/null +++ b/backend/src/modules/purchases/controllers/purchases.controller.ts @@ -0,0 +1,741 @@ +import { Request, Response, NextFunction } from 'express'; +import { DataSource } from 'typeorm'; +import { AuthenticatedRequest } from '../../../shared/types'; +import { + SupplierOrderService, + GoodsReceiptService, + PurchaseSuggestionService, +} from '../services'; +import { + createSupplierOrderSchema, + updateSupplierOrderSchema, + submitForApprovalSchema, + approveOrderSchema, + rejectOrderSchema, + sendToSupplierSchema, + supplierConfirmationSchema, + cancelOrderSchema, + listSupplierOrdersQuerySchema, + createGoodsReceiptSchema, + updateGoodsReceiptSchema, + createReceiptFromOrderSchema, + completeQualityCheckSchema, + postReceiptSchema, + cancelReceiptSchema, + listReceiptsQuerySchema, + createSuggestionSchema, + updateSuggestionSchema, + reviewSuggestionSchema, + bulkReviewSuggestionsSchema, + convertSuggestionsToOrdersSchema, + generateSuggestionsSchema, + listSuggestionsQuerySchema, + purchasesSummaryReportSchema, + supplierPerformanceReportSchema, +} from '../validation/purchases.schema'; +import { idParamSchema } from '../../../shared/validation/common.schema'; + +export class PurchasesController { + private supplierOrderService: SupplierOrderService; + private goodsReceiptService: GoodsReceiptService; + private purchaseSuggestionService: PurchaseSuggestionService; + + constructor(dataSource: DataSource) { + this.supplierOrderService = new SupplierOrderService(dataSource); + this.goodsReceiptService = new GoodsReceiptService(dataSource, this.supplierOrderService); + this.purchaseSuggestionService = new PurchaseSuggestionService(dataSource, this.supplierOrderService); + } + + // ==================== SUPPLIER ORDERS ==================== + + /** + * Create supplier order + */ + createOrder = async (req: AuthenticatedRequest, res: Response, next: NextFunction) => { + try { + const data = createSupplierOrderSchema.parse(req.body); + const result = await this.supplierOrderService.createOrder( + req.tenantId!, + data, + req.user!.id + ); + + if (!result.success) { + return res.status(400).json(result); + } + + res.status(201).json(result); + } catch (error) { + next(error); + } + }; + + /** + * Get supplier order by ID + */ + getOrder = async (req: AuthenticatedRequest, res: Response, next: NextFunction) => { + try { + const { id } = idParamSchema.parse(req.params); + const order = await this.supplierOrderService.findById(req.tenantId!, id, ['lines']); + + if (!order) { + return res.status(404).json({ + success: false, + error: { code: 'NOT_FOUND', message: 'Order not found' }, + }); + } + + res.json({ success: true, data: order }); + } catch (error) { + next(error); + } + }; + + /** + * Update supplier order + */ + updateOrder = async (req: AuthenticatedRequest, res: Response, next: NextFunction) => { + try { + const { id } = idParamSchema.parse(req.params); + const data = updateSupplierOrderSchema.parse(req.body); + + const result = await this.supplierOrderService.updateOrder( + req.tenantId!, + id, + data, + req.user!.id + ); + + if (!result.success) { + return res.status(400).json(result); + } + + res.json(result); + } catch (error) { + next(error); + } + }; + + /** + * List supplier orders + */ + listOrders = async (req: AuthenticatedRequest, res: Response, next: NextFunction) => { + try { + const query = listSupplierOrdersQuerySchema.parse(req.query); + const result = await this.supplierOrderService.listOrders( + req.tenantId!, + req.branchId!, + query + ); + + res.json({ success: true, ...result }); + } catch (error) { + next(error); + } + }; + + /** + * Submit order for approval + */ + submitForApproval = async (req: AuthenticatedRequest, res: Response, next: NextFunction) => { + try { + const { id } = idParamSchema.parse(req.params); + const { notes } = submitForApprovalSchema.parse(req.body); + + const result = await this.supplierOrderService.submitForApproval( + req.tenantId!, + id, + req.user!.id, + notes + ); + + if (!result.success) { + return res.status(400).json(result); + } + + res.json(result); + } catch (error) { + next(error); + } + }; + + /** + * Approve order + */ + approveOrder = async (req: AuthenticatedRequest, res: Response, next: NextFunction) => { + try { + const { id } = idParamSchema.parse(req.params); + const { notes } = approveOrderSchema.parse(req.body); + + const result = await this.supplierOrderService.approveOrder( + req.tenantId!, + id, + req.user!.id, + notes + ); + + if (!result.success) { + return res.status(400).json(result); + } + + res.json(result); + } catch (error) { + next(error); + } + }; + + /** + * Reject order + */ + rejectOrder = async (req: AuthenticatedRequest, res: Response, next: NextFunction) => { + try { + const { id } = idParamSchema.parse(req.params); + const { reason } = rejectOrderSchema.parse(req.body); + + const result = await this.supplierOrderService.rejectOrder( + req.tenantId!, + id, + req.user!.id, + reason + ); + + if (!result.success) { + return res.status(400).json(result); + } + + res.json(result); + } catch (error) { + next(error); + } + }; + + /** + * Send order to supplier + */ + sendToSupplier = async (req: AuthenticatedRequest, res: Response, next: NextFunction) => { + try { + const { id } = idParamSchema.parse(req.params); + const { method, emailTo, notes } = sendToSupplierSchema.parse(req.body); + + const result = await this.supplierOrderService.sendToSupplier( + req.tenantId!, + id, + req.user!.id, + method, + emailTo, + notes + ); + + if (!result.success) { + return res.status(400).json(result); + } + + res.json(result); + } catch (error) { + next(error); + } + }; + + /** + * Confirm order by supplier + */ + supplierConfirmation = async (req: AuthenticatedRequest, res: Response, next: NextFunction) => { + try { + const { id } = idParamSchema.parse(req.params); + const { supplierReference, expectedDate, notes } = supplierConfirmationSchema.parse(req.body); + + const result = await this.supplierOrderService.supplierConfirmation( + req.tenantId!, + id, + supplierReference, + expectedDate, + notes + ); + + if (!result.success) { + return res.status(400).json(result); + } + + res.json(result); + } catch (error) { + next(error); + } + }; + + /** + * Cancel order + */ + cancelOrder = async (req: AuthenticatedRequest, res: Response, next: NextFunction) => { + try { + const { id } = idParamSchema.parse(req.params); + const { reason } = cancelOrderSchema.parse(req.body); + + const result = await this.supplierOrderService.cancelOrder( + req.tenantId!, + id, + req.user!.id, + reason + ); + + if (!result.success) { + return res.status(400).json(result); + } + + res.json(result); + } catch (error) { + next(error); + } + }; + + /** + * Get order statistics + */ + getOrderStats = async (req: AuthenticatedRequest, res: Response, next: NextFunction) => { + try { + const query = purchasesSummaryReportSchema.parse(req.query); + const stats = await this.supplierOrderService.getOrderStats( + req.tenantId!, + req.branchId!, + query.startDate, + query.endDate + ); + + res.json({ success: true, data: stats }); + } catch (error) { + next(error); + } + }; + + // ==================== GOODS RECEIPTS ==================== + + /** + * Create goods receipt + */ + createReceipt = async (req: AuthenticatedRequest, res: Response, next: NextFunction) => { + try { + const data = createGoodsReceiptSchema.parse(req.body); + const result = await this.goodsReceiptService.createReceipt( + req.tenantId!, + data, + req.user!.id + ); + + if (!result.success) { + return res.status(400).json(result); + } + + res.status(201).json(result); + } catch (error) { + next(error); + } + }; + + /** + * Create receipt from order + */ + createReceiptFromOrder = async (req: AuthenticatedRequest, res: Response, next: NextFunction) => { + try { + const data = createReceiptFromOrderSchema.parse(req.body); + const result = await this.goodsReceiptService.createReceiptFromOrder( + req.tenantId!, + data, + req.user!.id + ); + + if (!result.success) { + return res.status(400).json(result); + } + + res.status(201).json(result); + } catch (error) { + next(error); + } + }; + + /** + * Get goods receipt by ID + */ + getReceipt = async (req: AuthenticatedRequest, res: Response, next: NextFunction) => { + try { + const { id } = idParamSchema.parse(req.params); + const receipt = await this.goodsReceiptService.findById(req.tenantId!, id, ['lines']); + + if (!receipt) { + return res.status(404).json({ + success: false, + error: { code: 'NOT_FOUND', message: 'Receipt not found' }, + }); + } + + res.json({ success: true, data: receipt }); + } catch (error) { + next(error); + } + }; + + /** + * Update goods receipt + */ + updateReceipt = async (req: AuthenticatedRequest, res: Response, next: NextFunction) => { + try { + const { id } = idParamSchema.parse(req.params); + const data = updateGoodsReceiptSchema.parse(req.body); + + const result = await this.goodsReceiptService.updateReceipt( + req.tenantId!, + id, + data, + req.user!.id + ); + + if (!result.success) { + return res.status(400).json(result); + } + + res.json(result); + } catch (error) { + next(error); + } + }; + + /** + * List goods receipts + */ + listReceipts = async (req: AuthenticatedRequest, res: Response, next: NextFunction) => { + try { + const query = listReceiptsQuerySchema.parse(req.query); + const result = await this.goodsReceiptService.listReceipts( + req.tenantId!, + req.branchId!, + query + ); + + res.json({ success: true, ...result }); + } catch (error) { + next(error); + } + }; + + /** + * Complete quality check + */ + completeQualityCheck = async (req: AuthenticatedRequest, res: Response, next: NextFunction) => { + try { + const { id } = idParamSchema.parse(req.params); + const { passed, notes, lineChecks } = completeQualityCheckSchema.parse(req.body); + + const result = await this.goodsReceiptService.completeQualityCheck( + req.tenantId!, + id, + req.user!.id, + passed, + notes, + lineChecks + ); + + if (!result.success) { + return res.status(400).json(result); + } + + res.json(result); + } catch (error) { + next(error); + } + }; + + /** + * Post receipt + */ + postReceipt = async (req: AuthenticatedRequest, res: Response, next: NextFunction) => { + try { + const { id } = idParamSchema.parse(req.params); + const { notes } = postReceiptSchema.parse(req.body); + + const result = await this.goodsReceiptService.postReceipt( + req.tenantId!, + id, + req.user!.id, + notes + ); + + if (!result.success) { + return res.status(400).json(result); + } + + res.json(result); + } catch (error) { + next(error); + } + }; + + /** + * Cancel receipt + */ + cancelReceipt = async (req: AuthenticatedRequest, res: Response, next: NextFunction) => { + try { + const { id } = idParamSchema.parse(req.params); + const { reason } = cancelReceiptSchema.parse(req.body); + + const result = await this.goodsReceiptService.cancelReceipt( + req.tenantId!, + id, + req.user!.id, + reason + ); + + if (!result.success) { + return res.status(400).json(result); + } + + res.json(result); + } catch (error) { + next(error); + } + }; + + /** + * Get receipt statistics + */ + getReceiptStats = async (req: AuthenticatedRequest, res: Response, next: NextFunction) => { + try { + const query = purchasesSummaryReportSchema.parse(req.query); + const stats = await this.goodsReceiptService.getReceiptStats( + req.tenantId!, + req.branchId!, + query.startDate, + query.endDate + ); + + res.json({ success: true, data: stats }); + } catch (error) { + next(error); + } + }; + + // ==================== PURCHASE SUGGESTIONS ==================== + + /** + * Create suggestion + */ + createSuggestion = async (req: AuthenticatedRequest, res: Response, next: NextFunction) => { + try { + const data = createSuggestionSchema.parse(req.body); + const result = await this.purchaseSuggestionService.createSuggestion( + req.tenantId!, + data, + req.user!.id + ); + + if (!result.success) { + return res.status(400).json(result); + } + + res.status(201).json(result); + } catch (error) { + next(error); + } + }; + + /** + * Get suggestion by ID + */ + getSuggestion = async (req: AuthenticatedRequest, res: Response, next: NextFunction) => { + try { + const { id } = idParamSchema.parse(req.params); + const suggestion = await this.purchaseSuggestionService.findById(req.tenantId!, id); + + if (!suggestion) { + return res.status(404).json({ + success: false, + error: { code: 'NOT_FOUND', message: 'Suggestion not found' }, + }); + } + + res.json({ success: true, data: suggestion }); + } catch (error) { + next(error); + } + }; + + /** + * Update suggestion + */ + updateSuggestion = async (req: AuthenticatedRequest, res: Response, next: NextFunction) => { + try { + const { id } = idParamSchema.parse(req.params); + const data = updateSuggestionSchema.parse(req.body); + + const result = await this.purchaseSuggestionService.updateSuggestion( + req.tenantId!, + id, + data, + req.user!.id + ); + + if (!result.success) { + return res.status(400).json(result); + } + + res.json(result); + } catch (error) { + next(error); + } + }; + + /** + * List suggestions + */ + listSuggestions = async (req: AuthenticatedRequest, res: Response, next: NextFunction) => { + try { + const query = listSuggestionsQuerySchema.parse(req.query); + const result = await this.purchaseSuggestionService.listSuggestions( + req.tenantId!, + req.branchId!, + query + ); + + res.json({ success: true, ...result }); + } catch (error) { + next(error); + } + }; + + /** + * Review suggestion + */ + reviewSuggestion = async (req: AuthenticatedRequest, res: Response, next: NextFunction) => { + try { + const { id } = idParamSchema.parse(req.params); + const review = reviewSuggestionSchema.parse(req.body); + + const result = await this.purchaseSuggestionService.reviewSuggestion( + req.tenantId!, + id, + req.user!.id, + review + ); + + if (!result.success) { + return res.status(400).json(result); + } + + res.json(result); + } catch (error) { + next(error); + } + }; + + /** + * Bulk review suggestions + */ + bulkReviewSuggestions = async (req: AuthenticatedRequest, res: Response, next: NextFunction) => { + try { + const { suggestionIds, action, notes } = bulkReviewSuggestionsSchema.parse(req.body); + + const result = await this.purchaseSuggestionService.bulkReviewSuggestions( + req.tenantId!, + suggestionIds, + req.user!.id, + action, + notes + ); + + res.json(result); + } catch (error) { + next(error); + } + }; + + /** + * Convert suggestions to orders + */ + convertToOrders = async (req: AuthenticatedRequest, res: Response, next: NextFunction) => { + try { + const data = convertSuggestionsToOrdersSchema.parse(req.body); + + const result = await this.purchaseSuggestionService.convertToOrders( + req.tenantId!, + data, + req.user!.id + ); + + if (!result.success) { + return res.status(400).json(result); + } + + res.json(result); + } catch (error) { + next(error); + } + }; + + /** + * Generate suggestions + */ + generateSuggestions = async (req: AuthenticatedRequest, res: Response, next: NextFunction) => { + try { + const data = generateSuggestionsSchema.parse(req.body); + + const result = await this.purchaseSuggestionService.generateSuggestions( + req.tenantId!, + req.branchId!, + data.warehouseId || req.branchId!, // Use branch as default warehouse + data, + req.user!.id + ); + + if (!result.success) { + return res.status(400).json(result); + } + + res.json(result); + } catch (error) { + next(error); + } + }; + + /** + * Get suggestion statistics + */ + getSuggestionStats = async (req: AuthenticatedRequest, res: Response, next: NextFunction) => { + try { + const stats = await this.purchaseSuggestionService.getSuggestionStats( + req.tenantId!, + req.branchId! + ); + + res.json({ success: true, data: stats }); + } catch (error) { + next(error); + } + }; + + /** + * Delete suggestion + */ + deleteSuggestion = async (req: AuthenticatedRequest, res: Response, next: NextFunction) => { + try { + const { id } = idParamSchema.parse(req.params); + + const result = await this.purchaseSuggestionService.delete(req.tenantId!, id); + + if (!result.success) { + return res.status(400).json(result); + } + + res.json({ success: true, message: 'Suggestion deleted' }); + } catch (error) { + next(error); + } + }; +} + +// Singleton instance (will be initialized with dataSource) +let purchasesController: PurchasesController; + +export const initializePurchasesController = (dataSource: DataSource) => { + purchasesController = new PurchasesController(dataSource); + return purchasesController; +}; + +export { purchasesController }; diff --git a/backend/src/modules/purchases/entities/goods-receipt-line.entity.ts b/backend/src/modules/purchases/entities/goods-receipt-line.entity.ts new file mode 100644 index 0000000..99da0b0 --- /dev/null +++ b/backend/src/modules/purchases/entities/goods-receipt-line.entity.ts @@ -0,0 +1,173 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + ManyToOne, + JoinColumn, + Index, +} from 'typeorm'; +import { GoodsReceipt } from './goods-receipt.entity'; + +export enum ReceiptLineStatus { + PENDING = 'pending', + RECEIVED = 'received', + REJECTED = 'rejected', + PARTIAL = 'partial', +} + +export enum QualityStatus { + NOT_CHECKED = 'not_checked', + PASSED = 'passed', + FAILED = 'failed', + PARTIAL = 'partial', +} + +@Entity('goods_receipt_lines', { schema: 'retail' }) +@Index(['tenantId', 'receiptId']) +@Index(['tenantId', 'productId']) +@Index(['tenantId', 'orderLineId']) +export class GoodsReceiptLine { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'tenant_id', type: 'uuid' }) + @Index() + tenantId: string; + + @Column({ name: 'receipt_id', type: 'uuid' }) + receiptId: string; + + @Column({ name: 'line_number', type: 'int' }) + lineNumber: number; + + @Column({ + type: 'enum', + enum: ReceiptLineStatus, + default: ReceiptLineStatus.RECEIVED, + }) + status: ReceiptLineStatus; + + // Order line reference + @Column({ name: 'order_line_id', type: 'uuid', nullable: true }) + orderLineId: string; + + // Product (from erp-core) + @Column({ name: 'product_id', type: 'uuid' }) + productId: string; + + @Column({ name: 'product_code', length: 50 }) + productCode: string; + + @Column({ name: 'product_name', length: 200 }) + productName: string; + + // Variant + @Column({ name: 'variant_id', type: 'uuid', nullable: true }) + variantId: string; + + @Column({ name: 'variant_name', length: 200, nullable: true }) + variantName: string; + + // UOM + @Column({ name: 'uom_id', type: 'uuid', nullable: true }) + uomId: string; + + @Column({ name: 'uom_name', length: 20, default: 'PZA' }) + uomName: string; + + // Quantities + @Column({ name: 'quantity_expected', type: 'decimal', precision: 15, scale: 4, nullable: true }) + quantityExpected: number; + + @Column({ name: 'quantity_received', type: 'decimal', precision: 15, scale: 4 }) + quantityReceived: number; + + @Column({ name: 'quantity_rejected', type: 'decimal', precision: 15, scale: 4, default: 0 }) + quantityRejected: number; + + @Column({ name: 'quantity_accepted', type: 'decimal', precision: 15, scale: 4 }) + quantityAccepted: number; + + // Pricing + @Column({ name: 'unit_cost', type: 'decimal', precision: 15, scale: 4 }) + unitCost: number; + + @Column({ name: 'tax_rate', type: 'decimal', precision: 5, scale: 4, default: 0 }) + taxRate: number; + + @Column({ name: 'tax_amount', type: 'decimal', precision: 15, scale: 2, default: 0 }) + taxAmount: number; + + // Totals + @Column({ type: 'decimal', precision: 15, scale: 2, default: 0 }) + subtotal: number; + + @Column({ type: 'decimal', precision: 15, scale: 2, default: 0 }) + total: number; + + // Storage location + @Column({ name: 'location_id', type: 'uuid', nullable: true }) + locationId: string; + + @Column({ name: 'location_code', length: 50, nullable: true }) + locationCode: string; + + // Lot/Serial tracking + @Column({ name: 'lot_number', length: 50, nullable: true }) + lotNumber: string; + + @Column({ name: 'serial_numbers', type: 'jsonb', nullable: true }) + serialNumbers: string[]; + + @Column({ name: 'expiry_date', type: 'date', nullable: true }) + expiryDate: Date; + + @Column({ name: 'manufacture_date', type: 'date', nullable: true }) + manufactureDate: Date; + + // Quality check + @Column({ + name: 'quality_status', + type: 'enum', + enum: QualityStatus, + default: QualityStatus.NOT_CHECKED, + }) + qualityStatus: QualityStatus; + + @Column({ name: 'quality_notes', type: 'text', nullable: true }) + qualityNotes: string; + + @Column({ name: 'rejection_reason', length: 255, nullable: true }) + rejectionReason: string; + + // Discrepancy + @Column({ name: 'has_discrepancy', type: 'boolean', default: false }) + hasDiscrepancy: boolean; + + @Column({ name: 'discrepancy_type', length: 30, nullable: true }) + discrepancyType: 'shortage' | 'overage' | 'damage' | 'wrong_item' | 'other'; + + @Column({ name: 'discrepancy_notes', type: 'text', nullable: true }) + discrepancyNotes: string; + + // Stock before receipt + @Column({ name: 'stock_before', type: 'decimal', precision: 15, scale: 4, nullable: true }) + stockBefore: number; + + // Notes + @Column({ type: 'text', nullable: true }) + notes: string; + + // Metadata + @Column({ type: 'jsonb', nullable: true }) + metadata: Record; + + @CreateDateColumn({ name: 'created_at' }) + createdAt: Date; + + // Relations + @ManyToOne(() => GoodsReceipt, (receipt) => receipt.lines) + @JoinColumn({ name: 'receipt_id' }) + receipt: GoodsReceipt; +} diff --git a/backend/src/modules/purchases/entities/goods-receipt.entity.ts b/backend/src/modules/purchases/entities/goods-receipt.entity.ts new file mode 100644 index 0000000..f6d4066 --- /dev/null +++ b/backend/src/modules/purchases/entities/goods-receipt.entity.ts @@ -0,0 +1,174 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + OneToMany, + Index, +} from 'typeorm'; +import { GoodsReceiptLine } from './goods-receipt-line.entity'; + +export enum ReceiptStatus { + DRAFT = 'draft', + IN_PROGRESS = 'in_progress', + COMPLETED = 'completed', + POSTED = 'posted', + CANCELLED = 'cancelled', +} + +@Entity('goods_receipts', { schema: 'retail' }) +@Index(['tenantId', 'branchId', 'status']) +@Index(['tenantId', 'supplierId']) +@Index(['tenantId', 'supplierOrderId']) +@Index(['tenantId', 'number'], { unique: true }) +@Index(['tenantId', 'receiptDate']) +export class GoodsReceipt { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'tenant_id', type: 'uuid' }) + @Index() + tenantId: string; + + @Column({ name: 'branch_id', type: 'uuid' }) + branchId: string; + + @Column({ name: 'warehouse_id', type: 'uuid' }) + warehouseId: string; + + @Column({ length: 30, unique: true }) + number: string; + + @Column({ + type: 'enum', + enum: ReceiptStatus, + default: ReceiptStatus.DRAFT, + }) + status: ReceiptStatus; + + @Column({ name: 'receipt_date', type: 'date' }) + receiptDate: Date; + + // Supplier Order reference + @Column({ name: 'supplier_order_id', type: 'uuid', nullable: true }) + supplierOrderId: string; + + @Column({ name: 'supplier_order_number', length: 30, nullable: true }) + supplierOrderNumber: string; + + // Supplier (from erp-core partners) + @Column({ name: 'supplier_id', type: 'uuid' }) + supplierId: string; + + @Column({ name: 'supplier_name', length: 200 }) + supplierName: string; + + // Supplier document reference + @Column({ name: 'supplier_invoice_number', length: 50, nullable: true }) + supplierInvoiceNumber: string; + + @Column({ name: 'supplier_delivery_note', length: 50, nullable: true }) + supplierDeliveryNote: string; + + // Line counts + @Column({ name: 'lines_count', type: 'int', default: 0 }) + linesCount: number; + + @Column({ name: 'items_received', type: 'decimal', precision: 15, scale: 4, default: 0 }) + itemsReceived: number; + + // Totals + @Column({ type: 'decimal', precision: 15, scale: 2, default: 0 }) + subtotal: number; + + @Column({ name: 'tax_amount', type: 'decimal', precision: 15, scale: 2, default: 0 }) + taxAmount: number; + + @Column({ type: 'decimal', precision: 15, scale: 2, default: 0 }) + total: number; + + // Currency + @Column({ name: 'currency_code', length: 3, default: 'MXN' }) + currencyCode: string; + + @Column({ name: 'exchange_rate', type: 'decimal', precision: 10, scale: 6, default: 1 }) + exchangeRate: number; + + // Quality check + @Column({ name: 'quality_checked', type: 'boolean', default: false }) + qualityChecked: boolean; + + @Column({ name: 'quality_checked_by', type: 'uuid', nullable: true }) + qualityCheckedBy: string; + + @Column({ name: 'quality_checked_at', type: 'timestamp with time zone', nullable: true }) + qualityCheckedAt: Date; + + @Column({ name: 'quality_notes', type: 'text', nullable: true }) + qualityNotes: string; + + // Discrepancies + @Column({ name: 'has_discrepancies', type: 'boolean', default: false }) + hasDiscrepancies: boolean; + + @Column({ name: 'discrepancy_notes', type: 'text', nullable: true }) + discrepancyNotes: string; + + // Receiver info + @Column({ name: 'received_by', type: 'uuid' }) + receivedBy: string; + + @Column({ name: 'receiver_name', length: 100, nullable: true }) + receiverName: string; + + // Delivery person info + @Column({ name: 'delivery_person', length: 100, nullable: true }) + deliveryPerson: string; + + @Column({ name: 'vehicle_plate', length: 20, nullable: true }) + vehiclePlate: string; + + // Posting + @Column({ name: 'posted_by', type: 'uuid', nullable: true }) + postedBy: string; + + @Column({ name: 'posted_at', type: 'timestamp with time zone', nullable: true }) + postedAt: Date; + + // Inventory impact + @Column({ name: 'stock_movements_created', type: 'boolean', default: false }) + stockMovementsCreated: boolean; + + // Cancellation + @Column({ name: 'cancelled_at', type: 'timestamp with time zone', nullable: true }) + cancelledAt: Date; + + @Column({ name: 'cancelled_by', type: 'uuid', nullable: true }) + cancelledBy: string; + + @Column({ name: 'cancellation_reason', length: 255, nullable: true }) + cancellationReason: string; + + // Attachments (delivery note photos, etc.) + @Column({ name: 'attachment_ids', type: 'jsonb', nullable: true }) + attachmentIds: string[]; + + // Notes + @Column({ type: 'text', nullable: true }) + notes: string; + + // Metadata + @Column({ type: 'jsonb', nullable: true }) + metadata: Record; + + @CreateDateColumn({ name: 'created_at' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at' }) + updatedAt: Date; + + // Relations + @OneToMany(() => GoodsReceiptLine, (line) => line.receipt) + lines: GoodsReceiptLine[]; +} diff --git a/backend/src/modules/purchases/entities/index.ts b/backend/src/modules/purchases/entities/index.ts new file mode 100644 index 0000000..fdf8b19 --- /dev/null +++ b/backend/src/modules/purchases/entities/index.ts @@ -0,0 +1,5 @@ +export * from './purchase-suggestion.entity'; +export * from './supplier-order.entity'; +export * from './supplier-order-line.entity'; +export * from './goods-receipt.entity'; +export * from './goods-receipt-line.entity'; diff --git a/backend/src/modules/purchases/entities/purchase-suggestion.entity.ts b/backend/src/modules/purchases/entities/purchase-suggestion.entity.ts new file mode 100644 index 0000000..6d37e2d --- /dev/null +++ b/backend/src/modules/purchases/entities/purchase-suggestion.entity.ts @@ -0,0 +1,178 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + Index, +} from 'typeorm'; + +export enum SuggestionStatus { + PENDING = 'pending', + REVIEWED = 'reviewed', + APPROVED = 'approved', + ORDERED = 'ordered', + REJECTED = 'rejected', + EXPIRED = 'expired', +} + +export enum SuggestionReason { + LOW_STOCK = 'low_stock', + OUT_OF_STOCK = 'out_of_stock', + REORDER_POINT = 'reorder_point', + SALES_FORECAST = 'sales_forecast', + SEASONAL = 'seasonal', + MANUAL = 'manual', +} + +@Entity('purchase_suggestions', { schema: 'retail' }) +@Index(['tenantId', 'branchId', 'status']) +@Index(['tenantId', 'productId']) +@Index(['tenantId', 'supplierId']) +@Index(['tenantId', 'createdAt']) +export class PurchaseSuggestion { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'tenant_id', type: 'uuid' }) + @Index() + tenantId: string; + + @Column({ name: 'branch_id', type: 'uuid' }) + branchId: string; + + @Column({ name: 'warehouse_id', type: 'uuid' }) + warehouseId: string; + + @Column({ + type: 'enum', + enum: SuggestionStatus, + default: SuggestionStatus.PENDING, + }) + status: SuggestionStatus; + + @Column({ + type: 'enum', + enum: SuggestionReason, + default: SuggestionReason.LOW_STOCK, + }) + reason: SuggestionReason; + + // Product (from erp-core) + @Column({ name: 'product_id', type: 'uuid' }) + productId: string; + + @Column({ name: 'product_code', length: 50 }) + productCode: string; + + @Column({ name: 'product_name', length: 200 }) + productName: string; + + // Supplier (from erp-core partners) + @Column({ name: 'supplier_id', type: 'uuid', nullable: true }) + supplierId: string; + + @Column({ name: 'supplier_name', length: 200, nullable: true }) + supplierName: string; + + // Stock levels + @Column({ name: 'current_stock', type: 'decimal', precision: 15, scale: 4, default: 0 }) + currentStock: number; + + @Column({ name: 'min_stock', type: 'decimal', precision: 15, scale: 4, default: 0 }) + minStock: number; + + @Column({ name: 'max_stock', type: 'decimal', precision: 15, scale: 4, nullable: true }) + maxStock: number; + + @Column({ name: 'reorder_point', type: 'decimal', precision: 15, scale: 4, default: 0 }) + reorderPoint: number; + + @Column({ name: 'safety_stock', type: 'decimal', precision: 15, scale: 4, default: 0 }) + safetyStock: number; + + // Suggested quantities + @Column({ name: 'suggested_quantity', type: 'decimal', precision: 15, scale: 4 }) + suggestedQuantity: number; + + @Column({ name: 'approved_quantity', type: 'decimal', precision: 15, scale: 4, nullable: true }) + approvedQuantity: number; + + @Column({ name: 'ordered_quantity', type: 'decimal', precision: 15, scale: 4, default: 0 }) + orderedQuantity: number; + + // UOM + @Column({ name: 'uom_id', type: 'uuid', nullable: true }) + uomId: string; + + @Column({ name: 'uom_name', length: 20, default: 'PZA' }) + uomName: string; + + // Pricing + @Column({ name: 'estimated_unit_cost', type: 'decimal', precision: 15, scale: 4, nullable: true }) + estimatedUnitCost: number; + + @Column({ name: 'estimated_total_cost', type: 'decimal', precision: 15, scale: 2, nullable: true }) + estimatedTotalCost: number; + + // Sales data (for forecast-based suggestions) + @Column({ name: 'avg_daily_sales', type: 'decimal', precision: 15, scale: 4, nullable: true }) + avgDailySales: number; + + @Column({ name: 'days_of_stock', type: 'int', nullable: true }) + daysOfStock: number; + + @Column({ name: 'lead_time_days', type: 'int', default: 0 }) + leadTimeDays: number; + + // Urgency + @Column({ type: 'int', default: 0 }) + priority: number; // 0=low, 1=medium, 2=high, 3=critical + + @Column({ name: 'due_date', type: 'date', nullable: true }) + dueDate: Date; + + // Generated order reference + @Column({ name: 'supplier_order_id', type: 'uuid', nullable: true }) + supplierOrderId: string; + + @Column({ name: 'supplier_order_number', length: 30, nullable: true }) + supplierOrderNumber: string; + + // Review info + @Column({ name: 'reviewed_by', type: 'uuid', nullable: true }) + reviewedBy: string; + + @Column({ name: 'reviewed_at', type: 'timestamp with time zone', nullable: true }) + reviewedAt: Date; + + @Column({ name: 'review_notes', type: 'text', nullable: true }) + reviewNotes: string; + + // Algorithm data + @Column({ name: 'calculation_data', type: 'jsonb', nullable: true }) + calculationData: { + algorithm: string; + salesPeriod: number; + salesTotal: number; + seasonalFactor?: number; + trendFactor?: number; + }; + + // Notes + @Column({ type: 'text', nullable: true }) + notes: string; + + // Metadata + @Column({ type: 'jsonb', nullable: true }) + metadata: Record; + + @CreateDateColumn({ name: 'created_at' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at' }) + updatedAt: Date; + + @Column({ name: 'expires_at', type: 'timestamp with time zone', nullable: true }) + expiresAt: Date; +} diff --git a/backend/src/modules/purchases/entities/supplier-order-line.entity.ts b/backend/src/modules/purchases/entities/supplier-order-line.entity.ts new file mode 100644 index 0000000..8ebf092 --- /dev/null +++ b/backend/src/modules/purchases/entities/supplier-order-line.entity.ts @@ -0,0 +1,144 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + ManyToOne, + JoinColumn, + Index, +} from 'typeorm'; +import { SupplierOrder } from './supplier-order.entity'; + +export enum OrderLineStatus { + PENDING = 'pending', + PARTIALLY_RECEIVED = 'partially_received', + RECEIVED = 'received', + CANCELLED = 'cancelled', +} + +@Entity('supplier_order_lines', { schema: 'retail' }) +@Index(['tenantId', 'orderId']) +@Index(['tenantId', 'productId']) +export class SupplierOrderLine { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'tenant_id', type: 'uuid' }) + @Index() + tenantId: string; + + @Column({ name: 'order_id', type: 'uuid' }) + orderId: string; + + @Column({ name: 'line_number', type: 'int' }) + lineNumber: number; + + @Column({ + type: 'enum', + enum: OrderLineStatus, + default: OrderLineStatus.PENDING, + }) + status: OrderLineStatus; + + // Product (from erp-core) + @Column({ name: 'product_id', type: 'uuid' }) + productId: string; + + @Column({ name: 'product_code', length: 50 }) + productCode: string; + + @Column({ name: 'product_name', length: 200 }) + productName: string; + + @Column({ name: 'supplier_product_code', length: 50, nullable: true }) + supplierProductCode: string; + + // Variant + @Column({ name: 'variant_id', type: 'uuid', nullable: true }) + variantId: string; + + @Column({ name: 'variant_name', length: 200, nullable: true }) + variantName: string; + + // UOM + @Column({ name: 'uom_id', type: 'uuid', nullable: true }) + uomId: string; + + @Column({ name: 'uom_name', length: 20, default: 'PZA' }) + uomName: string; + + // Quantities + @Column({ name: 'quantity_ordered', type: 'decimal', precision: 15, scale: 4 }) + quantityOrdered: number; + + @Column({ name: 'quantity_received', type: 'decimal', precision: 15, scale: 4, default: 0 }) + quantityReceived: number; + + @Column({ name: 'quantity_pending', type: 'decimal', precision: 15, scale: 4 }) + quantityPending: number; + + @Column({ name: 'quantity_cancelled', type: 'decimal', precision: 15, scale: 4, default: 0 }) + quantityCancelled: number; + + // Pricing + @Column({ name: 'unit_price', type: 'decimal', precision: 15, scale: 4 }) + unitPrice: number; + + @Column({ name: 'discount_percent', type: 'decimal', precision: 5, scale: 2, default: 0 }) + discountPercent: number; + + @Column({ name: 'discount_amount', type: 'decimal', precision: 15, scale: 2, default: 0 }) + discountAmount: number; + + // Tax + @Column({ name: 'tax_id', type: 'uuid', nullable: true }) + taxId: string; + + @Column({ name: 'tax_rate', type: 'decimal', precision: 5, scale: 4, default: 0 }) + taxRate: number; + + @Column({ name: 'tax_amount', type: 'decimal', precision: 15, scale: 2, default: 0 }) + taxAmount: number; + + // Totals + @Column({ type: 'decimal', precision: 15, scale: 2, default: 0 }) + subtotal: number; + + @Column({ type: 'decimal', precision: 15, scale: 2, default: 0 }) + total: number; + + // Received value + @Column({ name: 'received_value', type: 'decimal', precision: 15, scale: 2, default: 0 }) + receivedValue: number; + + // Last receipt + @Column({ name: 'last_receipt_date', type: 'date', nullable: true }) + lastReceiptDate: number; + + @Column({ name: 'last_receipt_quantity', type: 'decimal', precision: 15, scale: 4, nullable: true }) + lastReceiptQuantity: number; + + // Source suggestion + @Column({ name: 'suggestion_id', type: 'uuid', nullable: true }) + suggestionId: string; + + // Expected delivery + @Column({ name: 'expected_date', type: 'date', nullable: true }) + expectedDate: Date; + + // Notes + @Column({ type: 'text', nullable: true }) + notes: string; + + // Metadata + @Column({ type: 'jsonb', nullable: true }) + metadata: Record; + + @CreateDateColumn({ name: 'created_at' }) + createdAt: Date; + + // Relations + @ManyToOne(() => SupplierOrder, (order) => order.lines) + @JoinColumn({ name: 'order_id' }) + order: SupplierOrder; +} diff --git a/backend/src/modules/purchases/entities/supplier-order.entity.ts b/backend/src/modules/purchases/entities/supplier-order.entity.ts new file mode 100644 index 0000000..d30625e --- /dev/null +++ b/backend/src/modules/purchases/entities/supplier-order.entity.ts @@ -0,0 +1,201 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + OneToMany, + Index, +} from 'typeorm'; +import { SupplierOrderLine } from './supplier-order-line.entity'; + +export enum SupplierOrderStatus { + DRAFT = 'draft', + PENDING_APPROVAL = 'pending_approval', + APPROVED = 'approved', + SENT = 'sent', + CONFIRMED = 'confirmed', + PARTIALLY_RECEIVED = 'partially_received', + RECEIVED = 'received', + CANCELLED = 'cancelled', +} + +@Entity('supplier_orders', { schema: 'retail' }) +@Index(['tenantId', 'branchId', 'status']) +@Index(['tenantId', 'supplierId']) +@Index(['tenantId', 'number'], { unique: true }) +@Index(['tenantId', 'orderDate']) +export class SupplierOrder { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'tenant_id', type: 'uuid' }) + @Index() + tenantId: string; + + @Column({ name: 'branch_id', type: 'uuid' }) + branchId: string; + + @Column({ name: 'warehouse_id', type: 'uuid' }) + warehouseId: string; + + @Column({ length: 30, unique: true }) + number: string; + + @Column({ + type: 'enum', + enum: SupplierOrderStatus, + default: SupplierOrderStatus.DRAFT, + }) + status: SupplierOrderStatus; + + // Supplier (from erp-core partners) + @Column({ name: 'supplier_id', type: 'uuid' }) + supplierId: string; + + @Column({ name: 'supplier_code', length: 50, nullable: true }) + supplierCode: string; + + @Column({ name: 'supplier_name', length: 200 }) + supplierName: string; + + @Column({ name: 'supplier_contact', length: 100, nullable: true }) + supplierContact: string; + + @Column({ name: 'supplier_email', length: 100, nullable: true }) + supplierEmail: string; + + @Column({ name: 'supplier_phone', length: 20, nullable: true }) + supplierPhone: string; + + // Dates + @Column({ name: 'order_date', type: 'date' }) + orderDate: Date; + + @Column({ name: 'expected_date', type: 'date', nullable: true }) + expectedDate: Date; + + @Column({ name: 'sent_at', type: 'timestamp with time zone', nullable: true }) + sentAt: Date; + + @Column({ name: 'confirmed_at', type: 'timestamp with time zone', nullable: true }) + confirmedAt: Date; + + // Totals + @Column({ name: 'lines_count', type: 'int', default: 0 }) + linesCount: number; + + @Column({ type: 'decimal', precision: 15, scale: 2, default: 0 }) + subtotal: number; + + @Column({ name: 'discount_percent', type: 'decimal', precision: 5, scale: 2, default: 0 }) + discountPercent: number; + + @Column({ name: 'discount_amount', type: 'decimal', precision: 15, scale: 2, default: 0 }) + discountAmount: number; + + @Column({ name: 'tax_amount', type: 'decimal', precision: 15, scale: 2, default: 0 }) + taxAmount: number; + + @Column({ name: 'shipping_amount', type: 'decimal', precision: 15, scale: 2, default: 0 }) + shippingAmount: number; + + @Column({ type: 'decimal', precision: 15, scale: 2, default: 0 }) + total: number; + + // Currency + @Column({ name: 'currency_code', length: 3, default: 'MXN' }) + currencyCode: string; + + @Column({ name: 'exchange_rate', type: 'decimal', precision: 10, scale: 6, default: 1 }) + exchangeRate: number; + + // Payment terms + @Column({ name: 'payment_terms', length: 50, nullable: true }) + paymentTerms: string; + + @Column({ name: 'payment_due_date', type: 'date', nullable: true }) + paymentDueDate: Date; + + // Shipping + @Column({ name: 'shipping_method', length: 100, nullable: true }) + shippingMethod: string; + + @Column({ name: 'shipping_address', type: 'jsonb', nullable: true }) + shippingAddress: { + address1: string; + address2?: string; + city: string; + state: string; + postalCode: string; + country: string; + }; + + // Reference + @Column({ name: 'supplier_reference', length: 50, nullable: true }) + supplierReference: string; + + @Column({ name: 'quotation_reference', length: 50, nullable: true }) + quotationReference: string; + + // Users + @Column({ name: 'created_by', type: 'uuid' }) + createdBy: string; + + @Column({ name: 'approved_by', type: 'uuid', nullable: true }) + approvedBy: string; + + @Column({ name: 'approved_at', type: 'timestamp with time zone', nullable: true }) + approvedAt: Date; + + @Column({ name: 'sent_by', type: 'uuid', nullable: true }) + sentBy: string; + + // Communication + @Column({ name: 'email_sent', type: 'boolean', default: false }) + emailSent: boolean; + + @Column({ name: 'email_sent_at', type: 'timestamp with time zone', nullable: true }) + emailSentAt: Date; + + @Column({ name: 'email_sent_to', length: 100, nullable: true }) + emailSentTo: string; + + // Receipt tracking + @Column({ name: 'total_received_value', type: 'decimal', precision: 15, scale: 2, default: 0 }) + totalReceivedValue: number; + + @Column({ name: 'last_receipt_date', type: 'date', nullable: true }) + lastReceiptDate: Date; + + // Cancellation + @Column({ name: 'cancelled_at', type: 'timestamp with time zone', nullable: true }) + cancelledAt: Date; + + @Column({ name: 'cancelled_by', type: 'uuid', nullable: true }) + cancelledBy: string; + + @Column({ name: 'cancellation_reason', length: 255, nullable: true }) + cancellationReason: string; + + // Notes + @Column({ type: 'text', nullable: true }) + notes: string; + + @Column({ name: 'internal_notes', type: 'text', nullable: true }) + internalNotes: string; + + // Metadata + @Column({ type: 'jsonb', nullable: true }) + metadata: Record; + + @CreateDateColumn({ name: 'created_at' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at' }) + updatedAt: Date; + + // Relations + @OneToMany(() => SupplierOrderLine, (line) => line.order) + lines: SupplierOrderLine[]; +} diff --git a/backend/src/modules/purchases/index.ts b/backend/src/modules/purchases/index.ts new file mode 100644 index 0000000..0588326 --- /dev/null +++ b/backend/src/modules/purchases/index.ts @@ -0,0 +1,5 @@ +export * from './entities'; +export * from './services'; +export * from './controllers'; +export * from './routes'; +export * from './validation'; diff --git a/backend/src/modules/purchases/routes/index.ts b/backend/src/modules/purchases/routes/index.ts new file mode 100644 index 0000000..178850f --- /dev/null +++ b/backend/src/modules/purchases/routes/index.ts @@ -0,0 +1 @@ +export { default as purchasesRoutes } from './purchases.routes'; diff --git a/backend/src/modules/purchases/routes/purchases.routes.ts b/backend/src/modules/purchases/routes/purchases.routes.ts new file mode 100644 index 0000000..e905fcd --- /dev/null +++ b/backend/src/modules/purchases/routes/purchases.routes.ts @@ -0,0 +1,252 @@ +import { Router } from 'express'; +import { purchasesController } from '../controllers/purchases.controller'; +import { authMiddleware, requireRoles, requirePermissions } from '../../../shared/middleware/auth.middleware'; +import { tenantMiddleware } from '../../../shared/middleware/tenant.middleware'; +import { branchMiddleware, validateBranchMiddleware, requireBranchCapability } from '../../../shared/middleware/branch.middleware'; +import { AuthenticatedRequest } from '../../../shared/types'; + +const router = Router(); + +// All routes require tenant and authentication +router.use(tenantMiddleware); +router.use(authMiddleware); + +// ==================== SUPPLIER ORDER ROUTES ==================== + +// Routes that require branch context +router.use('/orders', branchMiddleware); +router.use('/orders', validateBranchMiddleware); + +// Create supplier order +router.post( + '/orders', + requirePermissions('purchases:create'), + (req, res, next) => purchasesController.createOrder(req as AuthenticatedRequest, res, next) +); + +// List supplier orders +router.get( + '/orders', + requirePermissions('purchases:read'), + (req, res, next) => purchasesController.listOrders(req as AuthenticatedRequest, res, next) +); + +// Get order statistics +router.get( + '/orders/stats', + requirePermissions('purchases:read'), + (req, res, next) => purchasesController.getOrderStats(req as AuthenticatedRequest, res, next) +); + +// Get supplier order by ID +router.get( + '/orders/:id', + requirePermissions('purchases:read'), + (req, res, next) => purchasesController.getOrder(req as AuthenticatedRequest, res, next) +); + +// Update supplier order +router.put( + '/orders/:id', + requirePermissions('purchases:update'), + (req, res, next) => purchasesController.updateOrder(req as AuthenticatedRequest, res, next) +); + +// Submit order for approval +router.post( + '/orders/:id/submit', + requirePermissions('purchases:update'), + (req, res, next) => purchasesController.submitForApproval(req as AuthenticatedRequest, res, next) +); + +// Approve order (requires manager+ role) +router.post( + '/orders/:id/approve', + requireRoles('admin', 'manager'), + requirePermissions('purchases:approve'), + (req, res, next) => purchasesController.approveOrder(req as AuthenticatedRequest, res, next) +); + +// Reject order (requires manager+ role) +router.post( + '/orders/:id/reject', + requireRoles('admin', 'manager'), + requirePermissions('purchases:approve'), + (req, res, next) => purchasesController.rejectOrder(req as AuthenticatedRequest, res, next) +); + +// Send order to supplier +router.post( + '/orders/:id/send', + requirePermissions('purchases:update'), + (req, res, next) => purchasesController.sendToSupplier(req as AuthenticatedRequest, res, next) +); + +// Supplier confirmation +router.post( + '/orders/:id/confirm', + requirePermissions('purchases:update'), + (req, res, next) => purchasesController.supplierConfirmation(req as AuthenticatedRequest, res, next) +); + +// Cancel order (requires supervisor+ role) +router.post( + '/orders/:id/cancel', + requireRoles('admin', 'manager', 'supervisor'), + requirePermissions('purchases:cancel'), + (req, res, next) => purchasesController.cancelOrder(req as AuthenticatedRequest, res, next) +); + +// ==================== GOODS RECEIPT ROUTES ==================== + +// Routes that require branch context +router.use('/receipts', branchMiddleware); +router.use('/receipts', validateBranchMiddleware); + +// Create goods receipt +router.post( + '/receipts', + requirePermissions('receipts:create'), + (req, res, next) => purchasesController.createReceipt(req as AuthenticatedRequest, res, next) +); + +// Create receipt from order +router.post( + '/receipts/from-order', + requirePermissions('receipts:create'), + (req, res, next) => purchasesController.createReceiptFromOrder(req as AuthenticatedRequest, res, next) +); + +// List goods receipts +router.get( + '/receipts', + requirePermissions('receipts:read'), + (req, res, next) => purchasesController.listReceipts(req as AuthenticatedRequest, res, next) +); + +// Get receipt statistics +router.get( + '/receipts/stats', + requirePermissions('receipts:read'), + (req, res, next) => purchasesController.getReceiptStats(req as AuthenticatedRequest, res, next) +); + +// Get goods receipt by ID +router.get( + '/receipts/:id', + requirePermissions('receipts:read'), + (req, res, next) => purchasesController.getReceipt(req as AuthenticatedRequest, res, next) +); + +// Update goods receipt +router.put( + '/receipts/:id', + requirePermissions('receipts:update'), + (req, res, next) => purchasesController.updateReceipt(req as AuthenticatedRequest, res, next) +); + +// Complete quality check +router.post( + '/receipts/:id/quality-check', + requirePermissions('receipts:quality'), + (req, res, next) => purchasesController.completeQualityCheck(req as AuthenticatedRequest, res, next) +); + +// Post receipt +router.post( + '/receipts/:id/post', + requireRoles('admin', 'manager', 'supervisor'), + requirePermissions('receipts:post'), + (req, res, next) => purchasesController.postReceipt(req as AuthenticatedRequest, res, next) +); + +// Cancel receipt +router.post( + '/receipts/:id/cancel', + requireRoles('admin', 'manager', 'supervisor'), + requirePermissions('receipts:cancel'), + (req, res, next) => purchasesController.cancelReceipt(req as AuthenticatedRequest, res, next) +); + +// ==================== PURCHASE SUGGESTION ROUTES ==================== + +// Routes that require branch context +router.use('/suggestions', branchMiddleware); +router.use('/suggestions', validateBranchMiddleware); + +// Create suggestion +router.post( + '/suggestions', + requirePermissions('suggestions:create'), + (req, res, next) => purchasesController.createSuggestion(req as AuthenticatedRequest, res, next) +); + +// Generate suggestions +router.post( + '/suggestions/generate', + requireRoles('admin', 'manager'), + requirePermissions('suggestions:generate'), + (req, res, next) => purchasesController.generateSuggestions(req as AuthenticatedRequest, res, next) +); + +// Bulk review suggestions +router.post( + '/suggestions/bulk-review', + requireRoles('admin', 'manager'), + requirePermissions('suggestions:approve'), + (req, res, next) => purchasesController.bulkReviewSuggestions(req as AuthenticatedRequest, res, next) +); + +// Convert suggestions to orders +router.post( + '/suggestions/convert', + requireRoles('admin', 'manager'), + requirePermissions('suggestions:convert'), + (req, res, next) => purchasesController.convertToOrders(req as AuthenticatedRequest, res, next) +); + +// List suggestions +router.get( + '/suggestions', + requirePermissions('suggestions:read'), + (req, res, next) => purchasesController.listSuggestions(req as AuthenticatedRequest, res, next) +); + +// Get suggestion statistics +router.get( + '/suggestions/stats', + requirePermissions('suggestions:read'), + (req, res, next) => purchasesController.getSuggestionStats(req as AuthenticatedRequest, res, next) +); + +// Get suggestion by ID +router.get( + '/suggestions/:id', + requirePermissions('suggestions:read'), + (req, res, next) => purchasesController.getSuggestion(req as AuthenticatedRequest, res, next) +); + +// Update suggestion +router.put( + '/suggestions/:id', + requirePermissions('suggestions:update'), + (req, res, next) => purchasesController.updateSuggestion(req as AuthenticatedRequest, res, next) +); + +// Review suggestion +router.post( + '/suggestions/:id/review', + requireRoles('admin', 'manager'), + requirePermissions('suggestions:approve'), + (req, res, next) => purchasesController.reviewSuggestion(req as AuthenticatedRequest, res, next) +); + +// Delete suggestion +router.delete( + '/suggestions/:id', + requireRoles('admin', 'manager'), + requirePermissions('suggestions:delete'), + (req, res, next) => purchasesController.deleteSuggestion(req as AuthenticatedRequest, res, next) +); + +export default router; diff --git a/backend/src/modules/purchases/services/goods-receipt.service.ts b/backend/src/modules/purchases/services/goods-receipt.service.ts new file mode 100644 index 0000000..0458028 --- /dev/null +++ b/backend/src/modules/purchases/services/goods-receipt.service.ts @@ -0,0 +1,700 @@ +import { Repository, DataSource, In } from 'typeorm'; +import { BaseService } from '../../../shared/services/base.service'; +import { ServiceResult, PaginatedResult } from '../../../shared/types'; +import { + GoodsReceipt, + ReceiptStatus, + GoodsReceiptLine, + ReceiptLineStatus, + QualityStatus, + SupplierOrder, + SupplierOrderLine, +} from '../entities'; +import { + CreateGoodsReceiptInput, + UpdateGoodsReceiptInput, + CreateReceiptFromOrderInput, +} from '../validation/purchases.schema'; +import { SupplierOrderService } from './supplier-order.service'; + +export class GoodsReceiptService extends BaseService { + private lineRepository: Repository; + private orderRepository: Repository; + private orderLineRepository: Repository; + + constructor( + private dataSource: DataSource, + private supplierOrderService: SupplierOrderService + ) { + super(dataSource.getRepository(GoodsReceipt)); + this.lineRepository = dataSource.getRepository(GoodsReceiptLine); + this.orderRepository = dataSource.getRepository(SupplierOrder); + this.orderLineRepository = dataSource.getRepository(SupplierOrderLine); + } + + /** + * Generate receipt number + */ + private async generateReceiptNumber(tenantId: string): Promise { + const date = new Date(); + const year = date.getFullYear().toString().slice(-2); + const month = (date.getMonth() + 1).toString().padStart(2, '0'); + + const count = await this.repository.count({ + where: { tenantId }, + }); + + const sequence = (count + 1).toString().padStart(5, '0'); + return `GR-${year}${month}-${sequence}`; + } + + /** + * Calculate receipt totals + */ + private calculateTotals(lines: GoodsReceiptLine[]): { + subtotal: number; + taxAmount: number; + total: number; + itemsReceived: number; + } { + let subtotal = 0; + let taxAmount = 0; + let itemsReceived = 0; + + for (const line of lines) { + const lineSubtotal = line.quantityAccepted * line.unitCost; + const lineTax = lineSubtotal * line.taxRate; + + subtotal += lineSubtotal; + taxAmount += lineTax; + itemsReceived += line.quantityAccepted; + } + + return { + subtotal: Math.round(subtotal * 100) / 100, + taxAmount: Math.round(taxAmount * 100) / 100, + total: Math.round((subtotal + taxAmount) * 100) / 100, + itemsReceived: Math.round(itemsReceived * 10000) / 10000, + }; + } + + /** + * Create goods receipt (standalone, without order) + */ + async createReceipt( + tenantId: string, + data: CreateGoodsReceiptInput, + userId: string + ): Promise> { + const queryRunner = this.dataSource.createQueryRunner(); + await queryRunner.connect(); + await queryRunner.startTransaction(); + + try { + const number = await this.generateReceiptNumber(tenantId); + + // Create receipt lines + const lines: GoodsReceiptLine[] = data.lines.map((lineData, index) => { + const quantityAccepted = lineData.quantityReceived - (lineData.quantityRejected || 0); + const lineSubtotal = quantityAccepted * lineData.unitCost; + const lineTax = lineSubtotal * (lineData.taxRate || 0.16); + + return queryRunner.manager.create(GoodsReceiptLine, { + tenantId, + lineNumber: index + 1, + status: ReceiptLineStatus.RECEIVED, + orderLineId: lineData.orderLineId, + productId: lineData.productId, + productCode: lineData.productCode, + productName: lineData.productName, + variantId: lineData.variantId, + variantName: lineData.variantName, + uomId: lineData.uomId, + uomName: lineData.uomName || 'PZA', + quantityExpected: lineData.quantityExpected, + quantityReceived: lineData.quantityReceived, + quantityRejected: lineData.quantityRejected || 0, + quantityAccepted, + unitCost: lineData.unitCost, + taxRate: lineData.taxRate || 0.16, + taxAmount: lineTax, + subtotal: lineSubtotal, + total: lineSubtotal + lineTax, + locationId: lineData.locationId, + locationCode: lineData.locationCode, + lotNumber: lineData.lotNumber, + serialNumbers: lineData.serialNumbers, + expiryDate: lineData.expiryDate, + manufactureDate: lineData.manufactureDate, + qualityStatus: lineData.qualityStatus || QualityStatus.NOT_CHECKED, + qualityNotes: lineData.qualityNotes, + rejectionReason: lineData.rejectionReason, + hasDiscrepancy: lineData.quantityRejected ? lineData.quantityRejected > 0 : false, + notes: lineData.notes, + }); + }); + + const totals = this.calculateTotals(lines); + + // Create receipt + const receipt = queryRunner.manager.create(GoodsReceipt, { + tenantId, + branchId: data.branchId, + warehouseId: data.warehouseId, + number, + status: ReceiptStatus.DRAFT, + receiptDate: data.receiptDate, + supplierOrderId: data.supplierOrderId, + supplierId: data.supplierId, + supplierName: data.supplierName, + supplierInvoiceNumber: data.supplierInvoiceNumber, + supplierDeliveryNote: data.supplierDeliveryNote, + linesCount: lines.length, + ...totals, + currencyCode: data.currencyCode || 'MXN', + exchangeRate: data.exchangeRate || 1, + deliveryPerson: data.deliveryPerson, + vehiclePlate: data.vehiclePlate, + receivedBy: userId, + hasDiscrepancies: lines.some(l => l.hasDiscrepancy), + notes: data.notes, + }); + + const savedReceipt = await queryRunner.manager.save(receipt); + + // Save lines with receipt reference + for (const line of lines) { + line.receiptId = savedReceipt.id; + } + await queryRunner.manager.save(lines); + + await queryRunner.commitTransaction(); + + const result = await this.findById(tenantId, savedReceipt.id, ['lines']); + return { success: true, data: result! }; + } catch (error: any) { + await queryRunner.rollbackTransaction(); + return { + success: false, + error: { + code: 'CREATE_RECEIPT_FAILED', + message: error.message || 'Failed to create goods receipt', + details: error, + }, + }; + } finally { + await queryRunner.release(); + } + } + + /** + * Create goods receipt from supplier order + */ + async createReceiptFromOrder( + tenantId: string, + data: CreateReceiptFromOrderInput, + userId: string + ): Promise> { + const queryRunner = this.dataSource.createQueryRunner(); + await queryRunner.connect(); + await queryRunner.startTransaction(); + + try { + // Get the order with lines + const order = await this.orderRepository.findOne({ + where: { tenantId, id: data.supplierOrderId }, + relations: ['lines'], + }); + + if (!order) { + return { + success: false, + error: { code: 'ORDER_NOT_FOUND', message: 'Supplier order not found' }, + }; + } + + const number = await this.generateReceiptNumber(tenantId); + + // Create receipt lines from order lines + const lines: GoodsReceiptLine[] = []; + let lineNumber = 1; + + for (const lineData of data.lines) { + const orderLine = order.lines.find(l => l.id === lineData.orderLineId); + if (!orderLine) continue; + + const quantityAccepted = lineData.quantityReceived - (lineData.quantityRejected || 0); + const lineSubtotal = quantityAccepted * orderLine.unitPrice; + const lineTax = lineSubtotal * orderLine.taxRate; + + const receiptLine = queryRunner.manager.create(GoodsReceiptLine, { + tenantId, + lineNumber: lineNumber++, + status: ReceiptLineStatus.RECEIVED, + orderLineId: orderLine.id, + productId: orderLine.productId, + productCode: orderLine.productCode, + productName: orderLine.productName, + variantId: orderLine.variantId, + variantName: orderLine.variantName, + uomId: orderLine.uomId, + uomName: orderLine.uomName, + quantityExpected: orderLine.quantityPending, + quantityReceived: lineData.quantityReceived, + quantityRejected: lineData.quantityRejected || 0, + quantityAccepted, + unitCost: orderLine.unitPrice, + taxRate: orderLine.taxRate, + taxAmount: lineTax, + subtotal: lineSubtotal, + total: lineSubtotal + lineTax, + locationId: lineData.locationId, + lotNumber: lineData.lotNumber, + serialNumbers: lineData.serialNumbers, + expiryDate: lineData.expiryDate, + qualityStatus: lineData.qualityStatus || QualityStatus.NOT_CHECKED, + qualityNotes: lineData.qualityNotes, + rejectionReason: lineData.rejectionReason, + hasDiscrepancy: (lineData.quantityRejected || 0) > 0 || + lineData.quantityReceived !== orderLine.quantityPending, + notes: lineData.notes, + }); + + lines.push(receiptLine); + } + + if (lines.length === 0) { + return { + success: false, + error: { code: 'NO_LINES', message: 'No valid lines to receive' }, + }; + } + + const totals = this.calculateTotals(lines); + + // Create receipt + const receipt = queryRunner.manager.create(GoodsReceipt, { + tenantId, + branchId: order.branchId, + warehouseId: order.warehouseId, + number, + status: ReceiptStatus.DRAFT, + receiptDate: data.receiptDate, + supplierOrderId: order.id, + supplierOrderNumber: order.number, + supplierId: order.supplierId, + supplierName: order.supplierName, + supplierInvoiceNumber: data.supplierInvoiceNumber, + supplierDeliveryNote: data.supplierDeliveryNote, + linesCount: lines.length, + ...totals, + currencyCode: order.currencyCode, + exchangeRate: order.exchangeRate, + deliveryPerson: data.deliveryPerson, + vehiclePlate: data.vehiclePlate, + receivedBy: userId, + hasDiscrepancies: lines.some(l => l.hasDiscrepancy), + notes: data.notes, + }); + + const savedReceipt = await queryRunner.manager.save(receipt); + + // Save lines with receipt reference + for (const line of lines) { + line.receiptId = savedReceipt.id; + } + await queryRunner.manager.save(lines); + + await queryRunner.commitTransaction(); + + const result = await this.findById(tenantId, savedReceipt.id, ['lines']); + return { success: true, data: result! }; + } catch (error: any) { + await queryRunner.rollbackTransaction(); + return { + success: false, + error: { + code: 'CREATE_RECEIPT_FAILED', + message: error.message || 'Failed to create goods receipt from order', + details: error, + }, + }; + } finally { + await queryRunner.release(); + } + } + + /** + * Update goods receipt + */ + async updateReceipt( + tenantId: string, + receiptId: string, + data: UpdateGoodsReceiptInput, + userId: string + ): Promise> { + const receipt = await this.findById(tenantId, receiptId, ['lines']); + if (!receipt) { + return { + success: false, + error: { code: 'NOT_FOUND', message: 'Receipt not found' }, + }; + } + + if (receipt.status !== ReceiptStatus.DRAFT && receipt.status !== ReceiptStatus.IN_PROGRESS) { + return { + success: false, + error: { code: 'INVALID_STATUS', message: 'Only draft or in-progress receipts can be updated' }, + }; + } + + // Update receipt fields (excluding lines for simplicity) + const { lines: _, ...updateData } = data; + Object.assign(receipt, updateData); + + const saved = await this.repository.save(receipt); + return { success: true, data: saved }; + } + + /** + * Complete quality check + */ + async completeQualityCheck( + tenantId: string, + receiptId: string, + userId: string, + passed: boolean, + notes?: string, + lineChecks?: { lineId: string; qualityStatus: QualityStatus; notes?: string; rejectionReason?: string }[] + ): Promise> { + const queryRunner = this.dataSource.createQueryRunner(); + await queryRunner.connect(); + await queryRunner.startTransaction(); + + try { + const receipt = await this.findById(tenantId, receiptId, ['lines']); + if (!receipt) { + return { + success: false, + error: { code: 'NOT_FOUND', message: 'Receipt not found' }, + }; + } + + // Update line quality statuses + if (lineChecks) { + for (const check of lineChecks) { + const line = receipt.lines.find(l => l.id === check.lineId); + if (line) { + line.qualityStatus = check.qualityStatus; + if (check.notes) { + line.qualityNotes = check.notes; + } + if (check.rejectionReason) { + line.rejectionReason = check.rejectionReason; + } + } + } + await queryRunner.manager.save(receipt.lines); + } + + // Update receipt quality check info + receipt.qualityChecked = true; + receipt.qualityCheckedBy = userId; + receipt.qualityCheckedAt = new Date(); + if (notes) { + receipt.qualityNotes = notes; + } + + // Update status based on quality check result + if (!passed) { + receipt.hasDiscrepancies = true; + receipt.discrepancyNotes = notes || 'Quality check failed'; + } + + const saved = await queryRunner.manager.save(receipt); + await queryRunner.commitTransaction(); + + return { success: true, data: saved }; + } catch (error: any) { + await queryRunner.rollbackTransaction(); + return { + success: false, + error: { + code: 'QUALITY_CHECK_FAILED', + message: error.message || 'Failed to complete quality check', + details: error, + }, + }; + } finally { + await queryRunner.release(); + } + } + + /** + * Post receipt (finalize and update inventory) + */ + async postReceipt( + tenantId: string, + receiptId: string, + userId: string, + notes?: string + ): Promise> { + const queryRunner = this.dataSource.createQueryRunner(); + await queryRunner.connect(); + await queryRunner.startTransaction(); + + try { + const receipt = await this.findById(tenantId, receiptId, ['lines']); + if (!receipt) { + return { + success: false, + error: { code: 'NOT_FOUND', message: 'Receipt not found' }, + }; + } + + if (receipt.status !== ReceiptStatus.DRAFT && receipt.status !== ReceiptStatus.COMPLETED) { + return { + success: false, + error: { code: 'INVALID_STATUS', message: 'Receipt must be in draft or completed status to post' }, + }; + } + + // Update supplier order if linked + if (receipt.supplierOrderId) { + const receivedLines = receipt.lines + .filter(l => l.orderLineId) + .map(l => ({ + lineId: l.orderLineId!, + quantityReceived: l.quantityAccepted, + value: l.total, + })); + + if (receivedLines.length > 0) { + await this.supplierOrderService.updateOrderFromReceipt( + tenantId, + receipt.supplierOrderId, + receivedLines + ); + } + } + + // TODO: Create stock movements for inventory update + // This would integrate with the inventory module + receipt.stockMovementsCreated = true; + + receipt.status = ReceiptStatus.POSTED; + receipt.postedBy = userId; + receipt.postedAt = new Date(); + if (notes) { + receipt.notes = receipt.notes + ? `${receipt.notes}\n[Posted] ${notes}` + : `[Posted] ${notes}`; + } + + const saved = await queryRunner.manager.save(receipt); + await queryRunner.commitTransaction(); + + return { success: true, data: saved }; + } catch (error: any) { + await queryRunner.rollbackTransaction(); + return { + success: false, + error: { + code: 'POST_RECEIPT_FAILED', + message: error.message || 'Failed to post receipt', + details: error, + }, + }; + } finally { + await queryRunner.release(); + } + } + + /** + * Cancel receipt + */ + async cancelReceipt( + tenantId: string, + receiptId: string, + userId: string, + reason: string + ): Promise> { + const receipt = await this.findById(tenantId, receiptId); + if (!receipt) { + return { + success: false, + error: { code: 'NOT_FOUND', message: 'Receipt not found' }, + }; + } + + if (receipt.status === ReceiptStatus.POSTED) { + return { + success: false, + error: { code: 'INVALID_STATUS', message: 'Posted receipts cannot be cancelled' }, + }; + } + + receipt.status = ReceiptStatus.CANCELLED; + receipt.cancelledAt = new Date(); + receipt.cancelledBy = userId; + receipt.cancellationReason = reason; + + const saved = await this.repository.save(receipt); + return { success: true, data: saved }; + } + + /** + * List goods receipts with filters + */ + async listReceipts( + tenantId: string, + branchId: string, + query: { + page?: number; + limit?: number; + status?: ReceiptStatus; + supplierId?: string; + supplierOrderId?: string; + startDate?: Date; + endDate?: Date; + search?: string; + hasDiscrepancies?: boolean; + } + ): Promise> { + const { + page = 1, + limit = 20, + status, + supplierId, + supplierOrderId, + startDate, + endDate, + search, + hasDiscrepancies, + } = query; + + const qb = this.repository.createQueryBuilder('receipt') + .where('receipt.tenantId = :tenantId', { tenantId }) + .andWhere('receipt.branchId = :branchId', { branchId }); + + if (status) { + qb.andWhere('receipt.status = :status', { status }); + } + + if (supplierId) { + qb.andWhere('receipt.supplierId = :supplierId', { supplierId }); + } + + if (supplierOrderId) { + qb.andWhere('receipt.supplierOrderId = :supplierOrderId', { supplierOrderId }); + } + + if (startDate) { + qb.andWhere('receipt.receiptDate >= :startDate', { startDate }); + } + + if (endDate) { + qb.andWhere('receipt.receiptDate <= :endDate', { endDate }); + } + + if (search) { + qb.andWhere( + '(receipt.number ILIKE :search OR receipt.supplierName ILIKE :search OR receipt.supplierInvoiceNumber ILIKE :search)', + { search: `%${search}%` } + ); + } + + if (hasDiscrepancies !== undefined) { + qb.andWhere('receipt.hasDiscrepancies = :hasDiscrepancies', { hasDiscrepancies }); + } + + const total = await qb.getCount(); + + qb.orderBy('receipt.createdAt', 'DESC') + .skip((page - 1) * limit) + .take(limit); + + const data = await qb.getMany(); + const totalPages = Math.ceil(total / limit); + + return { + data, + pagination: { + page, + limit, + total, + totalPages, + hasNext: page < totalPages, + hasPrev: page > 1, + }, + }; + } + + /** + * Get receipts pending for an order + */ + async getReceiptsForOrder( + tenantId: string, + orderId: string + ): Promise { + return this.repository.find({ + where: { tenantId, supplierOrderId: orderId }, + relations: ['lines'], + order: { creiptDate: 'DESC' }, + }); + } + + /** + * Get receipt statistics + */ + async getReceiptStats( + tenantId: string, + branchId: string, + startDate: Date, + endDate: Date + ): Promise<{ + totalReceipts: number; + totalValue: number; + withDiscrepancies: number; + byStatus: Record; + avgProcessingTime: number; + }> { + const receipts = await this.repository.find({ + where: { tenantId, branchId }, + }); + + // Filter by date range + const filteredReceipts = receipts.filter(r => { + const receiptDate = new Date(r.receiptDate); + return receiptDate >= startDate && receiptDate <= endDate; + }); + + const totalValue = filteredReceipts.reduce((sum, r) => sum + Number(r.total), 0); + const withDiscrepancies = filteredReceipts.filter(r => r.hasDiscrepancies).length; + + // Group by status + const byStatus: Record = {}; + for (const receipt of filteredReceipts) { + byStatus[receipt.status] = (byStatus[receipt.status] || 0) + 1; + } + + // Calculate average processing time (from creation to posting) + const postedReceipts = filteredReceipts.filter(r => r.postedAt); + let totalProcessingTime = 0; + for (const receipt of postedReceipts) { + const processingTime = new Date(receipt.postedAt!).getTime() - new Date(receipt.createdAt).getTime(); + totalProcessingTime += processingTime; + } + const avgProcessingTime = postedReceipts.length > 0 + ? totalProcessingTime / postedReceipts.length / (1000 * 60 * 60) // Convert to hours + : 0; + + return { + totalReceipts: filteredReceipts.length, + totalValue: Math.round(totalValue * 100) / 100, + withDiscrepancies, + byStatus, + avgProcessingTime: Math.round(avgProcessingTime * 10) / 10, + }; + } +} diff --git a/backend/src/modules/purchases/services/index.ts b/backend/src/modules/purchases/services/index.ts new file mode 100644 index 0000000..4286cd7 --- /dev/null +++ b/backend/src/modules/purchases/services/index.ts @@ -0,0 +1,3 @@ +export * from './supplier-order.service'; +export * from './goods-receipt.service'; +export * from './purchase-suggestion.service'; diff --git a/backend/src/modules/purchases/services/purchase-suggestion.service.ts b/backend/src/modules/purchases/services/purchase-suggestion.service.ts new file mode 100644 index 0000000..9d741bb --- /dev/null +++ b/backend/src/modules/purchases/services/purchase-suggestion.service.ts @@ -0,0 +1,558 @@ +import { Repository, DataSource, In } from 'typeorm'; +import { BaseService } from '../../../shared/services/base.service'; +import { ServiceResult, PaginatedResult } from '../../../shared/types'; +import { + PurchaseSuggestion, + SuggestionStatus, + SuggestionReason, + SupplierOrder, + SupplierOrderLine, +} from '../entities'; +import { + CreateSuggestionInput, + UpdateSuggestionInput, + ReviewSuggestionInput, + ConvertSuggestionsInput, + GenerateSuggestionsInput, +} from '../validation/purchases.schema'; +import { SupplierOrderService } from './supplier-order.service'; + +export class PurchaseSuggestionService extends BaseService { + constructor( + private dataSource: DataSource, + private supplierOrderService: SupplierOrderService + ) { + super(dataSource.getRepository(PurchaseSuggestion)); + } + + /** + * Create manual suggestion + */ + async createSuggestion( + tenantId: string, + data: CreateSuggestionInput, + userId: string + ): Promise> { + try { + const suggestion = this.repository.create({ + tenantId, + branchId: data.branchId, + warehouseId: data.warehouseId, + status: SuggestionStatus.PENDING, + reason: data.reason || SuggestionReason.MANUAL, + productId: data.productId, + productCode: data.productCode, + productName: data.productName, + supplierId: data.supplierId, + supplierName: data.supplierName, + suggestedQuantity: data.suggestedQuantity, + estimatedUnitCost: data.estimatedUnitCost, + estimatedTotalCost: data.estimatedUnitCost + ? data.estimatedUnitCost * data.suggestedQuantity + : undefined, + priority: data.priority || 0, + dueDate: data.dueDate, + notes: data.notes, + calculationData: { + algorithm: 'manual', + salesPeriod: 0, + salesTotal: 0, + }, + expiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000), // 30 days + }); + + const saved = await this.repository.save(suggestion); + return { success: true, data: saved }; + } catch (error: any) { + return { + success: false, + error: { + code: 'CREATE_SUGGESTION_FAILED', + message: error.message || 'Failed to create suggestion', + details: error, + }, + }; + } + } + + /** + * Update suggestion + */ + async updateSuggestion( + tenantId: string, + suggestionId: string, + data: UpdateSuggestionInput, + userId: string + ): Promise> { + const suggestion = await this.findById(tenantId, suggestionId); + if (!suggestion) { + return { + success: false, + error: { code: 'NOT_FOUND', message: 'Suggestion not found' }, + }; + } + + if (suggestion.status !== SuggestionStatus.PENDING) { + return { + success: false, + error: { code: 'INVALID_STATUS', message: 'Only pending suggestions can be updated' }, + }; + } + + Object.assign(suggestion, data); + + // Recalculate estimated total if unit cost or quantity changed + if (data.estimatedUnitCost !== undefined || data.suggestedQuantity !== undefined) { + const unitCost = data.estimatedUnitCost ?? suggestion.estimatedUnitCost; + const quantity = data.suggestedQuantity ?? suggestion.suggestedQuantity; + if (unitCost) { + suggestion.estimatedTotalCost = unitCost * quantity; + } + } + + const saved = await this.repository.save(suggestion); + return { success: true, data: saved }; + } + + /** + * Review suggestion (approve, reject, or modify) + */ + async reviewSuggestion( + tenantId: string, + suggestionId: string, + userId: string, + review: ReviewSuggestionInput + ): Promise> { + const suggestion = await this.findById(tenantId, suggestionId); + if (!suggestion) { + return { + success: false, + error: { code: 'NOT_FOUND', message: 'Suggestion not found' }, + }; + } + + if (suggestion.status !== SuggestionStatus.PENDING && suggestion.status !== SuggestionStatus.REVIEWED) { + return { + success: false, + error: { code: 'INVALID_STATUS', message: 'Suggestion cannot be reviewed in current status' }, + }; + } + + suggestion.reviewedBy = userId; + suggestion.reviewedAt = new Date(); + if (review.notes) { + suggestion.reviewNotes = review.notes; + } + + switch (review.action) { + case 'approve': + suggestion.status = SuggestionStatus.APPROVED; + suggestion.approvedQuantity = review.approvedQuantity ?? suggestion.suggestedQuantity; + if (review.supplierId) { + suggestion.supplierId = review.supplierId; + } + break; + + case 'reject': + suggestion.status = SuggestionStatus.REJECTED; + break; + + case 'modify': + suggestion.status = SuggestionStatus.REVIEWED; + if (review.approvedQuantity) { + suggestion.approvedQuantity = review.approvedQuantity; + } + if (review.supplierId) { + suggestion.supplierId = review.supplierId; + } + break; + } + + const saved = await this.repository.save(suggestion); + return { success: true, data: saved }; + } + + /** + * Bulk review suggestions + */ + async bulkReviewSuggestions( + tenantId: string, + suggestionIds: string[], + userId: string, + action: 'approve' | 'reject', + notes?: string + ): Promise> { + const suggestions = await this.repository.find({ + where: { + tenantId, + id: In(suggestionIds), + status: In([SuggestionStatus.PENDING, SuggestionStatus.REVIEWED]), + }, + }); + + const failed: string[] = []; + let processed = 0; + + for (const suggestion of suggestions) { + try { + suggestion.reviewedBy = userId; + suggestion.reviewedAt = new Date(); + suggestion.reviewNotes = notes; + + if (action === 'approve') { + suggestion.status = SuggestionStatus.APPROVED; + suggestion.approvedQuantity = suggestion.suggestedQuantity; + } else { + suggestion.status = SuggestionStatus.REJECTED; + } + + await this.repository.save(suggestion); + processed++; + } catch (error) { + failed.push(suggestion.id); + } + } + + // Add IDs that weren't found + const foundIds = suggestions.map(s => s.id); + for (const id of suggestionIds) { + if (!foundIds.includes(id) && !failed.includes(id)) { + failed.push(id); + } + } + + return { + success: true, + data: { processed, failed }, + }; + } + + /** + * Convert approved suggestions to supplier orders + */ + async convertToOrders( + tenantId: string, + data: ConvertSuggestionsInput, + userId: string + ): Promise> { + const queryRunner = this.dataSource.createQueryRunner(); + await queryRunner.connect(); + await queryRunner.startTransaction(); + + try { + // Get approved suggestions + const suggestions = await this.repository.find({ + where: { + tenantId, + id: In(data.suggestionIds), + status: SuggestionStatus.APPROVED, + }, + }); + + if (suggestions.length === 0) { + return { + success: false, + error: { code: 'NO_SUGGESTIONS', message: 'No approved suggestions found' }, + }; + } + + const orders: SupplierOrder[] = []; + + if (data.groupBySupplier) { + // Group by supplier + const bySupplier = new Map(); + for (const suggestion of suggestions) { + const key = suggestion.supplierId || 'no_supplier'; + const existing = bySupplier.get(key) || []; + existing.push(suggestion); + bySupplier.set(key, existing); + } + + // Create one order per supplier + for (const [supplierId, supplierSuggestions] of bySupplier.entries()) { + if (supplierId === 'no_supplier') { + // Skip suggestions without supplier + continue; + } + + const firstSuggestion = supplierSuggestions[0]; + const lines = supplierSuggestions.map(s => ({ + productId: s.productId, + productCode: s.productCode, + productName: s.productName, + quantityOrdered: s.approvedQuantity || s.suggestedQuantity, + unitPrice: s.estimatedUnitCost || 0, + taxRate: 0.16, + })); + + const orderResult = await this.supplierOrderService.createOrder( + tenantId, + { + branchId: firstSuggestion.branchId, + warehouseId: firstSuggestion.warehouseId, + supplierId, + supplierName: firstSuggestion.supplierName || '', + orderDate: new Date(), + expectedDate: data.expectedDate, + lines, + }, + userId + ); + + if (orderResult.success && orderResult.data) { + orders.push(orderResult.data); + + // Update suggestions with order reference + for (const suggestion of supplierSuggestions) { + suggestion.status = SuggestionStatus.ORDERED; + suggestion.supplierOrderId = orderResult.data.id; + suggestion.supplierOrderNumber = orderResult.data.number; + suggestion.orderedQuantity = suggestion.approvedQuantity || suggestion.suggestedQuantity; + } + await queryRunner.manager.save(supplierSuggestions); + } + } + } else { + // Create individual orders for each suggestion + for (const suggestion of suggestions) { + if (!suggestion.supplierId) continue; + + const orderResult = await this.supplierOrderService.createOrder( + tenantId, + { + branchId: suggestion.branchId, + warehouseId: suggestion.warehouseId, + supplierId: suggestion.supplierId, + supplierName: suggestion.supplierName || '', + orderDate: new Date(), + expectedDate: data.expectedDate, + lines: [{ + productId: suggestion.productId, + productCode: suggestion.productCode, + productName: suggestion.productName, + quantityOrdered: suggestion.approvedQuantity || suggestion.suggestedQuantity, + unitPrice: suggestion.estimatedUnitCost || 0, + taxRate: 0.16, + }], + }, + userId + ); + + if (orderResult.success && orderResult.data) { + orders.push(orderResult.data); + + suggestion.status = SuggestionStatus.ORDERED; + suggestion.supplierOrderId = orderResult.data.id; + suggestion.supplierOrderNumber = orderResult.data.number; + suggestion.orderedQuantity = suggestion.approvedQuantity || suggestion.suggestedQuantity; + await queryRunner.manager.save(suggestion); + } + } + } + + await queryRunner.commitTransaction(); + + return { success: true, data: orders }; + } catch (error: any) { + await queryRunner.rollbackTransaction(); + return { + success: false, + error: { + code: 'CONVERT_TO_ORDERS_FAILED', + message: error.message || 'Failed to convert suggestions to orders', + details: error, + }, + }; + } finally { + await queryRunner.release(); + } + } + + /** + * Generate purchase suggestions based on stock levels + */ + async generateSuggestions( + tenantId: string, + branchId: string, + warehouseId: string, + data: GenerateSuggestionsInput, + userId: string + ): Promise> { + // This is a simplified implementation + // In a real scenario, this would: + // 1. Query inventory levels from the inventory module + // 2. Compare against reorder points and min stock levels + // 3. Optionally use sales forecast data + // 4. Generate suggestions for items below threshold + + try { + // TODO: Integrate with inventory module to get actual stock data + // For now, return a placeholder response + + // Mark expired suggestions + await this.repository + .createQueryBuilder() + .update(PurchaseSuggestion) + .set({ status: SuggestionStatus.EXPIRED }) + .where('tenantId = :tenantId', { tenantId }) + .andWhere('branchId = :branchId', { branchId }) + .andWhere('status = :status', { status: SuggestionStatus.PENDING }) + .andWhere('expiresAt < :now', { now: new Date() }) + .execute(); + + return { + success: true, + data: { + generated: 0, + suggestions: [], + }, + }; + } catch (error: any) { + return { + success: false, + error: { + code: 'GENERATE_SUGGESTIONS_FAILED', + message: error.message || 'Failed to generate suggestions', + details: error, + }, + }; + } + } + + /** + * List suggestions with filters + */ + async listSuggestions( + tenantId: string, + branchId: string, + query: { + page?: number; + limit?: number; + status?: SuggestionStatus; + reason?: SuggestionReason; + supplierId?: string; + priority?: number; + startDate?: Date; + endDate?: Date; + search?: string; + } + ): Promise> { + const { + page = 1, + limit = 20, + status, + reason, + supplierId, + priority, + startDate, + endDate, + search, + } = query; + + const qb = this.repository.createQueryBuilder('suggestion') + .where('suggestion.tenantId = :tenantId', { tenantId }) + .andWhere('suggestion.branchId = :branchId', { branchId }); + + if (status) { + qb.andWhere('suggestion.status = :status', { status }); + } + + if (reason) { + qb.andWhere('suggestion.reason = :reason', { reason }); + } + + if (supplierId) { + qb.andWhere('suggestion.supplierId = :supplierId', { supplierId }); + } + + if (priority !== undefined) { + qb.andWhere('suggestion.priority = :priority', { priority }); + } + + if (startDate) { + qb.andWhere('suggestion.createdAt >= :startDate', { startDate }); + } + + if (endDate) { + qb.andWhere('suggestion.createdAt <= :endDate', { endDate }); + } + + if (search) { + qb.andWhere( + '(suggestion.productCode ILIKE :search OR suggestion.productName ILIKE :search)', + { search: `%${search}%` } + ); + } + + const total = await qb.getCount(); + + qb.orderBy('suggestion.priority', 'DESC') + .addOrderBy('suggestion.createdAt', 'DESC') + .skip((page - 1) * limit) + .take(limit); + + const data = await qb.getMany(); + const totalPages = Math.ceil(total / limit); + + return { + data, + pagination: { + page, + limit, + total, + totalPages, + hasNext: page < totalPages, + hasPrev: page > 1, + }, + }; + } + + /** + * Get suggestion statistics + */ + async getSuggestionStats( + tenantId: string, + branchId: string + ): Promise<{ + pending: number; + approved: number; + rejected: number; + ordered: number; + byPriority: Record; + byReason: Record; + totalEstimatedValue: number; + }> { + const suggestions = await this.repository.find({ + where: { tenantId, branchId }, + }); + + const pending = suggestions.filter(s => s.status === SuggestionStatus.PENDING).length; + const approved = suggestions.filter(s => s.status === SuggestionStatus.APPROVED).length; + const rejected = suggestions.filter(s => s.status === SuggestionStatus.REJECTED).length; + const ordered = suggestions.filter(s => s.status === SuggestionStatus.ORDERED).length; + + const byPriority: Record = { 0: 0, 1: 0, 2: 0, 3: 0 }; + const byReason: Record = {}; + let totalEstimatedValue = 0; + + for (const suggestion of suggestions) { + if (suggestion.status === SuggestionStatus.PENDING || suggestion.status === SuggestionStatus.APPROVED) { + byPriority[suggestion.priority] = (byPriority[suggestion.priority] || 0) + 1; + byReason[suggestion.reason] = (byReason[suggestion.reason] || 0) + 1; + if (suggestion.estimatedTotalCost) { + totalEstimatedValue += suggestion.estimatedTotalCost; + } + } + } + + return { + pending, + approved, + rejected, + ordered, + byPriority, + byReason, + totalEstimatedValue: Math.round(totalEstimatedValue * 100) / 100, + }; + } +} diff --git a/backend/src/modules/purchases/services/supplier-order.service.ts b/backend/src/modules/purchases/services/supplier-order.service.ts new file mode 100644 index 0000000..f3727da --- /dev/null +++ b/backend/src/modules/purchases/services/supplier-order.service.ts @@ -0,0 +1,796 @@ +import { Repository, DataSource, In } from 'typeorm'; +import { BaseService } from '../../../shared/services/base.service'; +import { ServiceResult, PaginatedResult } from '../../../shared/types'; +import { + SupplierOrder, + SupplierOrderStatus, + SupplierOrderLine, + OrderLineStatus, +} from '../entities'; +import { + CreateSupplierOrderInput, + UpdateSupplierOrderInput, +} from '../validation/purchases.schema'; + +export class SupplierOrderService extends BaseService { + private lineRepository: Repository; + + constructor( + private dataSource: DataSource + ) { + super(dataSource.getRepository(SupplierOrder)); + this.lineRepository = dataSource.getRepository(SupplierOrderLine); + } + + /** + * Generate order number + */ + private async generateOrderNumber(tenantId: string): Promise { + const date = new Date(); + const year = date.getFullYear().toString().slice(-2); + const month = (date.getMonth() + 1).toString().padStart(2, '0'); + + const count = await this.repository.count({ + where: { tenantId }, + }); + + const sequence = (count + 1).toString().padStart(5, '0'); + return `PO-${year}${month}-${sequence}`; + } + + /** + * Calculate order totals + */ + private calculateTotals(lines: SupplierOrderLine[], discountPercent = 0, shippingAmount = 0): { + subtotal: number; + discountAmount: number; + taxAmount: number; + total: number; + } { + let subtotal = 0; + let taxAmount = 0; + + for (const line of lines) { + const lineSubtotal = line.quantityOrdered * line.unitPrice; + const lineDiscount = line.discountAmount || (lineSubtotal * (line.discountPercent || 0) / 100); + const lineNet = lineSubtotal - lineDiscount; + const lineTax = lineNet * line.taxRate; + + subtotal += lineNet; + taxAmount += lineTax; + } + + const discountAmount = subtotal * (discountPercent / 100); + const total = subtotal - discountAmount + taxAmount + shippingAmount; + + return { + subtotal: Math.round(subtotal * 100) / 100, + discountAmount: Math.round(discountAmount * 100) / 100, + taxAmount: Math.round(taxAmount * 100) / 100, + total: Math.round(total * 100) / 100, + }; + } + + /** + * Create a new supplier order + */ + async createOrder( + tenantId: string, + data: CreateSupplierOrderInput, + userId: string + ): Promise> { + const queryRunner = this.dataSource.createQueryRunner(); + await queryRunner.connect(); + await queryRunner.startTransaction(); + + try { + const number = await this.generateOrderNumber(tenantId); + + // Create order lines + const lines: SupplierOrderLine[] = data.lines.map((lineData, index) => { + const lineSubtotal = lineData.quantityOrdered * lineData.unitPrice; + const lineDiscount = lineData.discountAmount || (lineSubtotal * (lineData.discountPercent || 0) / 100); + const lineNet = lineSubtotal - lineDiscount; + const lineTax = lineNet * (lineData.taxRate || 0.16); + + return queryRunner.manager.create(SupplierOrderLine, { + tenantId, + lineNumber: index + 1, + status: OrderLineStatus.PENDING, + productId: lineData.productId, + productCode: lineData.productCode, + productName: lineData.productName, + supplierProductCode: lineData.supplierProductCode, + variantId: lineData.variantId, + variantName: lineData.variantName, + uomId: lineData.uomId, + uomName: lineData.uomName || 'PZA', + quantityOrdered: lineData.quantityOrdered, + quantityPending: lineData.quantityOrdered, + quantityReceived: 0, + quantityCancelled: 0, + unitPrice: lineData.unitPrice, + discountPercent: lineData.discountPercent || 0, + discountAmount: lineDiscount, + taxId: lineData.taxId, + taxRate: lineData.taxRate || 0.16, + taxAmount: lineTax, + subtotal: lineNet, + total: lineNet + lineTax, + receivedValue: 0, + expectedDate: lineData.expectedDate, + notes: lineData.notes, + }); + }); + + const totals = this.calculateTotals( + lines, + data.discountPercent || 0, + data.shippingAmount || 0 + ); + + // Create order + const order = queryRunner.manager.create(SupplierOrder, { + tenantId, + branchId: data.branchId, + warehouseId: data.warehouseId, + number, + status: SupplierOrderStatus.DRAFT, + supplierId: data.supplierId, + supplierCode: data.supplierCode, + supplierName: data.supplierName, + supplierContact: data.supplierContact, + supplierEmail: data.supplierEmail, + supplierPhone: data.supplierPhone, + orderDate: data.orderDate, + expectedDate: data.expectedDate, + linesCount: lines.length, + ...totals, + discountPercent: data.discountPercent || 0, + shippingAmount: data.shippingAmount || 0, + currencyCode: data.currencyCode || 'MXN', + exchangeRate: data.exchangeRate || 1, + paymentTerms: data.paymentTerms, + shippingMethod: data.shippingMethod, + shippingAddress: data.shippingAddress, + quotationReference: data.quotationReference, + createdBy: userId, + notes: data.notes, + internalNotes: data.internalNotes, + }); + + const savedOrder = await queryRunner.manager.save(order); + + // Save lines with order reference + for (const line of lines) { + line.orderId = savedOrder.id; + } + await queryRunner.manager.save(lines); + + await queryRunner.commitTransaction(); + + // Return with lines + const result = await this.findById(tenantId, savedOrder.id, ['lines']); + return { success: true, data: result! }; + } catch (error: any) { + await queryRunner.rollbackTransaction(); + return { + success: false, + error: { + code: 'CREATE_ORDER_FAILED', + message: error.message || 'Failed to create supplier order', + details: error, + }, + }; + } finally { + await queryRunner.release(); + } + } + + /** + * Update supplier order + */ + async updateOrder( + tenantId: string, + orderId: string, + data: UpdateSupplierOrderInput, + userId: string + ): Promise> { + const queryRunner = this.dataSource.createQueryRunner(); + await queryRunner.connect(); + await queryRunner.startTransaction(); + + try { + const order = await this.findById(tenantId, orderId, ['lines']); + if (!order) { + return { + success: false, + error: { code: 'NOT_FOUND', message: 'Order not found' }, + }; + } + + if (order.status !== SupplierOrderStatus.DRAFT) { + return { + success: false, + error: { code: 'INVALID_STATUS', message: 'Only draft orders can be updated' }, + }; + } + + // Update lines if provided + if (data.lines) { + // Remove existing lines + await queryRunner.manager.delete(SupplierOrderLine, { orderId }); + + // Create new lines + const lines: SupplierOrderLine[] = data.lines.map((lineData, index) => { + const lineSubtotal = lineData.quantityOrdered * lineData.unitPrice; + const lineDiscount = lineData.discountAmount || (lineSubtotal * (lineData.discountPercent || 0) / 100); + const lineNet = lineSubtotal - lineDiscount; + const lineTax = lineNet * (lineData.taxRate || 0.16); + + return queryRunner.manager.create(SupplierOrderLine, { + tenantId, + orderId, + lineNumber: index + 1, + status: OrderLineStatus.PENDING, + productId: lineData.productId, + productCode: lineData.productCode, + productName: lineData.productName, + supplierProductCode: lineData.supplierProductCode, + variantId: lineData.variantId, + variantName: lineData.variantName, + uomId: lineData.uomId, + uomName: lineData.uomName || 'PZA', + quantityOrdered: lineData.quantityOrdered, + quantityPending: lineData.quantityOrdered, + quantityReceived: 0, + quantityCancelled: 0, + unitPrice: lineData.unitPrice, + discountPercent: lineData.discountPercent || 0, + discountAmount: lineDiscount, + taxId: lineData.taxId, + taxRate: lineData.taxRate || 0.16, + taxAmount: lineTax, + subtotal: lineNet, + total: lineNet + lineTax, + receivedValue: 0, + expectedDate: lineData.expectedDate, + notes: lineData.notes, + }); + }); + + await queryRunner.manager.save(lines); + + const totals = this.calculateTotals( + lines, + data.discountPercent ?? order.discountPercent, + data.shippingAmount ?? order.shippingAmount + ); + + Object.assign(order, { + linesCount: lines.length, + ...totals, + }); + } + + // Update order fields + const { lines: _, ...updateData } = data; + Object.assign(order, updateData); + + const savedOrder = await queryRunner.manager.save(order); + await queryRunner.commitTransaction(); + + const result = await this.findById(tenantId, savedOrder.id, ['lines']); + return { success: true, data: result! }; + } catch (error: any) { + await queryRunner.rollbackTransaction(); + return { + success: false, + error: { + code: 'UPDATE_ORDER_FAILED', + message: error.message || 'Failed to update order', + details: error, + }, + }; + } finally { + await queryRunner.release(); + } + } + + /** + * Submit order for approval + */ + async submitForApproval( + tenantId: string, + orderId: string, + userId: string, + notes?: string + ): Promise> { + const order = await this.findById(tenantId, orderId); + if (!order) { + return { + success: false, + error: { code: 'NOT_FOUND', message: 'Order not found' }, + }; + } + + if (order.status !== SupplierOrderStatus.DRAFT) { + return { + success: false, + error: { code: 'INVALID_STATUS', message: 'Order must be in draft status' }, + }; + } + + order.status = SupplierOrderStatus.PENDING_APPROVAL; + if (notes) { + order.internalNotes = order.internalNotes + ? `${order.internalNotes}\n[Submitted] ${notes}` + : `[Submitted] ${notes}`; + } + + const saved = await this.repository.save(order); + return { success: true, data: saved }; + } + + /** + * Approve order + */ + async approveOrder( + tenantId: string, + orderId: string, + userId: string, + notes?: string + ): Promise> { + const order = await this.findById(tenantId, orderId); + if (!order) { + return { + success: false, + error: { code: 'NOT_FOUND', message: 'Order not found' }, + }; + } + + if (order.status !== SupplierOrderStatus.PENDING_APPROVAL) { + return { + success: false, + error: { code: 'INVALID_STATUS', message: 'Order must be pending approval' }, + }; + } + + order.status = SupplierOrderStatus.APPROVED; + order.approvedBy = userId; + order.approvedAt = new Date(); + if (notes) { + order.internalNotes = order.internalNotes + ? `${order.internalNotes}\n[Approved] ${notes}` + : `[Approved] ${notes}`; + } + + const saved = await this.repository.save(order); + return { success: true, data: saved }; + } + + /** + * Reject order + */ + async rejectOrder( + tenantId: string, + orderId: string, + userId: string, + reason: string + ): Promise> { + const order = await this.findById(tenantId, orderId); + if (!order) { + return { + success: false, + error: { code: 'NOT_FOUND', message: 'Order not found' }, + }; + } + + if (order.status !== SupplierOrderStatus.PENDING_APPROVAL) { + return { + success: false, + error: { code: 'INVALID_STATUS', message: 'Order must be pending approval' }, + }; + } + + order.status = SupplierOrderStatus.DRAFT; + order.internalNotes = order.internalNotes + ? `${order.internalNotes}\n[Rejected] ${reason}` + : `[Rejected] ${reason}`; + + const saved = await this.repository.save(order); + return { success: true, data: saved }; + } + + /** + * Send order to supplier + */ + async sendToSupplier( + tenantId: string, + orderId: string, + userId: string, + method: 'email' | 'manual', + emailTo?: string, + notes?: string + ): Promise> { + const order = await this.findById(tenantId, orderId); + if (!order) { + return { + success: false, + error: { code: 'NOT_FOUND', message: 'Order not found' }, + }; + } + + if (order.status !== SupplierOrderStatus.APPROVED) { + return { + success: false, + error: { code: 'INVALID_STATUS', message: 'Order must be approved before sending' }, + }; + } + + order.status = SupplierOrderStatus.SENT; + order.sentAt = new Date(); + order.sentBy = userId; + + if (method === 'email' && emailTo) { + // TODO: Implement actual email sending + order.emailSent = true; + order.emailSentAt = new Date(); + order.emailSentTo = emailTo; + } + + if (notes) { + order.internalNotes = order.internalNotes + ? `${order.internalNotes}\n[Sent] ${notes}` + : `[Sent] ${notes}`; + } + + const saved = await this.repository.save(order); + return { success: true, data: saved }; + } + + /** + * Confirm order by supplier + */ + async supplierConfirmation( + tenantId: string, + orderId: string, + supplierReference?: string, + expectedDate?: Date, + notes?: string + ): Promise> { + const order = await this.findById(tenantId, orderId); + if (!order) { + return { + success: false, + error: { code: 'NOT_FOUND', message: 'Order not found' }, + }; + } + + if (order.status !== SupplierOrderStatus.SENT) { + return { + success: false, + error: { code: 'INVALID_STATUS', message: 'Order must be sent to supplier' }, + }; + } + + order.status = SupplierOrderStatus.CONFIRMED; + order.confirmedAt = new Date(); + if (supplierReference) { + order.supplierReference = supplierReference; + } + if (expectedDate) { + order.expectedDate = expectedDate; + } + if (notes) { + order.notes = order.notes + ? `${order.notes}\n[Confirmed] ${notes}` + : `[Confirmed] ${notes}`; + } + + const saved = await this.repository.save(order); + return { success: true, data: saved }; + } + + /** + * Cancel order + */ + async cancelOrder( + tenantId: string, + orderId: string, + userId: string, + reason: string + ): Promise> { + const order = await this.findById(tenantId, orderId, ['lines']); + if (!order) { + return { + success: false, + error: { code: 'NOT_FOUND', message: 'Order not found' }, + }; + } + + const cancellableStatuses = [ + SupplierOrderStatus.DRAFT, + SupplierOrderStatus.PENDING_APPROVAL, + SupplierOrderStatus.APPROVED, + SupplierOrderStatus.SENT, + SupplierOrderStatus.CONFIRMED, + ]; + + if (!cancellableStatuses.includes(order.status)) { + return { + success: false, + error: { code: 'INVALID_STATUS', message: 'Order cannot be cancelled in current status' }, + }; + } + + order.status = SupplierOrderStatus.CANCELLED; + order.cancelledAt = new Date(); + order.cancelledBy = userId; + order.cancellationReason = reason; + + // Cancel all pending lines + for (const line of order.lines) { + if (line.status === OrderLineStatus.PENDING) { + line.status = OrderLineStatus.CANCELLED; + line.quantityCancelled = line.quantityPending; + line.quantityPending = 0; + } + } + + await this.lineRepository.save(order.lines); + const saved = await this.repository.save(order); + + return { success: true, data: saved }; + } + + /** + * Update order status based on receipts + */ + async updateOrderFromReceipt( + tenantId: string, + orderId: string, + receivedLines: { lineId: string; quantityReceived: number; value: number }[] + ): Promise> { + const queryRunner = this.dataSource.createQueryRunner(); + await queryRunner.connect(); + await queryRunner.startTransaction(); + + try { + const order = await this.findById(tenantId, orderId, ['lines']); + if (!order) { + return { + success: false, + error: { code: 'NOT_FOUND', message: 'Order not found' }, + }; + } + + let allReceived = true; + let anyReceived = false; + + for (const receivedLine of receivedLines) { + const line = order.lines.find(l => l.id === receivedLine.lineId); + if (line) { + line.quantityReceived += receivedLine.quantityReceived; + line.quantityPending = Math.max(0, line.quantityOrdered - line.quantityReceived - line.quantityCancelled); + line.receivedValue += receivedLine.value; + line.lastReceiptDate = new Date() as any; + line.lastReceiptQuantity = receivedLine.quantityReceived; + + if (line.quantityPending === 0) { + line.status = OrderLineStatus.RECEIVED; + } else if (line.quantityReceived > 0) { + line.status = OrderLineStatus.PARTIALLY_RECEIVED; + } + + if (line.quantityPending > 0) { + allReceived = false; + } + if (line.quantityReceived > 0) { + anyReceived = true; + } + } + } + + await queryRunner.manager.save(order.lines); + + // Update order totals and status + order.totalReceivedValue = order.lines.reduce((sum, l) => sum + l.receivedValue, 0); + order.lastReceiptDate = new Date(); + + if (allReceived) { + order.status = SupplierOrderStatus.RECEIVED; + } else if (anyReceived) { + order.status = SupplierOrderStatus.PARTIALLY_RECEIVED; + } + + const saved = await queryRunner.manager.save(order); + await queryRunner.commitTransaction(); + + return { success: true, data: saved }; + } catch (error: any) { + await queryRunner.rollbackTransaction(); + return { + success: false, + error: { + code: 'UPDATE_FROM_RECEIPT_FAILED', + message: error.message || 'Failed to update order from receipt', + details: error, + }, + }; + } finally { + await queryRunner.release(); + } + } + + /** + * List supplier orders with filters + */ + async listOrders( + tenantId: string, + branchId: string, + query: { + page?: number; + limit?: number; + status?: SupplierOrderStatus; + supplierId?: string; + startDate?: Date; + endDate?: Date; + search?: string; + } + ): Promise> { + const { + page = 1, + limit = 20, + status, + supplierId, + startDate, + endDate, + search, + } = query; + + const qb = this.repository.createQueryBuilder('order') + .where('order.tenantId = :tenantId', { tenantId }) + .andWhere('order.branchId = :branchId', { branchId }); + + if (status) { + qb.andWhere('order.status = :status', { status }); + } + + if (supplierId) { + qb.andWhere('order.supplierId = :supplierId', { supplierId }); + } + + if (startDate) { + qb.andWhere('order.orderDate >= :startDate', { startDate }); + } + + if (endDate) { + qb.andWhere('order.orderDate <= :endDate', { endDate }); + } + + if (search) { + qb.andWhere( + '(order.number ILIKE :search OR order.supplierName ILIKE :search OR order.supplierReference ILIKE :search)', + { search: `%${search}%` } + ); + } + + const total = await qb.getCount(); + + qb.orderBy('order.createdAt', 'DESC') + .skip((page - 1) * limit) + .take(limit); + + const data = await qb.getMany(); + const totalPages = Math.ceil(total / limit); + + return { + data, + pagination: { + page, + limit, + total, + totalPages, + hasNext: page < totalPages, + hasPrev: page > 1, + }, + }; + } + + /** + * Get pending orders by supplier + */ + async getPendingOrdersBySupplier( + tenantId: string, + supplierId: string + ): Promise { + return this.repository.find({ + where: { + tenantId, + supplierId, + status: In([ + SupplierOrderStatus.APPROVED, + SupplierOrderStatus.SENT, + SupplierOrderStatus.CONFIRMED, + SupplierOrderStatus.PARTIALLY_RECEIVED, + ]), + }, + relations: ['lines'], + order: { orderDate: 'ASC' }, + }); + } + + /** + * Get order statistics + */ + async getOrderStats( + tenantId: string, + branchId: string, + startDate: Date, + endDate: Date + ): Promise<{ + totalOrders: number; + totalValue: number; + avgOrderValue: number; + byStatus: Record; + topSuppliers: { supplierId: string; supplierName: string; orders: number; value: number }[]; + }> { + const orders = await this.repository.find({ + where: { + tenantId, + branchId, + }, + }); + + // Filter by date range + const filteredOrders = orders.filter(o => { + const orderDate = new Date(o.orderDate); + return orderDate >= startDate && orderDate <= endDate; + }); + + const totalValue = filteredOrders.reduce((sum, o) => sum + Number(o.total), 0); + const avgOrderValue = filteredOrders.length > 0 ? totalValue / filteredOrders.length : 0; + + // Group by status + const byStatus: Record = {}; + for (const order of filteredOrders) { + if (!byStatus[order.status]) { + byStatus[order.status] = { count: 0, value: 0 }; + } + byStatus[order.status].count++; + byStatus[order.status].value += Number(order.total); + } + + // Group by supplier + const supplierMap = new Map(); + for (const order of filteredOrders) { + const existing = supplierMap.get(order.supplierId) || { + name: order.supplierName, + orders: 0, + value: 0, + }; + existing.orders++; + existing.value += Number(order.total); + supplierMap.set(order.supplierId, existing); + } + + const topSuppliers = Array.from(supplierMap.entries()) + .map(([supplierId, data]) => ({ + supplierId, + supplierName: data.name, + orders: data.orders, + value: data.value, + })) + .sort((a, b) => b.value - a.value) + .slice(0, 10); + + return { + totalOrders: filteredOrders.length, + totalValue: Math.round(totalValue * 100) / 100, + avgOrderValue: Math.round(avgOrderValue * 100) / 100, + byStatus, + topSuppliers, + }; + } +} diff --git a/backend/src/modules/purchases/validation/index.ts b/backend/src/modules/purchases/validation/index.ts new file mode 100644 index 0000000..00eee6e --- /dev/null +++ b/backend/src/modules/purchases/validation/index.ts @@ -0,0 +1 @@ +export * from './purchases.schema'; diff --git a/backend/src/modules/purchases/validation/purchases.schema.ts b/backend/src/modules/purchases/validation/purchases.schema.ts new file mode 100644 index 0000000..a358bd5 --- /dev/null +++ b/backend/src/modules/purchases/validation/purchases.schema.ts @@ -0,0 +1,385 @@ +import { z } from 'zod'; +import { + uuidSchema, + moneySchema, + quantitySchema, + percentSchema, + paginationSchema, + addressSchema, + emailSchema, + phoneSchema, +} from '../../../shared/validation/common.schema'; + +// ==================== ENUMS ==================== + +export const supplierOrderStatusEnum = z.enum([ + 'draft', + 'pending_approval', + 'approved', + 'sent', + 'confirmed', + 'partially_received', + 'received', + 'cancelled', +]); + +export const orderLineStatusEnum = z.enum([ + 'pending', + 'partially_received', + 'received', + 'cancelled', +]); + +export const receiptStatusEnum = z.enum([ + 'draft', + 'in_progress', + 'completed', + 'posted', + 'cancelled', +]); + +export const suggestionStatusEnum = z.enum([ + 'pending', + 'reviewed', + 'approved', + 'ordered', + 'rejected', + 'expired', +]); + +export const suggestionReasonEnum = z.enum([ + 'low_stock', + 'out_of_stock', + 'reorder_point', + 'sales_forecast', + 'seasonal', + 'manual', +]); + +export const qualityStatusEnum = z.enum([ + 'not_checked', + 'passed', + 'failed', + 'partial', +]); + +// ==================== SUPPLIER ORDER SCHEMAS ==================== + +// Order line schema +export const supplierOrderLineSchema = z.object({ + productId: uuidSchema, + productCode: z.string().min(1).max(50), + productName: z.string().min(1).max(200), + supplierProductCode: z.string().max(50).optional(), + variantId: uuidSchema.optional(), + variantName: z.string().max(200).optional(), + uomId: uuidSchema.optional(), + uomName: z.string().max(20).default('PZA'), + quantityOrdered: quantitySchema.positive('Quantity must be positive'), + unitPrice: moneySchema, + discountPercent: percentSchema.optional(), + discountAmount: moneySchema.optional(), + taxId: uuidSchema.optional(), + taxRate: z.coerce.number().min(0).max(1).default(0.16), + expectedDate: z.coerce.date().optional(), + notes: z.string().max(500).optional(), +}); + +// Create supplier order schema +export const createSupplierOrderSchema = z.object({ + branchId: uuidSchema, + warehouseId: uuidSchema, + supplierId: uuidSchema, + supplierCode: z.string().max(50).optional(), + supplierName: z.string().min(1).max(200), + supplierContact: z.string().max(100).optional(), + supplierEmail: emailSchema.optional(), + supplierPhone: phoneSchema.optional(), + orderDate: z.coerce.date(), + expectedDate: z.coerce.date().optional(), + currencyCode: z.string().length(3).default('MXN'), + exchangeRate: z.coerce.number().positive().default(1), + paymentTerms: z.string().max(50).optional(), + shippingMethod: z.string().max(100).optional(), + shippingAddress: addressSchema.optional(), + discountPercent: percentSchema.optional(), + discountAmount: moneySchema.optional(), + shippingAmount: moneySchema.optional(), + quotationReference: z.string().max(50).optional(), + lines: z.array(supplierOrderLineSchema).min(1, 'At least one line is required'), + notes: z.string().max(1000).optional(), + internalNotes: z.string().max(1000).optional(), +}); + +// Update supplier order schema +export const updateSupplierOrderSchema = createSupplierOrderSchema.partial().omit({ + branchId: true, + warehouseId: true, +}).extend({ + lines: z.array(supplierOrderLineSchema.extend({ + id: uuidSchema.optional(), // For updates + })).optional(), +}); + +// Submit for approval schema +export const submitForApprovalSchema = z.object({ + notes: z.string().max(500).optional(), +}); + +// Approve order schema +export const approveOrderSchema = z.object({ + notes: z.string().max(500).optional(), +}); + +// Reject order schema +export const rejectOrderSchema = z.object({ + reason: z.string().min(1, 'Rejection reason is required').max(500), +}); + +// Send order to supplier schema +export const sendToSupplierSchema = z.object({ + method: z.enum(['email', 'manual']), + emailTo: emailSchema.optional(), + notes: z.string().max(500).optional(), +}); + +// Supplier confirmation schema +export const supplierConfirmationSchema = z.object({ + supplierReference: z.string().max(50).optional(), + expectedDate: z.coerce.date().optional(), + notes: z.string().max(500).optional(), +}); + +// Cancel order schema +export const cancelOrderSchema = z.object({ + reason: z.string().min(1, 'Cancellation reason is required').max(255), +}); + +// List supplier orders query schema +export const listSupplierOrdersQuerySchema = paginationSchema.extend({ + status: supplierOrderStatusEnum.optional(), + supplierId: uuidSchema.optional(), + startDate: z.coerce.date().optional(), + endDate: z.coerce.date().optional(), + search: z.string().optional(), +}); + +// ==================== GOODS RECEIPT SCHEMAS ==================== + +// Receipt line schema +export const receiptLineSchema = z.object({ + orderLineId: uuidSchema.optional(), + productId: uuidSchema, + productCode: z.string().min(1).max(50), + productName: z.string().min(1).max(200), + variantId: uuidSchema.optional(), + variantName: z.string().max(200).optional(), + uomId: uuidSchema.optional(), + uomName: z.string().max(20).default('PZA'), + quantityExpected: quantitySchema.optional(), + quantityReceived: quantitySchema.min(0), + quantityRejected: quantitySchema.optional(), + unitCost: moneySchema, + taxRate: z.coerce.number().min(0).max(1).default(0.16), + locationId: uuidSchema.optional(), + locationCode: z.string().max(50).optional(), + lotNumber: z.string().max(50).optional(), + serialNumbers: z.array(z.string().max(100)).optional(), + expiryDate: z.coerce.date().optional(), + manufactureDate: z.coerce.date().optional(), + qualityStatus: qualityStatusEnum.optional(), + qualityNotes: z.string().max(500).optional(), + rejectionReason: z.string().max(255).optional(), + notes: z.string().max(500).optional(), +}); + +// Create goods receipt schema +export const createGoodsReceiptSchema = z.object({ + branchId: uuidSchema, + warehouseId: uuidSchema, + receiptDate: z.coerce.date(), + supplierOrderId: uuidSchema.optional(), + supplierId: uuidSchema, + supplierName: z.string().min(1).max(200), + supplierInvoiceNumber: z.string().max(50).optional(), + supplierDeliveryNote: z.string().max(50).optional(), + currencyCode: z.string().length(3).default('MXN'), + exchangeRate: z.coerce.number().positive().default(1), + deliveryPerson: z.string().max(100).optional(), + vehiclePlate: z.string().max(20).optional(), + lines: z.array(receiptLineSchema).min(1, 'At least one line is required'), + notes: z.string().max(1000).optional(), +}); + +// Update goods receipt schema +export const updateGoodsReceiptSchema = createGoodsReceiptSchema.partial().omit({ + branchId: true, + warehouseId: true, + supplierOrderId: true, +}).extend({ + lines: z.array(receiptLineSchema.extend({ + id: uuidSchema.optional(), + })).optional(), +}); + +// Create receipt from order schema +export const createReceiptFromOrderSchema = z.object({ + supplierOrderId: uuidSchema, + receiptDate: z.coerce.date(), + supplierInvoiceNumber: z.string().max(50).optional(), + supplierDeliveryNote: z.string().max(50).optional(), + deliveryPerson: z.string().max(100).optional(), + vehiclePlate: z.string().max(20).optional(), + lines: z.array(z.object({ + orderLineId: uuidSchema, + quantityReceived: quantitySchema.min(0), + quantityRejected: quantitySchema.optional(), + locationId: uuidSchema.optional(), + lotNumber: z.string().max(50).optional(), + serialNumbers: z.array(z.string().max(100)).optional(), + expiryDate: z.coerce.date().optional(), + qualityStatus: qualityStatusEnum.optional(), + qualityNotes: z.string().max(500).optional(), + rejectionReason: z.string().max(255).optional(), + notes: z.string().max(500).optional(), + })).min(1), + notes: z.string().max(1000).optional(), +}); + +// Complete quality check schema +export const completeQualityCheckSchema = z.object({ + passed: z.boolean(), + notes: z.string().max(1000).optional(), + lineChecks: z.array(z.object({ + lineId: uuidSchema, + qualityStatus: qualityStatusEnum, + notes: z.string().max(500).optional(), + rejectionReason: z.string().max(255).optional(), + })).optional(), +}); + +// Post receipt schema +export const postReceiptSchema = z.object({ + notes: z.string().max(500).optional(), +}); + +// Cancel receipt schema +export const cancelReceiptSchema = z.object({ + reason: z.string().min(1, 'Cancellation reason is required').max(255), +}); + +// List receipts query schema +export const listReceiptsQuerySchema = paginationSchema.extend({ + status: receiptStatusEnum.optional(), + supplierId: uuidSchema.optional(), + supplierOrderId: uuidSchema.optional(), + startDate: z.coerce.date().optional(), + endDate: z.coerce.date().optional(), + search: z.string().optional(), + hasDiscrepancies: z.coerce.boolean().optional(), +}); + +// ==================== PURCHASE SUGGESTION SCHEMAS ==================== + +// Create manual suggestion schema +export const createSuggestionSchema = z.object({ + branchId: uuidSchema, + warehouseId: uuidSchema, + productId: uuidSchema, + productCode: z.string().min(1).max(50), + productName: z.string().min(1).max(200), + supplierId: uuidSchema.optional(), + supplierName: z.string().max(200).optional(), + reason: suggestionReasonEnum.default('manual'), + suggestedQuantity: quantitySchema.positive('Quantity must be positive'), + estimatedUnitCost: moneySchema.optional(), + priority: z.coerce.number().int().min(0).max(3).default(0), + dueDate: z.coerce.date().optional(), + notes: z.string().max(1000).optional(), +}); + +// Update suggestion schema +export const updateSuggestionSchema = z.object({ + supplierId: uuidSchema.optional(), + supplierName: z.string().max(200).optional(), + suggestedQuantity: quantitySchema.positive().optional(), + estimatedUnitCost: moneySchema.optional(), + priority: z.coerce.number().int().min(0).max(3).optional(), + dueDate: z.coerce.date().optional().nullable(), + notes: z.string().max(1000).optional(), +}); + +// Review suggestion schema +export const reviewSuggestionSchema = z.object({ + action: z.enum(['approve', 'reject', 'modify']), + approvedQuantity: quantitySchema.optional(), + supplierId: uuidSchema.optional(), + notes: z.string().max(500).optional(), +}); + +// Bulk review suggestions schema +export const bulkReviewSuggestionsSchema = z.object({ + suggestionIds: z.array(uuidSchema).min(1), + action: z.enum(['approve', 'reject']), + notes: z.string().max(500).optional(), +}); + +// Convert suggestions to orders schema +export const convertSuggestionsToOrdersSchema = z.object({ + suggestionIds: z.array(uuidSchema).min(1), + groupBySupplier: z.boolean().default(true), + expectedDate: z.coerce.date().optional(), +}); + +// Generate suggestions schema +export const generateSuggestionsSchema = z.object({ + warehouseId: uuidSchema.optional(), + categoryIds: z.array(uuidSchema).optional(), + includeOutOfStock: z.boolean().default(true), + includeBelowReorderPoint: z.boolean().default(true), + useSalesForecast: z.boolean().default(false), + forecastDays: z.coerce.number().int().min(7).max(90).default(30), +}); + +// List suggestions query schema +export const listSuggestionsQuerySchema = paginationSchema.extend({ + status: suggestionStatusEnum.optional(), + reason: suggestionReasonEnum.optional(), + supplierId: uuidSchema.optional(), + priority: z.coerce.number().int().min(0).max(3).optional(), + startDate: z.coerce.date().optional(), + endDate: z.coerce.date().optional(), + search: z.string().optional(), +}); + +// ==================== REPORT SCHEMAS ==================== + +// Purchases summary report schema +export const purchasesSummaryReportSchema = z.object({ + startDate: z.coerce.date(), + endDate: z.coerce.date(), + groupBy: z.enum(['day', 'week', 'month', 'supplier', 'category']).default('day'), + supplierId: uuidSchema.optional(), + warehouseId: uuidSchema.optional(), +}); + +// Supplier performance report schema +export const supplierPerformanceReportSchema = z.object({ + startDate: z.coerce.date(), + endDate: z.coerce.date(), + supplierIds: z.array(uuidSchema).optional(), +}); + +// Type exports +export type CreateSupplierOrderInput = z.infer; +export type UpdateSupplierOrderInput = z.infer; +export type SupplierOrderLineInput = z.infer; +export type CreateGoodsReceiptInput = z.infer; +export type UpdateGoodsReceiptInput = z.infer; +export type CreateReceiptFromOrderInput = z.infer; +export type ReceiptLineInput = z.infer; +export type CreateSuggestionInput = z.infer; +export type UpdateSuggestionInput = z.infer; +export type ReviewSuggestionInput = z.infer; +export type ConvertSuggestionsInput = z.infer; +export type GenerateSuggestionsInput = z.infer; diff --git a/backend/src/shared/controllers/base.controller.ts b/backend/src/shared/controllers/base.controller.ts new file mode 100644 index 0000000..b49e4fa --- /dev/null +++ b/backend/src/shared/controllers/base.controller.ts @@ -0,0 +1,147 @@ +import { Response } from 'express'; +import { AuthenticatedRequest, ApiResponse, PaginatedResult } from '../types'; + +/** + * Base controller with common response methods + */ +export abstract class BaseController { + /** + * Send success response + */ + protected success(res: Response, data: T, statusCode: number = 200): Response { + const response: ApiResponse = { + success: true, + data, + meta: { + timestamp: new Date().toISOString(), + }, + }; + return res.status(statusCode).json(response); + } + + /** + * Send paginated response + */ + protected paginated(res: Response, result: PaginatedResult): Response { + const response: ApiResponse & { pagination: PaginatedResult['pagination'] } = { + success: true, + data: result.data, + pagination: result.pagination, + meta: { + timestamp: new Date().toISOString(), + }, + }; + return res.status(200).json(response); + } + + /** + * Send error response + */ + protected error( + res: Response, + code: string, + message: string, + statusCode: number = 400, + details?: any + ): Response { + const response: ApiResponse = { + success: false, + error: { + code, + message, + ...(details && { details }), + }, + meta: { + timestamp: new Date().toISOString(), + }, + }; + return res.status(statusCode).json(response); + } + + /** + * Send not found error + */ + protected notFound(res: Response, resource: string = 'Resource'): Response { + return this.error(res, 'NOT_FOUND', `${resource} not found`, 404); + } + + /** + * Send validation error + */ + protected validationError(res: Response, details: any): Response { + return this.error(res, 'VALIDATION_ERROR', 'Validation failed', 400, details); + } + + /** + * Send unauthorized error + */ + protected unauthorized(res: Response, message: string = 'Unauthorized'): Response { + return this.error(res, 'UNAUTHORIZED', message, 401); + } + + /** + * Send forbidden error + */ + protected forbidden(res: Response, message: string = 'Forbidden'): Response { + return this.error(res, 'FORBIDDEN', message, 403); + } + + /** + * Send conflict error + */ + protected conflict(res: Response, message: string): Response { + return this.error(res, 'CONFLICT', message, 409); + } + + /** + * Send internal error + */ + protected internalError(res: Response, error?: Error): Response { + console.error('Internal error:', error); + return this.error( + res, + 'INTERNAL_ERROR', + 'An unexpected error occurred', + 500, + process.env.NODE_ENV === 'development' ? error?.message : undefined + ); + } + + /** + * Get tenant ID from request + */ + protected getTenantId(req: AuthenticatedRequest): string { + return req.tenant.tenantId; + } + + /** + * Get user ID from request + */ + protected getUserId(req: AuthenticatedRequest): string { + return req.user.userId; + } + + /** + * Get branch ID from request + */ + protected getBranchId(req: AuthenticatedRequest): string | undefined { + return req.branch?.branchId; + } + + /** + * Parse pagination from query + */ + protected parsePagination(query: any): { + page: number; + limit: number; + sortBy?: string; + sortOrder?: 'ASC' | 'DESC'; + } { + return { + page: Math.max(1, parseInt(query.page) || 1), + limit: Math.min(100, Math.max(1, parseInt(query.limit) || 20)), + sortBy: query.sortBy, + sortOrder: query.sortOrder?.toUpperCase() === 'ASC' ? 'ASC' : 'DESC', + }; + } +} diff --git a/backend/src/shared/index.ts b/backend/src/shared/index.ts new file mode 100644 index 0000000..68d10ce --- /dev/null +++ b/backend/src/shared/index.ts @@ -0,0 +1,3 @@ +export * from './types'; +export * from './services/base.service'; +export * from './middleware'; diff --git a/backend/src/shared/middleware/auth.middleware.ts b/backend/src/shared/middleware/auth.middleware.ts new file mode 100644 index 0000000..c292e09 --- /dev/null +++ b/backend/src/shared/middleware/auth.middleware.ts @@ -0,0 +1,209 @@ +import { Request, Response, NextFunction } from 'express'; +import jwt from 'jsonwebtoken'; +import { AuthenticatedRequest, UserContext } from '../types'; + +const JWT_SECRET = process.env.JWT_SECRET || 'your-secret-key-change-in-production'; + +interface JWTPayload { + sub: string; + email: string; + name: string; + roles: string[]; + permissions?: string[]; + tenantId: string; + iat: number; + exp: number; +} + +/** + * Authentication middleware - validates JWT token + */ +export function authMiddleware( + req: Request, + res: Response, + next: NextFunction +): void { + const authHeader = req.headers.authorization; + + if (!authHeader) { + res.status(401).json({ + success: false, + error: { + code: 'MISSING_AUTH', + message: 'Authorization header is required', + }, + }); + return; + } + + const parts = authHeader.split(' '); + if (parts.length !== 2 || parts[0] !== 'Bearer') { + res.status(401).json({ + success: false, + error: { + code: 'INVALID_AUTH_FORMAT', + message: 'Authorization header must be: Bearer ', + }, + }); + return; + } + + const token = parts[1]; + + try { + const decoded = jwt.verify(token, JWT_SECRET) as JWTPayload; + + // Attach user context to request + const user: UserContext = { + userId: decoded.sub, + email: decoded.email, + name: decoded.name, + roles: decoded.roles || [], + permissions: decoded.permissions, + }; + + (req as AuthenticatedRequest).user = user; + + // Also set tenant from token if not already set + if (!(req as AuthenticatedRequest).tenant && decoded.tenantId) { + (req as AuthenticatedRequest).tenant = { tenantId: decoded.tenantId }; + } + + next(); + } catch (error: any) { + if (error.name === 'TokenExpiredError') { + res.status(401).json({ + success: false, + error: { + code: 'TOKEN_EXPIRED', + message: 'Authentication token has expired', + }, + }); + return; + } + + res.status(401).json({ + success: false, + error: { + code: 'INVALID_TOKEN', + message: 'Invalid authentication token', + }, + }); + } +} + +/** + * Optional auth middleware - sets user if token provided but doesn't require it + */ +export function optionalAuthMiddleware( + req: Request, + res: Response, + next: NextFunction +): void { + const authHeader = req.headers.authorization; + + if (!authHeader) { + next(); + return; + } + + const parts = authHeader.split(' '); + if (parts.length !== 2 || parts[0] !== 'Bearer') { + next(); + return; + } + + const token = parts[1]; + + try { + const decoded = jwt.verify(token, JWT_SECRET) as JWTPayload; + (req as AuthenticatedRequest).user = { + userId: decoded.sub, + email: decoded.email, + name: decoded.name, + roles: decoded.roles || [], + permissions: decoded.permissions, + }; + + if (decoded.tenantId) { + (req as AuthenticatedRequest).tenant = { tenantId: decoded.tenantId }; + } + } catch { + // Ignore errors for optional auth + } + + next(); +} + +/** + * Role-based authorization middleware + */ +export function requireRoles(...allowedRoles: string[]) { + return (req: Request, res: Response, next: NextFunction): void => { + const user = (req as AuthenticatedRequest).user; + + if (!user) { + res.status(401).json({ + success: false, + error: { + code: 'NOT_AUTHENTICATED', + message: 'Authentication required', + }, + }); + return; + } + + const hasRole = user.roles.some((role) => allowedRoles.includes(role)); + + if (!hasRole) { + res.status(403).json({ + success: false, + error: { + code: 'FORBIDDEN', + message: 'Insufficient permissions', + }, + }); + return; + } + + next(); + }; +} + +/** + * Permission-based authorization middleware + */ +export function requirePermissions(...requiredPermissions: string[]) { + return (req: Request, res: Response, next: NextFunction): void => { + const user = (req as AuthenticatedRequest).user; + + if (!user) { + res.status(401).json({ + success: false, + error: { + code: 'NOT_AUTHENTICATED', + message: 'Authentication required', + }, + }); + return; + } + + const userPermissions = user.permissions || []; + const hasAllPermissions = requiredPermissions.every((perm) => + userPermissions.includes(perm) + ); + + if (!hasAllPermissions) { + res.status(403).json({ + success: false, + error: { + code: 'FORBIDDEN', + message: 'Missing required permissions', + }, + }); + return; + } + + next(); + }; +} diff --git a/backend/src/shared/middleware/branch.middleware.ts b/backend/src/shared/middleware/branch.middleware.ts new file mode 100644 index 0000000..3e2bfbb --- /dev/null +++ b/backend/src/shared/middleware/branch.middleware.ts @@ -0,0 +1,208 @@ +import { Request, Response, NextFunction } from 'express'; +import { AuthenticatedRequest, BranchContext } from '../types'; +import { AppDataSource } from '../../config/typeorm'; +import { Branch } from '../../modules/branches/entities/branch.entity'; + +/** + * Middleware to extract and validate branch context from request headers + * + * Expected header: X-Branch-ID + */ +export function branchMiddleware( + req: Request, + res: Response, + next: NextFunction +): void { + const branchId = req.headers['x-branch-id'] as string; + + if (!branchId) { + res.status(400).json({ + success: false, + error: { + code: 'MISSING_BRANCH', + message: 'X-Branch-ID header is required', + }, + }); + return; + } + + // Validate UUID format + const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; + if (!uuidRegex.test(branchId)) { + res.status(400).json({ + success: false, + error: { + code: 'INVALID_BRANCH', + message: 'Invalid branch ID format', + }, + }); + return; + } + + // Attach branch context to request + const branch: BranchContext = { + branchId, + }; + + (req as AuthenticatedRequest).branch = branch; + next(); +} + +/** + * Optional branch middleware - sets branch if provided but doesn't require it + */ +export function optionalBranchMiddleware( + req: Request, + res: Response, + next: NextFunction +): void { + const branchId = req.headers['x-branch-id'] as string; + + if (branchId) { + const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; + if (uuidRegex.test(branchId)) { + (req as AuthenticatedRequest).branch = { branchId }; + } + } + + next(); +} + +/** + * Middleware that validates branch exists and belongs to tenant + * Must be used after tenantMiddleware + */ +export async function validateBranchMiddleware( + req: Request, + res: Response, + next: NextFunction +): Promise { + const authReq = req as AuthenticatedRequest; + const branchId = authReq.branch?.branchId; + const tenantId = authReq.tenant?.tenantId; + + if (!branchId || !tenantId) { + next(); + return; + } + + try { + const branchRepository = AppDataSource.getRepository(Branch); + const branch = await branchRepository.findOne({ + where: { id: branchId, tenantId }, + select: ['id', 'code', 'name', 'warehouseId', 'status'], + }); + + if (!branch) { + res.status(404).json({ + success: false, + error: { + code: 'BRANCH_NOT_FOUND', + message: 'Branch not found or does not belong to this tenant', + }, + }); + return; + } + + if (branch.status !== 'active') { + res.status(400).json({ + success: false, + error: { + code: 'BRANCH_INACTIVE', + message: 'Branch is not active', + }, + }); + return; + } + + // Enrich branch context + authReq.branch = { + branchId: branch.id, + branchCode: branch.code, + branchName: branch.name, + warehouseId: branch.warehouseId, + }; + + next(); + } catch (error) { + console.error('Error validating branch:', error); + res.status(500).json({ + success: false, + error: { + code: 'BRANCH_VALIDATION_ERROR', + message: 'Error validating branch', + }, + }); + } +} + +/** + * Middleware to require specific branch capabilities + */ +export function requireBranchCapability(capability: 'pos' | 'inventory' | 'ecommerce') { + return async (req: Request, res: Response, next: NextFunction): Promise => { + const authReq = req as AuthenticatedRequest; + const branchId = authReq.branch?.branchId; + const tenantId = authReq.tenant?.tenantId; + + if (!branchId || !tenantId) { + res.status(400).json({ + success: false, + error: { + code: 'MISSING_CONTEXT', + message: 'Branch and tenant context required', + }, + }); + return; + } + + try { + const branchRepository = AppDataSource.getRepository(Branch); + const branch = await branchRepository.findOne({ + where: { id: branchId, tenantId }, + select: ['id', 'type'], + }); + + if (!branch) { + res.status(404).json({ + success: false, + error: { + code: 'BRANCH_NOT_FOUND', + message: 'Branch not found', + }, + }); + return; + } + + // Check branch type capabilities + const capabilityMap: Record = { + pos: ['store', 'hybrid'], + inventory: ['store', 'warehouse', 'hybrid'], + ecommerce: ['store', 'warehouse', 'hybrid'], + }; + + const allowedTypes = capabilityMap[capability] || []; + if (!allowedTypes.includes(branch.type)) { + res.status(400).json({ + success: false, + error: { + code: 'BRANCH_CAPABILITY_ERROR', + message: `Branch does not support ${capability} operations`, + }, + }); + return; + } + + next(); + } catch (error) { + console.error('Error checking branch capability:', error); + res.status(500).json({ + success: false, + error: { + code: 'CAPABILITY_CHECK_ERROR', + message: 'Error checking branch capabilities', + }, + }); + } + }; +} diff --git a/backend/src/shared/middleware/index.ts b/backend/src/shared/middleware/index.ts new file mode 100644 index 0000000..4094e17 --- /dev/null +++ b/backend/src/shared/middleware/index.ts @@ -0,0 +1,3 @@ +export * from './tenant.middleware'; +export * from './auth.middleware'; +export * from './branch.middleware'; diff --git a/backend/src/shared/middleware/tenant.middleware.ts b/backend/src/shared/middleware/tenant.middleware.ts new file mode 100644 index 0000000..3e9e1e9 --- /dev/null +++ b/backend/src/shared/middleware/tenant.middleware.ts @@ -0,0 +1,67 @@ +import { Request, Response, NextFunction } from 'express'; +import { AuthenticatedRequest, TenantContext } from '../types'; + +/** + * Middleware to extract and validate tenant context from request headers + * + * Expected header: X-Tenant-ID + */ +export function tenantMiddleware( + req: Request, + res: Response, + next: NextFunction +): void { + const tenantId = req.headers['x-tenant-id'] as string; + + if (!tenantId) { + res.status(400).json({ + success: false, + error: { + code: 'MISSING_TENANT', + message: 'X-Tenant-ID header is required', + }, + }); + return; + } + + // Validate UUID format + const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; + if (!uuidRegex.test(tenantId)) { + res.status(400).json({ + success: false, + error: { + code: 'INVALID_TENANT', + message: 'Invalid tenant ID format', + }, + }); + return; + } + + // Attach tenant context to request + const tenant: TenantContext = { + tenantId, + }; + + (req as AuthenticatedRequest).tenant = tenant; + next(); +} + +/** + * Optional tenant middleware - sets tenant if provided but doesn't require it + */ +export function optionalTenantMiddleware( + req: Request, + res: Response, + next: NextFunction +): void { + const tenantId = req.headers['x-tenant-id'] as string; + + if (tenantId) { + const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; + if (uuidRegex.test(tenantId)) { + (req as AuthenticatedRequest).tenant = { tenantId }; + } + } + + next(); +} diff --git a/backend/src/shared/services/base.service.ts b/backend/src/shared/services/base.service.ts new file mode 100644 index 0000000..7a2763c --- /dev/null +++ b/backend/src/shared/services/base.service.ts @@ -0,0 +1,415 @@ +import { + Repository, + FindOptionsWhere, + FindOptionsOrder, + FindOptionsRelations, + SelectQueryBuilder, + DeepPartial, + In, +} from 'typeorm'; +import { + PaginationParams, + PaginatedResult, + FilterCondition, + QueryOptions, + ServiceResult, +} from '../types'; + +export abstract class BaseService { + constructor(protected readonly repository: Repository) {} + + /** + * Find all entities for a tenant with optional pagination and filters + */ + async findAll( + tenantId: string, + options?: QueryOptions + ): Promise> { + const { + pagination = { page: 1, limit: 20 }, + filters = [], + search, + relations = [], + select, + } = options || {}; + + const queryBuilder = this.repository.createQueryBuilder('entity'); + + // Always filter by tenant + queryBuilder.where('entity.tenantId = :tenantId', { tenantId }); + + // Apply filters + this.applyFilters(queryBuilder, filters); + + // Apply search + if (search && search.term && search.fields.length > 0) { + const searchConditions = search.fields + .map((field, index) => `entity.${field} ILIKE :search${index}`) + .join(' OR '); + + const searchParams: Record = {}; + search.fields.forEach((_, index) => { + searchParams[`search${index}`] = `%${search.term}%`; + }); + + queryBuilder.andWhere(`(${searchConditions})`, searchParams); + } + + // Apply relations + relations.forEach((relation) => { + queryBuilder.leftJoinAndSelect(`entity.${relation}`, relation); + }); + + // Apply select + if (select && select.length > 0) { + queryBuilder.select(select.map((s) => `entity.${s}`)); + } + + // Get total count before pagination + const total = await queryBuilder.getCount(); + + // Apply sorting + const sortBy = pagination.sortBy || 'createdAt'; + const sortOrder = pagination.sortOrder || 'DESC'; + queryBuilder.orderBy(`entity.${sortBy}`, sortOrder); + + // Apply pagination + const skip = (pagination.page - 1) * pagination.limit; + queryBuilder.skip(skip).take(pagination.limit); + + const data = await queryBuilder.getMany(); + + const totalPages = Math.ceil(total / pagination.limit); + + return { + data, + pagination: { + page: pagination.page, + limit: pagination.limit, + total, + totalPages, + hasNext: pagination.page < totalPages, + hasPrev: pagination.page > 1, + }, + }; + } + + /** + * Find one entity by ID + */ + async findById( + tenantId: string, + id: string, + relations?: string[] + ): Promise { + const where = { tenantId, id } as FindOptionsWhere; + const relationsOption = relations + ? (relations.reduce((acc, rel) => ({ ...acc, [rel]: true }), {}) as FindOptionsRelations) + : undefined; + + return this.repository.findOne({ + where, + relations: relationsOption, + }); + } + + /** + * Find entities by a specific field + */ + async findBy( + tenantId: string, + field: keyof T, + value: any, + relations?: string[] + ): Promise { + const where = { tenantId, [field]: value } as FindOptionsWhere; + const relationsOption = relations + ? (relations.reduce((acc, rel) => ({ ...acc, [rel]: true }), {}) as FindOptionsRelations) + : undefined; + + return this.repository.find({ + where, + relations: relationsOption, + }); + } + + /** + * Find one entity by a specific field + */ + async findOneBy( + tenantId: string, + field: keyof T, + value: any, + relations?: string[] + ): Promise { + const where = { tenantId, [field]: value } as FindOptionsWhere; + const relationsOption = relations + ? (relations.reduce((acc, rel) => ({ ...acc, [rel]: true }), {}) as FindOptionsRelations) + : undefined; + + return this.repository.findOne({ + where, + relations: relationsOption, + }); + } + + /** + * Create a new entity + */ + async create( + tenantId: string, + data: DeepPartial, + userId?: string + ): Promise> { + try { + const entity = this.repository.create({ + ...data, + tenantId, + createdBy: userId, + updatedBy: userId, + } as DeepPartial); + + const saved = await this.repository.save(entity); + return { success: true, data: saved }; + } catch (error: any) { + return { + success: false, + error: { + code: 'CREATE_FAILED', + message: error.message || 'Failed to create entity', + details: error, + }, + }; + } + } + + /** + * Create multiple entities + */ + async createMany( + tenantId: string, + dataArray: DeepPartial[], + userId?: string + ): Promise> { + try { + const entities = dataArray.map((data) => + this.repository.create({ + ...data, + tenantId, + createdBy: userId, + updatedBy: userId, + } as DeepPartial) + ); + + const saved = await this.repository.save(entities); + return { success: true, data: saved }; + } catch (error: any) { + return { + success: false, + error: { + code: 'CREATE_MANY_FAILED', + message: error.message || 'Failed to create entities', + details: error, + }, + }; + } + } + + /** + * Update an entity + */ + async update( + tenantId: string, + id: string, + data: DeepPartial, + userId?: string + ): Promise> { + try { + const existing = await this.findById(tenantId, id); + if (!existing) { + return { + success: false, + error: { + code: 'NOT_FOUND', + message: 'Entity not found', + }, + }; + } + + const updated = this.repository.merge(existing, { + ...data, + updatedBy: userId, + } as DeepPartial); + + const saved = await this.repository.save(updated); + return { success: true, data: saved }; + } catch (error: any) { + return { + success: false, + error: { + code: 'UPDATE_FAILED', + message: error.message || 'Failed to update entity', + details: error, + }, + }; + } + } + + /** + * Delete an entity (soft delete if entity has deletedAt field) + */ + async delete(tenantId: string, id: string): Promise> { + try { + const existing = await this.findById(tenantId, id); + if (!existing) { + return { + success: false, + error: { + code: 'NOT_FOUND', + message: 'Entity not found', + }, + }; + } + + await this.repository.remove(existing); + return { success: true, data: true }; + } catch (error: any) { + return { + success: false, + error: { + code: 'DELETE_FAILED', + message: error.message || 'Failed to delete entity', + details: error, + }, + }; + } + } + + /** + * Delete multiple entities by IDs + */ + async deleteMany( + tenantId: string, + ids: string[] + ): Promise> { + try { + const result = await this.repository.delete({ + tenantId, + id: In(ids), + } as FindOptionsWhere); + + return { success: true, data: result.affected || 0 }; + } catch (error: any) { + return { + success: false, + error: { + code: 'DELETE_MANY_FAILED', + message: error.message || 'Failed to delete entities', + details: error, + }, + }; + } + } + + /** + * Check if entity exists + */ + async exists(tenantId: string, id: string): Promise { + const count = await this.repository.count({ + where: { tenantId, id } as FindOptionsWhere, + }); + return count > 0; + } + + /** + * Count entities matching criteria + */ + async count( + tenantId: string, + filters?: FilterCondition[] + ): Promise { + const queryBuilder = this.repository.createQueryBuilder('entity'); + queryBuilder.where('entity.tenantId = :tenantId', { tenantId }); + + if (filters) { + this.applyFilters(queryBuilder, filters); + } + + return queryBuilder.getCount(); + } + + /** + * Apply filter conditions to query builder + */ + protected applyFilters( + queryBuilder: SelectQueryBuilder, + filters: FilterCondition[] + ): void { + filters.forEach((filter, index) => { + const paramName = `filter${index}`; + + switch (filter.operator) { + case 'eq': + queryBuilder.andWhere(`entity.${filter.field} = :${paramName}`, { + [paramName]: filter.value, + }); + break; + case 'ne': + queryBuilder.andWhere(`entity.${filter.field} != :${paramName}`, { + [paramName]: filter.value, + }); + break; + case 'gt': + queryBuilder.andWhere(`entity.${filter.field} > :${paramName}`, { + [paramName]: filter.value, + }); + break; + case 'gte': + queryBuilder.andWhere(`entity.${filter.field} >= :${paramName}`, { + [paramName]: filter.value, + }); + break; + case 'lt': + queryBuilder.andWhere(`entity.${filter.field} < :${paramName}`, { + [paramName]: filter.value, + }); + break; + case 'lte': + queryBuilder.andWhere(`entity.${filter.field} <= :${paramName}`, { + [paramName]: filter.value, + }); + break; + case 'like': + queryBuilder.andWhere(`entity.${filter.field} LIKE :${paramName}`, { + [paramName]: `%${filter.value}%`, + }); + break; + case 'ilike': + queryBuilder.andWhere(`entity.${filter.field} ILIKE :${paramName}`, { + [paramName]: `%${filter.value}%`, + }); + break; + case 'in': + queryBuilder.andWhere(`entity.${filter.field} IN (:...${paramName})`, { + [paramName]: filter.value, + }); + break; + case 'between': + queryBuilder.andWhere( + `entity.${filter.field} BETWEEN :${paramName}Start AND :${paramName}End`, + { + [`${paramName}Start`]: filter.value[0], + [`${paramName}End`]: filter.value[1], + } + ); + break; + case 'isNull': + queryBuilder.andWhere(`entity.${filter.field} IS NULL`); + break; + case 'isNotNull': + queryBuilder.andWhere(`entity.${filter.field} IS NOT NULL`); + break; + } + }); + } +} diff --git a/backend/src/shared/types/index.ts b/backend/src/shared/types/index.ts new file mode 100644 index 0000000..a62909d --- /dev/null +++ b/backend/src/shared/types/index.ts @@ -0,0 +1,130 @@ +import { Request } from 'express'; + +// Tenant context +export interface TenantContext { + tenantId: string; + tenantName?: string; +} + +// Branch context +export interface BranchContext { + branchId: string; + branchCode?: string; + branchName?: string; + warehouseId?: string; +} + +// User context from JWT +export interface UserContext { + userId: string; + email: string; + name: string; + roles: string[]; + permissions?: string[]; +} + +// Extended Express Request with context +export interface AuthenticatedRequest extends Request { + tenant: TenantContext; + user: UserContext; + branch?: BranchContext; +} + +// Pagination +export interface PaginationParams { + page: number; + limit: number; + sortBy?: string; + sortOrder?: 'ASC' | 'DESC'; +} + +export interface PaginatedResult { + data: T[]; + pagination: { + page: number; + limit: number; + total: number; + totalPages: number; + hasNext: boolean; + hasPrev: boolean; + }; +} + +// Filter operators +export type FilterOperator = + | 'eq' + | 'ne' + | 'gt' + | 'gte' + | 'lt' + | 'lte' + | 'like' + | 'ilike' + | 'in' + | 'between' + | 'isNull' + | 'isNotNull'; + +export interface FilterCondition { + field: string; + operator: FilterOperator; + value: any; +} + +// Query options for services +export interface QueryOptions { + pagination?: PaginationParams; + filters?: FilterCondition[]; + search?: { + fields: string[]; + term: string; + }; + relations?: string[]; + select?: string[]; +} + +// API Response types +export interface ApiResponse { + success: boolean; + data?: T; + error?: { + code: string; + message: string; + details?: any; + }; + meta?: { + timestamp: string; + requestId?: string; + }; +} + +// Service result type +export type ServiceResult = { + success: true; + data: T; +} | { + success: false; + error: { + code: string; + message: string; + details?: any; + }; +}; + +// Audit fields +export interface AuditFields { + createdAt: Date; + updatedAt: Date; + createdBy?: string; + updatedBy?: string; +} + +// Base entity interface +export interface BaseEntity extends AuditFields { + id: string; + tenantId: string; +} + +// Create/Update DTOs +export type CreateDTO = Omit; +export type UpdateDTO = Partial>; diff --git a/backend/src/shared/validation/common.schema.ts b/backend/src/shared/validation/common.schema.ts new file mode 100644 index 0000000..a2bc675 --- /dev/null +++ b/backend/src/shared/validation/common.schema.ts @@ -0,0 +1,92 @@ +import { z } from 'zod'; + +// Common validation patterns +export const uuidSchema = z.string().uuid('Invalid UUID format'); + +export const paginationSchema = z.object({ + page: z.coerce.number().int().min(1).default(1), + limit: z.coerce.number().int().min(1).max(100).default(20), + sortBy: z.string().optional(), + sortOrder: z.enum(['ASC', 'DESC', 'asc', 'desc']).transform(v => v.toUpperCase()).optional(), +}); + +export const dateSchema = z.coerce.date(); + +export const dateRangeSchema = z.object({ + startDate: dateSchema, + endDate: dateSchema, +}).refine(data => data.startDate <= data.endDate, { + message: 'Start date must be before or equal to end date', +}); + +export const moneySchema = z.coerce.number().min(0).multipleOf(0.01); + +export const quantitySchema = z.coerce.number().min(0); + +export const percentSchema = z.coerce.number().min(0).max(100); + +export const emailSchema = z.string().email('Invalid email format'); + +export const phoneSchema = z.string().regex(/^[+]?[\d\s()-]{7,20}$/, 'Invalid phone format'); + +export const rfcSchema = z.string() + .regex(/^[A-ZÑ&]{3,4}\d{6}[A-Z\d]{3}$/, 'Invalid RFC format') + .transform(v => v.toUpperCase()); + +export const postalCodeSchema = z.string().regex(/^\d{5}$/, 'Invalid postal code'); + +// Address schema +export const addressSchema = z.object({ + address1: z.string().min(1).max(255), + address2: z.string().max(255).optional(), + city: z.string().min(1).max(100), + state: z.string().min(1).max(100), + postalCode: postalCodeSchema, + country: z.string().length(3).default('MEX'), +}); + +// Shipping address with contact info +export const shippingAddressSchema = addressSchema.extend({ + firstName: z.string().min(1).max(100), + lastName: z.string().min(1).max(100), + company: z.string().max(200).optional(), + phone: phoneSchema, + instructions: z.string().max(500).optional(), +}); + +// Billing address with fiscal info +export const billingAddressSchema = addressSchema.extend({ + firstName: z.string().min(1).max(100), + lastName: z.string().min(1).max(100), + company: z.string().max(200).optional(), + rfc: rfcSchema.optional(), + phone: phoneSchema, +}); + +// Operating hours schema +export const operatingHoursSchema = z.object({ + monday: z.object({ open: z.string(), close: z.string() }).optional(), + tuesday: z.object({ open: z.string(), close: z.string() }).optional(), + wednesday: z.object({ open: z.string(), close: z.string() }).optional(), + thursday: z.object({ open: z.string(), close: z.string() }).optional(), + friday: z.object({ open: z.string(), close: z.string() }).optional(), + saturday: z.object({ open: z.string(), close: z.string() }).optional(), + sunday: z.object({ open: z.string(), close: z.string() }).optional(), +}).optional(); + +// Coordinates schema +export const coordinatesSchema = z.object({ + latitude: z.coerce.number().min(-90).max(90), + longitude: z.coerce.number().min(-180).max(180), +}); + +// ID params schema +export const idParamSchema = z.object({ + id: uuidSchema, +}); + +// Type exports +export type PaginationInput = z.infer; +export type AddressInput = z.infer; +export type ShippingAddressInput = z.infer; +export type BillingAddressInput = z.infer; diff --git a/backend/src/shared/validation/index.ts b/backend/src/shared/validation/index.ts new file mode 100644 index 0000000..d258d65 --- /dev/null +++ b/backend/src/shared/validation/index.ts @@ -0,0 +1,2 @@ +export * from './common.schema'; +export * from './validation.middleware'; diff --git a/backend/src/shared/validation/validation.middleware.ts b/backend/src/shared/validation/validation.middleware.ts new file mode 100644 index 0000000..c8c02d7 --- /dev/null +++ b/backend/src/shared/validation/validation.middleware.ts @@ -0,0 +1,133 @@ +import { Request, Response, NextFunction } from 'express'; +import { ZodSchema, ZodError } from 'zod'; + +type RequestPart = 'body' | 'query' | 'params'; + +/** + * Format Zod errors into a user-friendly object + */ +function formatZodError(error: ZodError): Record { + const formatted: Record = {}; + + for (const issue of error.issues) { + const path = issue.path.join('.'); + if (!formatted[path]) { + formatted[path] = issue.message; + } + } + + return formatted; +} + +/** + * Create a validation middleware for a specific request part + */ +export function validate(schema: ZodSchema, part: RequestPart = 'body') { + return async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const data = req[part]; + const validated = await schema.parseAsync(data); + + // Replace the request part with validated/transformed data + (req as any)[part] = validated; + + next(); + } catch (error) { + if (error instanceof ZodError) { + res.status(400).json({ + success: false, + error: { + code: 'VALIDATION_ERROR', + message: 'Validation failed', + details: formatZodError(error), + }, + }); + return; + } + + next(error); + } + }; +} + +/** + * Validate request body + */ +export function validateBody(schema: ZodSchema) { + return validate(schema, 'body'); +} + +/** + * Validate query parameters + */ +export function validateQuery(schema: ZodSchema) { + return validate(schema, 'query'); +} + +/** + * Validate URL parameters + */ +export function validateParams(schema: ZodSchema) { + return validate(schema, 'params'); +} + +/** + * Validate multiple request parts at once + */ +export function validateRequest< + TBody = any, + TQuery = any, + TParams = any +>(schemas: { + body?: ZodSchema; + query?: ZodSchema; + params?: ZodSchema; +}) { + return async (req: Request, res: Response, next: NextFunction): Promise => { + const errors: Record> = {}; + + try { + if (schemas.params) { + req.params = await schemas.params.parseAsync(req.params); + } + } catch (error) { + if (error instanceof ZodError) { + errors.params = formatZodError(error); + } + } + + try { + if (schemas.query) { + (req as any).query = await schemas.query.parseAsync(req.query); + } + } catch (error) { + if (error instanceof ZodError) { + errors.query = formatZodError(error); + } + } + + try { + if (schemas.body) { + req.body = await schemas.body.parseAsync(req.body); + } + } catch (error) { + if (error instanceof ZodError) { + errors.body = formatZodError(error); + } + } + + if (Object.keys(errors).length > 0) { + res.status(400).json({ + success: false, + error: { + code: 'VALIDATION_ERROR', + message: 'Validation failed', + details: errors, + }, + }); + return; + } + + next(); + }; +} diff --git a/backend/tsconfig.json b/backend/tsconfig.json new file mode 100644 index 0000000..8b85f0f --- /dev/null +++ b/backend/tsconfig.json @@ -0,0 +1,29 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "commonjs", + "lib": ["ES2022"], + "outDir": "./dist", + "rootDir": "./src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "experimentalDecorators": true, + "emitDecoratorMetadata": true, + "strictPropertyInitialization": false, + "moduleResolution": "node", + "baseUrl": "./src", + "paths": { + "@config/*": ["config/*"], + "@modules/*": ["modules/*"], + "@shared/*": ["shared/*"] + } + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} diff --git a/database/HERENCIA-ERP-CORE.md b/database/HERENCIA-ERP-CORE.md new file mode 100644 index 0000000..37faa13 --- /dev/null +++ b/database/HERENCIA-ERP-CORE.md @@ -0,0 +1,196 @@ +# Herencia de Base de Datos - ERP Core -> Retail + +**Fecha:** 2025-12-08 +**Versión:** 1.0 +**Vertical:** Retail +**Nivel:** 2B.2 + +--- + +## RESUMEN + +La vertical de Retail hereda los schemas base del ERP Core y extiende con schemas específicos del dominio de punto de venta y comercio minorista. + +**Ubicación DDL Core:** `apps/erp-core/database/ddl/` + +--- + +## ARQUITECTURA DE HERENCIA + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ ERP CORE (Base) │ +│ ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐ │ +│ │ auth │ │ core │ │financial│ │inventory│ │ purchase │ │ +│ │ 26 tbl │ │ 12 tbl │ │ 15 tbl │ │ 15 tbl │ │ 8 tbl │ │ +│ └─────────┘ └─────────┘ └─────────┘ └─────────┘ └─────────┘ │ +│ ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐ │ +│ │ sales │ │analytics│ │ system │ │ crm │ │ +│ │ 6 tbl │ │ 5 tbl │ │ 10 tbl │ │ 5 tbl │ │ +│ └─────────┘ └─────────┘ └─────────┘ └─────────┘ │ +│ TOTAL: ~102 tablas heredadas │ +└─────────────────────────────────────────────────────────────────┘ + │ + │ HEREDA + ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ RETAIL (Extensiones) │ +│ ┌───────────────┐ ┌───────────────┐ ┌───────────────┐ │ +│ │ pos │ │ stores │ │ pricing │ │ +│ │ (punto venta) │ │ (sucursales) │ │ (promociones) │ │ +│ └───────────────┘ └───────────────┘ └───────────────┘ │ +│ EXTENSIONES: ~30 tablas (planificadas) │ +└─────────────────────────────────────────────────────────────────┘ +``` + +--- + +## SCHEMAS HEREDADOS DEL CORE + +| Schema | Tablas | Uso en Retail | +|--------|--------|---------------| +| `auth` | 26 | Autenticación, usuarios por sucursal | +| `core` | 12 | Partners (clientes), catálogos | +| `financial` | 15 | Facturas, cuentas, caja | +| `inventory` | 15 | Inventario multi-sucursal | +| `purchase` | 8 | Compras a proveedores | +| `sales` | 6 | Ventas base | +| `crm` | 5 | Clientes frecuentes | +| `analytics` | 5 | Métricas de venta | +| `system` | 10 | Notificaciones | + +**Total heredado:** ~102 tablas + +--- + +## SCHEMAS ESPECÍFICOS DE RETAIL (Planificados) + +### 1. Schema `pos` (estimado 12+ tablas) + +**Propósito:** Punto de venta y operaciones de caja + +```sql +-- Tablas principales planificadas: +pos.cash_registers -- Cajas registradoras +pos.cash_sessions -- Sesiones de caja +pos.pos_orders -- Tickets/ventas POS +pos.pos_order_lines -- Líneas de ticket +pos.payment_methods -- Métodos de pago +pos.cash_movements -- Movimientos de caja +pos.cash_counts -- Cortes de caja +pos.receipts -- Recibos +``` + +### 2. Schema `stores` (estimado 8+ tablas) + +**Propósito:** Gestión de sucursales + +```sql +-- Tablas principales planificadas: +stores.branches -- Sucursales +stores.branch_inventory -- Inventario por sucursal +stores.transfers -- Transferencias entre sucursales +stores.transfer_lines -- Líneas de transferencia +stores.branch_employees -- Empleados por sucursal +``` + +### 3. Schema `pricing` (estimado 10+ tablas) + +**Propósito:** Precios y promociones + +```sql +-- Extiende: sales schema del core +pricing.price_lists -- Listas de precios +pricing.promotions -- Promociones +pricing.discounts -- Descuentos +pricing.loyalty_programs -- Programas de lealtad +pricing.coupons -- Cupones +pricing.price_history -- Historial de precios +``` + +--- + +## SPECS DEL CORE APLICABLES + +**Documento detallado:** `orchestration/00-guidelines/HERENCIA-SPECS-CORE.md` + +### Correcciones de DDL Core (2025-12-08) + +El DDL del ERP-Core fue corregido para resolver FK inválidas: + +1. **stock_valuation_layers**: Campos `journal_entry_id` y `journal_entry_line_id` (antes `account_move_*`) +2. **stock_move_consume_rel**: Nueva tabla de trazabilidad (antes `move_line_consume_rel`) +3. **category_stock_accounts**: FK corregida a `core.product_categories` +4. **product_categories**: ALTERs ahora apuntan a schema `core` + +### SPECS Obligatorias + +| Spec Core | Aplicación en Retail | SP | Estado | +|-----------|---------------------|----:|--------| +| SPEC-SISTEMA-SECUENCIAS | Foliado de tickets y facturas | 8 | ✅ DDL LISTO | +| SPEC-VALORACION-INVENTARIO | Costeo de mercancía | 21 | ✅ DDL LISTO | +| SPEC-SEGURIDAD-API-KEYS-PERMISOS | Control de acceso por sucursal | 31 | ✅ DDL LISTO | +| SPEC-PRICING-RULES | Precios y promociones | 8 | PENDIENTE | +| SPEC-INVENTARIOS-CICLICOS | Conteos en sucursales | 13 | ✅ DDL LISTO | +| SPEC-TRAZABILIDAD-LOTES-SERIES | Productos con lote/serie | 13 | ✅ DDL LISTO | +| SPEC-MAIL-THREAD-TRACKING | Comunicación con clientes | 13 | PENDIENTE | +| SPEC-WIZARD-TRANSIENT-MODEL | Wizards de cierre de caja | 8 | PENDIENTE | + +### SPECS Opcionales + +| Spec Core | Decisión | Razón | +|-----------|----------|-------| +| SPEC-PORTAL-PROVEEDORES | EVALUAR | Para compras centralizadas | +| SPEC-TAREAS-RECURRENTES | EVALUAR | Para reorden automático | + +### SPECS No Aplican + +| Spec Core | Razón | +|-----------|-------| +| SPEC-INTEGRACION-CALENDAR | No requiere calendario de citas | +| SPEC-PROYECTOS-DEPENDENCIAS-BURNDOWN | No hay proyectos largos | +| SPEC-FIRMA-ELECTRONICA-NOM151 | No aplica para tickets POS | + +--- + +## ORDEN DE EJECUCIÓN DDL (Futuro) + +```bash +# PASO 1: Cargar ERP Core (base) +cd apps/erp-core/database +./scripts/reset-database.sh --force + +# PASO 2: Cargar extensiones de Retail +cd apps/verticales/retail/database +psql $DATABASE_URL -f init/00-extensions.sql +psql $DATABASE_URL -f init/01-create-schemas.sql +psql $DATABASE_URL -f init/02-pos-tables.sql +psql $DATABASE_URL -f init/03-stores-tables.sql +psql $DATABASE_URL -f init/04-pricing-tables.sql +``` + +--- + +## MAPEO DE NOMENCLATURA + +| Core | Retail | +|------|--------| +| `core.partners` | Clientes, proveedores | +| `inventory.products` | Productos de venta | +| `inventory.locations` | Almacenes de sucursal | +| `sales.sale_orders` | Base para POS orders | +| `financial.invoices` | Facturas de venta | + +--- + +## REFERENCIAS + +- ERP Core DDL: `apps/erp-core/database/ddl/` +- ERP Core README: `apps/erp-core/database/README.md` +- Directivas: `orchestration/directivas/` +- Inventarios: `orchestration/inventarios/` + +--- + +**Documento de herencia oficial** +**Última actualización:** 2025-12-08 diff --git a/database/README.md b/database/README.md new file mode 100644 index 0000000..dff099d --- /dev/null +++ b/database/README.md @@ -0,0 +1,83 @@ +# Base de Datos - ERP Retail/POS + +## Resumen + +| Aspecto | Valor | +|---------|-------| +| **Schema principal** | `retail` | +| **Tablas específicas** | 16 | +| **ENUMs** | 6 | +| **Hereda de ERP-Core** | 144 tablas (12 schemas) | + +## Prerequisitos + +1. **ERP-Core instalado** con todos sus schemas +2. **Extensiones PostgreSQL**: pg_trgm + +## Orden de Ejecución DDL + +```bash +# 1. Instalar ERP-Core primero +cd apps/erp-core/database +./scripts/reset-database.sh + +# 2. Instalar extensión Retail +cd apps/verticales/retail/database +psql $DATABASE_URL -f init/00-extensions.sql +psql $DATABASE_URL -f init/01-create-schemas.sql +psql $DATABASE_URL -f init/02-rls-functions.sql +psql $DATABASE_URL -f init/03-retail-tables.sql +``` + +## Tablas Implementadas + +### Schema: retail (16 tablas) + +| Tabla | Módulo | Descripción | +|-------|--------|-------------| +| branches | RT-002 | Sucursales | +| cash_registers | RT-001 | Cajas registradoras | +| pos_sessions | RT-001 | Sesiones de POS | +| pos_orders | RT-001 | Ventas/Órdenes | +| pos_order_lines | RT-001 | Líneas de venta | +| pos_payments | RT-001 | Pagos (mixtos) | +| cash_movements | RT-001 | Entradas/salidas efectivo | +| branch_stock | RT-002 | Stock por sucursal | +| stock_transfers | RT-002 | Transferencias | +| stock_transfer_lines | RT-002 | Líneas de transferencia | +| product_barcodes | RT-003 | Códigos de barras | +| promotions | RT-003 | Promociones | +| promotion_products | RT-003 | Productos en promo | +| loyalty_programs | RT-004 | Programas fidelización | +| loyalty_cards | RT-004 | Tarjetas | +| loyalty_transactions | RT-004 | Transacciones puntos | + +## ENUMs + +| Enum | Valores | +|------|---------| +| pos_session_status | opening, open, closing, closed | +| pos_order_status | draft, paid, done, cancelled, refunded | +| payment_method | cash, card, transfer, credit, mixed | +| cash_movement_type | in, out | +| transfer_status | draft, pending, in_transit, received, cancelled | +| promotion_type | percentage, fixed_amount, buy_x_get_y, bundle | + +## Row Level Security + +Todas las tablas tienen RLS con: +```sql +tenant_id = current_setting('app.current_tenant_id', true)::UUID +``` + +## Consideraciones Especiales + +- **Operación offline**: POS puede operar sin conexión +- **Rendimiento**: <100ms por transacción +- **Hardware**: Integración con impresoras y lectores +- **CFDI 4.0**: Facturación en tiempo real + +## Referencias + +- [HERENCIA-ERP-CORE.md](./HERENCIA-ERP-CORE.md) +- [DATABASE_INVENTORY.yml](../orchestration/inventarios/DATABASE_INVENTORY.yml) diff --git a/database/init/00-extensions.sql b/database/init/00-extensions.sql new file mode 100644 index 0000000..87e9d45 --- /dev/null +++ b/database/init/00-extensions.sql @@ -0,0 +1,22 @@ +-- ============================================================================ +-- EXTENSIONES PostgreSQL - ERP Retail/POS +-- ============================================================================ +-- Versión: 1.0.0 +-- Fecha: 2025-12-09 +-- Prerequisito: ERP-Core debe estar instalado +-- ============================================================================ + +-- Verificar que ERP-Core esté instalado +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_namespace WHERE nspname = 'auth') THEN + RAISE EXCEPTION 'ERP-Core no instalado. Ejecutar primero DDL de erp-core.'; + END IF; +END $$; + +-- Extensión para búsqueda de texto (productos, códigos) +CREATE EXTENSION IF NOT EXISTS pg_trgm; + +-- ============================================================================ +-- FIN EXTENSIONES +-- ============================================================================ diff --git a/database/init/01-create-schemas.sql b/database/init/01-create-schemas.sql new file mode 100644 index 0000000..56518b4 --- /dev/null +++ b/database/init/01-create-schemas.sql @@ -0,0 +1,15 @@ +-- ============================================================================ +-- SCHEMAS - ERP Retail/POS +-- ============================================================================ +-- Versión: 1.0.0 +-- Fecha: 2025-12-09 +-- ============================================================================ + +-- Schema principal para operaciones de punto de venta +CREATE SCHEMA IF NOT EXISTS retail; + +COMMENT ON SCHEMA retail IS 'Schema para operaciones de punto de venta y retail'; + +-- ============================================================================ +-- FIN SCHEMAS +-- ============================================================================ diff --git a/database/init/02-rls-functions.sql b/database/init/02-rls-functions.sql new file mode 100644 index 0000000..e3085d4 --- /dev/null +++ b/database/init/02-rls-functions.sql @@ -0,0 +1,30 @@ +-- ============================================================================ +-- FUNCIONES RLS - ERP Retail/POS +-- ============================================================================ +-- Versión: 1.0.0 +-- Fecha: 2025-12-09 +-- Nota: Usa las funciones de contexto de ERP-Core (auth schema) +-- ============================================================================ + +-- Las funciones principales están en ERP-Core: +-- auth.get_current_tenant_id() +-- auth.get_current_user_id() +-- auth.get_current_company_id() + +-- Función para obtener sucursal actual del usuario (para POS) +CREATE OR REPLACE FUNCTION retail.get_current_branch_id() +RETURNS UUID AS $$ +BEGIN + RETURN current_setting('app.current_branch_id', true)::UUID; +EXCEPTION + WHEN OTHERS THEN + RETURN NULL; +END; +$$ LANGUAGE plpgsql STABLE; + +COMMENT ON FUNCTION retail.get_current_branch_id IS +'Obtiene el ID de la sucursal actual para operaciones POS'; + +-- ============================================================================ +-- FIN FUNCIONES RLS +-- ============================================================================ diff --git a/database/init/03-retail-tables.sql b/database/init/03-retail-tables.sql new file mode 100644 index 0000000..d2f8635 --- /dev/null +++ b/database/init/03-retail-tables.sql @@ -0,0 +1,723 @@ +-- ============================================================================ +-- TABLAS RETAIL/POS - ERP Retail +-- ============================================================================ +-- Módulos: RT-001 (POS), RT-002 (Inventario), RT-003 (Productos), RT-004 (Clientes) +-- Versión: 1.0.0 +-- Fecha: 2025-12-09 +-- ============================================================================ +-- PREREQUISITOS: +-- 1. ERP-Core instalado (auth, core, inventory, sales, financial) +-- 2. Schema retail creado +-- ============================================================================ + +-- ============================================================================ +-- TYPES (ENUMs) +-- ============================================================================ + +DO $$ BEGIN + CREATE TYPE retail.pos_session_status AS ENUM ( + 'opening', 'open', 'closing', 'closed' + ); +EXCEPTION WHEN duplicate_object THEN NULL; END $$; + +DO $$ BEGIN + CREATE TYPE retail.pos_order_status AS ENUM ( + 'draft', 'paid', 'done', 'cancelled', 'refunded' + ); +EXCEPTION WHEN duplicate_object THEN NULL; END $$; + +DO $$ BEGIN + CREATE TYPE retail.payment_method AS ENUM ( + 'cash', 'card', 'transfer', 'credit', 'mixed' + ); +EXCEPTION WHEN duplicate_object THEN NULL; END $$; + +DO $$ BEGIN + CREATE TYPE retail.cash_movement_type AS ENUM ( + 'in', 'out' + ); +EXCEPTION WHEN duplicate_object THEN NULL; END $$; + +DO $$ BEGIN + CREATE TYPE retail.transfer_status AS ENUM ( + 'draft', 'pending', 'in_transit', 'received', 'cancelled' + ); +EXCEPTION WHEN duplicate_object THEN NULL; END $$; + +DO $$ BEGIN + CREATE TYPE retail.promotion_type AS ENUM ( + 'percentage', 'fixed_amount', 'buy_x_get_y', 'bundle' + ); +EXCEPTION WHEN duplicate_object THEN NULL; END $$; + +-- ============================================================================ +-- SUCURSALES Y CONFIGURACIÓN +-- ============================================================================ + +-- Tabla: branches (Sucursales) +CREATE TABLE IF NOT EXISTS retail.branches ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE, + company_id UUID REFERENCES auth.companies(id), + + -- Identificación + code VARCHAR(20) NOT NULL, + name VARCHAR(100) NOT NULL, + + -- Ubicación + address VARCHAR(255), + city VARCHAR(100), + state VARCHAR(100), + zip_code VARCHAR(10), + country VARCHAR(100) DEFAULT 'México', + latitude DECIMAL(10,8), + longitude DECIMAL(11,8), + + -- Contacto + phone VARCHAR(20), + email VARCHAR(255), + manager_id UUID REFERENCES auth.users(id), + + -- Configuración + warehouse_id UUID, -- FK a inventory.warehouses (ERP Core) + default_pricelist_id UUID, + timezone VARCHAR(50) DEFAULT 'America/Mexico_City', + + -- Control + is_active BOOLEAN NOT NULL DEFAULT TRUE, + opening_date DATE, + + -- Auditoría + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + created_by UUID REFERENCES auth.users(id), + updated_at TIMESTAMPTZ, + updated_by UUID REFERENCES auth.users(id), + deleted_at TIMESTAMPTZ, + deleted_by UUID REFERENCES auth.users(id), + + CONSTRAINT uq_branches_code UNIQUE (tenant_id, code) +); + +-- Tabla: cash_registers (Cajas registradoras) +CREATE TABLE IF NOT EXISTS retail.cash_registers ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE, + branch_id UUID NOT NULL REFERENCES retail.branches(id), + + -- Identificación + code VARCHAR(20) NOT NULL, + name VARCHAR(100) NOT NULL, + + -- Configuración + is_active BOOLEAN NOT NULL DEFAULT TRUE, + default_payment_method retail.payment_method DEFAULT 'cash', + + -- Auditoría + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + created_by UUID REFERENCES auth.users(id), + updated_at TIMESTAMPTZ, + updated_by UUID REFERENCES auth.users(id), + + CONSTRAINT uq_cash_registers_code UNIQUE (tenant_id, branch_id, code) +); + +-- ============================================================================ +-- PUNTO DE VENTA (RT-001) +-- ============================================================================ + +-- Tabla: pos_sessions (Sesiones de POS) +CREATE TABLE IF NOT EXISTS retail.pos_sessions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE, + branch_id UUID NOT NULL REFERENCES retail.branches(id), + cash_register_id UUID NOT NULL REFERENCES retail.cash_registers(id), + + -- Usuario + user_id UUID NOT NULL REFERENCES auth.users(id), + + -- Estado + status retail.pos_session_status NOT NULL DEFAULT 'opening', + + -- Apertura + opening_date TIMESTAMPTZ NOT NULL DEFAULT NOW(), + opening_balance DECIMAL(14,2) NOT NULL DEFAULT 0, + + -- Cierre + closing_date TIMESTAMPTZ, + closing_balance DECIMAL(14,2), + closing_notes TEXT, + + -- Totales calculados + total_sales DECIMAL(14,2) DEFAULT 0, + total_refunds DECIMAL(14,2) DEFAULT 0, + total_cash_in DECIMAL(14,2) DEFAULT 0, + total_cash_out DECIMAL(14,2) DEFAULT 0, + total_card DECIMAL(14,2) DEFAULT 0, + total_transfer DECIMAL(14,2) DEFAULT 0, + + -- Diferencia + expected_balance DECIMAL(14,2), + difference DECIMAL(14,2), + + -- Auditoría + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + created_by UUID REFERENCES auth.users(id), + updated_at TIMESTAMPTZ, + updated_by UUID REFERENCES auth.users(id) +); + +-- Tabla: pos_orders (Órdenes/Ventas de POS) +CREATE TABLE IF NOT EXISTS retail.pos_orders ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE, + session_id UUID NOT NULL REFERENCES retail.pos_sessions(id), + branch_id UUID NOT NULL REFERENCES retail.branches(id), + + -- Número de ticket + order_number VARCHAR(30) NOT NULL, + order_date TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + -- Cliente (opcional) + customer_id UUID, -- FK a core.partners (ERP Core) + customer_name VARCHAR(200), + + -- Estado + status retail.pos_order_status NOT NULL DEFAULT 'draft', + + -- Totales + subtotal DECIMAL(14,2) NOT NULL DEFAULT 0, + discount_amount DECIMAL(14,2) DEFAULT 0, + tax_amount DECIMAL(14,2) DEFAULT 0, + total DECIMAL(14,2) NOT NULL DEFAULT 0, + + -- Pago + payment_method retail.payment_method, + amount_paid DECIMAL(14,2) DEFAULT 0, + change_amount DECIMAL(14,2) DEFAULT 0, + + -- Facturación + requires_invoice BOOLEAN DEFAULT FALSE, + invoice_id UUID, -- FK a financial.invoices (ERP Core) + + -- Notas + notes TEXT, + + -- Auditoría + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + created_by UUID REFERENCES auth.users(id), + updated_at TIMESTAMPTZ, + updated_by UUID REFERENCES auth.users(id), + + CONSTRAINT uq_pos_orders_number UNIQUE (tenant_id, order_number) +); + +-- Tabla: pos_order_lines (Líneas de venta) +CREATE TABLE IF NOT EXISTS retail.pos_order_lines ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE, + order_id UUID NOT NULL REFERENCES retail.pos_orders(id) ON DELETE CASCADE, + + -- Producto + product_id UUID NOT NULL, -- FK a inventory.products (ERP Core) + product_name VARCHAR(255) NOT NULL, + barcode VARCHAR(50), + + -- Cantidades + quantity DECIMAL(12,4) NOT NULL, + unit_price DECIMAL(12,4) NOT NULL, + + -- Descuentos + discount_percent DECIMAL(5,2) DEFAULT 0, + discount_amount DECIMAL(12,2) DEFAULT 0, + + -- Totales + subtotal DECIMAL(14,2) GENERATED ALWAYS AS (quantity * unit_price) STORED, + tax_amount DECIMAL(12,2) DEFAULT 0, + total DECIMAL(14,2) NOT NULL, + + -- Orden + sequence INTEGER DEFAULT 1, + + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + created_by UUID REFERENCES auth.users(id) +); + +-- Tabla: pos_payments (Pagos de orden - para pagos mixtos) +CREATE TABLE IF NOT EXISTS retail.pos_payments ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE, + order_id UUID NOT NULL REFERENCES retail.pos_orders(id) ON DELETE CASCADE, + + payment_method retail.payment_method NOT NULL, + amount DECIMAL(14,2) NOT NULL, + + -- Referencia (para tarjeta/transferencia) + reference VARCHAR(100), + card_last_four VARCHAR(4), + + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + created_by UUID REFERENCES auth.users(id) +); + +-- Tabla: cash_movements (Movimientos de efectivo) +CREATE TABLE IF NOT EXISTS retail.cash_movements ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE, + session_id UUID NOT NULL REFERENCES retail.pos_sessions(id), + + -- Tipo y monto + movement_type retail.cash_movement_type NOT NULL, + amount DECIMAL(14,2) NOT NULL, + + -- Razón + reason VARCHAR(255) NOT NULL, + notes TEXT, + + -- Autorización + authorized_by UUID REFERENCES auth.users(id), + + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + created_by UUID REFERENCES auth.users(id) +); + +-- ============================================================================ +-- INVENTARIO MULTI-SUCURSAL (RT-002) +-- ============================================================================ + +-- Tabla: branch_stock (Stock por sucursal) +CREATE TABLE IF NOT EXISTS retail.branch_stock ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE, + branch_id UUID NOT NULL REFERENCES retail.branches(id), + product_id UUID NOT NULL, -- FK a inventory.products (ERP Core) + + -- Cantidades + quantity_on_hand DECIMAL(12,4) NOT NULL DEFAULT 0, + quantity_reserved DECIMAL(12,4) DEFAULT 0, + quantity_available DECIMAL(12,4) GENERATED ALWAYS AS (quantity_on_hand - COALESCE(quantity_reserved, 0)) STORED, + + -- Límites + reorder_point DECIMAL(12,4), + max_stock DECIMAL(12,4), + + -- Control + last_count_date DATE, + last_count_qty DECIMAL(12,4), + + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ, + + CONSTRAINT uq_branch_stock UNIQUE (branch_id, product_id) +); + +-- Tabla: stock_transfers (Transferencias entre sucursales) +CREATE TABLE IF NOT EXISTS retail.stock_transfers ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE, + + -- Número + transfer_number VARCHAR(30) NOT NULL, + + -- Origen y destino + source_branch_id UUID NOT NULL REFERENCES retail.branches(id), + destination_branch_id UUID NOT NULL REFERENCES retail.branches(id), + + -- Estado + status retail.transfer_status NOT NULL DEFAULT 'draft', + + -- Fechas + request_date TIMESTAMPTZ NOT NULL DEFAULT NOW(), + ship_date TIMESTAMPTZ, + receive_date TIMESTAMPTZ, + + -- Responsables + requested_by UUID NOT NULL REFERENCES auth.users(id), + shipped_by UUID REFERENCES auth.users(id), + received_by UUID REFERENCES auth.users(id), + + -- Notas + notes TEXT, + + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + created_by UUID REFERENCES auth.users(id), + updated_at TIMESTAMPTZ, + updated_by UUID REFERENCES auth.users(id), + + CONSTRAINT uq_stock_transfers_number UNIQUE (tenant_id, transfer_number), + CONSTRAINT chk_different_branches CHECK (source_branch_id != destination_branch_id) +); + +-- Tabla: stock_transfer_lines (Líneas de transferencia) +CREATE TABLE IF NOT EXISTS retail.stock_transfer_lines ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE, + transfer_id UUID NOT NULL REFERENCES retail.stock_transfers(id) ON DELETE CASCADE, + + product_id UUID NOT NULL, -- FK a inventory.products (ERP Core) + quantity_requested DECIMAL(12,4) NOT NULL, + quantity_shipped DECIMAL(12,4), + quantity_received DECIMAL(12,4), + + notes TEXT, + + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + created_by UUID REFERENCES auth.users(id) +); + +-- ============================================================================ +-- PRODUCTOS RETAIL (RT-003) +-- ============================================================================ + +-- Tabla: product_barcodes (Códigos de barras múltiples) +CREATE TABLE IF NOT EXISTS retail.product_barcodes ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE, + product_id UUID NOT NULL, -- FK a inventory.products (ERP Core) + + barcode VARCHAR(50) NOT NULL, + barcode_type VARCHAR(20) DEFAULT 'EAN13', -- EAN13, EAN8, UPC, CODE128, etc. + is_primary BOOLEAN DEFAULT FALSE, + + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + created_by UUID REFERENCES auth.users(id), + + CONSTRAINT uq_product_barcodes UNIQUE (tenant_id, barcode) +); + +-- Tabla: promotions (Promociones) +CREATE TABLE IF NOT EXISTS retail.promotions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE, + + code VARCHAR(30) NOT NULL, + name VARCHAR(100) NOT NULL, + description TEXT, + + -- Tipo de promoción + promotion_type retail.promotion_type NOT NULL, + discount_value DECIMAL(10,2), -- Porcentaje o monto fijo + + -- Vigencia + start_date TIMESTAMPTZ NOT NULL, + end_date TIMESTAMPTZ NOT NULL, + + -- Aplicación + applies_to_all BOOLEAN DEFAULT FALSE, + min_quantity DECIMAL(12,4), + min_amount DECIMAL(14,2), + + -- Sucursales (NULL = todas) + branch_ids UUID[], + + -- Control + is_active BOOLEAN NOT NULL DEFAULT TRUE, + max_uses INTEGER, + current_uses INTEGER DEFAULT 0, + + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + created_by UUID REFERENCES auth.users(id), + updated_at TIMESTAMPTZ, + updated_by UUID REFERENCES auth.users(id), + + CONSTRAINT uq_promotions_code UNIQUE (tenant_id, code), + CONSTRAINT chk_promotion_dates CHECK (end_date > start_date) +); + +-- Tabla: promotion_products (Productos en promoción) +CREATE TABLE IF NOT EXISTS retail.promotion_products ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE, + promotion_id UUID NOT NULL REFERENCES retail.promotions(id) ON DELETE CASCADE, + product_id UUID NOT NULL, -- FK a inventory.products (ERP Core) + + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- ============================================================================ +-- CLIENTES Y FIDELIZACIÓN (RT-004) +-- ============================================================================ + +-- Tabla: loyalty_programs (Programas de fidelización) +CREATE TABLE IF NOT EXISTS retail.loyalty_programs ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE, + + code VARCHAR(20) NOT NULL, + name VARCHAR(100) NOT NULL, + description TEXT, + + -- Configuración de puntos + points_per_currency DECIMAL(10,4) DEFAULT 1, -- Puntos por peso gastado + currency_per_point DECIMAL(10,4) DEFAULT 0.01, -- Valor del punto en pesos + min_points_redeem INTEGER DEFAULT 100, + + -- Vigencia + points_expiry_days INTEGER, -- NULL = no expiran + + is_active BOOLEAN NOT NULL DEFAULT TRUE, + + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + created_by UUID REFERENCES auth.users(id), + updated_at TIMESTAMPTZ, + updated_by UUID REFERENCES auth.users(id), + + CONSTRAINT uq_loyalty_programs_code UNIQUE (tenant_id, code) +); + +-- Tabla: loyalty_cards (Tarjetas de fidelización) +CREATE TABLE IF NOT EXISTS retail.loyalty_cards ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE, + program_id UUID NOT NULL REFERENCES retail.loyalty_programs(id), + customer_id UUID NOT NULL, -- FK a core.partners (ERP Core) + + card_number VARCHAR(30) NOT NULL, + issue_date DATE NOT NULL DEFAULT CURRENT_DATE, + + -- Balance + points_balance INTEGER NOT NULL DEFAULT 0, + points_earned INTEGER NOT NULL DEFAULT 0, + points_redeemed INTEGER NOT NULL DEFAULT 0, + points_expired INTEGER NOT NULL DEFAULT 0, + + is_active BOOLEAN NOT NULL DEFAULT TRUE, + + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + created_by UUID REFERENCES auth.users(id), + updated_at TIMESTAMPTZ, + updated_by UUID REFERENCES auth.users(id), + + CONSTRAINT uq_loyalty_cards_number UNIQUE (tenant_id, card_number) +); + +-- Tabla: loyalty_transactions (Transacciones de puntos) +CREATE TABLE IF NOT EXISTS retail.loyalty_transactions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id UUID NOT NULL REFERENCES auth.tenants(id) ON DELETE CASCADE, + card_id UUID NOT NULL REFERENCES retail.loyalty_cards(id), + + -- Tipo + transaction_type VARCHAR(20) NOT NULL, -- earn, redeem, expire, adjust + points INTEGER NOT NULL, + + -- Referencia + order_id UUID REFERENCES retail.pos_orders(id), + description TEXT, + + -- Balance después de la transacción + balance_after INTEGER NOT NULL, + + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + created_by UUID REFERENCES auth.users(id) +); + +-- ============================================================================ +-- ÍNDICES +-- ============================================================================ + +-- Branches +CREATE INDEX IF NOT EXISTS idx_branches_tenant ON retail.branches(tenant_id); +CREATE INDEX IF NOT EXISTS idx_branches_company ON retail.branches(company_id); + +-- Cash registers +CREATE INDEX IF NOT EXISTS idx_cash_registers_tenant ON retail.cash_registers(tenant_id); +CREATE INDEX IF NOT EXISTS idx_cash_registers_branch ON retail.cash_registers(branch_id); + +-- POS sessions +CREATE INDEX IF NOT EXISTS idx_pos_sessions_tenant ON retail.pos_sessions(tenant_id); +CREATE INDEX IF NOT EXISTS idx_pos_sessions_branch ON retail.pos_sessions(branch_id); +CREATE INDEX IF NOT EXISTS idx_pos_sessions_user ON retail.pos_sessions(user_id); +CREATE INDEX IF NOT EXISTS idx_pos_sessions_status ON retail.pos_sessions(status); +CREATE INDEX IF NOT EXISTS idx_pos_sessions_date ON retail.pos_sessions(opening_date); + +-- POS orders +CREATE INDEX IF NOT EXISTS idx_pos_orders_tenant ON retail.pos_orders(tenant_id); +CREATE INDEX IF NOT EXISTS idx_pos_orders_session ON retail.pos_orders(session_id); +CREATE INDEX IF NOT EXISTS idx_pos_orders_branch ON retail.pos_orders(branch_id); +CREATE INDEX IF NOT EXISTS idx_pos_orders_customer ON retail.pos_orders(customer_id); +CREATE INDEX IF NOT EXISTS idx_pos_orders_date ON retail.pos_orders(order_date); +CREATE INDEX IF NOT EXISTS idx_pos_orders_status ON retail.pos_orders(status); + +-- POS order lines +CREATE INDEX IF NOT EXISTS idx_pos_order_lines_tenant ON retail.pos_order_lines(tenant_id); +CREATE INDEX IF NOT EXISTS idx_pos_order_lines_order ON retail.pos_order_lines(order_id); +CREATE INDEX IF NOT EXISTS idx_pos_order_lines_product ON retail.pos_order_lines(product_id); + +-- POS payments +CREATE INDEX IF NOT EXISTS idx_pos_payments_tenant ON retail.pos_payments(tenant_id); +CREATE INDEX IF NOT EXISTS idx_pos_payments_order ON retail.pos_payments(order_id); + +-- Cash movements +CREATE INDEX IF NOT EXISTS idx_cash_movements_tenant ON retail.cash_movements(tenant_id); +CREATE INDEX IF NOT EXISTS idx_cash_movements_session ON retail.cash_movements(session_id); + +-- Branch stock +CREATE INDEX IF NOT EXISTS idx_branch_stock_tenant ON retail.branch_stock(tenant_id); +CREATE INDEX IF NOT EXISTS idx_branch_stock_branch ON retail.branch_stock(branch_id); +CREATE INDEX IF NOT EXISTS idx_branch_stock_product ON retail.branch_stock(product_id); + +-- Stock transfers +CREATE INDEX IF NOT EXISTS idx_stock_transfers_tenant ON retail.stock_transfers(tenant_id); +CREATE INDEX IF NOT EXISTS idx_stock_transfers_source ON retail.stock_transfers(source_branch_id); +CREATE INDEX IF NOT EXISTS idx_stock_transfers_dest ON retail.stock_transfers(destination_branch_id); +CREATE INDEX IF NOT EXISTS idx_stock_transfers_status ON retail.stock_transfers(status); + +-- Product barcodes +CREATE INDEX IF NOT EXISTS idx_product_barcodes_tenant ON retail.product_barcodes(tenant_id); +CREATE INDEX IF NOT EXISTS idx_product_barcodes_barcode ON retail.product_barcodes(barcode); +CREATE INDEX IF NOT EXISTS idx_product_barcodes_product ON retail.product_barcodes(product_id); + +-- Promotions +CREATE INDEX IF NOT EXISTS idx_promotions_tenant ON retail.promotions(tenant_id); +CREATE INDEX IF NOT EXISTS idx_promotions_dates ON retail.promotions(start_date, end_date); +CREATE INDEX IF NOT EXISTS idx_promotions_active ON retail.promotions(is_active); + +-- Loyalty +CREATE INDEX IF NOT EXISTS idx_loyalty_cards_tenant ON retail.loyalty_cards(tenant_id); +CREATE INDEX IF NOT EXISTS idx_loyalty_cards_customer ON retail.loyalty_cards(customer_id); +CREATE INDEX IF NOT EXISTS idx_loyalty_transactions_tenant ON retail.loyalty_transactions(tenant_id); +CREATE INDEX IF NOT EXISTS idx_loyalty_transactions_card ON retail.loyalty_transactions(card_id); + +-- ============================================================================ +-- ROW LEVEL SECURITY +-- ============================================================================ + +ALTER TABLE retail.branches ENABLE ROW LEVEL SECURITY; +ALTER TABLE retail.cash_registers ENABLE ROW LEVEL SECURITY; +ALTER TABLE retail.pos_sessions ENABLE ROW LEVEL SECURITY; +ALTER TABLE retail.pos_orders ENABLE ROW LEVEL SECURITY; +ALTER TABLE retail.pos_order_lines ENABLE ROW LEVEL SECURITY; +ALTER TABLE retail.pos_payments ENABLE ROW LEVEL SECURITY; +ALTER TABLE retail.cash_movements ENABLE ROW LEVEL SECURITY; +ALTER TABLE retail.branch_stock ENABLE ROW LEVEL SECURITY; +ALTER TABLE retail.stock_transfers ENABLE ROW LEVEL SECURITY; +ALTER TABLE retail.stock_transfer_lines ENABLE ROW LEVEL SECURITY; +ALTER TABLE retail.product_barcodes ENABLE ROW LEVEL SECURITY; +ALTER TABLE retail.promotions ENABLE ROW LEVEL SECURITY; +ALTER TABLE retail.promotion_products ENABLE ROW LEVEL SECURITY; +ALTER TABLE retail.loyalty_programs ENABLE ROW LEVEL SECURITY; +ALTER TABLE retail.loyalty_cards ENABLE ROW LEVEL SECURITY; +ALTER TABLE retail.loyalty_transactions ENABLE ROW LEVEL SECURITY; + +-- Políticas de aislamiento por tenant +DO $$ BEGIN + DROP POLICY IF EXISTS tenant_isolation_branches ON retail.branches; + CREATE POLICY tenant_isolation_branches ON retail.branches + FOR ALL USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID); +EXCEPTION WHEN undefined_object THEN NULL; END $$; + +DO $$ BEGIN + DROP POLICY IF EXISTS tenant_isolation_cash_registers ON retail.cash_registers; + CREATE POLICY tenant_isolation_cash_registers ON retail.cash_registers + FOR ALL USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID); +EXCEPTION WHEN undefined_object THEN NULL; END $$; + +DO $$ BEGIN + DROP POLICY IF EXISTS tenant_isolation_pos_sessions ON retail.pos_sessions; + CREATE POLICY tenant_isolation_pos_sessions ON retail.pos_sessions + FOR ALL USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID); +EXCEPTION WHEN undefined_object THEN NULL; END $$; + +DO $$ BEGIN + DROP POLICY IF EXISTS tenant_isolation_pos_orders ON retail.pos_orders; + CREATE POLICY tenant_isolation_pos_orders ON retail.pos_orders + FOR ALL USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID); +EXCEPTION WHEN undefined_object THEN NULL; END $$; + +DO $$ BEGIN + DROP POLICY IF EXISTS tenant_isolation_pos_order_lines ON retail.pos_order_lines; + CREATE POLICY tenant_isolation_pos_order_lines ON retail.pos_order_lines + FOR ALL USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID); +EXCEPTION WHEN undefined_object THEN NULL; END $$; + +DO $$ BEGIN + DROP POLICY IF EXISTS tenant_isolation_pos_payments ON retail.pos_payments; + CREATE POLICY tenant_isolation_pos_payments ON retail.pos_payments + FOR ALL USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID); +EXCEPTION WHEN undefined_object THEN NULL; END $$; + +DO $$ BEGIN + DROP POLICY IF EXISTS tenant_isolation_cash_movements ON retail.cash_movements; + CREATE POLICY tenant_isolation_cash_movements ON retail.cash_movements + FOR ALL USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID); +EXCEPTION WHEN undefined_object THEN NULL; END $$; + +DO $$ BEGIN + DROP POLICY IF EXISTS tenant_isolation_branch_stock ON retail.branch_stock; + CREATE POLICY tenant_isolation_branch_stock ON retail.branch_stock + FOR ALL USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID); +EXCEPTION WHEN undefined_object THEN NULL; END $$; + +DO $$ BEGIN + DROP POLICY IF EXISTS tenant_isolation_stock_transfers ON retail.stock_transfers; + CREATE POLICY tenant_isolation_stock_transfers ON retail.stock_transfers + FOR ALL USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID); +EXCEPTION WHEN undefined_object THEN NULL; END $$; + +DO $$ BEGIN + DROP POLICY IF EXISTS tenant_isolation_stock_transfer_lines ON retail.stock_transfer_lines; + CREATE POLICY tenant_isolation_stock_transfer_lines ON retail.stock_transfer_lines + FOR ALL USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID); +EXCEPTION WHEN undefined_object THEN NULL; END $$; + +DO $$ BEGIN + DROP POLICY IF EXISTS tenant_isolation_product_barcodes ON retail.product_barcodes; + CREATE POLICY tenant_isolation_product_barcodes ON retail.product_barcodes + FOR ALL USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID); +EXCEPTION WHEN undefined_object THEN NULL; END $$; + +DO $$ BEGIN + DROP POLICY IF EXISTS tenant_isolation_promotions ON retail.promotions; + CREATE POLICY tenant_isolation_promotions ON retail.promotions + FOR ALL USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID); +EXCEPTION WHEN undefined_object THEN NULL; END $$; + +DO $$ BEGIN + DROP POLICY IF EXISTS tenant_isolation_promotion_products ON retail.promotion_products; + CREATE POLICY tenant_isolation_promotion_products ON retail.promotion_products + FOR ALL USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID); +EXCEPTION WHEN undefined_object THEN NULL; END $$; + +DO $$ BEGIN + DROP POLICY IF EXISTS tenant_isolation_loyalty_programs ON retail.loyalty_programs; + CREATE POLICY tenant_isolation_loyalty_programs ON retail.loyalty_programs + FOR ALL USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID); +EXCEPTION WHEN undefined_object THEN NULL; END $$; + +DO $$ BEGIN + DROP POLICY IF EXISTS tenant_isolation_loyalty_cards ON retail.loyalty_cards; + CREATE POLICY tenant_isolation_loyalty_cards ON retail.loyalty_cards + FOR ALL USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID); +EXCEPTION WHEN undefined_object THEN NULL; END $$; + +DO $$ BEGIN + DROP POLICY IF EXISTS tenant_isolation_loyalty_transactions ON retail.loyalty_transactions; + CREATE POLICY tenant_isolation_loyalty_transactions ON retail.loyalty_transactions + FOR ALL USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID); +EXCEPTION WHEN undefined_object THEN NULL; END $$; + +-- ============================================================================ +-- COMENTARIOS +-- ============================================================================ + +COMMENT ON TABLE retail.branches IS 'Sucursales de la empresa'; +COMMENT ON TABLE retail.cash_registers IS 'Cajas registradoras por sucursal'; +COMMENT ON TABLE retail.pos_sessions IS 'Sesiones de punto de venta'; +COMMENT ON TABLE retail.pos_orders IS 'Órdenes/Ventas de punto de venta'; +COMMENT ON TABLE retail.pos_order_lines IS 'Líneas de venta'; +COMMENT ON TABLE retail.pos_payments IS 'Pagos de orden (para pagos mixtos)'; +COMMENT ON TABLE retail.cash_movements IS 'Entradas/salidas de efectivo'; +COMMENT ON TABLE retail.branch_stock IS 'Stock por sucursal'; +COMMENT ON TABLE retail.stock_transfers IS 'Transferencias entre sucursales'; +COMMENT ON TABLE retail.stock_transfer_lines IS 'Líneas de transferencia'; +COMMENT ON TABLE retail.product_barcodes IS 'Códigos de barras múltiples por producto'; +COMMENT ON TABLE retail.promotions IS 'Promociones y descuentos'; +COMMENT ON TABLE retail.promotion_products IS 'Productos en promoción'; +COMMENT ON TABLE retail.loyalty_programs IS 'Programas de fidelización'; +COMMENT ON TABLE retail.loyalty_cards IS 'Tarjetas de fidelización'; +COMMENT ON TABLE retail.loyalty_transactions IS 'Transacciones de puntos'; + +-- ============================================================================ +-- FIN TABLAS RETAIL +-- Total: 16 tablas, 6 ENUMs +-- ============================================================================ diff --git a/docs/00-vision-general/VISION-RETAIL.md b/docs/00-vision-general/VISION-RETAIL.md new file mode 100644 index 0000000..300a88d --- /dev/null +++ b/docs/00-vision-general/VISION-RETAIL.md @@ -0,0 +1,97 @@ +# Visión General - ERP Retail + +**Versión:** 1.0 +**Fecha:** 2025-12-08 +**Nivel:** 2B.2 (Vertical) + +--- + +## Propósito del Sistema + +Sistema ERP especializado para comercio minorista con punto de venta (POS), gestión de inventario multi-sucursal, control de caja y programas de fidelización. Optimizado para operación en tienda física con capacidad offline. + +--- + +## Dominio del Negocio + +### Procesos Principales + +1. **Punto de Venta (POS)** + - Venta rápida en mostrador + - Múltiples métodos de pago + - Facturación CFDI 4.0 + - Operación offline + +2. **Inventario Multi-Sucursal** + - Control de stock por sucursal + - Transferencias entre tiendas + - Conteos cíclicos + - Alertas de reorden + +3. **Compras y Reabastecimiento** + - Órdenes de compra centralizadas + - Distribución a sucursales + - Control de proveedores + +4. **Clientes y Fidelización** + - Programa de lealtad + - Puntos y recompensas + - Historial de compras + +5. **Gestión de Caja** + - Apertura y cierre de caja + - Arqueos + - Control de efectivo + +--- + +## Arquitectura de Módulos + +``` +RT-001 Fundamentos → Auth, Users, Tenants (hereda 100% core) +RT-002 POS → Punto de venta (20% core) +RT-003 Inventario → Stock multi-sucursal (60% core) +RT-004 Compras → Reabastecimiento (80% core) +RT-005 Clientes → Programa fidelidad (40% core) +RT-006 Precios → Promociones, descuentos (30% core) +RT-007 Caja → Arqueos, cortes (10% core) +RT-008 Reportes → Dashboard ventas (70% core) +RT-009 E-commerce → Tienda online (20% core) +RT-010 Facturación → CFDI 4.0 (60% core) +``` + +--- + +## Stack Tecnológico + +- **Backend:** NestJS + TypeORM + PostgreSQL +- **Frontend POS:** React + PWA (offline-first) +- **Base de Datos:** PostgreSQL 15+ (hereda ERP Core) +- **Hardware:** Impresora térmica, lector de códigos, cajón + +--- + +## Métricas Objetivo + +| Métrica | Valor Objetivo | +|---------|----------------| +| Módulos | 10 | +| Tablas Específicas | ~30 | +| Tablas Heredadas | ~102 | +| Story Points Est. | ~280 | +| Tiempo Venta | < 30 segundos | +| Disponibilidad | 99.9% | + +--- + +## Referencias + +- ERP Core: `apps/erp-core/` +- Herencia DB: `database/HERENCIA-ERP-CORE.md` +- SPECS del Core: `HERENCIA-SPECS-CORE.md` +- Inventarios: `orchestration/inventarios/` + +--- + +**Documento de visión oficial** +**Última actualización:** 2025-12-08 diff --git a/docs/02-definicion-modulos/INDICE-MODULOS.md b/docs/02-definicion-modulos/INDICE-MODULOS.md new file mode 100644 index 0000000..ada07c9 --- /dev/null +++ b/docs/02-definicion-modulos/INDICE-MODULOS.md @@ -0,0 +1,116 @@ +# Índice de Módulos - ERP Retail + +**Versión:** 1.0 +**Fecha:** 2025-12-08 +**Total Módulos:** 10 + +--- + +## Resumen + +| Código | Nombre | Descripción | Reutilización Core | Estado | +|--------|--------|-------------|-------------------|--------| +| RT-001 | Fundamentos | Auth, Users, Tenants | 100% | PLANIFICADO | +| RT-002 | POS | Punto de venta | 20% | PLANIFICADO | +| RT-003 | Inventario | Stock multi-sucursal | 60% | PLANIFICADO | +| RT-004 | Compras | Reabastecimiento | 80% | PLANIFICADO | +| RT-005 | Clientes | Programa fidelidad | 40% | PLANIFICADO | +| RT-006 | Precios | Promociones y descuentos | 30% | PLANIFICADO | +| RT-007 | Caja | Arqueos y cortes | 10% | PLANIFICADO | +| RT-008 | Reportes | Dashboard de ventas | 70% | PLANIFICADO | +| RT-009 | E-commerce | Tienda online | 20% | PLANIFICADO | +| RT-010 | Facturación | CFDI 4.0 | 60% | PLANIFICADO | + +--- + +## Detalle por Módulo + +### RT-001: Fundamentos +**Herencia:** 100% del core +- Usuarios por sucursal +- Roles: Cajero, Supervisor, Gerente, Admin + +### RT-002: POS +**Herencia:** 20% +- Venta rápida en mostrador +- Múltiples formas de pago +- Operación offline (PWA) +- Integración con hardware + +### RT-003: Inventario +**Herencia:** 60% +- Stock por sucursal +- Transferencias entre tiendas +- Conteos cíclicos +- Alertas de mínimos + +### RT-004: Compras +**Herencia:** 80% +- Órdenes de compra centralizadas +- Distribución a sucursales +- Control de proveedores + +### RT-005: Clientes +**Herencia:** 40% +- Programa de lealtad +- Puntos y recompensas +- Historial de compras +- Membresías + +### RT-006: Precios +**Herencia:** 30% +- Listas de precios +- Promociones temporales +- Descuentos por volumen +- Cupones + +### RT-007: Caja +**Herencia:** 10% +- Sesiones de caja +- Apertura/cierre +- Arqueos +- Movimientos de efectivo + +### RT-008: Reportes +**Herencia:** 70% +- Dashboard de ventas +- Análisis por sucursal +- Top productos +- Métricas de cajeros + +### RT-009: E-commerce +**Herencia:** 20% +- Tienda online +- Carrito de compras +- Checkout +- Sincronización de inventario + +### RT-010: Facturación +**Herencia:** 60% +- CFDI 4.0 +- Timbrado automático +- Notas de crédito +- Reportes fiscales + +--- + +## Story Points Estimados + +| Módulo | SP Backend | SP Frontend | SP Total | +|--------|-----------|-------------|----------| +| RT-001 | 0 | 0 | 0 | +| RT-002 | 34 | 21 | 55 | +| RT-003 | 21 | 13 | 34 | +| RT-004 | 13 | 8 | 21 | +| RT-005 | 21 | 13 | 34 | +| RT-006 | 21 | 13 | 34 | +| RT-007 | 21 | 13 | 34 | +| RT-008 | 13 | 13 | 26 | +| RT-009 | 34 | 21 | 55 | +| RT-010 | 21 | 8 | 29 | +| **Total** | **199** | **123** | **322** | + +--- + +**Índice de módulos oficial** +**Última actualización:** 2025-12-08 diff --git a/docs/02-definicion-modulos/RT-001-fundamentos/README.md b/docs/02-definicion-modulos/RT-001-fundamentos/README.md new file mode 100644 index 0000000..09cd3ec --- /dev/null +++ b/docs/02-definicion-modulos/RT-001-fundamentos/README.md @@ -0,0 +1,16 @@ +# RT-001: Fundamentos + +**Módulo:** Fundamentos +**Estado:** PLANIFICADO + +## Descripción +Autenticación y usuarios por sucursal + +## Funcionalidades Principales +- Por definir en fase de análisis + +## SPECS Aplicables +- Ver HERENCIA-SPECS-CORE.md + +--- +**Última actualización:** 2025-12-08 diff --git a/docs/02-definicion-modulos/RT-002-pos/README.md b/docs/02-definicion-modulos/RT-002-pos/README.md new file mode 100644 index 0000000..a34e941 --- /dev/null +++ b/docs/02-definicion-modulos/RT-002-pos/README.md @@ -0,0 +1,16 @@ +# RT-002: Pos + +**Módulo:** Pos +**Estado:** PLANIFICADO + +## Descripción +Punto de venta con operación offline + +## Funcionalidades Principales +- Por definir en fase de análisis + +## SPECS Aplicables +- Ver HERENCIA-SPECS-CORE.md + +--- +**Última actualización:** 2025-12-08 diff --git a/docs/02-definicion-modulos/RT-003-inventario/README.md b/docs/02-definicion-modulos/RT-003-inventario/README.md new file mode 100644 index 0000000..b5cb035 --- /dev/null +++ b/docs/02-definicion-modulos/RT-003-inventario/README.md @@ -0,0 +1,16 @@ +# RT-003: Inventario + +**Módulo:** Inventario +**Estado:** PLANIFICADO + +## Descripción +Stock multi-sucursal y transferencias + +## Funcionalidades Principales +- Por definir en fase de análisis + +## SPECS Aplicables +- Ver HERENCIA-SPECS-CORE.md + +--- +**Última actualización:** 2025-12-08 diff --git a/docs/02-definicion-modulos/RT-004-compras/README.md b/docs/02-definicion-modulos/RT-004-compras/README.md new file mode 100644 index 0000000..54d1b02 --- /dev/null +++ b/docs/02-definicion-modulos/RT-004-compras/README.md @@ -0,0 +1,16 @@ +# RT-004: Compras + +**Módulo:** Compras +**Estado:** PLANIFICADO + +## Descripción +Reabastecimiento centralizado + +## Funcionalidades Principales +- Por definir en fase de análisis + +## SPECS Aplicables +- Ver HERENCIA-SPECS-CORE.md + +--- +**Última actualización:** 2025-12-08 diff --git a/docs/02-definicion-modulos/RT-005-clientes/README.md b/docs/02-definicion-modulos/RT-005-clientes/README.md new file mode 100644 index 0000000..fdc416d --- /dev/null +++ b/docs/02-definicion-modulos/RT-005-clientes/README.md @@ -0,0 +1,16 @@ +# RT-005: Clientes + +**Módulo:** Clientes +**Estado:** PLANIFICADO + +## Descripción +Programa de fidelidad y puntos + +## Funcionalidades Principales +- Por definir en fase de análisis + +## SPECS Aplicables +- Ver HERENCIA-SPECS-CORE.md + +--- +**Última actualización:** 2025-12-08 diff --git a/docs/02-definicion-modulos/RT-006-precios/README.md b/docs/02-definicion-modulos/RT-006-precios/README.md new file mode 100644 index 0000000..2c5fd6a --- /dev/null +++ b/docs/02-definicion-modulos/RT-006-precios/README.md @@ -0,0 +1,16 @@ +# RT-006: Precios + +**Módulo:** Precios +**Estado:** PLANIFICADO + +## Descripción +Promociones, descuentos y cupones + +## Funcionalidades Principales +- Por definir en fase de análisis + +## SPECS Aplicables +- Ver HERENCIA-SPECS-CORE.md + +--- +**Última actualización:** 2025-12-08 diff --git a/docs/02-definicion-modulos/RT-007-caja/README.md b/docs/02-definicion-modulos/RT-007-caja/README.md new file mode 100644 index 0000000..277be08 --- /dev/null +++ b/docs/02-definicion-modulos/RT-007-caja/README.md @@ -0,0 +1,16 @@ +# RT-007: Caja + +**Módulo:** Caja +**Estado:** PLANIFICADO + +## Descripción +Sesiones, arqueos y cortes de caja + +## Funcionalidades Principales +- Por definir en fase de análisis + +## SPECS Aplicables +- Ver HERENCIA-SPECS-CORE.md + +--- +**Última actualización:** 2025-12-08 diff --git a/docs/02-definicion-modulos/RT-008-reportes/README.md b/docs/02-definicion-modulos/RT-008-reportes/README.md new file mode 100644 index 0000000..0933529 --- /dev/null +++ b/docs/02-definicion-modulos/RT-008-reportes/README.md @@ -0,0 +1,16 @@ +# RT-008: Reportes + +**Módulo:** Reportes +**Estado:** PLANIFICADO + +## Descripción +Dashboard de ventas y métricas + +## Funcionalidades Principales +- Por definir en fase de análisis + +## SPECS Aplicables +- Ver HERENCIA-SPECS-CORE.md + +--- +**Última actualización:** 2025-12-08 diff --git a/docs/02-definicion-modulos/RT-009-ecommerce/README.md b/docs/02-definicion-modulos/RT-009-ecommerce/README.md new file mode 100644 index 0000000..c057114 --- /dev/null +++ b/docs/02-definicion-modulos/RT-009-ecommerce/README.md @@ -0,0 +1,16 @@ +# RT-009: Ecommerce + +**Módulo:** Ecommerce +**Estado:** PLANIFICADO + +## Descripción +Tienda online y carrito + +## Funcionalidades Principales +- Por definir en fase de análisis + +## SPECS Aplicables +- Ver HERENCIA-SPECS-CORE.md + +--- +**Última actualización:** 2025-12-08 diff --git a/docs/02-definicion-modulos/RT-010-facturacion/README.md b/docs/02-definicion-modulos/RT-010-facturacion/README.md new file mode 100644 index 0000000..6603ca2 --- /dev/null +++ b/docs/02-definicion-modulos/RT-010-facturacion/README.md @@ -0,0 +1,16 @@ +# RT-010: Facturacion + +**Módulo:** Facturacion +**Estado:** PLANIFICADO + +## Descripción +CFDI 4.0 y timbrado + +## Funcionalidades Principales +- Por definir en fase de análisis + +## SPECS Aplicables +- Ver HERENCIA-SPECS-CORE.md + +--- +**Última actualización:** 2025-12-08 diff --git a/docs/08-epicas/EPIC-RT-001-fundamentos.md b/docs/08-epicas/EPIC-RT-001-fundamentos.md new file mode 100644 index 0000000..8a6cbc5 --- /dev/null +++ b/docs/08-epicas/EPIC-RT-001-fundamentos.md @@ -0,0 +1,58 @@ +# Épica: Fundamentos del Sistema Retail + +**Código:** EPIC-RT-001 +**Módulos:** RT-001, RT-002, RT-007 +**Estado:** PLANIFICADO + +--- + +## Descripción + +Implementación de los módulos fundacionales del ERP Retail, incluyendo la configuración inicial, el punto de venta básico y la gestión de caja. + +--- + +## Objetivos + +1. Configurar el ambiente heredando del ERP Core +2. Implementar el POS básico con venta rápida +3. Establecer la gestión de sesiones de caja + +--- + +## Módulos Incluidos + +| Módulo | Descripción | SP Estimados | +|--------|-------------|--------------| +| RT-001 | Fundamentos | 0 | +| RT-002 | POS | 55 | +| RT-007 | Caja | 34 | + +--- + +## User Stories Principales + +1. Como cajero, quiero abrir mi sesión de caja con un fondo inicial +2. Como cajero, quiero registrar ventas rápidamente +3. Como cajero, quiero aceptar múltiples formas de pago +4. Como supervisor, quiero cerrar caja y generar el corte + +--- + +## Criterios de Aceptación + +- [ ] Sistema de autenticación por sucursal funcional +- [ ] POS operativo con venta básica +- [ ] Apertura y cierre de caja implementados +- [ ] Tiempo de transacción < 30 segundos + +--- + +## Story Points Totales + +**89 SP** + +--- + +**Épica fundacional** +**Última actualización:** 2025-12-08 diff --git a/docs/08-epicas/EPIC-RT-002-pos.md b/docs/08-epicas/EPIC-RT-002-pos.md new file mode 100644 index 0000000..c5cda0c --- /dev/null +++ b/docs/08-epicas/EPIC-RT-002-pos.md @@ -0,0 +1,251 @@ +# EPICA: EPIC-RT-002 - Punto de Venta (POS) + +## Metadata + +| Campo | Valor | +|-------|-------| +| **ID** | EPIC-RT-002 | +| **Nombre** | Punto de Venta (POS) | +| **Modulo** | pos | +| **Fase** | Fase 1 - MVP | +| **Prioridad** | P0 (Critico) | +| **Estado** | Backlog | +| **Story Points** | 55 | +| **Sprint(s)** | Sprint 2-4 | + +--- + +## Descripcion + +Sistema de punto de venta optimizado para retail. PWA que funciona offline, permite ventas rápidas con escaneo de código de barras, múltiples formas de pago, integración con programa de lealtad y sincronización automática con inventario central. + +--- + +## Objetivo de Negocio + +- Ventas rápidas (< 30 segundos por transacción) +- Operación sin interrupciones (offline) +- Control en tiempo real de ventas +- Experiencia fluida para cajeros +- Integración omnicanal + +--- + +## Historias de Usuario + +| ID | Historia | Prioridad | SP | Estado | +|----|----------|-----------|-----|--------| +| US-RT002-001 | Como cajero, quiero escanear código de barras para agregar productos rápidamente | P0 | 5 | Backlog | +| US-RT002-002 | Como cajero, quiero buscar producto por nombre si no tiene código de barras | P0 | 3 | Backlog | +| US-RT002-003 | Como cajero, quiero ver carrito de compra con totales en tiempo real | P0 | 3 | Backlog | +| US-RT002-004 | Como cajero, quiero aplicar descuento porcentual o monto fijo a la venta | P0 | 3 | Backlog | +| US-RT002-005 | Como cajero, quiero registrar pago en efectivo con cálculo de cambio | P0 | 3 | Backlog | +| US-RT002-006 | Como cajero, quiero registrar pago con tarjeta (terminal integrada) | P0 | 8 | Backlog | +| US-RT002-007 | Como cajero, quiero registrar pagos mixtos (parte efectivo, parte tarjeta) | P0 | 5 | Backlog | +| US-RT002-008 | Como cajero, quiero imprimir ticket de venta automáticamente | P0 | 3 | Backlog | +| US-RT002-009 | Como cajero, quiero que el POS funcione sin internet para no perder ventas | P0 | 8 | Backlog | +| US-RT002-010 | Como supervisor, quiero autorizar descuentos mayores al límite del cajero | P1 | 3 | Backlog | +| US-RT002-011 | Como cajero, quiero consultar puntos de cliente y aplicar canje | P1 | 5 | Backlog | +| US-RT002-012 | Como cajero, quiero cancelar venta parcial o totalmente | P1 | 3 | Backlog | +| US-RT002-013 | Como admin, quiero configurar impresoras y terminales de pago | P0 | 3 | Backlog | + +**Total Story Points:** 55 SP + +--- + +## Flujo de Venta + +``` +┌─────────────┐ +│ INICIO │ ← Cajero listo +└──────┬──────┘ + │ + ▼ +┌─────────────┐ +│ ESCANER │ ← Agregar productos +└──────┬──────┘ + │ + ▼ +┌─────────────┐ +│ CARRITO │ ← Revisar y aplicar descuentos +└──────┬──────┘ + │ + ▼ +┌─────────────┐ +│ PAGO │ ← Efectivo/Tarjeta/Mixto +└──────┬──────┘ + │ + ▼ +┌─────────────┐ +│ TICKET │ ← Imprimir comprobante +└──────┬──────┘ + │ + ▼ +┌─────────────┐ +│ COMPLETADO │ ← Venta registrada +└─────────────┘ +``` + +--- + +## Modos de Operación + +``` +┌─────────────────────────────────────────────────────────┐ +│ MODOS DEL POS │ +├─────────────────────────────────────────────────────────┤ +│ │ +│ ONLINE │ +│ ├── Sincronización en tiempo real │ +│ ├── Validación de stock central │ +│ ├── Procesamiento de pagos con tarjeta │ +│ └── Consulta de puntos de lealtad │ +│ │ +│ OFFLINE (PWA) │ +│ ├── Catálogo de productos en cache │ +│ ├── Ventas en cola para sincronizar │ +│ ├── Solo pagos en efectivo │ +│ ├── Cálculo local de puntos │ +│ └── Sincronización automática al reconectar │ +│ │ +└─────────────────────────────────────────────────────────┘ +``` + +--- + +## Criterios de Aceptacion de la Epica + +**Funcionales:** +- [ ] Escaneo de código de barras (cámara o lector USB) +- [ ] Búsqueda de productos por nombre/SKU +- [ ] Carrito con totales en tiempo real +- [ ] Descuentos (porcentuales y fijos) +- [ ] Pagos: efectivo, tarjeta, mixtos +- [ ] Impresión de tickets +- [ ] Modo offline con sincronización +- [ ] Consulta y canje de puntos + +**No Funcionales:** +- [ ] Agregar producto < 500ms +- [ ] Cierre de venta < 3 segundos +- [ ] PWA instalable en tablet/PC +- [ ] Funcionamiento offline 24+ horas + +**Tecnicos:** +- [ ] Service Workers para offline +- [ ] IndexedDB para almacenamiento local +- [ ] Integración con impresoras ESC/POS +- [ ] Integración con terminales de pago + +--- + +## Dependencias + +**Esta epica depende de:** +| Epica/Modulo | Estado | Bloqueante | +|--------------|--------|------------| +| EPIC-RT-001 Fundamentos | Backlog | Si | +| EPIC-RT-003 Inventario | Backlog | Si | +| EPIC-RT-006 Precios | Backlog | Si | + +**Esta epica bloquea:** +| Epica/Modulo | Razon | +|--------------|-------| +| EPIC-RT-007 Caja | Requiere ventas para arqueo | +| EPIC-RT-008 Reportes | Requiere datos de ventas | +| EPIC-RT-010 Facturacion | Requiere ventas completadas | + +--- + +## Desglose Tecnico + +**Database:** +- [ ] Schema: `pos` +- [ ] Tablas: 7 (sales, sale_items, payments, terminals, printers, offline_queue, sessions) +- [ ] Funciones: 3 (calculate_totals, apply_discount, sync_offline) +- [ ] Indices: Por fecha, cajero, terminal, ticket + +**Backend:** +- [ ] Modulo: `pos` +- [ ] Entities: 6 (Sale, SaleItem, Payment, Terminal, Printer, OfflineSale) +- [ ] Endpoints: 15 +- [ ] Tests: 35 + +**Frontend (PWA):** +- [ ] Paginas: 4 (POSMain, ProductSearch, Payment, Config) +- [ ] Componentes: 20 (Cart, ProductCard, PaymentModal, Scanner, etc.) +- [ ] Stores: 2 (posStore, offlineStore) +- [ ] Service Workers: Offline + Sync + +--- + +## Endpoints API + +| Metodo | Endpoint | Descripcion | +|--------|----------|-------------| +| POST | /api/pos/sales | Crear venta | +| GET | /api/pos/sales/:id | Detalle de venta | +| POST | /api/pos/sales/:id/items | Agregar item | +| DELETE | /api/pos/sales/:id/items/:itemId | Quitar item | +| POST | /api/pos/sales/:id/discount | Aplicar descuento | +| POST | /api/pos/sales/:id/pay | Registrar pago | +| POST | /api/pos/sales/:id/complete | Cerrar venta | +| POST | /api/pos/sales/:id/cancel | Cancelar venta | +| POST | /api/pos/sync | Sincronizar ventas offline | +| GET | /api/pos/products/search | Buscar productos | +| GET | /api/pos/products/:barcode | Buscar por código | + +--- + +## Integraciones de Hardware + +| Dispositivo | Protocolo | Notas | +|-------------|-----------|-------| +| Lector código barras | USB HID / WebSerial | Actúa como teclado | +| Impresora tickets | ESC/POS | USB o red | +| Terminal bancaria | API propietaria | Depende del proveedor | +| Cajón de dinero | Pulso via impresora | RJ-11 conectado a impresora | + +--- + +## Riesgos + +| Riesgo | Probabilidad | Impacto | Mitigacion | +|--------|--------------|---------|------------| +| Fallas de sincronización | Media | Alto | Cola de reintentos + alertas | +| Hardware incompatible | Media | Medio | Lista de dispositivos probados | +| Pérdida de datos offline | Baja | Alto | Respaldo en múltiples stores | + +--- + +## Definition of Ready (DoR) + +- [x] Historias de usuario definidas +- [x] Criterios de aceptacion claros +- [x] Dependencias identificadas +- [x] Estimacion completada +- [ ] Diseño de UI aprobado +- [ ] Hardware de prueba disponible + +## Definition of Done (DoD) + +- [ ] Flujo de venta completo funcionando +- [ ] Modo offline operativo +- [ ] Integración con impresora/lector +- [ ] Tests de integración pasando +- [ ] PWA instalable y funcional +- [ ] Documentación de API + +--- + +## Historial + +| Fecha | Cambio | Autor | +|-------|--------|-------| +| 2025-12-08 | Creacion de epica | Claude-Agent | + +--- + +**Creada por:** Claude-Agent +**Fecha:** 2025-12-08 +**Ultima actualizacion:** 2025-12-08 diff --git a/docs/08-epicas/EPIC-RT-003-inventario.md b/docs/08-epicas/EPIC-RT-003-inventario.md new file mode 100644 index 0000000..415527c --- /dev/null +++ b/docs/08-epicas/EPIC-RT-003-inventario.md @@ -0,0 +1,226 @@ +# EPICA: EPIC-RT-003 - Inventario Multi-Sucursal + +## Metadata + +| Campo | Valor | +|-------|-------| +| **ID** | EPIC-RT-003 | +| **Nombre** | Inventario Multi-Sucursal | +| **Modulo** | inventario | +| **Fase** | Fase 1 - MVP | +| **Prioridad** | P0 (Critico) | +| **Estado** | Backlog | +| **Story Points** | 42 | +| **Sprint(s)** | Sprint 3-4 | + +--- + +## Descripcion + +Gestión de inventario para cadenas de tiendas minoristas. Control de stock por sucursal, transferencias entre tiendas, recepción de mercancía, ajustes de inventario y alertas de reabastecimiento. Sincronización en tiempo real con todos los POS. + +--- + +## Objetivo de Negocio + +- Visibilidad de stock en todas las sucursales +- Evitar quiebres de stock +- Optimizar distribución de inventario +- Reducir sobre-stock y obsoletos +- Trazabilidad completa de movimientos + +--- + +## Historias de Usuario + +| ID | Historia | Prioridad | SP | Estado | +|----|----------|-----------|-----|--------| +| US-RT003-001 | Como gerente de tienda, quiero consultar stock de productos en mi sucursal | P0 | 3 | Backlog | +| US-RT003-002 | Como gerente de tienda, quiero ver stock de otras sucursales para transferencias | P0 | 3 | Backlog | +| US-RT003-003 | Como almacenista, quiero recibir mercancía de proveedor con validación de orden de compra | P0 | 5 | Backlog | +| US-RT003-004 | Como almacenista, quiero solicitar transferencia de otra sucursal para cubrir faltantes | P0 | 5 | Backlog | +| US-RT003-005 | Como almacenista, quiero confirmar recepción de transferencia para actualizar stock | P0 | 3 | Backlog | +| US-RT003-006 | Como gerente de tienda, quiero realizar ajuste de inventario con motivo documentado | P0 | 5 | Backlog | +| US-RT003-007 | Como gerente regional, quiero ver alertas de stock bajo por sucursal | P0 | 3 | Backlog | +| US-RT003-008 | Como gerente regional, quiero ver kardex de movimientos de un producto | P1 | 3 | Backlog | +| US-RT003-009 | Como gerente de tienda, quiero realizar inventario físico con conteo ciego | P1 | 5 | Backlog | +| US-RT003-010 | Como admin, quiero configurar stock mínimo y máximo por producto/sucursal | P0 | 3 | Backlog | +| US-RT003-011 | Como gerente regional, quiero ver valorización de inventario por sucursal | P1 | 4 | Backlog | + +**Total Story Points:** 42 SP + +--- + +## Tipos de Movimiento + +``` +ENTRADA +├── Compra a proveedor +├── Transferencia recibida +├── Devolución de cliente +└── Ajuste positivo + +SALIDA +├── Venta POS +├── Transferencia enviada +├── Merma/Robo +├── Ajuste negativo +└── Devolución a proveedor + +RESERVA +├── Apartado de cliente +└── Pedido e-commerce +``` + +--- + +## Flujo de Transferencia + +``` +SUCURSAL ORIGEN SUCURSAL DESTINO + │ │ + ▼ │ +┌─────────────┐ │ +│ SOLICITUD │ ◄────────────────────────┘ +└──────┬──────┘ (crea solicitud) + │ + ▼ +┌─────────────┐ +│ APROBADA │ ← Gerente origen aprueba +└──────┬──────┘ + │ + ▼ +┌─────────────┐ +│ EN_TRANSITO│ ← Mercancía en camino +└──────┬──────┘ + │ + ▼ +┌─────────────┐ +│ RECIBIDA │ ← Destino confirma recepción +└─────────────┘ +``` + +--- + +## Criterios de Aceptacion de la Epica + +**Funcionales:** +- [ ] Consultar stock por producto y sucursal +- [ ] Recibir mercancía con validación de OC +- [ ] Crear y aprobar transferencias +- [ ] Ajustes de inventario con motivo +- [ ] Alertas de stock bajo +- [ ] Kardex de movimientos +- [ ] Inventario físico con conteo ciego +- [ ] Valorización por sucursal + +**No Funcionales:** +- [ ] Consulta de stock < 500ms +- [ ] Sincronización con POS < 5 segundos +- [ ] Historial de 2 años de movimientos + +**Tecnicos:** +- [ ] Integración con módulo POS +- [ ] Integración con módulo Compras +- [ ] Eventos de stock para e-commerce +- [ ] Reportes de inventario + +--- + +## Dependencias + +**Esta epica depende de:** +| Epica/Modulo | Estado | Bloqueante | +|--------------|--------|------------| +| EPIC-RT-001 Fundamentos | Backlog | Si | + +**Esta epica bloquea:** +| Epica/Modulo | Razon | +|--------------|-------| +| EPIC-RT-002 POS | Requiere stock para vender | +| EPIC-RT-004 Compras | Requiere control de entradas | +| EPIC-RT-009 E-commerce | Requiere disponibilidad de stock | + +--- + +## Desglose Tecnico + +**Database:** +- [ ] Schema: `inventory` +- [ ] Tablas: 8 (stock, stock_movements, transfers, transfer_items, adjustments, counts, locations, alerts) +- [ ] Funciones: 4 (update_stock, create_movement, check_alerts, valuate_stock) +- [ ] Indices: Por producto, sucursal, fecha, tipo + +**Backend:** +- [ ] Modulo: `inventory` +- [ ] Entities: 7 (Stock, StockMovement, Transfer, TransferItem, Adjustment, PhysicalCount, Alert) +- [ ] Endpoints: 18 +- [ ] Tests: 35 + +**Frontend:** +- [ ] Paginas: 6 (StockList, Movements, Transfers, Adjustments, PhysicalCount, Alerts) +- [ ] Componentes: 15 (StockCard, MovementForm, TransferModal, CountSheet, etc.) +- [ ] Stores: 1 (inventoryStore) + +--- + +## Endpoints API + +| Metodo | Endpoint | Descripcion | +|--------|----------|-------------| +| GET | /api/inventory/stock | Consultar stock (filtros) | +| GET | /api/inventory/stock/:productId | Stock de producto en todas las sucursales | +| POST | /api/inventory/receipts | Recibir mercancía | +| POST | /api/inventory/transfers | Crear transferencia | +| PATCH | /api/inventory/transfers/:id | Actualizar estado | +| POST | /api/inventory/adjustments | Crear ajuste | +| GET | /api/inventory/kardex/:productId | Kardex de producto | +| POST | /api/inventory/counts | Iniciar inventario físico | +| POST | /api/inventory/counts/:id/items | Registrar conteo | +| POST | /api/inventory/counts/:id/close | Cerrar y ajustar | +| GET | /api/inventory/alerts | Ver alertas de stock | +| GET | /api/inventory/valuation | Valorización | + +--- + +## Riesgos + +| Riesgo | Probabilidad | Impacto | Mitigacion | +|--------|--------------|---------|------------| +| Desincronización con POS | Media | Alto | Eventos en tiempo real | +| Transferencias perdidas | Baja | Alto | Estados y confirmaciones | +| Diferencias de inventario | Alta | Medio | Inventarios frecuentes | + +--- + +## Definition of Ready (DoR) + +- [x] Historias de usuario definidas +- [x] Criterios de aceptacion claros +- [x] Dependencias identificadas +- [x] Estimacion completada +- [ ] Motivos de ajuste definidos +- [ ] Proceso de transferencias aprobado + +## Definition of Done (DoD) + +- [ ] Flujo de transferencias funcionando +- [ ] Sincronización con POS operativa +- [ ] Inventario físico completo +- [ ] Alertas de stock activas +- [ ] Tests de integración pasando +- [ ] Documentación de API + +--- + +## Historial + +| Fecha | Cambio | Autor | +|-------|--------|-------| +| 2025-12-08 | Creacion de epica | Claude-Agent | + +--- + +**Creada por:** Claude-Agent +**Fecha:** 2025-12-08 +**Ultima actualizacion:** 2025-12-08 diff --git a/docs/08-epicas/EPIC-RT-004-compras.md b/docs/08-epicas/EPIC-RT-004-compras.md new file mode 100644 index 0000000..4e0f292 --- /dev/null +++ b/docs/08-epicas/EPIC-RT-004-compras.md @@ -0,0 +1,228 @@ +# EPICA: EPIC-RT-004 - Compras y Reabastecimiento + +## Metadata + +| Campo | Valor | +|-------|-------| +| **ID** | EPIC-RT-004 | +| **Nombre** | Compras y Reabastecimiento | +| **Modulo** | compras | +| **Fase** | Fase 1 - MVP | +| **Prioridad** | P0 (Critico) | +| **Estado** | Backlog | +| **Story Points** | 38 | +| **Sprint(s)** | Sprint 5-6 | + +--- + +## Descripcion + +Gestión de compras a proveedores para reabastecimiento de tiendas. Incluye sugerencias automáticas de compra basadas en stock mínimo, gestión de órdenes de compra, recepción de mercancía y evaluación de proveedores. + +--- + +## Objetivo de Negocio + +- Evitar quiebres de stock +- Optimizar niveles de inventario +- Negociar mejores condiciones con proveedores +- Automatizar proceso de reabastecimiento +- Control de costos de compra + +--- + +## Historias de Usuario + +| ID | Historia | Prioridad | SP | Estado | +|----|----------|-----------|-----|--------| +| US-RT004-001 | Como comprador, quiero ver sugerencias de compra basadas en stock mínimo | P0 | 5 | Backlog | +| US-RT004-002 | Como comprador, quiero crear orden de compra desde sugerencias | P0 | 5 | Backlog | +| US-RT004-003 | Como comprador, quiero agregar productos manualmente a orden de compra | P0 | 3 | Backlog | +| US-RT004-004 | Como comprador, quiero enviar orden de compra al proveedor por email | P0 | 3 | Backlog | +| US-RT004-005 | Como almacenista, quiero recibir mercancía validando contra orden de compra | P0 | 5 | Backlog | +| US-RT004-006 | Como almacenista, quiero registrar diferencias en recepción (faltantes/sobrantes) | P0 | 3 | Backlog | +| US-RT004-007 | Como comprador, quiero gestionar catálogo de proveedores con datos de contacto | P0 | 3 | Backlog | +| US-RT004-008 | Como comprador, quiero ver historial de compras por proveedor | P1 | 3 | Backlog | +| US-RT004-009 | Como gerente, quiero ver métricas de cumplimiento de proveedores | P1 | 5 | Backlog | +| US-RT004-010 | Como admin, quiero configurar lead times por proveedor para sugerencias | P2 | 3 | Backlog | + +**Total Story Points:** 38 SP + +--- + +## Flujo de Orden de Compra + +``` +┌─────────────┐ +│ BORRADOR │ ← OC siendo creada +└──────┬──────┘ + │ + ▼ +┌─────────────┐ +│ CONFIRMADA │ ← Aprobada internamente +└──────┬──────┘ + │ + ▼ +┌─────────────┐ +│ ENVIADA │ ← Enviada al proveedor +└──────┬──────┘ + │ + ├──────────────┐ + ▼ ▼ +┌─────────────┐ ┌─────────────┐ +│ RECIB_PARC │ │ RECIBIDA │ +│ (parcial) │ │ (completa) │ +└──────┬──────┘ └──────┬──────┘ + │ │ + ▼ ▼ +┌─────────────────────────┐ +│ CERRADA │ +└─────────────────────────┘ +``` + +--- + +## Algoritmo de Sugerencias + +``` +┌─────────────────────────────────────────────────────────┐ +│ CÁLCULO DE SUGERENCIA DE COMPRA │ +├─────────────────────────────────────────────────────────┤ +│ │ +│ PARA CADA PRODUCTO: │ +│ │ +│ stock_actual = consultar_stock(producto, sucursal) │ +│ stock_minimo = producto.stock_minimo │ +│ stock_maximo = producto.stock_maximo │ +│ ventas_diarias = promedio_ventas(producto, 30_dias) │ +│ lead_time = proveedor.lead_time_dias │ +│ │ +│ punto_reorden = stock_minimo + (ventas_diarias * lead_time) +│ │ +│ SI stock_actual <= punto_reorden: │ +│ cantidad_sugerida = stock_maximo - stock_actual │ +│ CREAR_SUGERENCIA(producto, cantidad_sugerida) │ +│ │ +└─────────────────────────────────────────────────────────┘ +``` + +--- + +## Criterios de Aceptacion de la Epica + +**Funcionales:** +- [ ] Sugerencias automáticas de compra +- [ ] Crear OC desde sugerencias +- [ ] Agregar productos manualmente +- [ ] Enviar OC por email +- [ ] Recibir mercancía con validación +- [ ] Registrar diferencias +- [ ] Catálogo de proveedores +- [ ] Métricas de cumplimiento + +**No Funcionales:** +- [ ] Cálculo de sugerencias < 30 segundos (todos los productos) +- [ ] Historial de 2 años de OC +- [ ] Soporte para 50+ proveedores + +**Tecnicos:** +- [ ] Integración con módulo Inventario +- [ ] Envío de emails con PDF adjunto +- [ ] Job programado para sugerencias + +--- + +## Dependencias + +**Esta epica depende de:** +| Epica/Modulo | Estado | Bloqueante | +|--------------|--------|------------| +| EPIC-RT-001 Fundamentos | Backlog | Si | +| EPIC-RT-003 Inventario | Backlog | Si | + +**Esta epica bloquea:** +| Epica/Modulo | Razon | +|--------------|-------| +| EPIC-RT-003 Inventario | Recepciones actualizan stock | + +--- + +## Desglose Tecnico + +**Database:** +- [ ] Schema: `purchases` +- [ ] Tablas: 7 (purchase_orders, po_items, suppliers, supplier_products, receipts, receipt_items, suggestions) +- [ ] Funciones: 3 (calculate_suggestions, validate_receipt, evaluate_supplier) +- [ ] Indices: Por proveedor, fecha, estado + +**Backend:** +- [ ] Modulo: `purchases` +- [ ] Entities: 6 (PurchaseOrder, POItem, Supplier, SupplierProduct, Receipt, Suggestion) +- [ ] Endpoints: 15 +- [ ] Tests: 30 + +**Frontend:** +- [ ] Paginas: 5 (Suggestions, PurchaseOrders, PODetail, Suppliers, Receipts) +- [ ] Componentes: 12 (SuggestionCard, POForm, SupplierSelector, ReceiptValidator, etc.) +- [ ] Stores: 1 (purchasesStore) + +--- + +## Endpoints API + +| Metodo | Endpoint | Descripcion | +|--------|----------|-------------| +| GET | /api/purchases/suggestions | Ver sugerencias de compra | +| POST | /api/purchases/orders | Crear orden de compra | +| GET | /api/purchases/orders/:id | Detalle de OC | +| PATCH | /api/purchases/orders/:id | Actualizar OC | +| POST | /api/purchases/orders/:id/send | Enviar a proveedor | +| POST | /api/purchases/receipts | Registrar recepción | +| GET | /api/purchases/suppliers | Listar proveedores | +| POST | /api/purchases/suppliers | Crear proveedor | +| GET | /api/purchases/suppliers/:id/metrics | Métricas de proveedor | +| POST | /api/purchases/calculate-suggestions | Recalcular sugerencias | + +--- + +## Riesgos + +| Riesgo | Probabilidad | Impacto | Mitigacion | +|--------|--------------|---------|------------| +| Sugerencias incorrectas | Media | Alto | Validación manual antes de OC | +| Proveedor no cumple | Media | Alto | Proveedores alternativos | +| Recepciones incompletas | Media | Medio | Flujo de diferencias | + +--- + +## Definition of Ready (DoR) + +- [x] Historias de usuario definidas +- [x] Criterios de aceptacion claros +- [x] Dependencias identificadas +- [x] Estimacion completada +- [ ] Catálogo inicial de proveedores +- [ ] Lead times definidos + +## Definition of Done (DoD) + +- [ ] Sugerencias de compra funcionando +- [ ] Flujo completo de OC operativo +- [ ] Recepción con validación +- [ ] Métricas de proveedores +- [ ] Tests de integración pasando +- [ ] Documentación de API + +--- + +## Historial + +| Fecha | Cambio | Autor | +|-------|--------|-------| +| 2025-12-08 | Creacion de epica | Claude-Agent | + +--- + +**Creada por:** Claude-Agent +**Fecha:** 2025-12-08 +**Ultima actualizacion:** 2025-12-08 diff --git a/docs/08-epicas/EPIC-RT-005-clientes.md b/docs/08-epicas/EPIC-RT-005-clientes.md new file mode 100644 index 0000000..8b7489d --- /dev/null +++ b/docs/08-epicas/EPIC-RT-005-clientes.md @@ -0,0 +1,236 @@ +# EPICA: EPIC-RT-005 - Clientes y Programa de Lealtad + +## Metadata + +| Campo | Valor | +|-------|-------| +| **ID** | EPIC-RT-005 | +| **Nombre** | Clientes y Programa de Lealtad | +| **Modulo** | clientes | +| **Fase** | Fase 1 - MVP | +| **Prioridad** | P1 (Alto) | +| **Estado** | Backlog | +| **Story Points** | 34 | +| **Sprint(s)** | Sprint 4-5 | + +--- + +## Descripcion + +Gestión de clientes y programa de fidelización. Registro de clientes, acumulación y canje de puntos, niveles de membresía, historial de compras y comunicación personalizada. Integración con POS para experiencia fluida en punto de venta. + +--- + +## Objetivo de Negocio + +- Aumentar frecuencia de compra +- Incrementar ticket promedio +- Reducir rotación de clientes +- Conocer mejor al cliente +- Marketing personalizado + +--- + +## Historias de Usuario + +| ID | Historia | Prioridad | SP | Estado | +|----|----------|-----------|-----|--------| +| US-RT005-001 | Como cajero, quiero registrar cliente rápidamente con datos mínimos | P0 | 3 | Backlog | +| US-RT005-002 | Como cajero, quiero buscar cliente por teléfono o email para asociar venta | P0 | 3 | Backlog | +| US-RT005-003 | Como cliente, quiero acumular puntos automáticamente por mis compras | P0 | 5 | Backlog | +| US-RT005-004 | Como cajero, quiero consultar puntos del cliente en el POS | P0 | 2 | Backlog | +| US-RT005-005 | Como cajero, quiero aplicar canje de puntos como descuento en venta | P0 | 5 | Backlog | +| US-RT005-006 | Como cliente, quiero ver mi historial de compras y puntos en app/web | P1 | 5 | Backlog | +| US-RT005-007 | Como marketing, quiero segmentar clientes por nivel de compra | P1 | 3 | Backlog | +| US-RT005-008 | Como marketing, quiero enviar promociones por email a segmentos | P1 | 3 | Backlog | +| US-RT005-009 | Como admin, quiero configurar reglas de acumulación de puntos | P0 | 3 | Backlog | +| US-RT005-010 | Como admin, quiero configurar niveles de membresía con beneficios | P2 | 2 | Backlog | + +**Total Story Points:** 34 SP + +--- + +## Programa de Puntos + +``` +┌─────────────────────────────────────────────────────────┐ +│ PROGRAMA DE LEALTAD │ +├─────────────────────────────────────────────────────────┤ +│ │ +│ ACUMULACIÓN │ +│ ├── 1 punto por cada $10 de compra (configurable) │ +│ ├── Puntos extra en productos promocionales │ +│ ├── Bonificación por cumpleaños │ +│ └── Multiplicadores por nivel │ +│ │ +│ CANJE │ +│ ├── 100 puntos = $10 de descuento (configurable) │ +│ ├── Mínimo de puntos para canjear │ +│ ├── Máximo de descuento por transacción │ +│ └── Productos excluidos de canje │ +│ │ +│ NIVELES DE MEMBRESÍA │ +│ ├── BRONCE: 0-999 puntos/año → 1x puntos │ +│ ├── PLATA: 1000-4999 puntos/año → 1.5x puntos │ +│ ├── ORO: 5000+ puntos/año → 2x puntos │ +│ └── Beneficios adicionales por nivel │ +│ │ +└─────────────────────────────────────────────────────────┘ +``` + +--- + +## Flujo de Puntos en Venta + +``` +┌─────────────┐ +│ BUSCAR CLI │ ← Por teléfono o email +└──────┬──────┘ + │ + ▼ +┌─────────────┐ +│ VER PUNTOS │ ← Saldo disponible +└──────┬──────┘ + │ + ▼ +┌─────────────┐ +│ VENTA │ ← Agregar productos +└──────┬──────┘ + │ + ▼ +┌─────────────┐ +│ ¿CANJEAR? │ ─── Si ──► Aplicar descuento +└──────┬──────┘ + │ No + ▼ +┌─────────────┐ +│ PAGO │ ← Cerrar venta +└──────┬──────┘ + │ + ▼ +┌─────────────┐ +│ + PUNTOS │ ← Acumulación automática +└─────────────┘ +``` + +--- + +## Criterios de Aceptacion de la Epica + +**Funcionales:** +- [ ] Registro rápido de cliente +- [ ] Búsqueda por teléfono/email +- [ ] Acumulación automática de puntos +- [ ] Canje de puntos en POS +- [ ] Historial de cliente +- [ ] Niveles de membresía +- [ ] Segmentación de clientes +- [ ] Comunicación por email + +**No Funcionales:** +- [ ] Búsqueda de cliente < 500ms +- [ ] Cálculo de puntos en tiempo real +- [ ] Historial de 3 años + +**Tecnicos:** +- [ ] Integración con módulo POS +- [ ] Integración con e-commerce +- [ ] Servicio de email marketing +- [ ] API para app de cliente + +--- + +## Dependencias + +**Esta epica depende de:** +| Epica/Modulo | Estado | Bloqueante | +|--------------|--------|------------| +| EPIC-RT-001 Fundamentos | Backlog | Si | + +**Esta epica bloquea:** +| Epica/Modulo | Razon | +|--------------|-------| +| EPIC-RT-002 POS | Integración de puntos | +| EPIC-RT-009 E-commerce | Cuenta de cliente | + +--- + +## Desglose Tecnico + +**Database:** +- [ ] Schema: `customers` +- [ ] Tablas: 7 (customers, points_transactions, membership_levels, segments, campaigns, communications, preferences) +- [ ] Funciones: 3 (calculate_points, apply_redemption, update_level) +- [ ] Indices: Por teléfono, email, nivel, puntos + +**Backend:** +- [ ] Modulo: `customers` +- [ ] Entities: 6 (Customer, PointsTransaction, MembershipLevel, Segment, Campaign) +- [ ] Endpoints: 14 +- [ ] Tests: 28 + +**Frontend:** +- [ ] Paginas: 5 (CustomerList, CustomerDetail, Segments, Campaigns, Config) +- [ ] Componentes: 12 (CustomerSearch, PointsDisplay, RedemptionModal, etc.) +- [ ] Stores: 1 (customersStore) + +--- + +## Endpoints API + +| Metodo | Endpoint | Descripcion | +|--------|----------|-------------| +| POST | /api/customers | Crear cliente | +| GET | /api/customers/search | Buscar por teléfono/email | +| GET | /api/customers/:id | Detalle de cliente | +| GET | /api/customers/:id/points | Saldo de puntos | +| GET | /api/customers/:id/history | Historial de compras | +| POST | /api/customers/:id/earn | Acumular puntos | +| POST | /api/customers/:id/redeem | Canjear puntos | +| GET | /api/customers/segments | Listar segmentos | +| POST | /api/customers/campaigns | Crear campaña | +| POST | /api/customers/campaigns/:id/send | Enviar comunicación | + +--- + +## Riesgos + +| Riesgo | Probabilidad | Impacto | Mitigacion | +|--------|--------------|---------|------------| +| Fraude de puntos | Media | Alto | Auditoría + límites de canje | +| Clientes duplicados | Alta | Medio | Validación de teléfono/email | +| Baja adopción | Media | Medio | Capacitación a cajeros | + +--- + +## Definition of Ready (DoR) + +- [x] Historias de usuario definidas +- [x] Criterios de aceptacion claros +- [x] Dependencias identificadas +- [x] Estimacion completada +- [ ] Reglas de puntos definidas +- [ ] Niveles de membresía aprobados + +## Definition of Done (DoD) + +- [ ] Programa de puntos funcionando +- [ ] Integración con POS operativa +- [ ] Niveles de membresía activos +- [ ] Segmentación de clientes +- [ ] Tests de integración pasando +- [ ] Documentación de API + +--- + +## Historial + +| Fecha | Cambio | Autor | +|-------|--------|-------| +| 2025-12-08 | Creacion de epica | Claude-Agent | + +--- + +**Creada por:** Claude-Agent +**Fecha:** 2025-12-08 +**Ultima actualizacion:** 2025-12-08 diff --git a/docs/08-epicas/EPIC-RT-006-precios.md b/docs/08-epicas/EPIC-RT-006-precios.md new file mode 100644 index 0000000..dcf0355 --- /dev/null +++ b/docs/08-epicas/EPIC-RT-006-precios.md @@ -0,0 +1,241 @@ +# EPICA: EPIC-RT-006 - Precios y Promociones + +## Metadata + +| Campo | Valor | +|-------|-------| +| **ID** | EPIC-RT-006 | +| **Nombre** | Precios y Promociones | +| **Modulo** | precios | +| **Fase** | Fase 1 - MVP | +| **Prioridad** | P0 (Critico) | +| **Estado** | Backlog | +| **Story Points** | 36 | +| **Sprint(s)** | Sprint 3-4 | + +--- + +## Descripcion + +Gestión de precios y promociones para retail. Listas de precios por sucursal/canal, promociones temporales, descuentos por volumen, cupones y ofertas especiales. Motor de reglas para aplicación automática en POS y e-commerce. + +--- + +## Objetivo de Negocio + +- Flexibilidad en estrategia de precios +- Promociones efectivas y controladas +- Incrementar ventas con ofertas +- Competir en diferentes canales +- Medir ROI de promociones + +--- + +## Historias de Usuario + +| ID | Historia | Prioridad | SP | Estado | +|----|----------|-----------|-----|--------| +| US-RT006-001 | Como pricing, quiero definir precio base de producto para tener referencia | P0 | 2 | Backlog | +| US-RT006-002 | Como pricing, quiero crear listas de precios por canal (tienda, online) | P0 | 5 | Backlog | +| US-RT006-003 | Como pricing, quiero crear promoción de descuento porcentual temporal | P0 | 5 | Backlog | +| US-RT006-004 | Como pricing, quiero crear promoción 2x1 o 3x2 para productos seleccionados | P0 | 5 | Backlog | +| US-RT006-005 | Como pricing, quiero crear descuento por volumen (> 10 unidades = 15% off) | P0 | 3 | Backlog | +| US-RT006-006 | Como marketing, quiero generar cupones de descuento con código único | P0 | 5 | Backlog | +| US-RT006-007 | Como cajero, quiero que el POS aplique promociones automáticamente | P0 | 5 | Backlog | +| US-RT006-008 | Como cajero, quiero escanear cupón para aplicar descuento | P0 | 3 | Backlog | +| US-RT006-009 | Como gerente, quiero ver reportes de uso de promociones | P1 | 3 | Backlog | + +**Total Story Points:** 36 SP + +--- + +## Tipos de Promociones + +``` +┌─────────────────────────────────────────────────────────┐ +│ TIPOS DE PROMOCIONES │ +├─────────────────────────────────────────────────────────┤ +│ │ +│ DESCUENTO PORCENTUAL │ +│ ├── Aplica X% de descuento │ +│ ├── Sobre productos seleccionados o categoría │ +│ └── Período de vigencia │ +│ │ +│ DESCUENTO MONTO FIJO │ +│ ├── Aplica $X de descuento │ +│ ├── Requiere mínimo de compra │ +│ └── Por producto o total de venta │ +│ │ +│ NxM (ej: 3x2) │ +│ ├── Compra N productos, paga M │ +│ ├── Mismo producto o mezcla │ +│ └── El más barato gratis o porcentaje │ +│ │ +│ DESCUENTO POR VOLUMEN │ +│ ├── Escalas de cantidad │ +│ ├── > 5 unidades = 5% off │ +│ └── > 10 unidades = 10% off │ +│ │ +│ CUPÓN │ +│ ├── Código único o genérico │ +│ ├── Un uso o múltiples usos │ +│ └── Límite de usos totales │ +│ │ +└─────────────────────────────────────────────────────────┘ +``` + +--- + +## Motor de Reglas + +``` +┌─────────────────────────────────────────────────────────┐ +│ EVALUACIÓN DE PRECIO │ +├─────────────────────────────────────────────────────────┤ +│ │ +│ 1. Obtener precio base del producto │ +│ │ +│ 2. Aplicar lista de precios del canal │ +│ └── Si existe precio específico, usarlo │ +│ │ +│ 3. Evaluar promociones activas │ +│ ├── Ordenar por prioridad │ +│ ├── Verificar condiciones (fechas, productos) │ +│ └── Aplicar la mejor promoción (no acumulables) │ +│ │ +│ 4. Evaluar descuento por volumen │ +│ └── Si cantidad supera umbral, aplicar │ +│ │ +│ 5. Evaluar cupón (si se proporciona) │ +│ ├── Validar código │ +│ ├── Verificar vigencia y usos │ +│ └── Aplicar si es compatible │ +│ │ +│ 6. Retornar precio final con detalle de descuentos │ +│ │ +└─────────────────────────────────────────────────────────┘ +``` + +--- + +## Criterios de Aceptacion de la Epica + +**Funcionales:** +- [ ] Definir precio base de productos +- [ ] Crear listas de precios por canal +- [ ] Crear promociones temporales +- [ ] Promociones NxM +- [ ] Descuentos por volumen +- [ ] Generar y validar cupones +- [ ] Aplicación automática en POS +- [ ] Reportes de uso + +**No Funcionales:** +- [ ] Cálculo de precio < 100ms +- [ ] Soporte para 100+ promociones activas +- [ ] Historial de 1 año de promociones + +**Tecnicos:** +- [ ] Motor de reglas flexible +- [ ] Integración con POS +- [ ] Integración con e-commerce +- [ ] Cache de promociones activas + +--- + +## Dependencias + +**Esta epica depende de:** +| Epica/Modulo | Estado | Bloqueante | +|--------------|--------|------------| +| EPIC-RT-001 Fundamentos | Backlog | Si | + +**Esta epica bloquea:** +| Epica/Modulo | Razon | +|--------------|-------| +| EPIC-RT-002 POS | Requiere precios para vender | +| EPIC-RT-009 E-commerce | Requiere precios online | + +--- + +## Desglose Tecnico + +**Database:** +- [ ] Schema: `pricing` +- [ ] Tablas: 8 (price_lists, prices, promotions, promotion_products, volume_discounts, coupons, coupon_usages, promo_logs) +- [ ] Funciones: 3 (calculate_price, validate_coupon, apply_promotion) +- [ ] Indices: Por producto, canal, fecha vigencia + +**Backend:** +- [ ] Modulo: `pricing` +- [ ] Entities: 6 (PriceList, Price, Promotion, VolumeDiscount, Coupon, CouponUsage) +- [ ] Services: PricingEngine (motor de reglas) +- [ ] Endpoints: 15 +- [ ] Tests: 35 + +**Frontend:** +- [ ] Paginas: 5 (PriceLists, Promotions, Coupons, Reports, Config) +- [ ] Componentes: 12 (PriceEditor, PromotionBuilder, CouponGenerator, etc.) +- [ ] Stores: 1 (pricingStore) + +--- + +## Endpoints API + +| Metodo | Endpoint | Descripcion | +|--------|----------|-------------| +| GET | /api/pricing/products/:id | Obtener precio de producto | +| POST | /api/pricing/calculate | Calcular precio con promociones | +| GET | /api/pricing/lists | Listar listas de precios | +| POST | /api/pricing/lists | Crear lista de precios | +| GET | /api/pricing/promotions | Listar promociones | +| POST | /api/pricing/promotions | Crear promoción | +| PATCH | /api/pricing/promotions/:id | Actualizar promoción | +| POST | /api/pricing/coupons | Generar cupón | +| POST | /api/pricing/coupons/validate | Validar cupón | +| POST | /api/pricing/coupons/redeem | Usar cupón | +| GET | /api/pricing/reports/usage | Reporte de uso | + +--- + +## Riesgos + +| Riesgo | Probabilidad | Impacto | Mitigacion | +|--------|--------------|---------|------------| +| Promociones conflictivas | Media | Alto | Prioridades + no acumulable | +| Cupones fraudulentos | Baja | Medio | Códigos únicos + límites | +| Precios incorrectos | Media | Alto | Validación antes de activar | + +--- + +## Definition of Ready (DoR) + +- [x] Historias de usuario definidas +- [x] Criterios de aceptacion claros +- [x] Dependencias identificadas +- [x] Estimacion completada +- [ ] Tipos de promociones definidos +- [ ] Reglas de compatibilidad claras + +## Definition of Done (DoD) + +- [ ] Motor de precios funcionando +- [ ] Promociones aplicándose en POS +- [ ] Cupones generándose y validándose +- [ ] Reportes de uso disponibles +- [ ] Tests de integración pasando +- [ ] Documentación de API + +--- + +## Historial + +| Fecha | Cambio | Autor | +|-------|--------|-------| +| 2025-12-08 | Creacion de epica | Claude-Agent | + +--- + +**Creada por:** Claude-Agent +**Fecha:** 2025-12-08 +**Ultima actualizacion:** 2025-12-08 diff --git a/docs/08-epicas/EPIC-RT-007-caja.md b/docs/08-epicas/EPIC-RT-007-caja.md new file mode 100644 index 0000000..9399194 --- /dev/null +++ b/docs/08-epicas/EPIC-RT-007-caja.md @@ -0,0 +1,245 @@ +# EPICA: EPIC-RT-007 - Caja (Arqueos y Cortes) + +## Metadata + +| Campo | Valor | +|-------|-------| +| **ID** | EPIC-RT-007 | +| **Nombre** | Caja (Arqueos y Cortes) | +| **Modulo** | caja | +| **Fase** | Fase 1 - MVP | +| **Prioridad** | P0 (Critico) | +| **Estado** | Backlog | +| **Story Points** | 28 | +| **Sprint(s)** | Sprint 5-6 | + +--- + +## Descripcion + +Módulo 100% nuevo para control de efectivo en tiendas. Gestión de fondos de caja, arqueos, cortes de caja, movimientos de efectivo (retiros, depósitos) y conciliación con ventas. Control de diferencias y auditoría. + +--- + +## Objetivo de Negocio + +- Control preciso de efectivo +- Prevenir faltantes y robos +- Conciliación diaria de ventas +- Auditoría de movimientos +- Responsabilidad por cajero + +--- + +## Historias de Usuario + +| ID | Historia | Prioridad | SP | Estado | +|----|----------|-----------|-----|--------| +| US-RT007-001 | Como supervisor, quiero abrir caja con fondo inicial para comenzar turno | P0 | 3 | Backlog | +| US-RT007-002 | Como cajero, quiero registrar retiro de efectivo (para depósito) | P0 | 3 | Backlog | +| US-RT007-003 | Como cajero, quiero registrar ingreso de efectivo (cambio) | P0 | 2 | Backlog | +| US-RT007-004 | Como cajero, quiero realizar arqueo parcial sin cerrar caja | P0 | 3 | Backlog | +| US-RT007-005 | Como cajero, quiero realizar corte de caja al final del turno | P0 | 5 | Backlog | +| US-RT007-006 | Como cajero, quiero declarar efectivo contado por denominación | P0 | 3 | Backlog | +| US-RT007-007 | Como supervisor, quiero aprobar corte con diferencias | P0 | 3 | Backlog | +| US-RT007-008 | Como gerente, quiero ver reporte de diferencias por cajero | P1 | 3 | Backlog | +| US-RT007-009 | Como admin, quiero configurar tolerancia de diferencias | P2 | 2 | Backlog | +| US-RT007-010 | Como auditor, quiero ver historial completo de movimientos de caja | P1 | 1 | Backlog | + +**Total Story Points:** 28 SP + +--- + +## Flujo de Caja + +``` +┌─────────────┐ +│ CERRADA │ ← Estado inicial +└──────┬──────┘ + │ + ▼ (Apertura con fondo) +┌─────────────┐ +│ ABIERTA │ ◄──────────┐ +└──────┬──────┘ │ + │ │ + ▼ │ +┌─────────────┐ │ +│ OPERANDO │────────────┤ +│ │ Ventas │ +│ + Ventas │ Retiros │ +│ - Retiros │ Ingresos │ +│ + Ingresos │ │ +└──────┬──────┘ │ + │ │ + ▼ (Corte) │ +┌─────────────┐ │ +│ CORTE │ │ +│ - Conteo │ │ +│ - Diferenc │ │ +└──────┬──────┘ │ + │ │ + ├──── Si OK ────────┘ (Nueva apertura) + │ + ▼ (Cierre) +┌─────────────┐ +│ CERRADA │ +└─────────────┘ +``` + +--- + +## Conteo por Denominación + +``` +┌─────────────────────────────────────────────────────────┐ +│ DECLARACIÓN DE EFECTIVO │ +├─────────────────────────────────────────────────────────┤ +│ │ +│ BILLETES MONEDAS │ +│ ┌────────┬─────┬──────────┐ ┌────────┬─────┬─────┐ │ +│ │ Denom. │ Cant│ Total │ │ Denom. │ Cant│Total│ │ +│ ├────────┼─────┼──────────┤ ├────────┼─────┼─────┤ │ +│ │ $1000 │ 3 │ $3,000 │ │ $20 │ 5 │$100 │ │ +│ │ $500 │ 5 │ $2,500 │ │ $10 │ 8 │ $80 │ │ +│ │ $200 │ 8 │ $1,600 │ │ $5 │ 10 │ $50 │ │ +│ │ $100 │ 12 │ $1,200 │ │ $2 │ 15 │ $30 │ │ +│ │ $50 │ 10 │ $500 │ │ $1 │ 20 │ $20 │ │ +│ │ $20 │ 15 │ $300 │ │ $0.50 │ 10 │ $5 │ │ +│ └────────┴─────┴──────────┘ └────────┴─────┴─────┘ │ +│ │ +│ TOTAL BILLETES: $9,100 │ +│ TOTAL MONEDAS: $285 │ +│ ───────────────────────── │ +│ TOTAL DECLARADO: $9,385 │ +│ │ +│ ESPERADO (sistema): $9,400 │ +│ DIFERENCIA: -$15 │ +│ │ +└─────────────────────────────────────────────────────────┘ +``` + +--- + +## Criterios de Aceptacion de la Epica + +**Funcionales:** +- [ ] Apertura de caja con fondo +- [ ] Retiros e ingresos de efectivo +- [ ] Arqueos parciales +- [ ] Corte de caja con conteo +- [ ] Declaración por denominación +- [ ] Cálculo de diferencias +- [ ] Aprobación de cortes con diferencias +- [ ] Reportes de diferencias + +**No Funcionales:** +- [ ] Operación < 3 clicks +- [ ] Historial de 1 año +- [ ] Auditoría completa + +**Tecnicos:** +- [ ] Integración con módulo POS (ventas en efectivo) +- [ ] Bloqueo de caja durante corte +- [ ] Firmas/autorizaciones de supervisores + +--- + +## Dependencias + +**Esta epica depende de:** +| Epica/Modulo | Estado | Bloqueante | +|--------------|--------|------------| +| EPIC-RT-001 Fundamentos | Backlog | Si | +| EPIC-RT-002 POS | Backlog | Si | + +**Esta epica bloquea:** +| Epica/Modulo | Razon | +|--------------|-------| +| EPIC-RT-008 Reportes | Datos de caja para reportes | + +--- + +## Desglose Tecnico + +**Database:** +- [ ] Schema: `cash_register` +- [ ] Tablas: 6 (cash_sessions, cash_movements, cash_counts, count_denominations, cut_approvals, audit_log) +- [ ] Funciones: 3 (calculate_expected, calculate_difference, validate_cut) +- [ ] Indices: Por caja, cajero, fecha, estado + +**Backend:** +- [ ] Modulo: `cash-register` +- [ ] Entities: 5 (CashSession, CashMovement, CashCount, CountDenomination, CutApproval) +- [ ] Endpoints: 12 +- [ ] Tests: 25 + +**Frontend:** +- [ ] Paginas: 4 (CashDashboard, OpenClose, CashCount, Reports) +- [ ] Componentes: 10 (DenominationCounter, MovementForm, DifferenceAlert, etc.) +- [ ] Stores: 1 (cashStore) + +--- + +## Endpoints API + +| Metodo | Endpoint | Descripcion | +|--------|----------|-------------| +| POST | /api/cash/sessions/open | Abrir caja | +| POST | /api/cash/sessions/:id/close | Cerrar caja | +| GET | /api/cash/sessions/current | Sesión actual | +| POST | /api/cash/movements | Registrar movimiento | +| POST | /api/cash/counts | Registrar arqueo | +| POST | /api/cash/counts/:id/denominations | Agregar denominaciones | +| POST | /api/cash/counts/:id/submit | Enviar para aprobación | +| POST | /api/cash/counts/:id/approve | Aprobar corte | +| GET | /api/cash/reports/differences | Reporte de diferencias | + +--- + +## Riesgos + +| Riesgo | Probabilidad | Impacto | Mitigacion | +|--------|--------------|---------|------------| +| Faltantes de efectivo | Media | Alto | Arqueos frecuentes | +| Manipulación de cortes | Baja | Alto | Doble aprobación | +| Errores de conteo | Media | Medio | Conteo por denominación | + +--- + +## Nota Técnica + +Este módulo es **100% nuevo** y no tiene equivalente en el ERP-Core. Es específico para operaciones de retail con manejo de efectivo. + +--- + +## Definition of Ready (DoR) + +- [x] Historias de usuario definidas +- [x] Criterios de aceptacion claros +- [x] Dependencias identificadas +- [x] Estimacion completada +- [ ] Denominaciones locales definidas +- [ ] Tolerancias de diferencia aprobadas + +## Definition of Done (DoD) + +- [ ] Flujo de apertura/cierre funcionando +- [ ] Conteo por denominación operativo +- [ ] Cálculo de diferencias correcto +- [ ] Reportes de auditoría +- [ ] Tests de integración pasando +- [ ] Documentación de API + +--- + +## Historial + +| Fecha | Cambio | Autor | +|-------|--------|-------| +| 2025-12-08 | Creacion de epica | Claude-Agent | + +--- + +**Creada por:** Claude-Agent +**Fecha:** 2025-12-08 +**Ultima actualizacion:** 2025-12-08 diff --git a/docs/08-epicas/EPIC-RT-008-reportes.md b/docs/08-epicas/EPIC-RT-008-reportes.md new file mode 100644 index 0000000..b4db07a --- /dev/null +++ b/docs/08-epicas/EPIC-RT-008-reportes.md @@ -0,0 +1,256 @@ +# EPICA: EPIC-RT-008 - Reportes y Dashboard + +## Metadata + +| Campo | Valor | +|-------|-------| +| **ID** | EPIC-RT-008 | +| **Nombre** | Reportes y Dashboard | +| **Modulo** | reportes | +| **Fase** | Fase 1 - MVP | +| **Prioridad** | P1 (Alto) | +| **Estado** | Backlog | +| **Story Points** | 30 | +| **Sprint(s)** | Sprint 7-8 | + +--- + +## Descripcion + +Dashboard ejecutivo y reportes operativos para retail. Visualización de ventas en tiempo real, comparativos por sucursal, análisis de productos, tendencias y KPIs clave. Exportación a Excel/PDF para análisis offline. + +--- + +## Objetivo de Negocio + +- Visibilidad en tiempo real de operaciones +- Toma de decisiones basada en datos +- Identificar oportunidades y problemas +- Comparar rendimiento entre sucursales +- Medir efectividad de promociones + +--- + +## Historias de Usuario + +| ID | Historia | Prioridad | SP | Estado | +|----|----------|-----------|-----|--------| +| US-RT008-001 | Como gerente general, quiero ver dashboard de ventas del día por sucursal | P0 | 5 | Backlog | +| US-RT008-002 | Como gerente general, quiero comparar ventas vs mismo día semana anterior | P0 | 3 | Backlog | +| US-RT008-003 | Como gerente de tienda, quiero ver ranking de productos más vendidos | P0 | 3 | Backlog | +| US-RT008-004 | Como gerente de tienda, quiero ver reporte de ventas por hora del día | P1 | 3 | Backlog | +| US-RT008-005 | Como gerente regional, quiero comparar rendimiento entre sucursales | P0 | 5 | Backlog | +| US-RT008-006 | Como comprador, quiero ver productos con baja rotación | P1 | 3 | Backlog | +| US-RT008-007 | Como marketing, quiero ver efectividad de promociones activas | P1 | 3 | Backlog | +| US-RT008-008 | Como gerente, quiero exportar reportes a Excel para análisis | P0 | 3 | Backlog | +| US-RT008-009 | Como admin, quiero configurar dashboards personalizados | P2 | 2 | Backlog | + +**Total Story Points:** 30 SP + +--- + +## KPIs Principales + +``` +┌─────────────────────────────────────────────────────────┐ +│ DASHBOARD DE VENTAS │ +├─────────────────────────────────────────────────────────┤ +│ │ +│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │ +│ │ VENTAS HOY │ │ TRANSACCIONES│ │ TICKET PROM. │ │ +│ │ $45,230 │ │ 127 │ │ $356 │ │ +│ │ ▲ 12% │ │ ▲ 8% │ │ ▲ 4% │ │ +│ └──────────────┘ └──────────────┘ └──────────────┘ │ +│ │ +│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │ +│ │ CLIENTES │ │ PUNTOS USADOS│ │ DEVOLUCIONES │ │ +│ │ 89 │ │ 2,350 │ │ $450 │ │ +│ │ ▲ 15% │ │ ▲ 22% │ │ ▼ 5% │ │ +│ └──────────────┘ └──────────────┘ └──────────────┘ │ +│ │ +│ VENTAS POR HORA │ +│ ┌────────────────────────────────────────────────────┐ │ +│ │ ▄▄ │ │ +│ │ ████ ▄▄▄▄ ▄▄▄▄ │ │ +│ │ ██████ ████████ ▄▄▄▄ ████████ ▄▄ │ │ +│ │ ████████ ████████████████████ ████████████████ │ │ +│ │ ──────────────────────────────────────────────────│ │ +│ │ 08 09 10 11 12 13 14 15 16 17 18 19 20 21 │ │ +│ └────────────────────────────────────────────────────┘ │ +│ │ +│ TOP 5 PRODUCTOS │ +│ ┌────────────────────────────────────────────────────┐ │ +│ │ 1. Producto A ████████████████████ $8,500 (18.8%) │ │ +│ │ 2. Producto B ██████████████ $6,200 (13.7%) │ │ +│ │ 3. Producto C ███████████ $5,100 (11.3%) │ │ +│ │ 4. Producto D ████████ $3,800 (8.4%) │ │ +│ │ 5. Producto E ██████ $2,900 (6.4%) │ │ +│ └────────────────────────────────────────────────────┘ │ +│ │ +└─────────────────────────────────────────────────────────┘ +``` + +--- + +## Reportes Disponibles + +``` +┌─────────────────────────────────────────────────────────┐ +│ CATÁLOGO DE REPORTES │ +├─────────────────────────────────────────────────────────┤ +│ │ +│ VENTAS │ +│ ├── Ventas diarias por sucursal │ +│ ├── Ventas por hora │ +│ ├── Ventas por categoría │ +│ ├── Ventas por cajero │ +│ └── Comparativo periodo anterior │ +│ │ +│ PRODUCTOS │ +│ ├── Ranking de productos │ +│ ├── Productos sin movimiento │ +│ ├── Análisis ABC │ +│ └── Margen por producto │ +│ │ +│ INVENTARIO │ +│ ├── Stock actual por sucursal │ +│ ├── Alertas de stock bajo │ +│ ├── Transferencias pendientes │ +│ └── Valorización de inventario │ +│ │ +│ CLIENTES │ +│ ├── Clientes nuevos vs recurrentes │ +│ ├── Top clientes por compra │ +│ ├── Uso de programa de puntos │ +│ └── Segmentación de clientes │ +│ │ +│ PROMOCIONES │ +│ ├── Uso de promociones activas │ +│ ├── Cupones redimidos │ +│ └── ROI de promociones │ +│ │ +└─────────────────────────────────────────────────────────┘ +``` + +--- + +## Criterios de Aceptacion de la Epica + +**Funcionales:** +- [ ] Dashboard en tiempo real +- [ ] Comparativos con período anterior +- [ ] Filtros por sucursal, fecha, categoría +- [ ] Ranking de productos +- [ ] Comparativo entre sucursales +- [ ] Análisis de promociones +- [ ] Exportación Excel/PDF +- [ ] Dashboards personalizables + +**No Funcionales:** +- [ ] Carga de dashboard < 3 segundos +- [ ] Actualización cada 5 minutos +- [ ] Datos históricos de 2 años + +**Tecnicos:** +- [ ] Agregación eficiente de datos +- [ ] Caché de métricas frecuentes +- [ ] Jobs de precálculo nocturno +- [ ] Gráficos responsivos + +--- + +## Dependencias + +**Esta epica depende de:** +| Epica/Modulo | Estado | Bloqueante | +|--------------|--------|------------| +| EPIC-RT-001 Fundamentos | Backlog | Si | +| EPIC-RT-002 POS | Backlog | Si | +| EPIC-RT-003 Inventario | Backlog | Si | +| EPIC-RT-005 Clientes | Backlog | Si | +| EPIC-RT-006 Precios | Backlog | Si | +| EPIC-RT-007 Caja | Backlog | Si | + +--- + +## Desglose Tecnico + +**Database:** +- [ ] Schema: `analytics` +- [ ] Tablas: 5 (daily_sales, hourly_metrics, product_metrics, store_metrics, custom_dashboards) +- [ ] Vistas materializadas para reportes frecuentes +- [ ] Indices optimizados para consultas analíticas + +**Backend:** +- [ ] Modulo: `reports` +- [ ] Services: MetricsService, ReportGenerator +- [ ] Endpoints: 15 +- [ ] Jobs: Precálculo de métricas +- [ ] Tests: 25 + +**Frontend:** +- [ ] Paginas: 4 (Dashboard, Reports, DashboardBuilder, Export) +- [ ] Componentes: 15 (ChartCard, MetricWidget, FilterBar, DataTable, etc.) +- [ ] Librería: Chart.js o Recharts +- [ ] Stores: 1 (reportsStore) + +--- + +## Endpoints API + +| Metodo | Endpoint | Descripcion | +|--------|----------|-------------| +| GET | /api/reports/dashboard | Dashboard principal | +| GET | /api/reports/sales | Reporte de ventas | +| GET | /api/reports/products/ranking | Ranking de productos | +| GET | /api/reports/products/slow-moving | Productos sin rotación | +| GET | /api/reports/stores/comparison | Comparativo de sucursales | +| GET | /api/reports/customers/analysis | Análisis de clientes | +| GET | /api/reports/promotions/effectiveness | Efectividad de promociones | +| GET | /api/reports/export/:reportId | Exportar a Excel | +| GET | /api/reports/export/:reportId/pdf | Exportar a PDF | +| POST | /api/reports/custom-dashboards | Crear dashboard | + +--- + +## Riesgos + +| Riesgo | Probabilidad | Impacto | Mitigacion | +|--------|--------------|---------|------------| +| Consultas lentas | Media | Alto | Vistas materializadas + índices | +| Datos incorrectos | Baja | Alto | Validación de fuentes | +| Sobrecarga de BD | Media | Medio | Réplica de lectura | + +--- + +## Definition of Ready (DoR) + +- [x] Historias de usuario definidas +- [x] Criterios de aceptacion claros +- [x] Dependencias identificadas +- [x] Estimacion completada +- [ ] KPIs prioritarios definidos +- [ ] Diseño de dashboards aprobado + +## Definition of Done (DoD) + +- [ ] Dashboard principal funcionando +- [ ] Reportes operativos disponibles +- [ ] Exportación Excel/PDF operativa +- [ ] Performance aceptable +- [ ] Tests de integración pasando +- [ ] Documentación de API + +--- + +## Historial + +| Fecha | Cambio | Autor | +|-------|--------|-------| +| 2025-12-08 | Creacion de epica | Claude-Agent | + +--- + +**Creada por:** Claude-Agent +**Fecha:** 2025-12-08 +**Ultima actualizacion:** 2025-12-08 diff --git a/docs/08-epicas/EPIC-RT-009-ecommerce.md b/docs/08-epicas/EPIC-RT-009-ecommerce.md new file mode 100644 index 0000000..e97ec9f --- /dev/null +++ b/docs/08-epicas/EPIC-RT-009-ecommerce.md @@ -0,0 +1,261 @@ +# EPICA: EPIC-RT-009 - E-commerce + +## Metadata + +| Campo | Valor | +|-------|-------| +| **ID** | EPIC-RT-009 | +| **Nombre** | E-commerce | +| **Modulo** | ecommerce | +| **Fase** | Fase 2 - Extensión | +| **Prioridad** | P1 (Alto) | +| **Estado** | Backlog | +| **Story Points** | 55 | +| **Sprint(s)** | Sprint 9-12 | + +--- + +## Descripcion + +Tienda online integrada con el sistema de retail. Catálogo de productos sincronizado, stock en tiempo real, carrito de compras, checkout con múltiples formas de pago, integración con programa de lealtad y opciones de entrega (envío o pickup en tienda). + +--- + +## Objetivo de Negocio + +- Canal de ventas adicional 24/7 +- Experiencia omnicanal consistente +- Incrementar alcance geográfico +- Aprovechar inventario existente +- Programa de lealtad unificado + +--- + +## Historias de Usuario + +| ID | Historia | Prioridad | SP | Estado | +|----|----------|-----------|-----|--------| +| US-RT009-001 | Como cliente, quiero navegar catálogo de productos con filtros | P0 | 5 | Backlog | +| US-RT009-002 | Como cliente, quiero ver disponibilidad de stock en tiempo real | P0 | 5 | Backlog | +| US-RT009-003 | Como cliente, quiero agregar productos al carrito | P0 | 3 | Backlog | +| US-RT009-004 | Como cliente, quiero ingresar o crear cuenta para comprar | P0 | 5 | Backlog | +| US-RT009-005 | Como cliente, quiero pagar con tarjeta de crédito/débito | P0 | 8 | Backlog | +| US-RT009-006 | Como cliente, quiero elegir entre envío a domicilio o pickup en tienda | P0 | 5 | Backlog | +| US-RT009-007 | Como cliente, quiero usar mis puntos de lealtad como descuento | P1 | 5 | Backlog | +| US-RT009-008 | Como cliente, quiero ver historial de mis pedidos | P1 | 3 | Backlog | +| US-RT009-009 | Como admin, quiero gestionar pedidos online desde el backoffice | P0 | 5 | Backlog | +| US-RT009-010 | Como admin, quiero configurar zonas de envío y costos | P0 | 3 | Backlog | +| US-RT009-011 | Como marketing, quiero crear banners y promociones para la tienda | P1 | 3 | Backlog | +| US-RT009-012 | Como cliente, quiero recibir notificaciones del estado de mi pedido | P1 | 5 | Backlog | + +**Total Story Points:** 55 SP + +--- + +## Flujo de Compra + +``` +┌─────────────┐ +│ CATÁLOGO │ ← Cliente navega productos +└──────┬──────┘ + │ + ▼ +┌─────────────┐ +│ CARRITO │ ← Agrega productos +└──────┬──────┘ + │ + ▼ +┌─────────────┐ +│ CUENTA │ ← Login o registro +└──────┬──────┘ + │ + ▼ +┌─────────────┐ +│ ENVÍO │ ← Domicilio o pickup +└──────┬──────┘ + │ + ▼ +┌─────────────┐ +│ PAGO │ ← Tarjeta + puntos +└──────┬──────┘ + │ + ▼ +┌─────────────┐ +│ CONFIRMADO │ ← Email de confirmación +└─────────────┘ +``` + +--- + +## Estados de Pedido + +``` +┌─────────────┐ +│ PENDIENTE │ ← Pedido creado, pago pendiente +└──────┬──────┘ + │ + ▼ +┌─────────────┐ +│ PAGADO │ ← Pago confirmado +└──────┬──────┘ + │ + ├── Si envío ──────────────────┐ + │ │ + ▼ ▼ +┌─────────────┐ ┌─────────────┐ +│ PREPARANDO │ │ EN_ENVÍO │ +│ (pickup) │ │ │ +└──────┬──────┘ └──────┬──────┘ + │ │ + ▼ ▼ +┌─────────────┐ ┌─────────────┐ +│LISTO_PICKUP │ │ ENTREGADO │ +└──────┬──────┘ └─────────────┘ + │ + ▼ +┌─────────────┐ +│ RECOGIDO │ +└─────────────┘ +``` + +--- + +## Criterios de Aceptacion de la Epica + +**Funcionales:** +- [ ] Catálogo de productos con búsqueda y filtros +- [ ] Stock en tiempo real desde inventario +- [ ] Carrito persistente +- [ ] Registro/login de clientes +- [ ] Checkout con tarjeta de crédito +- [ ] Envío a domicilio y pickup +- [ ] Integración con puntos de lealtad +- [ ] Gestión de pedidos en backoffice +- [ ] Notificaciones de estado + +**No Funcionales:** +- [ ] Tiempo de carga < 3 segundos +- [ ] SEO optimizado +- [ ] Responsive (mobile-first) +- [ ] Pasarela de pago segura (PCI-DSS) + +**Tecnicos:** +- [ ] Integración con inventario multi-sucursal +- [ ] Integración con programa de lealtad +- [ ] Pasarela de pago (Stripe/Conekta) +- [ ] Servicio de envíos (ej: Fedex, DHL API) + +--- + +## Dependencias + +**Esta epica depende de:** +| Epica/Modulo | Estado | Bloqueante | +|--------------|--------|------------| +| EPIC-RT-001 Fundamentos | Backlog | Si | +| EPIC-RT-003 Inventario | Backlog | Si | +| EPIC-RT-005 Clientes | Backlog | Si | +| EPIC-RT-006 Precios | Backlog | Si | + +**Esta epica bloquea:** +| Epica/Modulo | Razon | +|--------------|-------| +| EPIC-RT-010 Facturacion | Requiere pedidos para facturar | + +--- + +## Desglose Tecnico + +**Database:** +- [ ] Schema: `ecommerce` +- [ ] Tablas: 10 (orders, order_items, carts, cart_items, shipping_zones, addresses, payments, banners, categories_web, product_images) +- [ ] Funciones: 3 (calculate_shipping, validate_stock, process_payment) +- [ ] Indices: Por cliente, estado, fecha + +**Backend:** +- [ ] Modulo: `ecommerce` +- [ ] Entities: 8 (Order, OrderItem, Cart, CartItem, ShippingZone, Address, Payment, Banner) +- [ ] Endpoints: 25 +- [ ] Tests: 45 + +**Frontend (Web):** +- [ ] Paginas: 10 (Home, Category, Product, Cart, Checkout, Account, Orders, etc.) +- [ ] Componentes: 25 (ProductCard, CartDrawer, CheckoutForm, OrderTracker, etc.) +- [ ] SEO: Meta tags, sitemap, schema.org +- [ ] Framework: Next.js recomendado + +--- + +## Endpoints API + +| Metodo | Endpoint | Descripcion | +|--------|----------|-------------| +| GET | /api/shop/products | Listar productos | +| GET | /api/shop/products/:id | Detalle de producto | +| GET | /api/shop/categories | Categorías | +| POST | /api/shop/cart | Crear/obtener carrito | +| POST | /api/shop/cart/items | Agregar al carrito | +| DELETE | /api/shop/cart/items/:id | Quitar del carrito | +| POST | /api/shop/checkout | Iniciar checkout | +| POST | /api/shop/orders | Crear pedido | +| GET | /api/shop/orders/:id | Detalle de pedido | +| POST | /api/shop/payments | Procesar pago | +| GET | /api/shop/shipping/zones | Zonas de envío | +| POST | /api/shop/shipping/calculate | Calcular envío | + +--- + +## Integraciones Externas + +| Servicio | Propósito | Notas | +|----------|-----------|-------| +| Stripe / Conekta | Pagos con tarjeta | PCI-DSS compliant | +| Fedex / DHL / Estafeta | Cálculo de envío | APIs de cotización | +| SendGrid / Mailgun | Emails transaccionales | Confirmación, tracking | +| Google Analytics | Tracking de conversión | E-commerce enhanced | + +--- + +## Riesgos + +| Riesgo | Probabilidad | Impacto | Mitigacion | +|--------|--------------|---------|------------| +| Stock desincronizado | Media | Alto | Eventos en tiempo real | +| Fraude con tarjetas | Media | Alto | 3DS + validaciones | +| Problemas de envío | Media | Medio | Múltiples proveedores | + +--- + +## Definition of Ready (DoR) + +- [x] Historias de usuario definidas +- [x] Criterios de aceptacion claros +- [x] Dependencias identificadas +- [x] Estimacion completada +- [ ] Diseño de UI aprobado +- [ ] Pasarela de pago seleccionada +- [ ] Proveedores de envío definidos + +## Definition of Done (DoD) + +- [ ] Tienda online funcional +- [ ] Checkout completo con pago +- [ ] Integración con inventario +- [ ] Programa de lealtad integrado +- [ ] Tests E2E pasando +- [ ] SEO implementado +- [ ] Documentación de API + +--- + +## Historial + +| Fecha | Cambio | Autor | +|-------|--------|-------| +| 2025-12-08 | Creacion de epica | Claude-Agent | + +--- + +**Creada por:** Claude-Agent +**Fecha:** 2025-12-08 +**Ultima actualizacion:** 2025-12-08 diff --git a/docs/08-epicas/EPIC-RT-010-facturacion.md b/docs/08-epicas/EPIC-RT-010-facturacion.md new file mode 100644 index 0000000..ecc56a5 --- /dev/null +++ b/docs/08-epicas/EPIC-RT-010-facturacion.md @@ -0,0 +1,292 @@ +# EPICA: EPIC-RT-010 - Facturación CFDI + +## Metadata + +| Campo | Valor | +|-------|-------| +| **ID** | EPIC-RT-010 | +| **Nombre** | Facturación CFDI | +| **Modulo** | facturacion | +| **Fase** | Fase 1 - MVP | +| **Prioridad** | P0 (Critico) | +| **Estado** | Backlog | +| **Story Points** | 35 | +| **Sprint(s)** | Sprint 6-7 | + +--- + +## Descripcion + +Generación de comprobantes fiscales digitales (CFDI 4.0) para ventas en tienda y online. Incluye facturación a público en general, facturación a cliente con RFC, notas de crédito, cancelaciones y portal de autofactura para clientes. + +--- + +## Objetivo de Negocio + +- Cumplimiento fiscal obligatorio +- Facturación inmediata en POS +- Autofactura para clientes +- Control de notas de crédito +- Reportes fiscales + +--- + +## Historias de Usuario + +| ID | Historia | Prioridad | SP | Estado | +|----|----------|-----------|-----|--------| +| US-RT010-001 | Como cajero, quiero generar factura inmediata al cerrar venta si el cliente la solicita | P0 | 5 | Backlog | +| US-RT010-002 | Como cajero, quiero generar factura a público en general para ventas sin RFC | P0 | 3 | Backlog | +| US-RT010-003 | Como cliente, quiero autofacturar desde portal web usando mi ticket | P0 | 8 | Backlog | +| US-RT010-004 | Como contador, quiero generar nota de crédito por devolución | P0 | 5 | Backlog | +| US-RT010-005 | Como contador, quiero cancelar CFDI con motivo de cancelación | P0 | 3 | Backlog | +| US-RT010-006 | Como cliente, quiero recibir mi factura PDF y XML por email | P0 | 3 | Backlog | +| US-RT010-007 | Como contador, quiero ver reporte de CFDIs emitidos por período | P0 | 3 | Backlog | +| US-RT010-008 | Como admin, quiero configurar datos fiscales de la empresa | P0 | 2 | Backlog | +| US-RT010-009 | Como contador, quiero regenerar PDF de factura existente | P1 | 2 | Backlog | +| US-RT010-010 | Como admin, quiero validar RFC del cliente contra SAT (opcional) | P2 | 1 | Backlog | + +**Total Story Points:** 35 SP + +--- + +## CFDI 4.0 - Elementos Clave + +``` +┌─────────────────────────────────────────────────────────┐ +│ ESTRUCTURA CFDI 4.0 │ +├─────────────────────────────────────────────────────────┤ +│ │ +│ EMISOR │ +│ ├── RFC de la empresa │ +│ ├── Nombre o razón social │ +│ ├── Régimen fiscal │ +│ └── Código postal del domicilio fiscal │ +│ │ +│ RECEPTOR │ +│ ├── RFC del cliente (o XAXX010101000 para público) │ +│ ├── Nombre o razón social │ +│ ├── Régimen fiscal │ +│ ├── Código postal del domicilio fiscal │ +│ └── Uso del CFDI (G03 - Gastos en general) │ +│ │ +│ CONCEPTOS │ +│ ├── Clave de producto/servicio SAT │ +│ ├── Descripción │ +│ ├── Cantidad y unidad │ +│ ├── Valor unitario │ +│ ├── Importe │ +│ └── Impuestos (IVA, IEPS si aplica) │ +│ │ +│ COMPLEMENTOS │ +│ └── Pago (si es PPD - Pago en Parcialidades) │ +│ │ +│ TIMBRE FISCAL DIGITAL (TFD) │ +│ ├── UUID (folio fiscal) │ +│ ├── Fecha de timbrado │ +│ ├── Sello del SAT │ +│ └── Sello del emisor │ +│ │ +└─────────────────────────────────────────────────────────┘ +``` + +--- + +## Flujo de Facturación + +``` +FACTURACIÓN INMEDIATA (POS) + +┌─────────────┐ +│ VENTA │ ← Cajero cierra venta +└──────┬──────┘ + │ + ▼ +┌─────────────┐ +│ ¿FACTURA? │ ─── No ──► Ticket simple +└──────┬──────┘ + │ Si + ▼ +┌─────────────┐ +│ DATOS RFC │ ← Captura RFC y datos fiscales +└──────┬──────┘ + │ + ▼ +┌─────────────┐ +│ TIMBRADO │ ← PAC genera UUID +└──────┬──────┘ + │ + ▼ +┌─────────────┐ +│ PDF + XML │ ← Envío por email +└─────────────┘ + + +AUTOFACTURA (Portal) + +┌─────────────┐ +│ PORTAL │ ← Cliente ingresa +└──────┬──────┘ + │ + ▼ +┌─────────────┐ +│ TICKET │ ← Ingresa folio del ticket +└──────┬──────┘ + │ + ▼ +┌─────────────┐ +│ DATOS RFC │ ← Ingresa sus datos fiscales +└──────┬──────┘ + │ + ▼ +┌─────────────┐ +│ TIMBRADO │ ← PAC genera UUID +└──────┬──────┘ + │ + ▼ +┌─────────────┐ +│ DESCARGA │ ← PDF + XML disponibles +└─────────────┘ +``` + +--- + +## Criterios de Aceptacion de la Epica + +**Funcionales:** +- [ ] Facturación inmediata en POS +- [ ] Factura a público en general +- [ ] Portal de autofactura +- [ ] Notas de crédito por devolución +- [ ] Cancelación de CFDIs +- [ ] Envío por email +- [ ] Reportes de CFDIs +- [ ] Configuración de datos fiscales + +**No Funcionales:** +- [ ] Timbrado < 5 segundos +- [ ] Alta disponibilidad del PAC +- [ ] Almacenamiento de XMLs por 5 años + +**Tecnicos:** +- [ ] Integración con PAC (Finkok, Facturama, etc.) +- [ ] Generación de XML CFDI 4.0 +- [ ] Generación de PDF personalizado +- [ ] Validación de RFC vs lista negra SAT + +--- + +## Dependencias + +**Esta epica depende de:** +| Epica/Modulo | Estado | Bloqueante | +|--------------|--------|------------| +| EPIC-RT-001 Fundamentos | Backlog | Si | +| EPIC-RT-002 POS | Backlog | Si | + +**Esta epica bloquea:** +| Epica/Modulo | Razon | +|--------------|-------| +| Reportes fiscales | Requiere CFDIs emitidos | + +--- + +## Desglose Tecnico + +**Database:** +- [ ] Schema: `invoicing` +- [ ] Tablas: 6 (invoices, invoice_items, cancellations, fiscal_data, xml_storage, pac_logs) +- [ ] Funciones: 2 (generate_xml, validate_rfc) +- [ ] Indices: Por UUID, fecha, RFC + +**Backend:** +- [ ] Modulo: `invoicing` +- [ ] Entities: 4 (Invoice, InvoiceItem, Cancellation, FiscalData) +- [ ] Services: PACService, XMLGenerator, PDFGenerator +- [ ] Endpoints: 12 +- [ ] Tests: 28 + +**Frontend:** +- [ ] Paginas: 4 (InvoiceList, InvoiceDetail, SelfService, Config) +- [ ] Componentes: 10 (InvoiceForm, RFCInput, PDFViewer, etc.) +- [ ] Portal público de autofactura +- [ ] Stores: 1 (invoicingStore) + +--- + +## Endpoints API + +| Metodo | Endpoint | Descripcion | +|--------|----------|-------------| +| POST | /api/invoices | Crear y timbrar factura | +| GET | /api/invoices/:id | Detalle de factura | +| GET | /api/invoices/:id/pdf | Descargar PDF | +| GET | /api/invoices/:id/xml | Descargar XML | +| POST | /api/invoices/:id/send | Enviar por email | +| POST | /api/invoices/:id/cancel | Cancelar factura | +| POST | /api/invoices/credit-note | Crear nota de crédito | +| GET | /api/invoices/report | Reporte de CFDIs | +| POST | /api/invoices/self-service | Autofactura (público) | +| POST | /api/invoices/validate-rfc | Validar RFC | + +--- + +## PAC (Proveedor Autorizado de Certificación) + +| PAC | Notas | +|-----|-------| +| Finkok | Popular, buena documentación | +| Facturama | API REST moderna | +| SW Sapien | Económico para alto volumen | +| Facturify | Fácil integración | + +--- + +## Riesgos + +| Riesgo | Probabilidad | Impacto | Mitigacion | +|--------|--------------|---------|------------| +| Caída del PAC | Baja | Alto | PAC de respaldo | +| Cambios normativos | Media | Alto | Monitoreo de actualizaciones | +| XMLs corruptos | Baja | Alto | Validación antes de timbrar | + +--- + +## Nota Técnica + +La facturación electrónica en México (CFDI) es obligatoria y debe cumplir con los lineamientos del SAT. El sistema debe estar preparado para actualizaciones normativas frecuentes. + +--- + +## Definition of Ready (DoR) + +- [x] Historias de usuario definidas +- [x] Criterios de aceptacion claros +- [x] Dependencias identificadas +- [x] Estimacion completada +- [ ] PAC seleccionado y contratado +- [ ] Certificados de sello digital (CSD) disponibles +- [ ] Catálogo de productos con claves SAT + +## Definition of Done (DoD) + +- [ ] Facturación inmediata funcionando +- [ ] Portal de autofactura operativo +- [ ] Notas de crédito y cancelaciones +- [ ] Integración con PAC estable +- [ ] Tests de integración pasando +- [ ] Documentación de API + +--- + +## Historial + +| Fecha | Cambio | Autor | +|-------|--------|-------| +| 2025-12-08 | Creacion de epica | Claude-Agent | + +--- + +**Creada por:** Claude-Agent +**Fecha:** 2025-12-08 +**Ultima actualizacion:** 2025-12-08 diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..bc2edc0 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,38 @@ +# DOCUMENTACIÓN - ERP Retail + +**Proyecto:** ERP Retail +**Versión:** 1.0.0 +**Fecha:** 2025-12-05 +**Estado:** Por iniciar + +--- + +## Estructura de Documentación + +``` +docs/ +├── 00-vision-general/ # Visión, objetivos y alcance +├── 01-analisis-referencias/ # Análisis de sistemas de referencia +├── 02-definicion-modulos/ # Lista, índice y dependencias de módulos +├── 03-requerimientos/ # Requerimientos funcionales por módulo +├── 04-modelado/ # Diseño técnico +│ ├── database-design/ # DDL specs, schemas +│ ├── domain-models/ # Modelos de dominio +│ └── especificaciones-tecnicas/ # ET backend/frontend +├── 05-user-stories/ # Historias de usuario +├── 06-test-plans/ # Planes de prueba +├── 07-devops/ # CI/CD, infraestructura +├── 90-transversal/ # Documentos transversales +├── 95-guias-desarrollo/ # Guías para desarrolladores +└── 97-adr/ # Architecture Decision Records +``` + +--- + +## Directiva Aplicable + +Ver: `/workspace/core/orchestration/directivas/DIRECTIVA-ESTRUCTURA-DOCUMENTACION-PROYECTOS.md` + +--- + +**Última actualización:** 2025-12-05 diff --git a/docs/_MAP.md b/docs/_MAP.md new file mode 100644 index 0000000..7efa860 --- /dev/null +++ b/docs/_MAP.md @@ -0,0 +1,40 @@ +# Mapa de Documentacion: erp-retail + +**Proyecto:** erp-retail +**Actualizado:** 2026-01-04 +**Generado por:** EPIC-008 adapt-simco.sh + +--- + +## Estructura de Documentacion + +``` +docs/ +├── _MAP.md # Este archivo (indice de navegacion) +├── 00-overview/ # Vision general del proyecto +├── 01-architecture/ # Arquitectura y decisiones (ADRs) +├── 02-specs/ # Especificaciones tecnicas +├── 03-api/ # Documentacion de APIs +├── 04-guides/ # Guias de desarrollo +└── 99-finiquito/ # Entregables cliente (si aplica) +``` + +## Navegacion Rapida + +| Seccion | Descripcion | Estado | +|---------|-------------|--------| +| Overview | Vision general | - | +| Architecture | Decisiones arquitectonicas | - | +| Specs | Especificaciones tecnicas | - | +| API | Documentacion de endpoints | - | +| Guides | Guias de desarrollo | - | + +## Estadisticas + +- Total archivos en docs/: 23 +- Fecha de adaptacion: 2026-01-04 + +--- + +**Nota:** Este archivo fue generado automaticamente por EPIC-008. +Actualizar manualmente con la estructura real del proyecto. diff --git a/orchestration/00-guidelines/CONTEXTO-PROYECTO.md b/orchestration/00-guidelines/CONTEXTO-PROYECTO.md new file mode 100644 index 0000000..88677e2 --- /dev/null +++ b/orchestration/00-guidelines/CONTEXTO-PROYECTO.md @@ -0,0 +1,162 @@ +# Contexto del Proyecto: ERP Retail / Punto de Venta + +## Metadatos + +| Campo | Valor | +|-------|-------| +| **Nombre** | ERP Retail - Punto de Venta | +| **Tipo** | STANDALONE (Proyecto Independiente) | +| **Nivel** | Vertical que extiende erp-core | +| **Estado** | Por iniciar | +| **Progreso** | 0% | +| **Version** | 0.0.1 | +| **Base** | Extiende projects/erp-core (60-70%) | +| **Extension** | Modulos especificos (+30-40%) | +| **Path** | `/home/isem/workspace-v1/projects/erp-retail/` | +| **Fecha Migracion** | 2025-12-27 | + +--- + +## VARIABLES PARA DIRECTIVAS GLOBALES + +```yaml +# Identificacion del Proyecto +PROJECT: erp-retail +PROJECT_NAME: ERP Retail +PROJECT_LEVEL: STANDALONE + +# Paths Principales (WORKSPACE-V1) +WORKSPACE_ROOT: ~/workspace-v1 +PROJECT_ROOT: ~/workspace-v1/projects/erp-retail +APPS_ROOT: ~/workspace-v1/projects/erp-retail +DOCS_ROOT: ~/workspace-v1/projects/erp-retail/docs +ORCHESTRATION: ~/workspace-v1/projects/erp-retail/orchestration + +# Herencia de ERP-Core +ERP_CORE_ROOT: ~/workspace-v1/projects/erp-core +HERENCIA_DOC: orchestration/00-guidelines/HERENCIA-ERP-CORE.md + +# Base Orchestration (Directivas y Perfiles) +DIRECTIVAS_PATH: ~/workspace-v1/orchestration/directivas +PERFILES_PATH: ~/workspace-v1/orchestration/agents/perfiles +CATALOG_PATH: ~/workspace-v1/core/catalog + +# Base de Datos +DB_NAME: erp_retail +DB_DDL_PATH: ~/workspace-v1/projects/erp-retail/database/ddl +DB_SCRIPTS_PATH: ~/workspace-v1/projects/erp-retail/database + +# Backend +BACKEND_ROOT: ~/workspace-v1/projects/erp-retail/backend +BACKEND_SRC: ~/workspace-v1/projects/erp-retail/backend/src + +# Frontend +FRONTEND_ROOT: ~/workspace-v1/projects/erp-retail/frontend +FRONTEND_SRC: ~/workspace-v1/projects/erp-retail/frontend/src +``` + +--- + +## Descripcion + +ERP especializado para comercio minorista y punto de venta. Extiende el ERP Core con funcionalidades especificas de retail, POS y gestion de tiendas. + +**Funcionalidades principales:** +- Punto de Venta (POS) tactil +- Gestion de caja y turnos +- Control de inventario multi-sucursal +- Tarjetas de lealtad y promociones +- Facturacion electronica (CFDI) +- Reportes de ventas en tiempo real +- Integracion con lectores de codigo de barras + +--- + +## Stack Tecnologico + +Hereda completamente del ERP Core: +- **Backend:** Node.js + Express + TypeScript +- **Frontend:** React + TypeScript + Tailwind +- **Database:** PostgreSQL 15+ +- **Auth:** JWT + Multi-tenant +- **POS:** PWA para funcionamiento offline + +--- + +## Paths del Proyecto + +``` +/home/isem/workspace-v1/projects/erp-retail/ +├── backend/ # Extensiones backend +├── frontend/ # UI especializada (incluye POS) +├── database/ # DDL vertical +├── docs/ # Documentacion +└── orchestration/ # Sistema NEXUS + ├── 00-guidelines/ + └── referencias/ +``` + +--- + +## Modulos Especificos (MRT-*) + +| Codigo | Modulo | Descripcion | +|--------|--------|-------------| +| MRT-001 | pos | Punto de venta tactil | +| MRT-002 | caja | Gestion de caja y turnos | +| MRT-003 | sucursales | Multi-sucursal | +| MRT-004 | promociones | Ofertas y descuentos | +| MRT-005 | lealtad | Tarjetas y puntos | +| MRT-006 | codigo-barras | Integracion lectores | + +--- + +## Modulos del Core que Extiende + +| Modulo Core | Extension | +|-------------|-----------| +| MGN-002 Users | Roles (cajero, supervisor, gerente) | +| MGN-005 Catalogs | Categorias retail | +| MGN-010 Financial | Caja, cortes, arqueos | +| MGN-011 Inventory | Multi-ubicacion, minimos | +| MGN-013 Sales | POS, tickets | +| MGN-014 CRM | Clientes frecuentes | + +--- + +## Schemas de Base de Datos + +``` +vertical_retail # Schema principal +├── pos_sessions # Sesiones de caja +├── cash_movements # Movimientos de caja +├── cash_closings # Cortes de caja +├── promotions # Promociones activas +├── loyalty_cards # Tarjetas de lealtad +├── loyalty_transactions # Acumulacion/Redencion +└── branches # Sucursales +``` + +--- + +## Principios Especificos + +1. **Velocidad:** POS debe responder en <100ms +2. **Offline:** Funcionar sin conexion (sincronizar despues) +3. **Fiscal:** Cumplir CFDI 4.0 en tiempo real +4. **Multi-sucursal:** Inventario centralizado pero distribuido + +--- + +## Referencias + +| Recurso | Path | +|---------|------| +| Directivas globales | `/home/isem/workspace-v1/orchestration/directivas/` | +| Directivas ERP-Core | `/home/isem/workspace-v1/projects/erp-core/orchestration/directivas/` | +| Herencia directivas | `./HERENCIA-DIRECTIVAS.md` | +| Dependencias ERP-Core | `../referencias/DEPENDENCIAS-ERP-CORE.yml` | +| Dependencias Shared | `../referencias/DEPENDENCIAS-SHARED.yml` | + +--- +*Ultima actualizacion: Diciembre 2025* diff --git a/orchestration/00-guidelines/HERENCIA-DIRECTIVAS.md b/orchestration/00-guidelines/HERENCIA-DIRECTIVAS.md new file mode 100644 index 0000000..bf668b8 --- /dev/null +++ b/orchestration/00-guidelines/HERENCIA-DIRECTIVAS.md @@ -0,0 +1,93 @@ +# Herencia de Directivas - ERP Retail + +## Jerarquia de Directivas + +Este proyecto hereda directivas en el siguiente orden de precedencia: + +``` +1. Directivas Globales (CORE) → /home/isem/workspace/core/orchestration/directivas/ +2. Directivas ERP-Core → /home/isem/workspace/projects/erp-suite/apps/erp-core/orchestration/directivas/ +3. Directivas Retail → ./directivas/ (este proyecto) +``` + +**Regla:** Las directivas especificas pueden **EXTENDER** las heredadas, nunca **REDUCIRLAS**. + +--- + +## Directivas Heredadas de CORE + +| Directiva | Proposito | +|-----------|-----------| +| `DIRECTIVA-FLUJO-5-FASES.md` | Flujo de trabajo obligatorio | +| `DIRECTIVA-DOCUMENTACION-OBLIGATORIA.md` | Docs en tiempo real | +| `DIRECTIVA-CALIDAD-CODIGO.md` | Estandares de codigo | +| `DIRECTIVA-DISENO-BASE-DATOS.md` | Diseno BD | +| `DIRECTIVA-CONTROL-VERSIONES.md` | Git | + +--- + +## Directivas Heredadas de ERP-Core + +| Directiva | Proposito | +|-----------|-----------| +| `DIRECTIVA-MULTI-TENANT.md` | Aislamiento por tenant | +| `DIRECTIVA-EXTENSION-VERTICALES.md` | Como extender el core | +| `DIRECTIVA-DOCUMENTACION-PRE-DESARROLLO.md` | Documentar antes de desarrollar | +| `DIRECTIVA-PATRONES-ODOO.md` | Patrones de diseno | +| `DIRECTIVA-HERENCIA-MODULOS.md` | Extension de modulos | +| `ESTANDARES-API-REST-GENERICO.md` | APIs REST | + +--- + +## Directivas Especificas de Retail + +| Directiva | Proposito | Estado | +|-----------|-----------|--------| +| `DIRECTIVA-POS-OFFLINE.md` | Funcionamiento sin conexion | Por crear | +| `DIRECTIVA-SINCRONIZACION.md` | Sync multi-sucursal | Por crear | +| `DIRECTIVA-CAJA-FISCAL.md` | Cumplimiento fiscal | Por crear | +| `DIRECTIVA-RENDIMIENTO-POS.md` | Performance <100ms | Por crear | + +--- + +## Modulos que Usa del Core + +| Modulo Core | Uso en Retail | +|-------------|---------------| +| MGN-001 Auth | Directo | +| MGN-002 Users | Extendido (cajero, supervisor) | +| MGN-004 Tenants | Extendido (sucursales) | +| MGN-005 Catalogs | Extendido (categorias retail) | +| MGN-010 Financial | Extendido (caja, cortes) | +| MGN-011 Inventory | Extendido (multi-ubicacion) | +| MGN-013 Sales | Extendido (POS, tickets) | +| MGN-014 CRM | Extendido (clientes frecuentes) | + +--- + +## Consideraciones Especiales + +### Performance +- Transacciones POS en <100ms +- Cache agresivo de productos +- Indices optimizados para busqueda + +### Offline First +- PWA con Service Worker +- IndexedDB para datos locales +- Cola de sincronizacion + +### Fiscal +- Timbrado CFDI en tiempo real +- Folios fiscales por sucursal +- Bitacora de operaciones + +--- + +## Referencias + +- Core: `/home/isem/workspace/core/orchestration/directivas/` +- ERP-Core: `/home/isem/workspace/projects/erp-suite/apps/erp-core/orchestration/directivas/` + +--- +*Ultima actualizacion: Diciembre 2025* diff --git a/orchestration/00-guidelines/HERENCIA-ERP-CORE.md b/orchestration/00-guidelines/HERENCIA-ERP-CORE.md new file mode 100644 index 0000000..d1856a4 --- /dev/null +++ b/orchestration/00-guidelines/HERENCIA-ERP-CORE.md @@ -0,0 +1,192 @@ +# Herencia de ERP Core - Vertical Retail / POS + +**Version:** 1.0.0 +**Vertical:** Retail / Punto de Venta +**Nivel:** STANDALONE (proyecto independiente) +**Version ERP-Core:** 1.2.0 +**Ruta ERP-Core:** projects/erp-core +**Herencia:** 60-70% de funcionalidad base de erp-core +**Fecha Migracion:** 2025-12-27 + +--- + +## RESUMEN DE HERENCIA + +Este documento especifica exactamente que hereda la vertical Retail del ERP Core y como lo extiende. + +--- + +## 1. MODULOS HEREDADOS (100%) + +| Modulo Core | Codigo | Uso en Retail | +|-------------|--------|---------------| +| Auth | MGN-001 | Autenticacion rapida para POS | +| Users | MGN-002 | Cajeros, supervisores, gerentes | +| Roles | MGN-003 | Roles por puesto | +| Audit | MGN-007 | Auditoria de transacciones | +| Notifications | MGN-008 | Alertas de stock bajo | +| Reports | MGN-009 | Reportes de ventas | + +**Accion:** NO crear codigo para estos modulos. Usar directamente del core. + +--- + +## 2. MODULOS HEREDADOS Y EXTENDIDOS + +### MGN-004: Tenants → Tiendas/Sucursales + +```yaml +herencia_base: + - Multi-tenancy basico + +extension_retail: + - Empresa matriz → sucursales + - Campos adicionales: + - tipo_establecimiento + - horario_operacion + - numero_cajas + - zona_geografica + - Relaciones: + - empresa → sucursales (1:N) + - sucursal → cajas (1:N) +``` + +### MGN-005: Catalogs → Catalogos de Productos + +```yaml +herencia_base: + - CRUD de catalogos genericos + +extension_retail: + - Catalogo de categorias de productos + - Catalogo de marcas + - Catalogo de proveedores retail + - Catalogo de promociones + - Catalogo de formas de pago +``` + +### MGN-011: Inventory → Inventario Multi-Sucursal + +```yaml +herencia_base: + - Productos y variantes + - Movimientos de stock + +extension_retail: + - Stock por sucursal + - Transferencias entre sucursales + - Minimos/maximos por sucursal + - Reposicion automatica +``` + +--- + +## 3. ESPECIFICACIONES TRANSVERSALES HEREDADAS + +### Obligatorias + +| Especificacion | Gap | Uso | +|----------------|-----|-----| +| `SPEC-PRICING-RULES.md` | GAP-MGN-007 | Precios, promociones, descuentos | +| `SPEC-INVENTARIOS-CICLICOS.md` | GAP-MGN-005 | Conteo de productos | +| `SPEC-TRAZABILIDAD-LOTES-SERIES.md` | GAP-MGN-005 | Productos con caducidad | + +### Recomendadas + +| Especificacion | Gap | Uso | +|----------------|-----|-----| +| `SPEC-VALORACION-INVENTARIO.md` | GAP-MGN-005 | Costeo de productos | +| `SPEC-SISTEMA-SECUENCIAS.md` | GAP-MGN-004 | Foliado de tickets | +| `SPEC-REPORTES-FINANCIEROS.md` | GAP-MGN-004 | Cortes de caja | + +--- + +## 4. MODULOS PROPIOS (No heredados) + +| Codigo | Modulo | Descripcion | +|--------|--------|-------------| +| RT-001 | pos | Punto de venta tactil | +| RT-002 | cash_register | Gestion de caja y turnos | +| RT-003 | branches | Multi-sucursal | +| RT-004 | promotions | Ofertas y descuentos | +| RT-005 | loyalty | Tarjetas y puntos | +| RT-006 | barcode | Integracion lectores | +| RT-007 | invoicing | Facturacion CFDI | + +--- + +## 5. SCHEMAS DE BASE DE DATOS + +### Heredados de Core + +```yaml +schemas_core: + - auth + - core_users + - core_rbac + - core_tenants (extendido) + - core_catalogs (extendido) + - core_audit +``` + +### Propios de Retail + +```yaml +schemas_vertical: + - vertical_retail + - pos_sessions + - cash_movements + - cash_closings + - promotions + - loyalty_cards + - loyalty_transactions + - branches +``` + +--- + +## 6. CONSIDERACIONES ESPECIALES + +### Rendimiento POS + +**CRITICO:** El POS debe responder en menos de 100ms. + +- Queries optimizados para ventas +- Cache de productos frecuentes +- Modo offline con sincronizacion + +### Operacion Offline + +El POS debe funcionar sin conexion: +- Base de datos local (SQLite/IndexedDB) +- Sincronizacion al reconectar +- Cola de transacciones pendientes + +### CFDI en Tiempo Real + +- Timbrado al momento de la venta +- Manejo de errores de timbrado +- Reintento automatico + +### Hardware + +- Lectores de codigo de barras +- Impresoras de tickets +- Cajones de dinero +- Terminales de pago + +--- + +## 7. REFERENCIAS + +| Recurso | Ubicacion | +|---------|-----------| +| MASTER_INVENTORY Core | `erp-core/orchestration/inventarios/MASTER_INVENTORY.yml` | +| Specs Transversales | `erp-core/docs/04-modelado/especificaciones-tecnicas/transversal/` | +| HERENCIA-DIRECTIVAS | `./HERENCIA-DIRECTIVAS.md` | + +--- + +*Sistema NEXUS + SIMCO v2.2.0* +*Vertical: Retail/POS (Nivel 2B.2)* +*Ultima actualizacion: 2025-12-08* diff --git a/orchestration/00-guidelines/HERENCIA-SIMCO.md b/orchestration/00-guidelines/HERENCIA-SIMCO.md new file mode 100644 index 0000000..1c0ba06 --- /dev/null +++ b/orchestration/00-guidelines/HERENCIA-SIMCO.md @@ -0,0 +1,122 @@ +# Herencia SIMCO - ERP Retail + +**Sistema:** SIMCO v2.2.0 + CAPVED + CCA Protocol +**Fecha:** 2025-12-08 + +--- + +## Configuración del Proyecto + +| Propiedad | Valor | +|-----------|-------| +| **Proyecto** | ERP Retail - Vertical para Comercios | +| **Nivel** | VERTICAL (Nivel 3) | +| **Padre** | erp-core | +| **Suite** | erp-suite | +| **SIMCO Version** | 2.2.0 | +| **CAPVED** | Habilitado | +| **CCA Protocol** | Habilitado | +| **Estado** | 0% - Futuro | + +## Jerarquía de Herencia + +``` +Nivel 0: core/orchestration/ ← FUENTE PRINCIPAL + └── Nivel 1: erp-suite/orchestration/ + └── Nivel 2: erp-core/orchestration/ ← PADRE DIRECTO + └── Nivel 3: retail/orchestration/ ← ESTE PROYECTO +``` + +--- + +## Directivas Heredadas de CORE (OBLIGATORIAS) + +### Ciclo de Vida +| Alias | Propósito | +|-------|-----------| +| `@TAREA` | Punto de entrada para toda HU | +| `@CAPVED` | Ciclo de 6 fases | +| `@INICIALIZACION` | Bootstrap de agentes | + +### Operaciones Universales +| Alias | Propósito | +|-------|-----------| +| `@CREAR` | Crear archivos nuevos | +| `@MODIFICAR` | Modificar existentes | +| `@VALIDAR` | Validar código | +| `@DOCUMENTAR` | Documentar trabajo | +| `@BUSCAR` | Buscar información | +| `@DELEGAR` | Delegar a subagentes | + +### Principios Fundamentales +| Alias | Resumen | +|-------|---------| +| `@CAPVED` | Toda tarea pasa por 6 fases | +| `@DOC_PRIMERO` | Consultar docs/ antes de implementar | +| `@ANTI_DUP` | Verificar que no existe | +| `@VALIDACION` | Build y lint DEBEN pasar | +| `@TOKENS` | Desglosar tareas grandes | + +--- + +## Directivas por Dominio Técnico + +| Alias | Aplica | Notas | +|-------|--------|-------| +| `@OP_DDL` | **SÍ** | Schemas de comercio | +| `@OP_BACKEND` | **SÍ** | Servicios POS, inventario | +| `@OP_FRONTEND` | **SÍ** | UI de punto de venta | +| `@OP_MOBILE` | **SÍ** | App de ventas | +| `@OP_ML` | Futuro | Predicción de demanda | + +--- + +## Patrones Heredados (OBLIGATORIOS) + +Todos los patrones de `core/orchestration/patrones/` aplican. + +--- + +## Directivas Heredadas de ERP Core + +| Directiva | Extensión Local | +|-----------|-----------------| +| `DIRECTIVA-MULTI-TENANT.md` | Por `comercio_id` | +| `DIRECTIVA-EXTENSION-VERTICALES.md` | Módulos de retail | + +--- + +## Variables de Contexto CCA + +```yaml +PROJECT_NAME: "retail" +PROJECT_LEVEL: "VERTICAL" +PROJECT_ROOT: "./" +PARENT_PROJECT: "erp-core" +SUITE_PROJECT: "erp-suite" + +DB_DDL_PATH: "database/ddl" +BACKEND_ROOT: "backend/src" +FRONTEND_ROOT: "frontend/src" + +TENANT_COLUMN: "comercio_id" +RLS_CONTEXT: "app.current_comercio_id" +``` + +--- + +## Módulos Específicos de Retail (Por definir) + +| Módulo | Descripción | Estado | +|--------|-------------|--------| +| RET-POS | Punto de venta | Por definir | +| RET-INV | Inventario | Por definir | +| RET-PRO | Productos | Por definir | +| RET-CLI | Clientes/fidelización | Por definir | +| RET-REP | Reportes de ventas | Por definir | + +--- + +**Sistema:** SIMCO v2.2.0 + CAPVED + CCA Protocol +**Nivel:** VERTICAL (3) +**Última actualización:** 2025-12-08 diff --git a/orchestration/00-guidelines/HERENCIA-SPECS-CORE.md b/orchestration/00-guidelines/HERENCIA-SPECS-CORE.md new file mode 100644 index 0000000..58041b5 --- /dev/null +++ b/orchestration/00-guidelines/HERENCIA-SPECS-CORE.md @@ -0,0 +1,184 @@ +# Herencia de SPECS del Core - Retail + +**Fecha:** 2025-12-08 +**Versión:** 1.0 +**Vertical:** Retail (RT) +**Nivel:** 2B.2 + +--- + +## Resumen + +| Métrica | Valor | +|---------|-------| +| SPECS Aplicables | 24/30 | +| SPECS Obligatorias | 21 | +| SPECS Opcionales | 3 | +| SPECS No Aplican | 6 | +| Estado Implementación | 0% | + +--- + +## SPECS Obligatorias (Deben Implementarse) + +### P0 - Críticas + +| SPEC | Gap Original | SP | Estado | Módulos Afectados | +|------|-------------|----:|--------|-------------------| +| SPEC-SISTEMA-SECUENCIAS | ir.sequence | 8 | PENDIENTE | RT-001, RT-002, RT-007 | +| SPEC-VALORACION-INVENTARIO | FIFO/AVCO | 21 | PENDIENTE | RT-003 | +| SPEC-SEGURIDAD-API-KEYS-PERMISOS | API Keys + ACL | 31 | PENDIENTE | RT-001 | +| SPEC-REPORTES-FINANCIEROS | Balance/P&L SAT | 13 | PENDIENTE | RT-008, RT-010 | +| SPEC-NOMINA-BASICA | hr_payroll | 21 | PENDIENTE | RT-001 | +| SPEC-GASTOS-EMPLEADOS | hr_expense | 13 | PENDIENTE | RT-001 | +| SPEC-SCHEDULER-REPORTES | ir.cron + mail | 8 | PENDIENTE | RT-008 | + +### P1 - Complementarias + +| SPEC | Gap Original | SP | Estado | Módulos Afectados | +|------|-------------|----:|--------|-------------------| +| SPEC-CONTABILIDAD-ANALITICA | Centros de costo | 21 | PENDIENTE | RT-008 | +| SPEC-CONCILIACION-BANCARIA | Conciliación | 21 | PENDIENTE | RT-007, RT-008 | +| SPEC-TWO-FACTOR-AUTHENTICATION | 2FA | 13 | PENDIENTE | RT-001 | +| SPEC-TRAZABILIDAD-LOTES-SERIES | Lotes/Series | 13 | PENDIENTE | RT-003 | +| SPEC-PRICING-RULES | Reglas precio | 8 | PENDIENTE | RT-006 | +| SPEC-BLANKET-ORDERS | Órdenes marco | 13 | PENDIENTE | RT-004 | +| SPEC-INVENTARIOS-CICLICOS | Conteo cíclico | 13 | PENDIENTE | RT-003 | +| SPEC-IMPUESTOS-AVANZADOS | IVA, ISR | 8 | PENDIENTE | RT-010 | +| SPEC-PLANTILLAS-CUENTAS | Plan contable | 8 | PENDIENTE | RT-008 | +| SPEC-TASAS-CAMBIO-AUTOMATICAS | Tipos cambio | 5 | PENDIENTE | RT-008 | +| SPEC-ALERTAS-PRESUPUESTO | Alertas | 8 | PENDIENTE | RT-008 | +| SPEC-RRHH-EVALUACIONES-SKILLS | Evaluaciones | 26 | PENDIENTE | RT-001 | +| SPEC-LOCALIZACION-PAISES | Localización | 13 | PENDIENTE | RT-001, RT-010 | + +### Patrones Técnicos + +| SPEC | Patrón | SP | Estado | Aplicación | +|------|--------|----:|--------|------------| +| SPEC-MAIL-THREAD-TRACKING | mail.thread | 13 | PENDIENTE | Órdenes, Clientes | +| SPEC-WIZARD-TRANSIENT-MODEL | TransientModel | 8 | PENDIENTE | Wizards de cierre, arqueo | + +--- + +## SPECS Opcionales + +| SPEC | Descripción | SP | Decisión | Razón | +|------|-------------|----:|----------|-------| +| SPEC-PORTAL-PROVEEDORES | Portal RFQ | 13 | EVALUAR | Para compras centralizadas | +| SPEC-TAREAS-RECURRENTES | Recurrencia | 13 | EVALUAR | Para reorden automático | +| SPEC-PRESUPUESTOS-REVISIONES | Aprobación | 8 | DIFERIR | Menos relevante en retail | + +--- + +## SPECS No Aplicables + +| SPEC | Razón | +|------|-------| +| SPEC-INTEGRACION-CALENDAR | No requiere calendario de citas | +| SPEC-PROYECTOS-DEPENDENCIAS-BURNDOWN | No hay proyectos largos | +| SPEC-FIRMA-ELECTRONICA-NOM151 | No aplica para tickets POS | +| SPEC-OAUTH2-SOCIAL-LOGIN | El personal usa login tradicional | +| SPEC-CONSOLIDACION-FINANCIERA | Generalmente una empresa | + +--- + +## Adaptaciones Requeridas + +### Mapeo de Conceptos Core → Retail + +| Concepto Core | Concepto Retail | +|---------------|-----------------| +| `sales.sale_orders` | Tickets POS | +| `inventory.products` | Productos de venta | +| `inventory.locations` | Sucursales | +| `inventory.stock_moves` | Transferencias entre tiendas | +| `core.partners` | Clientes con membresía | +| `financial.payments` | Pagos en caja | + +### Extensiones de Entidad + +```sql +-- Sucursales +stores.branches ( + id UUID, + location_id → inventory.locations, + nombre VARCHAR, + direccion TEXT, + gerente_id → hr.employees, + horario JSONB, + activa BOOLEAN +) + +-- Sesiones de caja +pos.cash_sessions ( + id UUID, + branch_id → branches, + cajero_id → hr.employees, + caja_id → cash_registers, + fecha_apertura TIMESTAMPTZ, + fecha_cierre TIMESTAMPTZ, + saldo_inicial DECIMAL, + saldo_final DECIMAL, + estado ENUM +) + +-- Tickets POS +pos.pos_orders ( + id UUID, + session_id → cash_sessions, + sale_order_id → sales.sale_orders, + numero_ticket VARCHAR, + subtotal DECIMAL, + descuentos DECIMAL, + impuestos DECIMAL, + total DECIMAL +) + +-- Programa de lealtad +pricing.loyalty_programs ( + id UUID, + nombre VARCHAR, + tipo ENUM('puntos', 'cashback', 'descuento'), + reglas JSONB, + activo BOOLEAN +) +``` + +--- + +## Plan de Implementación + +### Fase 1: Fundamentos (SP: 52) +1. SPEC-SISTEMA-SECUENCIAS +2. SPEC-SEGURIDAD-API-KEYS-PERMISOS +3. SPEC-TWO-FACTOR-AUTHENTICATION + +### Fase 2: Inventario (SP: 55) +4. SPEC-VALORACION-INVENTARIO +5. SPEC-TRAZABILIDAD-LOTES-SERIES +6. SPEC-INVENTARIOS-CICLICOS +7. SPEC-PRICING-RULES + +### Fase 3: Operaciones POS (SP: 21) +8. SPEC-MAIL-THREAD-TRACKING +9. SPEC-WIZARD-TRANSIENT-MODEL + +### Fase 4: Financiero (SP: 65) +10. SPEC-REPORTES-FINANCIEROS +11. SPEC-CONTABILIDAD-ANALITICA +12. SPEC-CONCILIACION-BANCARIA +13. SPEC-IMPUESTOS-AVANZADOS + +--- + +## Referencias + +- Documento Core: `erp-core/docs/04-modelado/MAPEO-SPECS-VERTICALES.md` +- SPECS del Core: `erp-core/docs/04-modelado/especificaciones-tecnicas/transversal/` +- Herencia DB: `database/HERENCIA-ERP-CORE.md` +- Directivas: `orchestration/directivas/` + +--- + +**Documento de herencia de SPECS oficial** +**Última actualización:** 2025-12-08 diff --git a/orchestration/00-guidelines/HERENCIA-SPECS-ERP-CORE.md b/orchestration/00-guidelines/HERENCIA-SPECS-ERP-CORE.md new file mode 100644 index 0000000..119d4d6 --- /dev/null +++ b/orchestration/00-guidelines/HERENCIA-SPECS-ERP-CORE.md @@ -0,0 +1,148 @@ +# Herencia de Especificaciones - ERP Core -> Retail + +**Fecha:** 2025-12-08 +**Version:** 1.0 +**Vertical:** Retail +**Nivel:** 2B.2 + +--- + +## RESUMEN + +Este documento define las especificaciones transversales del ERP Core que la vertical de Retail debe heredar e implementar. + +**Ubicacion specs core:** `apps/erp-core/docs/04-modelado/especificaciones-tecnicas/transversal/` + +--- + +## ESPECIFICACIONES A HEREDAR + +### 1. SPEC-PRICING-RULES.md + +**Prioridad:** ALTA +**Relevancia:** Gestion de precios y promociones + +**Funcionalidades heredadas:** +- Listas de precios multiples +- Descuentos escalonados por cantidad +- Promociones temporales +- Precios por segmento de cliente +- Reglas de precio basadas en condiciones + +**Adaptacion para retail:** +- Precios por tienda/zona geografica +- Promociones por temporada +- Descuentos por volumen +- Precios especiales para mayoristas +- Combos y paquetes promocionales + +**Modulos afectados:** +- Punto de Venta +- E-commerce +- Mayoreo +- Promociones + +--- + +### 2. SPEC-INVENTARIOS-CICLICOS.md + +**Prioridad:** ALTA +**Relevancia:** Control de inventario multitienda + +**Funcionalidades heredadas:** +- Conteos ciclicos programados +- Reglas ABC de clasificacion +- Ajustes de inventario +- Reportes de discrepancias + +**Adaptacion para retail:** +- Conteos por tienda/ubicacion +- Clasificacion ABC por rotacion de ventas +- Deteccion de mermas y robos +- Reconciliacion entre tiendas y almacen central +- Conteos de temporada alta + +**Modulos afectados:** +- Inventarios +- Tiendas +- Almacen Central +- Perdidas + +--- + +### 3. SPEC-TRAZABILIDAD-LOTES-SERIES.md + +**Prioridad:** MEDIA +**Relevancia:** Trazabilidad de productos perecederos + +**Funcionalidades heredadas:** +- Gestion de lotes (stock_lots) +- Numeros de serie +- Trazabilidad upstream/downstream +- Fechas de vencimiento + +**Adaptacion para retail:** +- Control de fechas de caducidad +- Lotes de productos frescos +- Trazabilidad para recalls +- FIFO por vencimiento +- Alertas de productos proximos a vencer + +**Modulos afectados:** +- Almacen +- Perecederos +- Control de Calidad +- Mermas + +--- + +## ESPECIFICACIONES ADICIONALES RECOMENDADAS + +| Especificacion | Relevancia | Prioridad | +|----------------|------------|-----------| +| SPEC-VALORACION-INVENTARIO.md | Costeo de mercancia | Alta | +| SPEC-MAIL-THREAD-TRACKING.md | Tracking de pedidos | Media | +| SPEC-WIZARD-TRANSIENT-MODEL.md | Asistentes de reabasto | Media | +| SPEC-REPORTES-FINANCIEROS.md | Estados financieros | Media | + +--- + +## MATRIZ DE HERENCIA + +| Spec Core | Modulos Retail | Prioridad | Estado | +|-----------|----------------|-----------|--------| +| SPEC-PRICING-RULES | POS, E-commerce, Mayoreo | ALTA | Pendiente | +| SPEC-INVENTARIOS-CICLICOS | Inventarios, Tiendas | ALTA | Pendiente | +| SPEC-TRAZABILIDAD-LOTES-SERIES | Almacen, Perecederos | MEDIA | Pendiente | + +--- + +## IMPLEMENTACION + +### Orden Sugerido + +1. **Fase 1 - Precios** + - SPEC-PRICING-RULES (critico para operacion) + +2. **Fase 2 - Inventario** + - SPEC-INVENTARIOS-CICLICOS (control de mermas) + - SPEC-TRAZABILIDAD-LOTES-SERIES (perecederos) + +### Consideraciones Especificas + +- Retail requiere precios dinamicos y promociones frecuentes +- El control de inventario multitienda es complejo +- Los perecederos requieren trazabilidad estricta por fechas + +--- + +## REFERENCIAS + +- Specs Core: `apps/erp-core/docs/04-modelado/especificaciones-tecnicas/transversal/` +- Gap Analysis: `apps/erp-core/orchestration/01-analisis/ANALISIS-GAPS-CONSOLIDADO.md` + +--- + +**Documento generado por:** Requirements-Analyst +**Fecha:** 2025-12-08 +**Version:** 1.0 diff --git a/orchestration/00-guidelines/PROJECT-STATUS.md b/orchestration/00-guidelines/PROJECT-STATUS.md new file mode 100644 index 0000000..b36ece3 --- /dev/null +++ b/orchestration/00-guidelines/PROJECT-STATUS.md @@ -0,0 +1,33 @@ +# PROJECT STATUS: erp-retail + +**Ultima actualizacion:** 2026-01-04 +**Estado general:** Activo + +--- + +## Metricas Rapidas + +| Metrica | Valor | +|---------|-------| +| Archivos docs/ | 23 | +| Archivos orchestration/ | 48 | +| Estado SIMCO | Adaptado | + +## Migracion EPIC-008 + +- [x] Migracion desde workspace-v1-bckp (EPIC-004/005) +- [x] Adaptacion SIMCO (EPIC-008) +- [x] docs/_MAP.md creado +- [x] PROJECT-STATUS.md creado +- [x] HERENCIA-SIMCO.md verificado +- [x] CONTEXTO-PROYECTO.md verificado + +## Historial de Cambios + +| Fecha | Cambio | EPIC | +|-------|--------|------| +| 2026-01-04 | Adaptacion SIMCO completada | EPIC-008 | + +--- + +**Generado por:** EPIC-008 adapt-simco.sh diff --git a/orchestration/PROXIMA-ACCION.md b/orchestration/PROXIMA-ACCION.md new file mode 100644 index 0000000..f9635d5 --- /dev/null +++ b/orchestration/PROXIMA-ACCION.md @@ -0,0 +1,105 @@ +# Próxima Acción - ERP Retail / POS + +## Estado Actual +**Fecha:** Diciembre 2025 +**Progreso:** 20% (Planificación completa) + +--- + +## Documentación Disponible + +### Módulos Definidos (10 módulos - 322 SP) + +| Módulo | Nombre | SP | Estado | +|--------|--------|---:|--------| +| RT-001 | Fundamentos | 0 | PLANIFICADO | +| RT-002 | POS | 55 | PLANIFICADO | +| RT-003 | Inventario | 34 | PLANIFICADO | +| RT-004 | Compras | 21 | PLANIFICADO | +| RT-005 | Clientes | 34 | PLANIFICADO | +| RT-006 | Precios | 42 | PLANIFICADO | +| RT-007 | Caja | 34 | PLANIFICADO | +| RT-008 | Reportes | 34 | PLANIFICADO | +| RT-009 | E-commerce | 47 | PLANIFICADO | +| RT-010 | Facturación | 21 | PLANIFICADO | + +### Documentos de Referencia +- Visión: `docs/00-vision-general/VISION-RETAIL.md` +- Módulos: `docs/02-definicion-modulos/INDICE-MODULOS.md` +- Herencia SPECS: `orchestration/00-guidelines/HERENCIA-SPECS-CORE.md` +- Inventario: `orchestration/inventarios/MASTER_INVENTORY.yml` + +--- + +## Prerrequisitos + +Este proyecto requiere que **erp-core** esté completado primero: +- [ ] Módulo auth de erp-core +- [ ] Módulo users de erp-core +- [ ] Módulo tenants de erp-core +- [ ] Módulo inventory base de erp-core +- [ ] Módulo sales base de erp-core +- [ ] CFDI de erp-core + +--- + +## Tarea Prioritaria (Cuando esté listo) + +### 1. Crear DDL del Schema POS + +**Objetivo:** Definir estructura de base de datos para terminal punto de venta. + +**Tablas a crear:** +- `pos.pos_sessions` +- `pos.pos_orders` +- `pos.pos_order_lines` +- `pos.cash_registers` +- `pos.cash_movements` +- `pos.cash_closings` + +**Archivo destino:** `database/ddl/01-pos-schema.sql` + +### 2. Implementar SPEC-PRICING-RULES + +**Objetivo:** Motor de precios y promociones. + +**Referencia:** Ver `erp-core/docs/04-modelado/especificaciones-tecnicas/transversal/SPEC-PRICING-RULES.md` + +--- + +## Consideraciones Especiales + +1. **Modo Offline:** RT-002 (POS) requiere funcionamiento offline-first +2. **Hardware:** Integración con impresoras térmicas y scanners +3. **Multi-sucursal:** Inventarios independientes por sucursal +4. **CFDI 4.0:** Con complementos de pago + +--- + +## Ambiente de Desarrollo + +Según `DEVENV-PORTS.md`: + +```yaml +proyecto: retail +rango_base: 3400 +puertos: + backend: 3400 + frontend: 5177 + database: 5436 + redis: 6383 +``` + +--- + +## Próximos Pasos + +1. [ ] Esperar completitud de erp-core (auth, users, tenants, inventory) +2. [ ] Crear DDL schema POS +3. [ ] Crear DDL schema loyalty +4. [ ] Iniciar backend RT-001 (heredar de core) +5. [ ] Implementar RT-002 (POS - crítico) + +--- + +**Última actualización:** 2025-12-08 diff --git a/orchestration/directivas/DIRECTIVA-INVENTARIO-SUCURSALES.md b/orchestration/directivas/DIRECTIVA-INVENTARIO-SUCURSALES.md new file mode 100644 index 0000000..07c11ae --- /dev/null +++ b/orchestration/directivas/DIRECTIVA-INVENTARIO-SUCURSALES.md @@ -0,0 +1,213 @@ +# DIRECTIVA-INVENTARIO-SUCURSALES + +**Version:** 1.0 +**Fecha:** 2025-12-08 +**Vertical:** Retail / POS +**Nivel:** 2B.2 + +--- + +## PROPOSITO + +Define las directrices para la gestion de inventario multi-sucursal en retail. + +--- + +## ALCANCE + +- Control de stock por sucursal +- Transferencias entre sucursales +- Reabastecimiento centralizado +- Conteos ciclicos + +--- + +## PRINCIPIOS + +### 1. Visibilidad en Tiempo Real + +- Stock actualizado inmediatamente con cada venta +- Dashboard centralizado de todas las sucursales +- Alertas de stock bajo automaticas + +### 2. Control Centralizado + +- Politicas de inventario desde central +- Transferencias autorizadas +- Precios unificados + +### 3. Autonomia Operativa + +- Cada sucursal opera independiente +- Conteos locales permitidos +- Ajustes menores autorizados + +--- + +## MODELO DE DATOS + +### branches (sucursales) +```yaml +campos: + - id: uuid + - code: string (ej: SUC001) + - name: string + - address: text + - manager_id: FK -> auth.users + - location_id: FK -> inventory.locations + - status: enum(active, inactive) + - is_warehouse: boolean +``` + +### branch_stock +```yaml +campos: + - id: uuid + - branch_id: FK -> branches + - product_id: FK -> retail.products + - quantity_available: decimal + - quantity_reserved: decimal + - minimum_stock: decimal + - reorder_point: decimal + - last_count_date: date +``` + +### stock_transfers +```yaml +campos: + - id: uuid + - transfer_number: string + - origin_branch_id: FK -> branches + - destination_branch_id: FK -> branches + - status: enum(draft, requested, approved, in_transit, received, cancelled) + - requested_by: FK -> auth.users + - approved_by: FK -> auth.users + - requested_at: timestamp + - shipped_at: timestamp + - received_at: timestamp +``` + +--- + +## FLUJO DE TRANSFERENCIA + +``` +1. Sucursal A solicita productos + | +2. Central aprueba transferencia + | +3. Almacen prepara envio + | +4. Registro de salida (origen) + | +5. Transito + | +6. Sucursal B recibe productos + | +7. Registro de entrada (destino) +``` + +### Estados de Transferencia + +``` +draft --> requested --> approved --> in_transit --> received + | | | + v v v + cancelled rejected lost +``` + +--- + +## REABASTECIMIENTO + +### Tipos de Reabastecimiento + +| Tipo | Trigger | Proceso | +|------|---------|---------| +| Automatico | Stock < reorder_point | Sistema genera solicitud | +| Manual | Solicitud de sucursal | Manager aprueba | +| Push | Decision central | Central envia sin solicitud | + +### Algoritmo de Reabastecimiento + +```python +# Pseudocodigo +for product in products_below_reorder: + needed = max_stock - current_stock + available_in_warehouse = get_warehouse_stock(product) + + if available_in_warehouse >= needed: + create_transfer(warehouse, branch, product, needed) + else: + create_purchase_order(product, needed - available_in_warehouse) + create_transfer(warehouse, branch, product, available_in_warehouse) +``` + +--- + +## CONTEOS CICLICOS + +### Frecuencia por Clasificacion ABC + +| Clasificacion | Frecuencia | Productos | +|---------------|------------|-----------| +| A | Semanal | Alta rotacion/valor | +| B | Quincenal | Rotacion media | +| C | Mensual | Baja rotacion | + +### Proceso de Conteo + +``` +1. Sistema genera lista de conteo + | +2. Encargado realiza conteo fisico + | +3. Registra cantidades en sistema + | +4. Sistema calcula diferencias + | +5. Aprobacion de ajustes + | +6. Actualizacion de stock +``` + +--- + +## INTEGRACION CON CORE + +### Herencia de Specs + +| Spec Core | Aplicacion | +|-----------|------------| +| SPEC-INVENTARIOS-CICLICOS | Conteos por sucursal | +| SPEC-VALORACION-INVENTARIO | Costeo centralizado | +| SPEC-TRAZABILIDAD-LOTES-SERIES | Caducidades | + +### Mapeo con Core + +- `branches` extiende `inventory.locations` +- `branch_stock` extiende `inventory.quants` +- `stock_transfers` usa `inventory.stock_moves` + +--- + +## REPORTES + +| Reporte | Frecuencia | Destinatario | +|---------|------------|--------------| +| Stock por sucursal | Diario | Managers | +| Movimientos | Semanal | Central | +| Diferencias | Por conteo | Auditoria | +| Rotacion | Mensual | Compras | + +--- + +## REFERENCIAS + +- SPEC-INVENTARIOS-CICLICOS.md (core) +- SPEC-VALORACION-INVENTARIO.md (core) +- DIRECTIVA-PUNTO-VENTA.md + +--- + +**Documento de directiva oficial** diff --git a/orchestration/directivas/DIRECTIVA-PUNTO-VENTA.md b/orchestration/directivas/DIRECTIVA-PUNTO-VENTA.md new file mode 100644 index 0000000..951875c --- /dev/null +++ b/orchestration/directivas/DIRECTIVA-PUNTO-VENTA.md @@ -0,0 +1,256 @@ +# DIRECTIVA-PUNTO-VENTA + +**Version:** 1.0 +**Fecha:** 2025-12-08 +**Vertical:** Retail / POS +**Nivel:** 2B.2 + +--- + +## PROPOSITO + +Define las directrices para la implementacion del sistema de punto de venta (POS). + +--- + +## ALCANCE + +- Terminal de venta +- Sesiones de caja +- Procesamiento de pagos +- Cortes y arqueos +- Operacion offline + +--- + +## PRINCIPIOS + +### 1. Rendimiento Critico + +El POS debe responder en menos de 100ms por transaccion: +- Busqueda de productos: < 50ms +- Calculo de totales: < 10ms +- Registro de venta: < 100ms + +### 2. Disponibilidad + +- El sistema debe funcionar offline +- Sincronizacion automatica cuando hay conexion +- No perder transacciones nunca + +### 3. Seguridad en Efectivo + +- Control estricto de sesiones de caja +- Movimientos de efectivo auditados +- Cortes obligatorios al cerrar + +--- + +## FLUJO DE OPERACION + +### Apertura de Caja + +``` +1. Cajero inicia sesion + | +2. Verifica caja asignada + | +3. Registra fondo inicial + | +4. Sesion activa +``` + +### Venta + +``` +1. Escaneo/busqueda de producto + | +2. Agregar al carrito + | +3. Aplicar descuentos/promociones + | +4. Calcular total + | +5. Seleccionar metodo de pago + | +6. Procesar pago + | +7. Imprimir ticket + | +8. Actualizar inventario +``` + +### Cierre de Caja + +``` +1. Iniciar corte + | +2. Declarar efectivo fisico + | +3. Sistema calcula esperado + | +4. Registrar diferencias + | +5. Cerrar sesion +``` + +--- + +## MODELO DE DATOS + +### pos_sessions +```yaml +campos: + - id: uuid + - user_id: FK -> auth.users (cajero) + - cash_register_id: FK -> cash_registers + - opening_balance: decimal + - closing_balance: decimal + - expected_balance: decimal + - difference: decimal + - status: enum(open, closing, closed) + - opened_at: timestamp + - closed_at: timestamp +``` + +### pos_orders +```yaml +campos: + - id: uuid + - session_id: FK -> pos_sessions + - customer_id: FK -> retail.customers (opcional) + - order_number: string + - subtotal: decimal + - discount: decimal + - tax: decimal + - total: decimal + - status: enum(draft, paid, refunded, cancelled) + - payment_method: enum(cash, card, mixed) + - created_at: timestamp +``` + +### cash_movements +```yaml +campos: + - id: uuid + - session_id: FK -> pos_sessions + - movement_type: enum(sale, refund, cash_in, cash_out) + - amount: decimal + - payment_method: enum(cash, card) + - reference: string + - notes: text + - created_at: timestamp +``` + +--- + +## OPERACION OFFLINE + +### Datos en Cache Local + +- Catalogo de productos +- Precios vigentes +- Promociones activas +- Clientes frecuentes + +### Sincronizacion + +``` +[Terminal POS] <---> [Cola Local] <---> [Servidor Central] + | | | + Operacion Buffer Sincronizador + | | | + Cache IndexedDB PostgreSQL +``` + +### Conflictos + +- El servidor tiene la version autoritativa +- Las ventas offline siempre se registran +- Los ajustes de inventario se reconcilian + +--- + +## INTEGRACION CON HARDWARE + +### Dispositivos Soportados + +| Dispositivo | Protocolo | Notas | +|-------------|-----------|-------| +| Impresora tickets | ESC/POS | USB o Red | +| Lector codigo barras | HID | USB | +| Cajon de dinero | Pulso via impresora | - | +| Terminal bancaria | ISO 8583 | Integracion PAC | + +### Arquitectura + +``` +[App POS] + | +[Driver Layer] + | + +---+---+---+ + | | | | +Print Scan Drawer Card +``` + +--- + +## FACTURACION CFDI + +### Flujo de Facturacion + +1. Cliente solicita factura +2. Sistema recupera datos de venta +3. Genera XML CFDI 4.0 +4. Envia a PAC para timbrado +5. Almacena UUID +6. Envia por email + +### Consideraciones + +- Facturacion global para tickets sin factura +- Complementos de pago para credito +- Cancelaciones dentro de plazo legal + +--- + +## INTEGRACION CON ERP CORE + +### Herencia de Specs + +| Spec Core | Aplicacion | +|-----------|------------| +| SPEC-PRICING-RULES | Precios y promociones | +| SPEC-INVENTARIOS-CICLICOS | Conteos de tienda | +| SPEC-TRAZABILIDAD-LOTES-SERIES | Productos con caducidad | + +### APIs a Extender + +- `ProductService` -> `RetailProductService` +- `InventoryService` -> Multi-sucursal +- `InvoiceService` -> CFDI automatico + +--- + +## METRICAS + +| Metrica | Objetivo | Alerta | +|---------|----------|--------| +| Tiempo de venta | < 30 seg | > 60 seg | +| Disponibilidad | 99.9% | < 99% | +| Diferencias de caja | 0 | > $100 | +| Ventas sin factura | < 10% | > 20% | + +--- + +## REFERENCIAS + +- HERENCIA-SPECS-ERP-CORE.md +- DATABASE_INVENTORY.yml +- MASTER_INVENTORY.yml +- CFDI 4.0 SAT + +--- + +**Documento de directiva oficial** diff --git a/orchestration/environment/PROJECT-ENV-CONFIG.yml b/orchestration/environment/PROJECT-ENV-CONFIG.yml new file mode 100644 index 0000000..825382e --- /dev/null +++ b/orchestration/environment/PROJECT-ENV-CONFIG.yml @@ -0,0 +1,206 @@ +# ============================================================================= +# PROJECT-ENV-CONFIG.yml - ERP RETAIL / POS +# ============================================================================= +# Vertical de ERP-Suite especializada en Comercio Minorista y Punto de Venta +# Actualizado: 2025-12-08 +# Referencia: ~/workspace/core/devtools/environment/DEVENV-PORTS.md +# ============================================================================= + +project: + name: "ERP-RETAIL" + code: "RT" + description: "Sistema de Punto de Venta y Comercio Minorista" + type: "vertical" + level: "2B.2" + status: "planning" + parent: "erp-suite" + + paths: + root: "/home/isem/workspace/projects/erp-suite/apps/verticales/retail" + backend: "backend/" + frontend: "frontend/" + database: "database/" + docs: "docs/" + orchestration: "orchestration/" + +# ============================================================================= +# PUERTOS (Según DEVENV-PORTS.md) +# ============================================================================= +ports: + backend: 3400 + frontend: 5177 + database: 5436 + redis: 6383 + +# ============================================================================= +# BASE DE DATOS +# ============================================================================= +database: + type: "postgresql" + host: "localhost" + port: 5436 + name: "retail_db" + user: "retail_user" + + schemas: + core_inherited: 12 # Schemas heredados de erp-core + vertical_specific: + - pos # Sesiones, órdenes, pagos, cierres + - loyalty # Tarjetas, puntos, recompensas + - pricing # Listas, promociones, cupones + - ecommerce # Pedidos online, carritos + + migration: + tool: "typeorm" + directory: "database/migrations/" + +# ============================================================================= +# STACK TECNOLOGICO +# ============================================================================= +stack: + runtime: "Node.js 20+" + language: "TypeScript 5.3+" + backend: + framework: "Express.js" + orm: "TypeORM 0.3.17" + frontend: + framework: "React 18" + build: "Vite" + ui: "Tailwind CSS + shadcn/ui" + pwa: + enabled: true + offline_support: true + +# ============================================================================= +# HERENCIA DEL CORE +# ============================================================================= +core_inheritance: + version: "0.6.0" + tables_inherited: 97 + modules_inherited: + - auth + - users + - roles + - tenants + - inventory + - sales + - cfdi + + specs_applicable: 5 + specs_implemented: 0 + specs_detail: + - SPEC-PRICING-RULES + - SPEC-INVENTARIOS-CICLICOS + - SPEC-TRAZABILIDAD-LOTES-SERIES + - SPEC-FACTURACION-CFDI + - SPEC-VALORACION-INVENTARIO + +# ============================================================================= +# MODULOS DE LA VERTICAL +# ============================================================================= +modules: + total: 10 + story_points: 322 + list: + - code: RT-001 + name: Fundamentos + sp: 0 + priority: P0 + status: pending + notes: "100% heredado del core" + - code: RT-002 + name: POS Terminal + sp: 55 + priority: P0 + status: pending + notes: "OFFLINE-FIRST critical" + - code: RT-003 + name: Inventario Multi-Sucursal + sp: 42 + priority: P0 + status: pending + - code: RT-004 + name: Compras + sp: 34 + priority: P0 + status: pending + - code: RT-005 + name: Clientes/CRM + sp: 34 + priority: P0 + status: pending + - code: RT-006 + name: Precios/Promociones + sp: 42 + priority: P0 + status: pending + - code: RT-007 + name: Caja + sp: 34 + priority: P0 + status: pending + - code: RT-008 + name: Reportes + sp: 21 + priority: P1 + status: pending + - code: RT-009 + name: E-commerce + sp: 39 + priority: P2 + status: pending + - code: RT-010 + name: Facturación CFDI + sp: 21 + priority: P0 + status: pending + +# ============================================================================= +# CONSIDERACIONES ESPECIALES +# ============================================================================= +special_requirements: + offline_first: + enabled: true + sync_strategy: "queue-based" + local_storage: "IndexedDB" + conflict_resolution: "server-wins" + + hardware_integration: + thermal_printers: + protocol: "ESC/POS" + connection: ["USB", "Bluetooth", "Network"] + barcode_scanners: + connection: ["USB", "Bluetooth"] + cash_drawers: + trigger: "automatic" + +# ============================================================================= +# ARCHIVOS DE ENTORNO +# ============================================================================= +env_files: + template: "orchestration/environment/.env.example" + backend: "backend/.env" + frontend: "frontend/.env" + +env_variables: + required: + - NODE_ENV + - PORT + - DATABASE_URL + - JWT_SECRET + - REDIS_URL + optional: + - LOG_LEVEL + - CORS_ORIGIN + - OFFLINE_SYNC_INTERVAL + +# ============================================================================= +# NOTAS +# ============================================================================= +notes: | + - Vertical especializada en retail y punto de venta + - CRITICO: POS debe funcionar OFFLINE-FIRST + - Sincronización cuando recupere conexión + - Soporte para impresoras térmicas ESC/POS + - Integración con scanners de código de barras + - Puertos asignados según DEVENV-PORTS.md (rango 3400) diff --git a/orchestration/inventarios/BACKEND_INVENTORY.yml b/orchestration/inventarios/BACKEND_INVENTORY.yml new file mode 100644 index 0000000..3484b82 --- /dev/null +++ b/orchestration/inventarios/BACKEND_INVENTORY.yml @@ -0,0 +1,98 @@ +# BACKEND INVENTORY - ERP Retail/POS (Vertical) +# Generado: 2025-12-08 +# Sistema: NEXUS + SIMCO v2.2.0 + +proyecto: + nombre: ERP Retail / Punto de Venta + codigo: retail + nivel: 2B.2 (Vertical) + estado: Planificacion + +herencia_core: + backend: erp-core + servicios_heredados: 45+ + referencia: "apps/erp-core/backend/" + +servicios_planificados: + pos: + - nombre: POSSessionService + modulo: RT-001 + prioridad: P0 + endpoints: + - POST /api/v1/retail/pos/sessions/open + - POST /api/v1/retail/pos/sessions/close + - GET /api/v1/retail/pos/sessions/current + + - nombre: POSOrderService + modulo: RT-001 + prioridad: P0 + rendimiento: "<100ms" + endpoints: + - POST /api/v1/retail/pos/orders + - GET /api/v1/retail/pos/orders/:id + - POST /api/v1/retail/pos/orders/:id/pay + + - nombre: CashRegisterService + modulo: RT-001 + prioridad: P0 + endpoints: + - GET /api/v1/retail/cash-registers + - POST /api/v1/retail/cash-registers/:id/movements + - POST /api/v1/retail/cash-registers/:id/close + + inventario: + - nombre: BranchService + modulo: RT-002 + prioridad: P0 + endpoints: + - GET /api/v1/retail/branches + - GET /api/v1/retail/branches/:id/stock + + - nombre: StockTransferService + modulo: RT-002 + prioridad: P1 + endpoints: + - POST /api/v1/retail/stock-transfers + - GET /api/v1/retail/stock-transfers + + productos: + - nombre: RetailProductService + modulo: RT-003 + prioridad: P0 + endpoints: + - GET /api/v1/retail/products + - GET /api/v1/retail/products/barcode/:code + - GET /api/v1/retail/products/:id/price + + - nombre: PromotionService + modulo: RT-003 + prioridad: P1 + endpoints: + - GET /api/v1/retail/promotions/active + - POST /api/v1/retail/promotions + + clientes: + - nombre: RetailCustomerService + modulo: RT-004 + prioridad: P1 + endpoints: + - GET /api/v1/retail/customers + - GET /api/v1/retail/customers/:id/loyalty + + facturacion: + - nombre: RetailInvoiceService + modulo: RT-007 + prioridad: P0 + endpoints: + - POST /api/v1/retail/invoices + - POST /api/v1/retail/invoices/:id/stamp-cfdi + +resumen: + servicios_heredados: 45+ + servicios_planificados: 9 + endpoints_planificados: 22 + estado_general: PLANIFICACION + ultima_actualizacion: 2025-12-08 + +referencias: + herencia_core: "../00-guidelines/HERENCIA-ERP-CORE.md" diff --git a/orchestration/inventarios/DATABASE_INVENTORY.yml b/orchestration/inventarios/DATABASE_INVENTORY.yml new file mode 100644 index 0000000..b398017 --- /dev/null +++ b/orchestration/inventarios/DATABASE_INVENTORY.yml @@ -0,0 +1,170 @@ +# DATABASE INVENTORY - ERP Retail/POS (Vertical) +# Generado: 2025-12-08 +# Sistema: NEXUS + SIMCO v2.2.0 + +proyecto: + nombre: ERP Retail / Punto de Venta + codigo: retail + nivel: 2B.2 (Vertical) + estado: Planificacion + +herencia_core: + base_de_datos: erp-core + version_core: "1.2.0" + tablas_heredadas: 144 # Actualizado 2025-12-09 según conteo real DDL + schemas_heredados: + - nombre: auth + tablas: 26 # Autenticación, MFA, OAuth, API Keys + - nombre: core + tablas: 12 # Partners (clientes), catálogos, UoM + - nombre: financial + tablas: 15 # Contabilidad, facturas, pagos + - nombre: inventory + tablas: 20 # Productos, stock, valoración + - nombre: purchase + tablas: 8 # Compras, proveedores + - nombre: sales + tablas: 10 # Ventas, cotizaciones + - nombre: projects + tablas: 10 # Proyectos (opcional) + - nombre: analytics + tablas: 7 # Centros de costo por tienda + - nombre: system + tablas: 13 # Mensajes, notificaciones, logs + - nombre: billing + tablas: 11 # SaaS (opcional) + - nombre: crm + tablas: 6 # Leads, fidelización (opcional) + - nombre: hr + tablas: 6 # Empleados, turnos + referencia_ddl: "apps/erp-core/database/ddl/" + documento_herencia: "../database/HERENCIA-ERP-CORE.md" + variable_rls: "app.current_tenant_id" + +schemas_especificos: + - nombre: retail + descripcion: Schema para operaciones de punto de venta + estado: PLANIFICADO + modulos_relacionados: [RT-001, RT-002, RT-003, RT-004, RT-005, RT-006, RT-007] + +tablas_planificadas: + pos: + - nombre: retail.pos_sessions + descripcion: Sesiones de punto de venta + modulo: RT-001 + prioridad: P0 + + - nombre: retail.pos_orders + descripcion: Ventas en punto de venta + modulo: RT-001 + prioridad: P0 + + - nombre: retail.pos_order_lines + descripcion: Lineas de venta + modulo: RT-001 + prioridad: P0 + + - nombre: retail.cash_registers + descripcion: Cajas registradoras + modulo: RT-001 + prioridad: P0 + + - nombre: retail.cash_movements + descripcion: Movimientos de efectivo + modulo: RT-001 + prioridad: P0 + + - nombre: retail.cash_closings + descripcion: Cortes de caja + modulo: RT-001 + prioridad: P0 + + inventario: + - nombre: retail.branches + descripcion: Sucursales + modulo: RT-002 + prioridad: P0 + + - nombre: retail.branch_stock + descripcion: Stock por sucursal + modulo: RT-002 + prioridad: P0 + + - nombre: retail.stock_transfers + descripcion: Transferencias entre sucursales + modulo: RT-002 + prioridad: P1 + + productos: + - nombre: retail.products + descripcion: Productos de venta + modulo: RT-003 + prioridad: P0 + extiende: inventory.products + + - nombre: retail.product_barcodes + descripcion: Codigos de barras + modulo: RT-003 + prioridad: P0 + + - nombre: retail.promotions + descripcion: Promociones y descuentos + modulo: RT-003 + prioridad: P1 + + clientes: + - nombre: retail.customers + descripcion: Clientes + modulo: RT-004 + prioridad: P1 + + - nombre: retail.loyalty_cards + descripcion: Tarjetas de fidelizacion + modulo: RT-004 + prioridad: P2 + + - nombre: retail.loyalty_transactions + descripcion: Transacciones de puntos + modulo: RT-004 + prioridad: P2 + + proveedores: + - nombre: retail.suppliers + descripcion: Proveedores + modulo: RT-005 + prioridad: P1 + + - nombre: retail.purchase_orders + descripcion: Ordenes de compra + modulo: RT-005 + prioridad: P1 + + facturacion: + - nombre: retail.invoices + descripcion: Facturas CFDI + modulo: RT-007 + prioridad: P0 + +specs_core_requeridas: + - spec: SPEC-PRICING-RULES.md + aplicacion: Precios, promociones, descuentos + - spec: SPEC-INVENTARIOS-CICLICOS.md + aplicacion: Conteo de productos + - spec: SPEC-TRAZABILIDAD-LOTES-SERIES.md + aplicacion: Productos con caducidad + +consideraciones_especiales: + - Operacion offline del POS (sincronizacion posterior) + - Rendimiento critico (<100ms por transaccion) + - Integracion con hardware (impresoras, lectores) + - CFDI 4.0 en tiempo real + +resumen: + tablas_heredadas: 140+ + tablas_especificas_planificadas: 18 + schemas_especificos: 1 + estado_general: PLANIFICACION + ultima_actualizacion: 2025-12-08 + +referencias: + herencia_core: "../00-guidelines/HERENCIA-ERP-CORE.md" diff --git a/orchestration/inventarios/DEPENDENCY_GRAPH.yml b/orchestration/inventarios/DEPENDENCY_GRAPH.yml new file mode 100644 index 0000000..f128da8 --- /dev/null +++ b/orchestration/inventarios/DEPENDENCY_GRAPH.yml @@ -0,0 +1,61 @@ +# DEPENDENCY GRAPH - ERP Retail/POS (Vertical) +# Generado: 2025-12-08 +# Sistema: NEXUS + SIMCO v2.2.0 + +proyecto: + nombre: ERP Retail / Punto de Venta + nivel: 2B.2 (Vertical) + +modulos_verticales: + RT-001_pos: + depende_de: + - RT-002_inventario + - RT-003_productos + core: [auth, users, tenants, audit] + critico: true + + RT-002_inventario: + depende_de: [] + core: [auth, tenants, inventory] + + RT-003_productos: + depende_de: [] + core: [auth, catalogs, inventory] + + RT-004_clientes: + depende_de: [] + core: [auth, tenants] + + RT-005_proveedores: + depende_de: [] + core: [auth, purchase] + + RT-006_reportes: + depende_de: + - RT-001_pos + - RT-002_inventario + core: [auth, reports] + + RT-007_facturacion: + depende_de: + - RT-001_pos + core: [auth, financial] + +modulos_core_heredados: + - MGN-001_auth (100%) + - MGN-002_users (100%) + - MGN-003_roles (100%) + - MGN-004_tenants (extendido para sucursales) + - MGN-005_catalogs (extendido) + - MGN-011_inventory (extendido multi-sucursal) + +orden_implementacion: + fase_1_base: [RT-002, RT-003, RT-004, RT-005] + fase_2_pos: [RT-001] + fase_3_comercial: [RT-006, RT-007] + +resumen: + total_modulos: 7 + dependencias_internas: 5 + estado: PLANIFICACION + ultima_actualizacion: 2025-12-08 diff --git a/orchestration/inventarios/FRONTEND_INVENTORY.yml b/orchestration/inventarios/FRONTEND_INVENTORY.yml new file mode 100644 index 0000000..1f34dad --- /dev/null +++ b/orchestration/inventarios/FRONTEND_INVENTORY.yml @@ -0,0 +1,92 @@ +# FRONTEND INVENTORY - ERP Retail/POS (Vertical) +# Generado: 2025-12-08 +# Sistema: NEXUS + SIMCO v2.2.0 + +proyecto: + nombre: ERP Retail / Punto de Venta + codigo: retail + nivel: 2B.2 (Vertical) + estado: Planificacion + +herencia_core: + frontend: erp-core + componentes_heredados: 80+ + referencia: "apps/erp-core/frontend/" + +componentes_planificados: + pos: + - nombre: POSTerminal + descripcion: Terminal principal de venta + modulo: RT-001 + prioridad: P0 + optimizado: true + + - nombre: ProductSearch + descripcion: Busqueda rapida de productos + modulo: RT-001 + prioridad: P0 + + - nombre: BarcodeScanner + descripcion: Integrador de lector de codigos + modulo: RT-001 + prioridad: P0 + + - nombre: PaymentModal + descripcion: Modal de pago + modulo: RT-001 + prioridad: P0 + + - nombre: CashDrawer + descripcion: Control de cajon de dinero + modulo: RT-001 + prioridad: P0 + + - nombre: ReceiptPrinter + descripcion: Integrador de impresora + modulo: RT-001 + prioridad: P0 + + - nombre: CashClosingForm + descripcion: Formulario de corte de caja + modulo: RT-001 + prioridad: P0 + + inventario: + - nombre: BranchSelector + modulo: RT-002 + + - nombre: StockDashboard + modulo: RT-002 + + - nombre: TransferForm + modulo: RT-002 + + productos: + - nombre: ProductCatalog + modulo: RT-003 + + - nombre: PromotionManager + modulo: RT-003 + + clientes: + - nombre: CustomerLookup + modulo: RT-004 + + - nombre: LoyaltyCard + modulo: RT-004 + + reportes: + - nombre: SalesReport + modulo: RT-006 + + - nombre: InventoryReport + modulo: RT-006 + +resumen: + componentes_heredados: 80+ + componentes_planificados: 16 + estado_general: PLANIFICACION + ultima_actualizacion: 2025-12-08 + +referencias: + herencia_core: "../00-guidelines/HERENCIA-ERP-CORE.md" diff --git a/orchestration/inventarios/MASTER_INVENTORY.yml b/orchestration/inventarios/MASTER_INVENTORY.yml new file mode 100644 index 0000000..869049f --- /dev/null +++ b/orchestration/inventarios/MASTER_INVENTORY.yml @@ -0,0 +1,194 @@ +# MASTER INVENTORY - ERP Retail/POS (Vertical) +# Generado: 2025-12-08 +# Sistema: NEXUS + SIMCO v2.2.0 + +proyecto: + nombre: ERP Retail / Punto de Venta + codigo: RT + nivel: 2B.2 (Vertical) + estado: EPICAS_COMPLETAS + version: 0.3.0 + path: /home/isem/workspace/projects/erp-suite/apps/verticales/retail + herencia: + core_version: "0.6.0" + tablas_heredadas: 144 + schemas_heredados: 12 + specs_aplicables: 26 + specs_implementadas: 0 + +resumen_general: + total_modulos: 10 + total_schemas_planificados: 1 + total_tablas_planificadas: 16 + total_tablas_implementadas: 16 + total_servicios_backend: 0 + total_componentes_frontend: 0 + story_points_estimados: 353 + test_coverage: N/A + ultima_actualizacion: 2025-12-09 + +modulos: + total: 10 + lista: + - codigo: RT-001 + nombre: Fundamentos + descripcion: Auth, Users, Tenants (hereda 100% core) + herencia: 100% + prioridad: P0 + estado: PLANIFICADO + sp: 0 + + - codigo: RT-002 + nombre: POS + descripcion: Terminal punto de venta + herencia: 0% + prioridad: P0 + estado: EPICA_COMPLETA + sp: 55 + epica: docs/08-epicas/EPIC-RT-002-pos.md + + - codigo: RT-003 + nombre: Inventario + descripcion: Stock multi-sucursal + herencia: 70% + prioridad: P0 + estado: EPICA_COMPLETA + sp: 42 + epica: docs/08-epicas/EPIC-RT-003-inventario.md + + - codigo: RT-004 + nombre: Compras + descripcion: Órdenes de compra y recepción + herencia: 60% + prioridad: P0 + estado: EPICA_COMPLETA + sp: 38 + epica: docs/08-epicas/EPIC-RT-004-compras.md + + - codigo: RT-005 + nombre: Clientes + descripcion: CRM y fidelización + herencia: 50% + prioridad: P0 + estado: EPICA_COMPLETA + sp: 34 + epica: docs/08-epicas/EPIC-RT-005-clientes.md + + - codigo: RT-006 + nombre: Precios + descripcion: Promociones y descuentos + herencia: 40% + prioridad: P0 + estado: EPICA_COMPLETA + sp: 36 + epica: docs/08-epicas/EPIC-RT-006-precios.md + + - codigo: RT-007 + nombre: Caja + descripcion: Arqueos y cortes + herencia: 0% + prioridad: P0 + estado: EPICA_COMPLETA + sp: 28 + epica: docs/08-epicas/EPIC-RT-007-caja.md + + - codigo: RT-008 + nombre: Reportes + descripcion: Dashboards de ventas + herencia: 40% + prioridad: P1 + estado: EPICA_COMPLETA + sp: 30 + epica: docs/08-epicas/EPIC-RT-008-reportes.md + + - codigo: RT-009 + nombre: E-commerce + descripcion: Tienda online integrada + herencia: 20% + prioridad: P2 + estado: EPICA_COMPLETA + sp: 55 + epica: docs/08-epicas/EPIC-RT-009-ecommerce.md + + - codigo: RT-010 + nombre: Facturacion + descripcion: CFDI 4.0 y complementos + herencia: 80% + prioridad: P0 + estado: EPICA_COMPLETA + sp: 35 + epica: docs/08-epicas/EPIC-RT-010-facturacion.md + +specs_core: + aplicables: 26 + implementadas: 0 + por_implementar: 26 + documento: orchestration/00-guidelines/HERENCIA-SPECS-CORE.md + detalle: + - spec: SPEC-SISTEMA-SECUENCIAS + estado: PENDIENTE + - spec: SPEC-VALORACION-INVENTARIO + estado: PENDIENTE + - spec: SPEC-SEGURIDAD-API-KEYS-PERMISOS + estado: PENDIENTE + - spec: SPEC-TRAZABILIDAD-LOTES-SERIES + estado: PENDIENTE + - spec: SPEC-PRICING-RULES + estado: PENDIENTE + - spec: SPEC-INVENTARIOS-CICLICOS + estado: PENDIENTE + - spec: SPEC-FACTURACION-CFDI + estado: PENDIENTE + - spec: SPEC-INTEGRACION-BANCARIA + estado: PENDIENTE + +capas: + database: + inventario: DATABASE_INVENTORY.yml + schemas_implementados: [retail] + tablas_implementadas: 16 + enums_implementados: 6 + ddl_files: + - init/00-extensions.sql + - init/01-create-schemas.sql + - init/02-rls-functions.sql + - init/03-retail-tables.sql + estado: DDL_COMPLETO + + backend: + inventario: BACKEND_INVENTORY.yml + modulos_planificados: 10 + estado: PLANIFICADO + + frontend: + inventario: FRONTEND_INVENTORY.yml + paginas_planificadas: 20 + estado: PLANIFICADO + +dependencias_core: + obligatorias: + - auth (MGN-001) + - users (MGN-002) + - roles (MGN-003) + - tenants (MGN-004) + opcionales: + - catalogs (MGN-005) + - financial (MGN-010) + - inventory (MGN-011) + - sales (MGN-013) + - cfdi (MGN-016) + +consideraciones_especiales: + - Operación offline del POS con sincronización + - Multi-sucursal con inventarios independientes + - Integración hardware (impresoras térmicas, lectores) + - CFDI 4.0 con complementos de pago + - Promociones y reglas de precios complejas + +referencias: + docs: docs/ + vision: docs/00-vision-general/VISION-RETAIL.md + modulos: docs/02-definicion-modulos/INDICE-MODULOS.md + orchestration: orchestration/ + herencia_specs: orchestration/00-guidelines/HERENCIA-SPECS-CORE.md + herencia_db: database/HERENCIA-ERP-CORE.md diff --git a/orchestration/inventarios/README.md b/orchestration/inventarios/README.md new file mode 100644 index 0000000..20a7ca9 --- /dev/null +++ b/orchestration/inventarios/README.md @@ -0,0 +1,95 @@ +# Inventarios - ERP Retail + +**Version:** 1.0.0 +**Fecha:** 2025-12-08 +**Nivel SIMCO:** 2B.2 + +--- + +## Descripción + +Este directorio contiene los inventarios YAML que sirven como **Single Source of Truth (SSOT)** para el proyecto ERP Retail. Estos archivos son la referencia canónica para métricas, trazabilidad y componentes del sistema. + +--- + +## Archivos de Inventario + +| Archivo | Descripción | Estado | +|---------|-------------|--------| +| [MASTER_INVENTORY.yml](./MASTER_INVENTORY.yml) | Inventario maestro con métricas globales | Completo | +| [DATABASE_INVENTORY.yml](./DATABASE_INVENTORY.yml) | Inventario de objetos de base de datos | Planificado | +| [BACKEND_INVENTORY.yml](./BACKEND_INVENTORY.yml) | Inventario de componentes backend | Planificado | +| [FRONTEND_INVENTORY.yml](./FRONTEND_INVENTORY.yml) | Inventario de componentes frontend | Planificado | +| [TRACEABILITY_MATRIX.yml](./TRACEABILITY_MATRIX.yml) | Matriz de trazabilidad RF->ET->US | Completo | +| [DEPENDENCY_GRAPH.yml](./DEPENDENCY_GRAPH.yml) | Grafo de dependencias entre módulos | Completo | + +--- + +## Herencia del Core + +Este proyecto hereda del **ERP Core** (nivel 2B.1): + +| Aspecto | Heredado | Específico | +|---------|----------|------------| +| **Tablas DB** | ~100 | Planificado | +| **Schemas** | 8+ | Planificado | +| **Specs** | 3 | - | + +### Specs Heredadas + +1. SPEC-PRICING-RULES.md +2. SPEC-INVENTARIOS-CICLICOS.md +3. SPEC-TRAZABILIDAD-LOTES-SERIES.md + +--- + +## Resumen Ejecutivo + +### Métricas del Proyecto + +| Métrica | Valor | +|---------|-------| +| **Módulos** | 5 (RT-001 a RT-005) | +| **Estado** | PLANIFICACION_COMPLETA | +| **Completitud** | 15% | + +### Dominio del Negocio + +- Punto de venta (POS) +- Inventario multi-sucursal +- Gestión de precios y promociones +- Control de cajas + +--- + +## Directivas Específicas + +1. [DIRECTIVA-PUNTO-VENTA.md](../directivas/DIRECTIVA-PUNTO-VENTA.md) +2. [DIRECTIVA-INVENTARIO-SUCURSALES.md](../directivas/DIRECTIVA-INVENTARIO-SUCURSALES.md) + +--- + +## Configuración de Puertos (Planificado) + +| Servicio | Puerto | +|----------|--------| +| Backend API | 3400 | +| Frontend Web | 5177 | +| POS App | 5178 | + +--- + +## Alineación con ERP Core + +Estos inventarios siguen la misma estructura que: +- `/erp-core/orchestration/inventarios/` (proyecto padre) + +### Referencias + +- Suite Master: `orchestration/inventarios/SUITE_MASTER_INVENTORY.yml` +- Core: `apps/erp-core/orchestration/inventarios/` +- Status Global: `orchestration/inventarios/STATUS.yml` + +--- + +**Última actualización:** 2025-12-08 diff --git a/orchestration/inventarios/TRACEABILITY_MATRIX.yml b/orchestration/inventarios/TRACEABILITY_MATRIX.yml new file mode 100644 index 0000000..2773737 --- /dev/null +++ b/orchestration/inventarios/TRACEABILITY_MATRIX.yml @@ -0,0 +1,403 @@ +# ============================================================================= +# TRACEABILITY MATRIX - ERP Retail/POS (Vertical) +# ============================================================================= +# Generado: 2025-12-08 +# Sistema: NEXUS + SIMCO v2.2.0 +# Propósito: Matriz de trazabilidad Módulos -> SPECS -> Componentes +# ============================================================================= + +metadata: + proyecto: ERP Retail / Punto de Venta + codigo: RT + version: 1.0.0 + fecha_actualizacion: 2025-12-08 + base_core: erp-core v0.6.0 + +# ============================================================================= +# RESUMEN GLOBAL +# ============================================================================= +resumen: + modulos_total: 10 + modulos_documentados: 10 + epicas_completas: 10 + story_points_total: 353 + specs_core_aplicables: 26 + specs_implementadas: 0 + cobertura_specs: 0% + estado: EPICAS_COMPLETAS + +# ============================================================================= +# TRAZABILIDAD POR MÓDULO +# ============================================================================= +trazabilidad: + # --------------------------------------------------------------------------- + # RT-001: Fundamentos (100% herencia core) + # --------------------------------------------------------------------------- + RT-001: + nombre: Fundamentos + herencia: 100% + prioridad: P0 + sp: 0 + extiende: + - MGN-001 (auth) + - MGN-002 (users) + - MGN-003 (roles) + - MGN-004 (tenants) + database: + heredadas: [auth.users, auth.sessions, auth.roles, tenants.tenants] + extensiones: [] + backend: + heredados: [AuthService, UserService, RoleService, TenantService] + extensiones: [] + frontend: + heredados: [LoginForm, UserProfile, RoleSelector] + extensiones: [] + specs_core: + - SPEC-SISTEMA-SECUENCIAS + - SPEC-SEGURIDAD-API-KEYS-PERMISOS + + # --------------------------------------------------------------------------- + # RT-002: POS (Terminal Punto de Venta) + # --------------------------------------------------------------------------- + RT-002: + nombre: POS + herencia: 0% + prioridad: P0 + sp: 55 + epica: docs/08-epicas/EPIC-RT-002-pos.md + critico: true + database: + tablas: + - pos.pos_sessions + - pos.pos_orders + - pos.pos_order_lines + - pos.payment_methods + - pos.payment_transactions + backend: + servicios: + - POSSessionService + - POSOrderService + - PaymentService + - OfflineSyncService + frontend: + componentes: + - POSTerminal + - ProductSearch + - BarcodeScanner + - PaymentModal + - QuickButtons + - OrderSummary + - CustomerDisplay + specs_core: [] + caracteristicas: + - offline_first: true + - hardware_integration: [printer, scanner, cash_drawer] + + # --------------------------------------------------------------------------- + # RT-003: Inventario Multi-Sucursal + # --------------------------------------------------------------------------- + RT-003: + nombre: Inventario + herencia: 70% + prioridad: P0 + sp: 42 + epica: docs/08-epicas/EPIC-RT-003-inventario.md + database: + tablas: + - inventory.branches + - inventory.branch_stock + - inventory.stock_transfers + - inventory.transfer_lines + backend: + servicios: + - BranchService + - BranchStockService + - StockTransferService + frontend: + componentes: + - BranchSelector + - StockDashboard + - TransferForm + - StockAlertPanel + - MultiBranchView + specs_core: + - SPEC-VALORACION-INVENTARIO + - SPEC-INVENTARIOS-CICLICOS + - SPEC-TRAZABILIDAD-LOTES-SERIES + + # --------------------------------------------------------------------------- + # RT-004: Compras + # --------------------------------------------------------------------------- + RT-004: + nombre: Compras + herencia: 60% + prioridad: P0 + sp: 21 + database: + tablas: + - purchasing.purchase_orders + - purchasing.purchase_order_lines + - purchasing.goods_receipts + backend: + servicios: + - PurchaseOrderService + - GoodsReceiptService + - SupplierService + frontend: + componentes: + - PurchaseOrderForm + - PurchaseOrderList + - GoodsReceiptForm + - SupplierSelector + specs_core: + - SPEC-SISTEMA-SECUENCIAS + + # --------------------------------------------------------------------------- + # RT-005: Clientes (CRM + Fidelización) + # --------------------------------------------------------------------------- + RT-005: + nombre: Clientes + herencia: 50% + prioridad: P0 + sp: 34 + database: + tablas: + - crm.retail_customers + - loyalty.loyalty_cards + - loyalty.loyalty_transactions + - loyalty.reward_tiers + backend: + servicios: + - RetailCustomerService + - LoyaltyCardService + - RewardService + frontend: + componentes: + - CustomerLookup + - CustomerForm + - LoyaltyCardView + - PointsHistory + - RewardCatalog + specs_core: + - SPEC-MAIL-THREAD-TRACKING (comunicación clientes) + + # --------------------------------------------------------------------------- + # RT-006: Precios y Promociones + # --------------------------------------------------------------------------- + RT-006: + nombre: Precios + herencia: 40% + prioridad: P0 + sp: 42 + database: + tablas: + - pricing.price_lists + - pricing.promotions + - pricing.promotion_rules + - pricing.discount_coupons + backend: + servicios: + - PriceListService + - PromotionEngine + - CouponService + - PriceCalculator + frontend: + componentes: + - PriceListManager + - PromotionBuilder + - PromotionCalendar + - CouponGenerator + - DiscountPreview + specs_core: + - SPEC-PRICING-RULES (motor de promociones) + + # --------------------------------------------------------------------------- + # RT-007: Caja (Arqueos y Cortes) + # --------------------------------------------------------------------------- + RT-007: + nombre: Caja + herencia: 30% + prioridad: P0 + sp: 34 + database: + tablas: + - pos.cash_registers + - pos.cash_movements + - pos.cash_closings + - pos.denomination_counts + backend: + servicios: + - CashRegisterService + - CashClosingService + - CashReportService + frontend: + componentes: + - CashDrawer + - CashClosingForm + - DenominationCounter + - CashMovementLog + - ClosingReport + specs_core: + - SPEC-WIZARD-TRANSIENT-MODEL (asistente cierre) + + # --------------------------------------------------------------------------- + # RT-008: Reportes y Dashboards + # --------------------------------------------------------------------------- + RT-008: + nombre: Reportes + herencia: 40% + prioridad: P1 + sp: 34 + database: + tablas: [] + backend: + servicios: + - SalesReportService + - InventoryReportService + - AnalyticsService + frontend: + componentes: + - SalesDashboard + - SalesReport + - InventoryReport + - TopProductsChart + - SalesTrendChart + - BranchComparison + specs_core: [] + + # --------------------------------------------------------------------------- + # RT-009: E-commerce + # --------------------------------------------------------------------------- + RT-009: + nombre: E-commerce + herencia: 20% + prioridad: P2 + sp: 47 + database: + tablas: + - ecommerce.online_orders + - ecommerce.shopping_carts + - ecommerce.product_catalog + - ecommerce.shipping_methods + backend: + servicios: + - OnlineOrderService + - CartService + - CatalogSyncService + - ShippingService + frontend: + componentes: + - OnlineOrderManager + - CatalogEditor + - ShippingConfigurator + - OrderFulfillment + specs_core: + - SPEC-MAIL-THREAD-TRACKING (notificaciones pedidos) + + # --------------------------------------------------------------------------- + # RT-010: Facturación CFDI + # --------------------------------------------------------------------------- + RT-010: + nombre: Facturacion + herencia: 80% + prioridad: P0 + sp: 21 + database: + tablas: + - invoicing.retail_invoices + - invoicing.invoice_complements + backend: + servicios: + - RetailInvoiceService + - CFDIService + - ComplementService + frontend: + componentes: + - InvoiceGenerator + - InvoiceList + - CFDIViewer + - ComplementForm + specs_core: + - SPEC-FACTURACION-CFDI + - SPEC-INTEGRACION-BANCARIA (complementos pago) + +# ============================================================================= +# REFERENCIAS CRUZADAS CON ERP-CORE +# ============================================================================= +referencias_core: + specs_implementadas: [] + + specs_pendientes: + - spec: SPEC-SISTEMA-SECUENCIAS + modulos: [RT-001, RT-002, RT-004] + prioridad: P0 + estado: PENDIENTE + + - spec: SPEC-VALORACION-INVENTARIO + modulos: [RT-003] + prioridad: P0 + estado: PENDIENTE + adaptacion: "Valorización multi-sucursal con costo promedio" + + - spec: SPEC-INVENTARIOS-CICLICOS + modulos: [RT-003] + prioridad: P1 + estado: PENDIENTE + adaptacion: "Conteos por sucursal" + + - spec: SPEC-PRICING-RULES + modulos: [RT-006] + prioridad: P0 + estado: PENDIENTE + adaptacion: "Promociones, combos, descuentos por volumen" + + - spec: SPEC-FACTURACION-CFDI + modulos: [RT-010] + prioridad: P0 + estado: PENDIENTE + adaptacion: "CFDI 4.0 con complementos de pago" + + modulos_extendidos: + - core: MGN-001 (auth) + vertical: RT-001 + tipo: herencia_directa + + - core: MGN-011 (inventory) + vertical: RT-003 + tipo: extension_multi_sucursal + + - core: MGN-013 (sales) + vertical: RT-002 + tipo: extension_pos + + - core: MGN-016 (cfdi) + vertical: RT-010 + tipo: extension_retail + +# ============================================================================= +# VALIDACIONES +# ============================================================================= +validaciones: + modulos_huerfanos: 0 + specs_sin_modulo: 0 + + alertas: + - tipo: implementacion_pendiente + mensaje: "0% de código implementado - fase planificación" + + - tipo: modulo_critico + modulo: RT-002 + mensaje: "POS es crítico - requiere modo offline" + + - tipo: integracion_hardware + modulo: RT-002 + mensaje: "Requiere integración con impresoras térmicas, scanners" + +# ============================================================================= +# METADATA +# ============================================================================= +metadata_documento: + creado_por: Claude-Code + fecha_creacion: 2025-12-08 + ultima_actualizacion: 2025-12-08 + version_documento: 1.0.0 diff --git a/orchestration/planes/PLAN-MAESTRO-MIGRACION-RETAIL.md b/orchestration/planes/PLAN-MAESTRO-MIGRACION-RETAIL.md new file mode 100644 index 0000000..57571b7 --- /dev/null +++ b/orchestration/planes/PLAN-MAESTRO-MIGRACION-RETAIL.md @@ -0,0 +1,482 @@ +# PLAN MAESTRO: Migracion, Integracion y Adaptacion ERP-Core a Retail + +**Version:** 1.0.0 +**Fecha:** 2025-12-18 +**Autor:** Requirements-Analyst (Perfil NEXUS) +**Proyecto Base:** erp-core +**Proyecto Destino:** erp-suite/verticales/retail + +--- + +## RESUMEN EJECUTIVO + +Este documento define el plan maestro para la migracion, integracion y adaptacion del proyecto base `erp-core` hacia la vertical especializada `retail`. El proceso se divide en 5 fases con subagentes especializados para garantizar completitud y trazabilidad. + +### Metricas del Proyecto + +| Concepto | Valor | +|----------|-------| +| Tablas heredadas de core | 144 | +| Schemas heredados | 12 | +| Modulos retail | 10 | +| Story Points estimados | 353 | +| % Reutilizacion promedio | 45% | + +--- + +## FASE 1: PLANEACION - ANALISIS DETALLADO + +### Objetivo +Realizar un analisis exhaustivo del estado actual de erp-core y los requerimientos especificos de retail para identificar todos los componentes a migrar, extender o crear. + +### Entregables + +1. **ANALISIS-ERP-CORE-INVENTARIO.md** + - Inventario completo de modulos del core + - Estado de implementacion de cada modulo + - Especificaciones tecnicas existentes + - DDL implementados + +2. **ANALISIS-RETAIL-REQUERIMIENTOS.md** + - Requerimientos funcionales especificos de retail + - Requerimientos no funcionales (offline, performance) + - Hardware e integraciones requeridas + +3. **MATRIZ-MAPEO-CORE-RETAIL.md** + - Mapeo modulo-a-modulo entre core y retail + - Identificacion de herencia directa (100%) + - Identificacion de extensiones necesarias + - Identificacion de componentes nuevos + +4. **GAP-ANALYSIS-RETAIL.md** + - Gaps funcionales por modulo + - Gaps de especificaciones + - Gaps de implementacion + +### Subagente Especializado + +```yaml +agente: Explore-Agent +tipo: subagent_type=Explore +nivel_exploracion: very thorough +prompt: | + Realizar analisis exhaustivo del proyecto erp-core: + 1. Explorar todos los modulos en backend/src/modules/ + 2. Documentar estado de cada servicio (.service.ts) + 3. Revisar DDL en database/ddl/ + 4. Catalogar especificaciones en docs/ + 5. Verificar inventarios existentes + + Contexto: Se migrara a vertical retail (punto de venta) + Entregar: Inventario completo con estado de implementacion +``` + +### Archivos a Analizar + +| Ruta | Proposito | +|------|-----------| +| `erp-core/backend/src/modules/` | Servicios backend | +| `erp-core/database/ddl/` | Esquemas DDL | +| `erp-core/docs/` | Documentacion y especificaciones | +| `retail/docs/` | Requerimientos retail | +| `retail/orchestration/inventarios/` | Inventarios actuales | + +--- + +## FASE 2: EJECUCION DEL ANALISIS + +### Objetivo +Ejecutar el analisis planificado modulo por modulo, generando documentacion detallada de cada componente. + +### Entregables por Modulo + +Para cada uno de los 10 modulos de retail (RT-001 a RT-010): + +1. **ANALISIS-RT-{XXX}-{nombre}.md** + - Componentes del core que hereda + - Extensiones requeridas + - Componentes nuevos a crear + - Dependencias identificadas + - Story points ajustados + +### Matriz de Analisis + +| Modulo Retail | Modulos Core Relacionados | % Herencia | Analisis | +|---------------|---------------------------|------------|----------| +| RT-001 Fundamentos | MGN-001, MGN-002, MGN-003, MGN-004 | 100% | PENDIENTE | +| RT-002 POS | MGN-013 Sales | 20% | PENDIENTE | +| RT-003 Inventario | MGN-011 Inventory | 60% | PENDIENTE | +| RT-004 Compras | MGN-012 Purchase | 80% | PENDIENTE | +| RT-005 Clientes | MGN-014 CRM | 40% | PENDIENTE | +| RT-006 Precios | MGN-013 Sales (pricing) | 30% | PENDIENTE | +| RT-007 Caja | - | 10% | PENDIENTE | +| RT-008 Reportes | MGN-009 Reports | 70% | PENDIENTE | +| RT-009 E-commerce | MGN-013 Sales | 20% | PENDIENTE | +| RT-010 Facturacion | MGN-010 Financial | 60% | PENDIENTE | + +### Subagentes Especializados + +```yaml +# Para cada modulo ejecutar: +agente: Plan-Agent +tipo: subagent_type=Plan +prompt: | + Analizar modulo RT-{XXX} {nombre}: + + 1. HERENCIA DEL CORE: + - Listar servicios de erp-core/{modulo_core} que aplican + - Identificar tablas DDL que se reutilizan + - Revisar specs transversales aplicables + + 2. EXTENSIONES: + - Campos adicionales requeridos + - Logica de negocio adicional + - Nuevos endpoints necesarios + + 3. COMPONENTES NUEVOS: + - Entidades especificas de retail + - Servicios nuevos + - Controladores nuevos + + 4. DEPENDENCIAS: + - Modulos core requeridos + - Otros modulos retail requeridos + - Servicios externos (PAC, hardware) + + Contexto: [Incluir documentacion del modulo retail] + Entregar: Documento ANALISIS-RT-{XXX}.md +``` + +--- + +## FASE 3: PLANEACION DE IMPLEMENTACIONES + +### Objetivo +Crear planes de implementacion detallados para cada componente identificado en la Fase 2. + +### Entregables + +1. **PLAN-IMPL-DATABASE.md** + - Orden de creacion de tablas + - Migraciones de core requeridas + - DDL especificos de retail + - Indices y optimizaciones + +2. **PLAN-IMPL-BACKEND.md** + - Servicios a heredar (import directo) + - Servicios a extender (class extends) + - Servicios a crear (nuevos) + - Controladores y rutas + - Middlewares especificos + +3. **PLAN-IMPL-FRONTEND.md** + - Componentes reutilizables del core + - Componentes especificos POS + - PWA y modo offline + - Integracion hardware + +4. **ROADMAP-SPRINTS.md** + - Sprints por prioridad (P0, P1, P2) + - Dependencias entre sprints + - Criterios de completitud + +### Estructura del Plan por Capa + +```yaml +database: + sprint_1: + - Crear schema retail + - Crear tablas pos_sessions, pos_orders, pos_order_lines + - Crear tablas cash_registers, cash_movements + - Configurar RLS policies + sprint_2: + - Tablas branches, branch_stock + - Tablas loyalty_cards, loyalty_transactions + - Tablas promotions + +backend: + sprint_1: + - Heredar AuthModule de @erp-core/auth + - Heredar UsersModule de @erp-core/users + - Crear POSModule + - Crear CashModule + sprint_2: + - Extender InventoryModule + - Crear LoyaltyModule + - Crear PromotionsModule + +frontend: + sprint_1: + - Dashboard principal + - Pantalla POS + - Gestion de caja + sprint_2: + - Inventario por sucursal + - Clientes y fidelizacion +``` + +### Subagente Especializado + +```yaml +agente: Plan-Agent +tipo: subagent_type=Plan +prompt: | + Con base en los analisis de Fase 2, crear plan de implementacion: + + INPUT: + - ANALISIS-RT-001 a RT-010 + - GAP-ANALYSIS-RETAIL + - MATRIZ-MAPEO-CORE-RETAIL + + OUTPUT: + - Plan de base de datos (orden DDL) + - Plan de backend (herencia vs extension vs nuevo) + - Plan de frontend (componentes) + - Sprints priorizados + + Considerar: + - Dependencias entre modulos + - Prioridad P0 primero + - Modo offline del POS + - CFDI 4.0 en tiempo real +``` + +--- + +## FASE 4: VALIDACION DE PLANEACION + +### Objetivo +Validar que el plan de implementacion cubre todos los componentes identificados y que no faltan dependencias. + +### Entregables + +1. **VALIDACION-COMPLETITUD.md** + - Checklist de todos los objetos a implementar + - Verificacion de dependencias cruzadas + - Objetos faltantes identificados + +2. **DEPENDENCY-GRAPH-VALIDADO.yml** + - Grafo de dependencias actualizado + - Orden de implementacion validado + - Ciclos detectados (si existen) + +3. **IMPACTO-CAMBIOS.md** + - Componentes impactados por cada cambio + - Propagacion de cambios en cascada + - Riesgos identificados + +### Checklist de Validacion + +```yaml +validacion_database: + - [ ] Todas las tablas de ANALISIS tienen DDL en PLAN-IMPL + - [ ] FK apuntan a tablas existentes o en plan + - [ ] RLS policies definidas para todas las tablas + - [ ] Indices de rendimiento para POS (<100ms) + +validacion_backend: + - [ ] Cada servicio tiene su controlador + - [ ] Cada endpoint tiene ruta definida + - [ ] Imports de core son resolubles + - [ ] No hay dependencias circulares + +validacion_frontend: + - [ ] Cada pantalla tiene componentes definidos + - [ ] PWA manifest configurado + - [ ] Service workers para offline + - [ ] Integracion hardware documentada + +validacion_herencia: + - [ ] Modulos 100% herencia no duplican codigo + - [ ] Extensiones usan 'extends' correctamente + - [ ] Specs transversales aplicadas +``` + +### Subagente Especializado + +```yaml +agente: General-Purpose-Agent +tipo: subagent_type=general-purpose +prompt: | + Validar completitud del plan de implementacion: + + 1. VERIFICAR OBJETOS: + - Leer ANALISIS-RT-* (todos los modulos) + - Leer PLAN-IMPL-* (todos los planes) + - Cruzar: cada objeto analizado tiene plan + + 2. VERIFICAR DEPENDENCIAS: + - Construir grafo de dependencias + - Detectar objetos huerfanos + - Detectar ciclos + + 3. VERIFICAR IMPACTO: + - Para cada cambio en core, que retail impacta + - Para cada nuevo en retail, que core requiere + + 4. GENERAR REPORTE: + - Lista de gaps encontrados + - Recomendaciones de ajuste + - Plan de correccion + + Entregar: VALIDACION-COMPLETITUD.md +``` + +--- + +## FASE 5: EJECUCION DE IMPLEMENTACIONES + +### Objetivo +Ejecutar las implementaciones segun el plan validado, usando subagentes especializados por capa. + +### Subagentes por Dominio + +#### Database-Agent + +```yaml +agente: Database-Agent +prompt: | + Implementar DDL para retail segun PLAN-IMPL-DATABASE: + + CONTEXTO: + - Schema: retail + - Herencia: schemas auth, core, inventory, sales de erp-core + - Variable RLS: app.current_tenant_id + + ARCHIVOS A CREAR/MODIFICAR: + - database/init/03-retail-tables.sql (completar) + - database/init/04-retail-functions.sql + - database/init/05-retail-triggers.sql + + VALIDAR: + - FK validas a tablas de core + - RLS en todas las tablas + - Indices para performance POS +``` + +#### Backend-Agent + +```yaml +agente: Backend-Agent +prompt: | + Implementar backend segun PLAN-IMPL-BACKEND: + + MODULOS A CREAR: + - src/modules/pos/ + - src/modules/cash/ + - src/modules/loyalty/ + - src/modules/promotions/ + + MODULOS A HEREDAR: + - Importar @erp-core/auth + - Importar @erp-core/users + - Importar @erp-core/inventory (extender) + + PATRON: + - NestJS con TypeORM + - Controllers + Services + Entities + - DTOs para validacion +``` + +#### Frontend-Agent + +```yaml +agente: Frontend-Agent +prompt: | + Implementar frontend segun PLAN-IMPL-FRONTEND: + + STACK: + - React 18 + TypeScript + - Vite + PWA plugin + - Tailwind CSS + - Zustand para estado + + PANTALLAS PRIORITARIAS: + 1. POS (punto de venta) - offline-first + 2. Dashboard de ventas + 3. Gestion de caja + 4. Inventario por sucursal + + CONSIDERACIONES: + - Service workers para offline + - IndexedDB para cache local + - Sincronizacion con backend +``` + +### Control de Progreso + +| Sprint | Modulos | Estado | % | +|--------|---------|--------|---| +| Sprint 1 | RT-001, RT-002, RT-007 | PENDIENTE | 0% | +| Sprint 2 | RT-003, RT-004, RT-010 | PENDIENTE | 0% | +| Sprint 3 | RT-005, RT-006 | PENDIENTE | 0% | +| Sprint 4 | RT-008, RT-009 | PENDIENTE | 0% | + +--- + +## CRONOGRAMA DE FASES + +| Fase | Descripcion | Estado | Dependencias | +|------|-------------|--------|--------------| +| F1 | Planeacion - Analisis Detallado | **SIGUIENTE** | - | +| F2 | Ejecucion del Analisis | PENDIENTE | F1 | +| F3 | Plan de Implementaciones | PENDIENTE | F2 | +| F4 | Validacion de Plan | PENDIENTE | F3 | +| F5 | Ejecucion de Implementaciones | PENDIENTE | F4 | + +--- + +## ARCHIVOS DE SOPORTE + +### Estructura de Carpetas para Entregables + +``` +retail/orchestration/planes/ +├── PLAN-MAESTRO-MIGRACION-RETAIL.md (este documento) +├── fase-1-analisis/ +│ ├── ANALISIS-ERP-CORE-INVENTARIO.md +│ ├── ANALISIS-RETAIL-REQUERIMIENTOS.md +│ ├── MATRIZ-MAPEO-CORE-RETAIL.md +│ └── GAP-ANALYSIS-RETAIL.md +├── fase-2-analisis-modulos/ +│ ├── ANALISIS-RT-001-fundamentos.md +│ ├── ANALISIS-RT-002-pos.md +│ ├── ANALISIS-RT-003-inventario.md +│ ├── ANALISIS-RT-004-compras.md +│ ├── ANALISIS-RT-005-clientes.md +│ ├── ANALISIS-RT-006-precios.md +│ ├── ANALISIS-RT-007-caja.md +│ ├── ANALISIS-RT-008-reportes.md +│ ├── ANALISIS-RT-009-ecommerce.md +│ └── ANALISIS-RT-010-facturacion.md +├── fase-3-implementacion/ +│ ├── PLAN-IMPL-DATABASE.md +│ ├── PLAN-IMPL-BACKEND.md +│ ├── PLAN-IMPL-FRONTEND.md +│ └── ROADMAP-SPRINTS.md +├── fase-4-validacion/ +│ ├── VALIDACION-COMPLETITUD.md +│ ├── DEPENDENCY-GRAPH-VALIDADO.yml +│ └── IMPACTO-CAMBIOS.md +└── fase-5-ejecucion/ + └── TRAZA-EJECUCION.md +``` + +--- + +## PROXIMA ACCION + +**Fase Actual:** FASE 1 - Planeacion +**Accion Inmediata:** Ejecutar analisis exhaustivo de erp-core con Explore-Agent + +```bash +# Comando para iniciar Fase 1: +# Usar subagente Explore con nivel "very thorough" +# Target: /projects/erp-suite/apps/erp-core/ +``` + +--- + +**Documento creado:** 2025-12-18 +**Sistema:** NEXUS + SIMCO v2.2.0 +**Perfil:** Requirements-Analyst diff --git a/orchestration/planes/fase-1-analisis/ANALISIS-ERP-CORE-INVENTARIO.md b/orchestration/planes/fase-1-analisis/ANALISIS-ERP-CORE-INVENTARIO.md new file mode 100644 index 0000000..3308d75 --- /dev/null +++ b/orchestration/planes/fase-1-analisis/ANALISIS-ERP-CORE-INVENTARIO.md @@ -0,0 +1,261 @@ +# ANALISIS ERP-CORE - INVENTARIO COMPLETO + +**Fecha:** 2025-12-18 +**Fase:** 1 - Planeacion +**Objetivo:** Inventario detallado del estado actual de erp-core + +--- + +## RESUMEN EJECUTIVO + +| Metrica | Valor | +|---------|-------| +| **Modulos Backend** | 16 | +| **Modulos Completados** | 0 (0%) | +| **Modulos En Desarrollo** | 5 (31%) | +| **Modulos Planificados** | 11 (69%) | +| **Total Tablas DDL** | 144 | +| **Total Lineas DDL** | 9,752 | +| **Total Entidades TypeORM** | 30+ | +| **Total Documentacion** | 650+ archivos | + +--- + +## 1. MODULOS BACKEND + +### 1.1 Modulos Foundation (MGN-001 a MGN-004) + +| Modulo | Codigo | Estado | Progreso | Archivos | Dependencias | +|--------|--------|--------|----------|----------|--------------| +| Auth | MGN-001 | En desarrollo | 40% | auth.service.ts, auth.controller.ts, auth.routes.ts, apiKeys.service.ts, 11 entities | Ninguna | +| Users | MGN-002 | En desarrollo | 30% | users.service.ts, users.controller.ts, users.routes.ts | MGN-001 | +| Roles | MGN-003 | Planificado | 0% | roles.service.ts, permissions.service.ts | MGN-001, MGN-002 | +| Tenants | MGN-004 | Planificado | 0% | tenants/ (planificado) | MGN-001 | + +### 1.2 Modulos Core Business + +| Modulo | Codigo | Estado | Progreso | Archivos Principales | +|--------|--------|--------|----------|---------------------| +| Catalogs | MGN-005 | Planificado | 0% | countries.service.ts, currencies.service.ts, uom.service.ts, product-categories.service.ts | +| Settings | MGN-006 | Planificado | 0% | (pendiente) | +| Audit | MGN-007 | Planificado | 0% | (pendiente) | +| Notifications | MGN-008 | Planificado | 0% | (pendiente) | +| Reports | MGN-009 | Planificado | 0% | (pendiente) | +| Financial | MGN-010 | En desarrollo | 70% | accounts.service.ts, journals.service.ts, taxes.service.ts, invoices.service.ts, payments.service.ts, 11 entities | +| Inventory | MGN-011 | En desarrollo | 60% | products.service.ts, warehouses.service.ts, locations.service.ts, lots.service.ts, pickings.service.ts, valuation.service.ts | +| Purchasing | MGN-012 | Planificado | 0% | rfqs.service.ts, purchases.service.ts | +| Sales | MGN-013 | En desarrollo | 50% | orders.service.ts, quotations.service.ts, pricelists.service.ts, customer-groups.service.ts | +| CRM | MGN-014 | Planificado | 0% | leads.service.ts, opportunities.service.ts, stages.service.ts | +| Projects | MGN-015 | Planificado | 0% | projects.service.ts, tasks.service.ts | +| HR | MGN-016 | Planificado | 0% | employees.service.ts, departments.service.ts, contracts.service.ts, leaves.service.ts | + +--- + +## 2. ESTRUCTURA DATABASE DDL + +### 2.1 Archivos DDL + +| Archivo | Lineas | Schema | Descripcion | +|---------|--------|--------|-------------| +| 00-prerequisites.sql | 207 | - | Extensiones PostgreSQL | +| 01-auth.sql | 620 | auth | Autenticacion base | +| 01-auth-extensions.sql | 891 | auth | OAuth, MFA, API Keys | +| 02-core.sql | 755 | core | Partners, contactos | +| 03-analytics.sql | 510 | analytics | Contabilidad analitica | +| 04-financial.sql | 970 | financial | Contabilidad, facturas | +| 05-inventory.sql | 772 | inventory | Stock, productos | +| 05-inventory-extensions.sql | 966 | inventory | Valoracion, lotes | +| 06-purchase.sql | 583 | purchasing | Ordenes de compra | +| 07-sales.sql | 705 | sales | Ventas, cotizaciones | +| 08-projects.sql | 537 | projects | Proyectos, tareas | +| 09-system.sql | 853 | system | Auditoria, settings | +| 10-billing.sql | 638 | billing | SaaS (opcional) | +| 11-crm.sql | 366 | crm | Leads, oportunidades | +| 12-hr.sql | 379 | hr | Empleados, contratos | +| **TOTAL** | **9,752** | **12** | **144 tablas** | + +### 2.2 Schemas y Tablas + +| Schema | Tablas | Uso Principal | +|--------|--------|---------------| +| auth | 26 | Autenticacion, MFA, OAuth, API Keys, roles, permisos | +| core | 12 | Partners (clientes/proveedores), catalogos, UoM | +| financial | 15 | Contabilidad, facturas, pagos, asientos | +| inventory | 20 | Productos, stock, valoracion FIFO/AVCO, lotes | +| purchase | 8 | Ordenes de compra, proveedores | +| sales | 10 | Ventas, cotizaciones, equipos de venta | +| projects | 10 | Proyectos, tareas, dependencias | +| analytics | 7 | Contabilidad analitica, centros de costo | +| system | 13 | Mensajes, notificaciones, logs, auditoria | +| billing | 11 | SaaS/Suscripciones (opcional) | +| crm | 6 | Leads, oportunidades | +| hr | 6 | Empleados, contratos, ausencias | + +--- + +## 3. ENTIDADES TYPEORM + +### 3.1 Entidades por Modulo + +**Auth Module (11 entities):** +- User, Tenant, Company, Session, Role, Permission +- PasswordReset, OAuthUserLink, TrustedDevice +- VerificationCode, MFAAuditLog + +**Financial Module (11 entities):** +- Account, AccountType, Journal, JournalEntry, JournalEntryLine +- Invoice, InvoiceLine, Payment, Tax +- FiscalYear, FiscalPeriod + +**Core Module (7 entities):** +- Country, Currency, UoM, UoMCategory +- ProductCategory, Sequence, Partner + +**Inventory Module:** +- Product, ProductVariant, Warehouse, Location +- Lot, StockQuant, Picking, StockMove +- InventoryAdjustment, InventoryAdjustmentLine + +--- + +## 4. DOCUMENTACION EXISTENTE + +### 4.1 Estructura de Docs + +``` +docs/ +├── 00-vision-general/ # Vision y objetivos +├── 01-analisis-referencias/ # Analisis Odoo, Gamilit +│ ├── odoo/ # 9 analisis de modulos Odoo +│ ├── gamilit/ # Patrones de arquitectura +│ └── construccion/ # Analisis construccion +├── 01-fase-foundation/ # MGN-001 a MGN-004 +│ ├── MGN-001-auth/ +│ ├── MGN-002-users/ +│ ├── MGN-003-roles/ +│ └── MGN-004-tenants/ +├── 02-definicion-modulos/ # Indice y alcance +│ └── gaps/ # GAP-ANALYSIS por modulo +├── 02-fase-core-business/ # MGN-005 a MGN-010 +└── 04-modelado/ + ├── domain-models/ + └── especificaciones-tecnicas/ +``` + +### 4.2 Especificaciones Tecnicas Existentes + +| Especificacion | Proposito | +|----------------|-----------| +| SPEC-PRICING-RULES.md | Reglas de precios y promociones | +| SPEC-INVENTARIOS-CICLICOS.md | Conteo de inventarios | +| SPEC-TRAZABILIDAD-LOTES-SERIES.md | Lotes y numeros de serie | +| SPEC-VALORACION-INVENTARIO.md | Costeo FIFO/LIFO/Promedio | +| SPEC-SISTEMA-SECUENCIAS.md | Foliado automatico | +| SPEC-REPORTES-FINANCIEROS.md | Balance, P&L | + +### 4.3 GAP Analysis Existentes + +- GAP-ANALYSIS-MGN-001.md (Auth) +- GAP-ANALYSIS-MGN-003.md (Roles) +- GAP-ANALYSIS-MGN-006.md (Settings) +- GAP-ANALYSIS-MGN-009.md (Reports) +- GAP-ANALYSIS-MGN-010.md (Financial) +- Y otros 10+ documentos + +--- + +## 5. ESTADO DE MIGRACION TYPEORM + +### 5.1 Financial Module + +| Servicio | Estado | Notas | +|----------|--------|-------| +| accounts.service.ts | MIGRADO | Completo con TypeORM | +| journals.service.ts | EN PROGRESO | Migrando | +| taxes.service.ts | PENDIENTE | Archivo .old.ts disponible | +| invoices.service.ts | PENDIENTE | | +| payments.service.ts | PENDIENTE | | + +### 5.2 Inventory Module + +| Servicio | Estado | +|----------|--------| +| products.service.ts | EN PROGRESO | +| warehouses.service.ts | EN PROGRESO | +| locations.service.ts | PENDIENTE | +| lots.service.ts | PENDIENTE | +| valuation.service.ts | PENDIENTE | + +--- + +## 6. SHARED CODE + +### 6.1 Ubicacion: /src/shared/ + +| Directorio | Archivos | Proposito | +|------------|----------|-----------| +| middleware/ | auth.middleware.ts, apiKeyAuth.middleware.ts, fieldPermissions.middleware.ts | Autenticacion y permisos | +| services/ | base.service.ts | CRUD generico | +| errors/ | index.ts | Errores customizados | +| types/ | index.ts | Tipos TypeScript | +| utils/ | logger.ts | Logging | + +--- + +## 7. BLOQUEADORES PARA RETAIL + +### 7.1 Modulos Criticos (Deben completarse) + +| Modulo | Estado Actual | Accion Requerida | +|--------|---------------|------------------| +| MGN-001 Auth | 40% | Completar al 100% | +| MGN-005 Catalogs | 0% | Implementar basico | +| MGN-010 Financial | 70% | Finalizar migracion TypeORM | +| MGN-011 Inventory | 60% | Completar con extensiones | +| MGN-013 Sales | 50% | Completar | + +### 7.2 Orden de Implementacion Recomendado + +1. **Sprint 1-2:** MGN-001 Auth (40% → 100%) +2. **Sprint 3-4:** MGN-002 Users + MGN-005 Catalogs +3. **Sprint 5-8:** MGN-010 Financial + MGN-011 Inventory +4. **Sprint 9-10:** MGN-012 Purchasing + MGN-013 Sales + +--- + +## 8. CONFIGURACION TECNICA + +### 8.1 Stack + +| Capa | Tecnologia | Version | +|------|------------|---------| +| Backend | Node.js + Express | 20+, 4.x | +| ORM | TypeORM | 0.3.17 | +| Database | PostgreSQL | 15+ | +| Auth | JWT + bcrypt | | +| Cache | Redis | (planificado) | + +### 8.2 Variable RLS + +```sql +current_setting('app.current_tenant_id', true)::UUID +``` + +--- + +## 9. REFERENCIAS + +| Recurso | Ubicacion | +|---------|-----------| +| README Principal | apps/erp-core/README.md | +| Project Status | apps/erp-core/PROJECT-STATUS.md | +| Backend Modules | apps/erp-core/backend/src/modules/ | +| DDL Files | apps/erp-core/database/ddl/ | +| Documentacion | apps/erp-core/docs/ | +| TypeORM Config | apps/erp-core/backend/src/config/typeorm.ts | + +--- + +**Documento generado:** 2025-12-18 +**Fase:** 1 - Planeacion - Analisis Detallado +**Estado:** COMPLETO diff --git a/orchestration/planes/fase-1-analisis/ANALISIS-RETAIL-REQUERIMIENTOS.md b/orchestration/planes/fase-1-analisis/ANALISIS-RETAIL-REQUERIMIENTOS.md new file mode 100644 index 0000000..4482913 --- /dev/null +++ b/orchestration/planes/fase-1-analisis/ANALISIS-RETAIL-REQUERIMIENTOS.md @@ -0,0 +1,459 @@ +# ANALISIS RETAIL - REQUERIMIENTOS COMPLETOS + +**Fecha:** 2025-12-18 +**Fase:** 1 - Planeacion +**Objetivo:** Documentar todos los requerimientos especificos de la vertical retail + +--- + +## RESUMEN EJECUTIVO + +| Metrica | Valor | +|---------|-------| +| **Modulos** | 10 | +| **Story Points Totales** | 353 SP | +| **Tablas Heredadas (Core)** | ~144 tablas | +| **Tablas Especificas Retail** | ~26 tablas | +| **Herencia Promedio** | 45% | +| **Performance Requerido** | < 100ms por transaccion | +| **Disponibilidad** | 99.9% | + +--- + +## 1. MODULOS RETAIL (RT-001 a RT-010) + +### 1.1 Resumen de Modulos + +| Codigo | Nombre | Herencia | SP | Prioridad | Estado | +|--------|--------|----------|----:|-----------|--------| +| RT-001 | Fundamentos | 100% | 0 | P0 | PLANIFICADO | +| RT-002 | POS | 20% | 55 | P0 | PLANIFICADO | +| RT-003 | Inventario | 60% | 42 | P0 | PLANIFICADO | +| RT-004 | Compras | 80% | 38 | P0 | PLANIFICADO | +| RT-005 | Clientes | 40% | 34 | P1 | PLANIFICADO | +| RT-006 | Precios | 30% | 36 | P0 | PLANIFICADO | +| RT-007 | Caja | 10% | 28 | P0 | PLANIFICADO | +| RT-008 | Reportes | 70% | 30 | P1 | PLANIFICADO | +| RT-009 | E-commerce | 20% | 55 | P2 | PLANIFICADO | +| RT-010 | Facturacion | 60% | 35 | P0 | PLANIFICADO | + +--- + +## 2. DETALLE POR MODULO + +### RT-001: Fundamentos (0 SP) + +**Descripcion:** Autenticacion y usuarios por sucursal +**Herencia:** 100% del core + +**Funcionalidades:** +- Autenticacion JWT multi-tenancy +- Usuarios segregados por sucursal +- Roles: Cajero, Supervisor, Gerente, Admin +- RBAC completo + +**Dependencias Core:** MGN-001 Auth, MGN-002 Users, MGN-003 Roles + +--- + +### RT-002: Punto de Venta - POS (55 SP) + +**Descripcion:** Terminal de venta con operacion offline +**Herencia:** 20% + +**Funcionalidades Criticas:** + +1. **Venta Rapida** + - Escaneo codigo de barras (< 500ms) + - Busqueda producto por nombre/SKU + - Carrito en tiempo real + - Calculo totales < 10ms + +2. **Formas de Pago** + - Efectivo con calculo de cambio + - Tarjeta credito/debito + - Pagos mixtos + - Transferencia + - Credito interno + +3. **Modo Offline (PWA)** + - Funcionamiento 24+ horas sin conexion + - Cache local IndexedDB + - Sincronizacion automatica al reconectar + - Cola de transacciones + +4. **Hardware** + - Lector codigo barras (USB HID) + - Impresora termica (ESC/POS) + - Terminal bancaria + - Cajon de dinero + +5. **Descuentos** + - Porcentuales y monto fijo + - Promociones automaticas + - Autorizacion supervisor + +**Performance Requerido:** +| Operacion | Objetivo | +|-----------|----------| +| Busqueda producto | < 50ms | +| Calculo totales | < 10ms | +| Registro venta | < 100ms | +| Cierre venta | < 3s | + +--- + +### RT-003: Inventario Multi-Sucursal (42 SP) + +**Descripcion:** Control de stock distribuido +**Herencia:** 60% + +**Funcionalidades:** + +1. **Consulta Stock** + - Stock por sucursal en tiempo real + - Stock disponible vs reservado + +2. **Transferencias** + - Solicitud desde tienda + - Estados: draft → pending → in_transit → received + - Confirmacion de recepcion + +3. **Recepcion Mercancia** + - Validacion contra OC + - Registro faltantes/sobrantes + +4. **Alertas Reorden** + - Stock minimo automatico + - Por sucursal + +5. **Conteos Ciclicos** + - Conteo ciego + - Clasificacion ABC + - Registro diferencias + +6. **Kardex** + - Historial completo (2 anos) + - Trazabilidad por usuario + +--- + +### RT-004: Compras y Reabastecimiento (38 SP) + +**Descripcion:** Gestion centralizada de compras +**Herencia:** 80% + +**Funcionalidades:** + +1. **Sugerencias Automaticas** + - Basadas en stock minimo + - Calculo punto de reorden + +2. **Ordenes de Compra** + - Estados: borrador → confirmada → recibida + - Envio automatico por email + +3. **Recepcion** + - Validacion contra OC + - Actualizacion de stock + +4. **Gestion Proveedores** + - Catalogo con lead times + - Historico de compras + +--- + +### RT-005: Clientes y Programa de Lealtad (34 SP) + +**Descripcion:** CRM y fidelizacion +**Herencia:** 40% + +**Funcionalidades:** + +1. **Registro Cliente** + - Datos minimos en POS + - Busqueda por telefono/email + +2. **Programa de Puntos** + - Acumulacion: 1 punto por $10 + - Canje: 100 puntos = $10 + - Puntos extra promocionales + +3. **Niveles Membresia** + - BRONCE: 0-999 pts/ano (1x) + - PLATA: 1000-4999 pts/ano (1.5x) + - ORO: 5000+ pts/ano (2x) + +4. **Historial Compras** + - 3 anos minimo + - Accesible en backoffice + +--- + +### RT-006: Precios y Promociones (36 SP) + +**Descripcion:** Motor de reglas de precios +**Herencia:** 30% + +**Funcionalidades:** + +1. **Listas de Precios** + - Por canal (tienda, online) + - Por sucursal + - Por cliente + +2. **Tipos de Promociones** + - Descuento porcentual + - Descuento monto fijo + - NxM (3x2) + - Por volumen + - Cupones + +3. **Motor de Reglas** + - Evaluacion por prioridad + - No acumulables + - Calculo < 100ms + +--- + +### RT-007: Caja - Arqueos y Cortes (28 SP) + +**Descripcion:** Control de efectivo +**Herencia:** 10% (casi nuevo) + +**Funcionalidades:** + +1. **Sesiones de Caja** + - Apertura con fondo inicial + - Estados: opening → open → closing → closed + +2. **Movimientos Efectivo** + - Retiros (deposito banca) + - Ingresos (cambio) + - Motivo documentado + +3. **Arqueos Parciales** + - Sin cerrar caja + - Validar diferencias + +4. **Corte de Caja** + - Conteo fisico + - Declaracion por denominacion + - Calculo diferencias + +5. **Auditoria** + - Historial 1 ano minimo + - Responsabilidad por cajero + +--- + +### RT-008: Reportes y Dashboard (30 SP) + +**Descripcion:** Analytics y dashboards +**Herencia:** 70% + +**Funcionalidades:** + +1. **Dashboard Principal** + - Ventas del dia + - Transacciones + - Ticket promedio + - Ventas por hora + +2. **Reportes Ventas** + - Por sucursal + - Por categoria + - Por cajero + - Comparativo periodos + +3. **Analisis Productos** + - Top 5 mas vendidos + - Stock muerto + - Clasificacion ABC + +4. **Exportacion** + - Excel, PDF + +--- + +### RT-009: E-commerce (55 SP) + +**Descripcion:** Tienda online integrada +**Herencia:** 20% + +**Funcionalidades:** + +1. **Catalogo Online** + - Navegacion con filtros + - Stock en tiempo real + +2. **Carrito y Checkout** + - Carrito persistente + - Validacion disponibilidad + +3. **Formas de Pago** + - Stripe/Conekta + - Paypal/MercadoPago + - Puntos de lealtad + +4. **Entrega** + - Envio a domicilio + - Pickup en tienda + +5. **Gestion Pedidos** + - Estados: pendiente → pagado → enviado → entregado + +--- + +### RT-010: Facturacion CFDI 4.0 (35 SP) + +**Descripcion:** Comprobantes fiscales +**Herencia:** 60% + +**Funcionalidades:** + +1. **Facturacion POS** + - Generacion al cerrar venta + - Timbrado < 5 segundos + - PDF + XML + +2. **Factura Publico General** + - RFC generico XAXX010101000 + +3. **Portal Autofactura** + - Cliente ingresa folio + - Timbrado automatico + +4. **Notas de Credito** + - Por devolucion + - Reversion IVA + +5. **Cancelacion** + - Dentro de plazo legal + - Con motivo + +--- + +## 3. REQUERIMIENTOS ESPECIALES + +### 3.1 Modo Offline + +| Aspecto | Requerimiento | +|---------|---------------| +| Duracion | 24+ horas sin conexion | +| Almacenamiento | IndexedDB (> 50MB) | +| Sincronizacion | < 5 minutos al reconectar | +| Transacciones | No perder nunca | +| Cache | Productos, precios, clientes frecuentes | + +### 3.2 Integracion Hardware + +| Dispositivo | Protocolo | Conexion | +|------------|-----------|----------| +| Impresora tickets | ESC/POS | USB/Red | +| Lector codigo barras | USB HID | USB | +| Terminal bancaria | ISO 8583 | USB/Red | +| Cajon dinero | Pulso via impresora | RJ-11 | + +### 3.3 Performance + +| Operacion | Objetivo | Alerta | +|-----------|----------|--------| +| Busqueda producto | < 50ms | > 100ms | +| Calculo totales | < 10ms | > 20ms | +| Registro venta | < 100ms | > 200ms | +| Venta completa | < 30s | > 60s | +| Dashboard carga | < 3s | > 5s | + +### 3.4 CFDI 4.0 + +| Aspecto | Requerimiento | +|---------|---------------| +| Timbrado | < 5 segundos | +| PAC respaldo | Configurado | +| Almacenamiento | 5 anos minimo | +| Cancelacion | Dentro de 30 dias | + +--- + +## 4. DDL EXISTENTE + +### 4.1 Archivo: 03-retail-tables.sql (723 lineas) + +**ENUMs:** +- pos_session_status +- pos_order_status +- payment_method +- cash_movement_type +- transfer_status +- promotion_type + +**Tablas (26):** +- branches +- cash_registers +- pos_sessions +- pos_orders +- pos_order_lines +- pos_payments +- cash_movements +- branch_stock +- stock_transfers +- stock_transfer_lines +- product_barcodes +- promotions +- promotion_products +- loyalty_programs +- loyalty_cards +- loyalty_transactions + +--- + +## 5. DEPENDENCIAS ENTRE MODULOS + +``` +RT-001 (Fundamentos) ◄─── Base de todo + │ + ├─► RT-002 (POS) + │ └─ Requiere: RT-006 precios, RT-003 inventario + │ + ├─► RT-003 (Inventario) ◄─► RT-004 (Compras) + │ + ├─► RT-005 (Clientes) + │ └─ Integra con: RT-002 POS + │ + ├─► RT-006 (Precios) + │ └─ Base para: RT-002, RT-009 + │ + ├─► RT-007 (Caja) + │ └─ Requiere: RT-002 POS + │ + ├─► RT-008 (Reportes) + │ └─ Requiere: Todos los anteriores + │ + ├─► RT-009 (E-commerce) + │ └─ Requiere: RT-003, RT-005, RT-006 + │ + └─► RT-010 (Facturacion) + └─ Requiere: RT-002, RT-009 +``` + +--- + +## 6. SPRINTS SUGERIDOS + +| Sprint | Modulos | Semanas | +|--------|---------|---------| +| 1-2 | RT-001 (setup) | 1-2 | +| 3-4 | RT-006 (precios) | 3-4 | +| 5-8 | RT-002 (POS) + RT-003 (inventario) | 5-8 | +| 9-10 | RT-004 (compras) | 9-10 | +| 11-12 | RT-007 (caja) | 11-12 | +| 13-14 | RT-005 (clientes) + RT-008 (reportes) | 13-14 | +| 15-18 | RT-009 (e-commerce) | 15-18 | +| 19-20 | RT-010 (facturacion) | 19-20 | + +--- + +**Documento generado:** 2025-12-18 +**Fase:** 1 - Planeacion - Analisis Detallado +**Estado:** COMPLETO diff --git a/orchestration/planes/fase-1-analisis/GAP-ANALYSIS-RETAIL.md b/orchestration/planes/fase-1-analisis/GAP-ANALYSIS-RETAIL.md new file mode 100644 index 0000000..ade252a --- /dev/null +++ b/orchestration/planes/fase-1-analisis/GAP-ANALYSIS-RETAIL.md @@ -0,0 +1,461 @@ +# GAP ANALYSIS: ERP-CORE → RETAIL + +**Fecha:** 2025-12-18 +**Fase:** 1 - Planeacion +**Objetivo:** Identificar gaps entre estado actual de core y requerimientos de retail + +--- + +## 1. RESUMEN DE GAPS + +### 1.1 Clasificacion + +| Tipo | Cantidad | Impacto | +|------|----------|---------| +| GAPS BLOQUEADORES | 5 | Critico - Impiden inicio | +| GAPS FUNCIONALES | 12 | Alto - Funcionalidad incompleta | +| GAPS TECNICOS | 8 | Medio - Optimizaciones | +| GAPS DOCUMENTACION | 4 | Bajo - Documentar | +| **TOTAL** | **29** | | + +--- + +## 2. GAPS BLOQUEADORES (Deben resolverse antes de iniciar) + +### GAP-BLK-001: MGN-001 Auth incompleto + +| Aspecto | Detalle | +|---------|---------| +| **Estado actual** | 40% completado | +| **Requerido por** | RT-001 Fundamentos (100% herencia) | +| **Impacto** | Sin auth, nada funciona | +| **Accion** | Completar auth al 100% antes de retail | +| **Estimado** | 2 sprints | + +**Faltantes:** +- [ ] OAuth2/OIDC (Google, Azure) +- [ ] 2FA/MFA completo +- [ ] Rate limiting avanzado +- [ ] Session management mejorado + +--- + +### GAP-BLK-002: MGN-005 Catalogs no implementado + +| Aspecto | Detalle | +|---------|---------| +| **Estado actual** | 0% - Solo planificado | +| **Requerido por** | RT-003 Inventario, RT-005 Clientes | +| **Impacto** | Sin catalogos base no hay productos ni clientes | +| **Accion** | Implementar catalogos basicos | +| **Estimado** | 1 sprint | + +**Faltantes:** +- [ ] Countries CRUD +- [ ] Currencies con tasas +- [ ] UoM con conversiones +- [ ] Product categories + +--- + +### GAP-BLK-003: MGN-010 Financial - Migracion TypeORM incompleta + +| Aspecto | Detalle | +|---------|---------| +| **Estado actual** | 70% - Migracion parcial | +| **Requerido por** | RT-010 Facturacion (60% herencia) | +| **Impacto** | Inconsistencia en servicios financieros | +| **Accion** | Completar migracion TypeORM | +| **Estimado** | 1 sprint | + +**Faltantes:** +- [ ] journals.service.ts - migrar a TypeORM +- [ ] taxes.service.ts - migrar a TypeORM +- [ ] invoices.service.ts - migrar a TypeORM +- [ ] payments.service.ts - migrar a TypeORM +- [ ] Eliminar archivos .old.ts + +--- + +### GAP-BLK-004: MGN-011 Inventory incompleto + +| Aspecto | Detalle | +|---------|---------| +| **Estado actual** | 60% completado | +| **Requerido por** | RT-003 Inventario (60% herencia) | +| **Impacto** | Stock base incompleto | +| **Accion** | Completar servicios de inventario | +| **Estimado** | 2 sprints | + +**Faltantes:** +- [ ] Valoracion completa (FIFO, LIFO, AVCO) +- [ ] Lotes y series +- [ ] Ajustes de inventario +- [ ] Conteos ciclicos + +--- + +### GAP-BLK-005: MGN-013 Sales incompleto + +| Aspecto | Detalle | +|---------|---------| +| **Estado actual** | 50% completado | +| **Requerido por** | RT-006 Precios (30% herencia), RT-002 POS | +| **Impacto** | Sin precios base, POS no funciona | +| **Accion** | Completar servicios de ventas | +| **Estimado** | 2 sprints | + +**Faltantes:** +- [ ] Pricelists completo +- [ ] Reglas de precios +- [ ] Customer groups +- [ ] Sales teams + +--- + +## 3. GAPS FUNCIONALES + +### GAP-FUN-001: Motor de Promociones no existe + +| Aspecto | Detalle | +|---------|---------| +| **Modulo Core** | MGN-013 Sales (SPEC-PRICING-RULES) | +| **Requerido por** | RT-006 Precios | +| **Estado** | Especificacion existe, no implementada | +| **Accion** | Implementar SPEC-PRICING-RULES | +| **SP Estimados** | 21 | + +--- + +### GAP-FUN-002: Multi-sucursal no existe en core + +| Aspecto | Detalle | +|---------|---------| +| **Modulo Core** | MGN-004 Tenants, MGN-011 Inventory | +| **Requerido por** | RT-003 Inventario Multi-Sucursal | +| **Estado** | Core es single-warehouse | +| **Accion** | Crear extension retail para branches | +| **SP Estimados** | 34 | + +--- + +### GAP-FUN-003: Programa de Lealtad no existe + +| Aspecto | Detalle | +|---------|---------| +| **Modulo Core** | MGN-014 CRM | +| **Requerido por** | RT-005 Clientes | +| **Estado** | CRM no tiene programa de puntos | +| **Accion** | Crear modulo loyalty para retail | +| **SP Estimados** | 21 | + +--- + +### GAP-FUN-004: POS (Terminal de Venta) no existe + +| Aspecto | Detalle | +|---------|---------| +| **Modulo Core** | N/A | +| **Requerido por** | RT-002 POS | +| **Estado** | No existe en core | +| **Accion** | Crear modulo completo POS | +| **SP Estimados** | 55 | + +--- + +### GAP-FUN-005: Gestion de Caja no existe + +| Aspecto | Detalle | +|---------|---------| +| **Modulo Core** | N/A | +| **Requerido por** | RT-007 Caja | +| **Estado** | No existe en core | +| **Accion** | Crear modulo completo cash management | +| **SP Estimados** | 28 | + +--- + +### GAP-FUN-006: CFDI 4.0 no implementado + +| Aspecto | Detalle | +|---------|---------| +| **Modulo Core** | MGN-010 Financial | +| **Requerido por** | RT-010 Facturacion | +| **Estado** | Facturas base, sin CFDI | +| **Accion** | Implementar integracion PAC | +| **SP Estimados** | 21 | + +--- + +### GAP-FUN-007: E-commerce no existe + +| Aspecto | Detalle | +|---------|---------| +| **Modulo Core** | MGN-013 Sales | +| **Requerido por** | RT-009 E-commerce | +| **Estado** | No existe tienda online | +| **Accion** | Crear modulo ecommerce | +| **SP Estimados** | 55 | + +--- + +### GAP-FUN-008: Dashboard POS no existe + +| Aspecto | Detalle | +|---------|---------| +| **Modulo Core** | MGN-009 Reports | +| **Requerido por** | RT-008 Reportes | +| **Estado** | Reports planificado, no implementado | +| **Accion** | Implementar dashboards retail | +| **SP Estimados** | 30 | + +--- + +### GAP-FUN-009: Conteos Ciclicos no implementado + +| Aspecto | Detalle | +|---------|---------| +| **Modulo Core** | MGN-011 Inventory (SPEC-INVENTARIOS-CICLICOS) | +| **Requerido por** | RT-003 Inventario | +| **Estado** | Especificacion existe, no implementada | +| **Accion** | Implementar SPEC-INVENTARIOS-CICLICOS | +| **SP Estimados** | 13 | + +--- + +### GAP-FUN-010: Transferencias entre ubicaciones limitado + +| Aspecto | Detalle | +|---------|---------| +| **Modulo Core** | MGN-011 Inventory | +| **Requerido por** | RT-003 Inventario (transferencias sucursales) | +| **Estado** | Solo pickings basicos | +| **Accion** | Extender para transferencias retail | +| **SP Estimados** | 13 | + +--- + +### GAP-FUN-011: Codigos de Barras multiples no existe + +| Aspecto | Detalle | +|---------|---------| +| **Modulo Core** | MGN-011 Inventory | +| **Requerido por** | RT-002 POS | +| **Estado** | Un solo codigo por producto | +| **Accion** | Crear tabla product_barcodes | +| **SP Estimados** | 5 | + +--- + +### GAP-FUN-012: Integracion Hardware no existe + +| Aspecto | Detalle | +|---------|---------| +| **Modulo Core** | N/A | +| **Requerido por** | RT-002 POS | +| **Estado** | No existe | +| **Accion** | Crear drivers para impresora, lector, cajon | +| **SP Estimados** | 13 | + +--- + +## 4. GAPS TECNICOS + +### GAP-TEC-001: PWA/Offline no existe + +| Aspecto | Detalle | +|---------|---------| +| **Tecnologia** | Service Workers, IndexedDB | +| **Requerido por** | RT-002 POS | +| **Accion** | Implementar PWA con offline support | +| **SP Estimados** | 21 | + +--- + +### GAP-TEC-002: Cache Redis no implementado + +| Aspecto | Detalle | +|---------|---------| +| **Tecnologia** | Redis | +| **Requerido por** | RT-002 POS (performance < 100ms) | +| **Accion** | Configurar Redis para cache productos/precios | +| **SP Estimados** | 8 | + +--- + +### GAP-TEC-003: WebSocket no implementado + +| Aspecto | Detalle | +|---------|---------| +| **Tecnologia** | Socket.io / WS | +| **Requerido por** | RT-002 POS (sync tiempo real) | +| **Accion** | Implementar WebSocket para actualizaciones | +| **SP Estimados** | 8 | + +--- + +### GAP-TEC-004: Indices optimizados faltantes + +| Aspecto | Detalle | +|---------|---------| +| **Tecnologia** | PostgreSQL | +| **Requerido por** | RT-002 POS (performance) | +| **Accion** | Crear indices en tablas criticas | +| **SP Estimados** | 3 | + +--- + +### GAP-TEC-005: Vistas materializadas no existen + +| Aspecto | Detalle | +|---------|---------| +| **Tecnologia** | PostgreSQL | +| **Requerido por** | RT-008 Reportes | +| **Accion** | Crear vistas para reportes frecuentes | +| **SP Estimados** | 5 | + +--- + +### GAP-TEC-006: Integracion PAC no existe + +| Aspecto | Detalle | +|---------|---------| +| **Tecnologia** | API PAC (Finkok, Facturama) | +| **Requerido por** | RT-010 Facturacion | +| **Accion** | Implementar cliente PAC | +| **SP Estimados** | 13 | + +--- + +### GAP-TEC-007: Integracion Pagos no existe + +| Aspecto | Detalle | +|---------|---------| +| **Tecnologia** | Stripe, Conekta, MercadoPago | +| **Requerido por** | RT-009 E-commerce | +| **Accion** | Implementar pasarelas de pago | +| **SP Estimados** | 13 | + +--- + +### GAP-TEC-008: Queue system no implementado + +| Aspecto | Detalle | +|---------|---------| +| **Tecnologia** | Bull/BullMQ | +| **Requerido por** | RT-002 POS (sync offline) | +| **Accion** | Implementar cola de trabajos | +| **SP Estimados** | 8 | + +--- + +## 5. GAPS DOCUMENTACION + +### GAP-DOC-001: User Stories RT incompletas + +| Aspecto | Detalle | +|---------|---------| +| **Estado** | Epicas completas, US pendientes | +| **Accion** | Detallar user stories | + +--- + +### GAP-DOC-002: Especificaciones tecnicas retail faltantes + +| Aspecto | Detalle | +|---------|---------| +| **Estado** | Directivas existen, specs tecnicas no | +| **Accion** | Crear specs para POS, Caja, E-commerce | + +--- + +### GAP-DOC-003: API Documentation no existe + +| Aspecto | Detalle | +|---------|---------| +| **Estado** | Sin OpenAPI/Swagger | +| **Accion** | Documentar endpoints con Swagger | + +--- + +### GAP-DOC-004: Manual de Integracion Hardware faltante + +| Aspecto | Detalle | +|---------|---------| +| **Estado** | Sin documentacion | +| **Accion** | Crear guia de configuracion hardware | + +--- + +## 6. PLAN DE CIERRE DE GAPS + +### 6.1 Fase Pre-Retail (Cerrar Bloqueadores) + +| Gap | Sprint | Responsable | +|-----|--------|-------------| +| GAP-BLK-001 Auth | 1-2 | Backend-Agent | +| GAP-BLK-002 Catalogs | 3 | Backend-Agent | +| GAP-BLK-003 Financial | 4 | Backend-Agent | +| GAP-BLK-004 Inventory | 5-6 | Backend-Agent | +| GAP-BLK-005 Sales | 7-8 | Backend-Agent | + +### 6.2 Fase Retail Sprint 1-4 + +| Gap | Sprint | Responsable | +|-----|--------|-------------| +| GAP-FUN-004 POS | 1-4 | Retail-Team | +| GAP-FUN-011 Barcodes | 1 | Database-Agent | +| GAP-TEC-001 PWA | 2-3 | Frontend-Agent | +| GAP-TEC-002 Redis | 1 | Backend-Agent | + +### 6.3 Fase Retail Sprint 5-8 + +| Gap | Sprint | Responsable | +|-----|--------|-------------| +| GAP-FUN-002 Multi-sucursal | 5-6 | Backend-Agent | +| GAP-FUN-005 Caja | 5-6 | Backend-Agent | +| GAP-FUN-001 Promociones | 7-8 | Backend-Agent | +| GAP-FUN-010 Transferencias | 7 | Backend-Agent | + +### 6.4 Fase Retail Sprint 9-12 + +| Gap | Sprint | Responsable | +|-----|--------|-------------| +| GAP-FUN-003 Lealtad | 9-10 | Backend-Agent | +| GAP-FUN-006 CFDI | 9-10 | Backend-Agent | +| GAP-FUN-007 E-commerce | 11-12 | Full-Stack | +| GAP-FUN-008 Dashboard | 11-12 | Frontend-Agent | + +--- + +## 7. RESUMEN EJECUTIVO + +### 7.1 Story Points por Tipo de Gap + +| Tipo | SP Total | +|------|----------| +| Bloqueadores | ~60 SP (en core) | +| Funcionales | ~296 SP | +| Tecnicos | ~79 SP | +| **TOTAL** | **~435 SP** | + +### 7.2 Riesgos Identificados + +1. **ALTO:** Dependencia de completitud de core +2. **ALTO:** Complejidad de PWA offline +3. **MEDIO:** Integracion PAC (terceros) +4. **MEDIO:** Integracion hardware variado +5. **BAJO:** Documentacion incompleta + +### 7.3 Proximos Pasos + +1. Cerrar gaps bloqueadores (pre-retail) +2. Iniciar RT-001 Fundamentos (heredar core) +3. Desarrollar RT-002 POS (critico) +4. Iterar sobre gaps funcionales + +--- + +**Documento generado:** 2025-12-18 +**Fase:** 1 - Planeacion - GAP Analysis +**Estado:** COMPLETO diff --git a/orchestration/planes/fase-1-analisis/MATRIZ-MAPEO-CORE-RETAIL.md b/orchestration/planes/fase-1-analisis/MATRIZ-MAPEO-CORE-RETAIL.md new file mode 100644 index 0000000..2374946 --- /dev/null +++ b/orchestration/planes/fase-1-analisis/MATRIZ-MAPEO-CORE-RETAIL.md @@ -0,0 +1,302 @@ +# MATRIZ DE MAPEO: ERP-CORE → RETAIL + +**Fecha:** 2025-12-18 +**Fase:** 1 - Planeacion +**Objetivo:** Mapear componentes del core hacia requerimientos de retail + +--- + +## 1. MAPEO MODULO A MODULO + +### 1.1 Herencia Directa (100%) + +| Modulo Retail | Modulo Core | Tipo | Accion | +|---------------|-------------|------|--------| +| RT-001 Fundamentos | MGN-001 Auth | HEREDAR | Import directo | +| RT-001 Fundamentos | MGN-002 Users | HEREDAR | Import directo | +| RT-001 Fundamentos | MGN-003 Roles | HEREDAR | Import directo | +| RT-001 Fundamentos | MGN-004 Tenants | HEREDAR | Import directo | + +### 1.2 Herencia con Extension + +| Modulo Retail | Modulo Core | % Herencia | Extensiones Requeridas | +|---------------|-------------|------------|------------------------| +| RT-003 Inventario | MGN-011 Inventory | 60% | Stock por sucursal, transferencias | +| RT-004 Compras | MGN-012 Purchasing | 80% | Distribucion a sucursales | +| RT-005 Clientes | MGN-014 CRM | 40% | Programa de lealtad, puntos | +| RT-006 Precios | MGN-013 Sales (pricing) | 30% | Promociones complejas, cupones | +| RT-008 Reportes | MGN-009 Reports | 70% | Dashboard POS, metricas ventas | +| RT-010 Facturacion | MGN-010 Financial | 60% | CFDI 4.0, timbrado PAC | + +### 1.3 Componentes Nuevos (Sin herencia directa) + +| Modulo Retail | % Herencia | Componentes Nuevos | +|---------------|------------|-------------------| +| RT-002 POS | 20% | Terminal POS, offline, hardware | +| RT-007 Caja | 10% | Arqueos, cortes, denominaciones | +| RT-009 E-commerce | 20% | Tienda online, carrito, checkout | + +--- + +## 2. MAPEO DE TABLAS DDL + +### 2.1 Tablas Heredadas de Core + +| Schema Core | Tablas | Uso en Retail | +|-------------|--------|---------------| +| auth.users | 26 tablas | Autenticacion completa | +| auth.tenants | | Multi-tenancy | +| auth.roles | | Roles (cajero, supervisor, gerente) | +| auth.permissions | | Permisos granulares | +| core.partners | 12 tablas | Clientes y proveedores | +| core.countries | | Paises | +| core.currencies | | Monedas | +| financial.accounts | 15 tablas | Contabilidad basica | +| financial.invoices | | Facturas base para CFDI | +| financial.payments | | Pagos | +| inventory.products | 20 tablas | Productos base | +| inventory.warehouses | | Almacenes (sucursales) | +| inventory.locations | | Ubicaciones | +| inventory.stock_moves | | Movimientos de stock | +| sales.orders | 10 tablas | Ordenes de venta base | +| sales.pricelists | | Listas de precios | + +### 2.2 Tablas Nuevas de Retail + +| Tabla Retail | Schema | Relaciona Con Core | +|--------------|--------|-------------------| +| retail.branches | retail | auth.tenants, inventory.warehouses | +| retail.cash_registers | retail | retail.branches | +| retail.pos_sessions | retail | auth.users, retail.cash_registers | +| retail.pos_orders | retail | core.partners, retail.pos_sessions | +| retail.pos_order_lines | retail | inventory.products | +| retail.pos_payments | retail | retail.pos_orders | +| retail.cash_movements | retail | retail.pos_sessions | +| retail.branch_stock | retail | retail.branches, inventory.products | +| retail.stock_transfers | retail | retail.branches | +| retail.stock_transfer_lines | retail | inventory.products | +| retail.product_barcodes | retail | inventory.products | +| retail.promotions | retail | auth.tenants | +| retail.promotion_products | retail | inventory.products | +| retail.loyalty_programs | retail | auth.tenants | +| retail.loyalty_cards | retail | core.partners | +| retail.loyalty_transactions | retail | retail.loyalty_cards | + +--- + +## 3. MAPEO DE SERVICIOS BACKEND + +### 3.1 Servicios a Heredar (Import) + +| Servicio Retail | Servicio Core | Metodo | +|-----------------|---------------|--------| +| AuthService | auth.service.ts | Import directo | +| UsersService | users.service.ts | Import directo | +| RolesService | roles.service.ts | Import directo | +| TenantsService | tenants.service.ts | Import directo | +| CountriesService | countries.service.ts | Import directo | +| CurrenciesService | currencies.service.ts | Import directo | + +### 3.2 Servicios a Extender (class extends) + +| Servicio Retail | Servicio Core Base | Extensiones | +|-----------------|-------------------|-------------| +| RetailProductsService | products.service.ts | + barcodes, + promotions | +| RetailInventoryService | inventory.service.ts | + branch_stock, + transfers | +| RetailSalesService | orders.service.ts | + pos_orders, + payments | +| RetailPricelistService | pricelists.service.ts | + promotions, + cupones | +| RetailInvoiceService | invoices.service.ts | + CFDI 4.0, + timbrado | +| RetailCustomersService | partners.service.ts | + loyalty, + points | + +### 3.3 Servicios Nuevos (Crear) + +| Servicio | Modulo | Descripcion | +|----------|--------|-------------| +| POSSessionService | RT-002 | Sesiones de caja | +| POSOrderService | RT-002 | Ventas POS | +| CashRegisterService | RT-007 | Gestion de cajas | +| CashMovementService | RT-007 | Movimientos efectivo | +| CashClosingService | RT-007 | Cortes de caja | +| BranchService | RT-003 | Gestion sucursales | +| BranchStockService | RT-003 | Stock por sucursal | +| TransferService | RT-003 | Transferencias | +| PromotionService | RT-006 | Promociones | +| LoyaltyService | RT-005 | Programa puntos | +| EcommerceCartService | RT-009 | Carrito compras | +| EcommerceCheckoutService | RT-009 | Checkout | +| CFDIService | RT-010 | Timbrado CFDI | + +--- + +## 4. MAPEO DE ENTIDADES TYPEORM + +### 4.1 Entidades Heredadas + +| Entidad Core | Uso en Retail | +|--------------|---------------| +| User | Usuarios y cajeros | +| Tenant | Empresas retail | +| Company | Companias | +| Role | Roles (cajero, supervisor) | +| Permission | Permisos | +| Partner | Clientes y proveedores | +| Product | Productos base | +| ProductVariant | Variantes | +| Warehouse | Almacenes (por sucursal) | +| Location | Ubicaciones | +| Invoice | Facturas base | +| Payment | Pagos | +| Pricelist | Listas de precios | + +### 4.2 Entidades Nuevas de Retail + +| Entidad | Relaciones | +|---------|-----------| +| Branch | → Tenant, → Warehouse | +| CashRegister | → Branch | +| POSSession | → CashRegister, → User | +| POSOrder | → POSSession, → Partner | +| POSOrderLine | → POSOrder, → Product | +| POSPayment | → POSOrder | +| CashMovement | → POSSession | +| BranchStock | → Branch, → Product | +| StockTransfer | → Branch (source), → Branch (dest) | +| StockTransferLine | → StockTransfer, → Product | +| ProductBarcode | → Product | +| Promotion | → Tenant | +| PromotionProduct | → Promotion, → Product | +| LoyaltyProgram | → Tenant | +| LoyaltyCard | → LoyaltyProgram, → Partner | +| LoyaltyTransaction | → LoyaltyCard | + +--- + +## 5. MAPEO DE ESPECIFICACIONES TRANSVERSALES + +### 5.1 SPECs Aplicables + +| SPEC Core | Modulo Retail | Aplicacion | +|-----------|---------------|------------| +| SPEC-PRICING-RULES | RT-006 | Motor de precios y promociones | +| SPEC-INVENTARIOS-CICLICOS | RT-003 | Conteos de inventario | +| SPEC-TRAZABILIDAD-LOTES-SERIES | RT-003 | Productos con caducidad | +| SPEC-VALORACION-INVENTARIO | RT-003 | Costeo de productos | +| SPEC-SISTEMA-SECUENCIAS | RT-002, RT-010 | Foliado tickets, facturas | +| SPEC-REPORTES-FINANCIEROS | RT-008 | Reportes de caja | + +### 5.2 SPECs No Aplicables + +| SPEC Core | Razon | +|-----------|-------| +| SPEC-INTEGRACION-CALENDAR | No requiere calendario | +| SPEC-PROYECTOS-DEPENDENCIAS | No hay proyectos largos | +| SPEC-FIRMA-ELECTRONICA-NOM151 | Solo CFDI, no NOM 151 | + +--- + +## 6. MAPEO DE ENDPOINTS API + +### 6.1 Endpoints Heredados + +| Endpoint Core | Uso en Retail | +|---------------|---------------| +| POST /auth/login | Login cajeros | +| POST /auth/logout | Logout | +| GET /users | Listado usuarios | +| GET /products | Productos | +| GET /partners | Clientes | +| GET /pricelists | Listas de precios | + +### 6.2 Endpoints Nuevos + +| Endpoint | Metodo | Descripcion | +|----------|--------|-------------| +| /pos/sessions | GET, POST | Sesiones de caja | +| /pos/sessions/:id/close | POST | Cerrar sesion | +| /pos/orders | GET, POST | Ventas POS | +| /pos/orders/:id/pay | POST | Pagar orden | +| /cash/movements | GET, POST | Movimientos efectivo | +| /cash/closings | GET, POST | Cortes de caja | +| /branches | GET, POST, PUT | Sucursales | +| /branches/:id/stock | GET | Stock sucursal | +| /transfers | GET, POST | Transferencias | +| /promotions | GET, POST, PUT | Promociones | +| /loyalty/cards | GET, POST | Tarjetas lealtad | +| /loyalty/transactions | GET, POST | Movimientos puntos | +| /cfdi/timbrar | POST | Timbrar factura | +| /cfdi/cancelar | POST | Cancelar CFDI | + +--- + +## 7. MATRIZ DE DEPENDENCIAS CRUZADAS + +### 7.1 Core → Retail + +``` +MGN-001 Auth ───────► RT-001 Fundamentos (100%) +MGN-002 Users ──────► RT-001 Fundamentos (100%) +MGN-003 Roles ──────► RT-001 Fundamentos (100%) +MGN-004 Tenants ────► RT-001 Fundamentos (100%) +MGN-010 Financial ──► RT-010 Facturacion (60%) +MGN-011 Inventory ──► RT-003 Inventario (60%) +MGN-012 Purchasing ─► RT-004 Compras (80%) +MGN-013 Sales ──────► RT-006 Precios (30%) +MGN-014 CRM ────────► RT-005 Clientes (40%) +MGN-009 Reports ────► RT-008 Reportes (70%) +``` + +### 7.2 Retail Interno + +``` +RT-001 ──► RT-002 (base auth) +RT-001 ──► RT-003 (base auth) +RT-001 ──► RT-004 (base auth) +RT-001 ──► RT-005 (base auth) +RT-001 ──► RT-006 (base auth) +RT-001 ──► RT-007 (base auth) +RT-001 ──► RT-008 (base auth) +RT-001 ──► RT-009 (base auth) +RT-001 ──► RT-010 (base auth) + +RT-006 ──► RT-002 (precios en POS) +RT-003 ──► RT-002 (stock en POS) +RT-005 ──► RT-002 (clientes en POS) + +RT-003 ◄──► RT-004 (inventario y compras) + +RT-002 ──► RT-007 (ventas → caja) +RT-002 ──► RT-010 (ventas → factura) + +RT-003 ──► RT-009 (stock en ecommerce) +RT-005 ──► RT-009 (clientes en ecommerce) +RT-006 ──► RT-009 (precios en ecommerce) + +RT-002, RT-003, RT-005, RT-006, RT-007 ──► RT-008 (reportes) +``` + +--- + +## 8. RESUMEN DE ACCIONES + +### 8.1 Por Tipo de Integracion + +| Tipo | Cantidad | Modulos | +|------|----------|---------| +| HEREDAR (100%) | 4 | RT-001 | +| EXTENDER (30-80%) | 6 | RT-003, RT-004, RT-005, RT-006, RT-008, RT-010 | +| CREAR NUEVO (10-20%) | 3 | RT-002, RT-007, RT-009 | + +### 8.2 Por Capa + +| Capa | Heredar | Extender | Crear | +|------|---------|----------|-------| +| Database | 144 tablas | 0 | 26 tablas | +| Backend Services | 6 | 6 | 13 | +| Entities | 13 | 0 | 16 | +| Endpoints | 10+ | 5 | 20+ | + +--- + +**Documento generado:** 2025-12-18 +**Fase:** 1 - Planeacion - Mapeo Core-Retail +**Estado:** COMPLETO diff --git a/orchestration/planes/fase-2-analisis-modulos/ANALISIS-RT-001-fundamentos.md b/orchestration/planes/fase-2-analisis-modulos/ANALISIS-RT-001-fundamentos.md new file mode 100644 index 0000000..a04e076 --- /dev/null +++ b/orchestration/planes/fase-2-analisis-modulos/ANALISIS-RT-001-fundamentos.md @@ -0,0 +1,286 @@ +# ANALISIS MODULO RT-001: FUNDAMENTOS + +**Fecha:** 2025-12-18 +**Fase:** 2 - Analisis por Modulo +**Modulo:** RT-001 Fundamentos +**Herencia:** 100% +**Story Points:** 0 (heredado) +**Prioridad:** P0 + +--- + +## 1. DESCRIPCION GENERAL + +### 1.1 Proposito +Modulo base que proporciona autenticacion, gestion de usuarios, roles y multi-tenancy para toda la vertical retail. Hereda el 100% del ERP-Core sin modificaciones. + +### 1.2 Funcionalidades + +| Funcionalidad | Descripcion | +|---------------|-------------| +| Autenticacion | Login/logout con JWT | +| Usuarios | CRUD de usuarios por sucursal | +| Roles | Cajero, Supervisor, Gerente, Admin | +| Multi-tenancy | Aislamiento por tenant | +| Sesiones | Gestion de sesiones activas | + +--- + +## 2. HERENCIA DEL CORE + +### 2.1 Modulos Core Heredados + +| Modulo Core | Codigo | % Herencia | Tipo | +|-------------|--------|------------|------| +| Auth | MGN-001 | 100% | Import directo | +| Users | MGN-002 | 100% | Import directo | +| Roles | MGN-003 | 100% | Import directo | +| Tenants | MGN-004 | 100% | Import directo | + +### 2.2 Servicios Heredados + +```typescript +// Imports directos desde @erp-core +import { AuthService } from '@erp-core/auth'; +import { UsersService } from '@erp-core/users'; +import { RolesService } from '@erp-core/roles'; +import { TenantsService } from '@erp-core/tenants'; +``` + +### 2.3 Tablas Heredadas (26 tablas schema auth) + +| Tabla | Proposito | +|-------|-----------| +| auth.users | Usuarios del sistema | +| auth.tenants | Tenants (empresas) | +| auth.companies | Companias por tenant | +| auth.roles | Roles del sistema | +| auth.permissions | Permisos granulares | +| auth.user_roles | Asignacion usuario-rol | +| auth.role_permissions | Asignacion rol-permiso | +| auth.sessions | Sesiones activas | +| auth.user_companies | Usuario-compania | +| auth.password_resets | Tokens reset password | +| auth.oauth_user_links | OAuth providers | +| auth.trusted_devices | Dispositivos confiables | +| auth.verification_codes | Codigos verificacion | +| auth.mfa_audit_log | Log de MFA | +| ... | (12 tablas adicionales) | + +--- + +## 3. EXTENSIONES REQUERIDAS + +### 3.1 Roles Especificos Retail + +```yaml +roles_retail: + - codigo: CAJERO + descripcion: Operador de caja + permisos: + - pos.sales.create + - pos.sales.read + - cash.movements.read + - inventory.stock.read + + - codigo: SUPERVISOR + descripcion: Supervisor de sucursal + permisos: + - pos.sales.* + - cash.* + - inventory.stock.* + - reports.sales.read + - promotions.apply + + - codigo: GERENTE + descripcion: Gerente de sucursal + permisos: + - pos.* + - cash.* + - inventory.* + - reports.* + - users.sucursal.* + + - codigo: ADMIN_RETAIL + descripcion: Administrador retail + permisos: + - "*" # Todos los permisos +``` + +### 3.2 Configuracion por Sucursal + +```typescript +interface RetailUserConfig { + userId: string; + branchId: string; // Sucursal asignada + canAccessAllBranches: boolean; + defaultCashRegisterId?: string; + maxDiscountPercent: number; // Limite descuento +} +``` + +--- + +## 4. COMPONENTES A IMPLEMENTAR + +### 4.1 Backend + +| Componente | Tipo | Accion | +|------------|------|--------| +| AuthModule | Module | HEREDAR de @erp-core | +| UsersModule | Module | HEREDAR de @erp-core | +| RolesModule | Module | HEREDAR de @erp-core | +| TenantsModule | Module | HEREDAR de @erp-core | +| RetailRolesSeeder | Seeder | CREAR - Roles retail | +| RetailPermissionsSeeder | Seeder | CREAR - Permisos retail | + +### 4.2 Frontend + +| Componente | Tipo | Accion | +|------------|------|--------| +| LoginPage | Page | HEREDAR de @erp-core | +| UserProfile | Component | HEREDAR de @erp-core | +| RoleSelector | Component | HEREDAR de @erp-core | + +### 4.3 Database + +| Tabla | Accion | +|-------|--------| +| auth.* (todas) | HEREDAR - Sin cambios | + +### 4.4 Seeds Retail + +```sql +-- Roles especificos retail +INSERT INTO auth.roles (id, tenant_id, name, code, description) VALUES + (uuid_generate_v4(), :tenant_id, 'Cajero', 'CAJERO', 'Operador de caja'), + (uuid_generate_v4(), :tenant_id, 'Supervisor', 'SUPERVISOR', 'Supervisor sucursal'), + (uuid_generate_v4(), :tenant_id, 'Gerente', 'GERENTE', 'Gerente sucursal'), + (uuid_generate_v4(), :tenant_id, 'Admin Retail', 'ADMIN_RETAIL', 'Administrador'); + +-- Permisos especificos retail +INSERT INTO auth.permissions (id, code, name, module) VALUES + (uuid_generate_v4(), 'pos.sales.create', 'Crear ventas POS', 'pos'), + (uuid_generate_v4(), 'pos.sales.read', 'Ver ventas POS', 'pos'), + (uuid_generate_v4(), 'pos.sales.void', 'Anular ventas', 'pos'), + (uuid_generate_v4(), 'cash.open', 'Abrir caja', 'cash'), + (uuid_generate_v4(), 'cash.close', 'Cerrar caja', 'cash'), + (uuid_generate_v4(), 'cash.movements.create', 'Crear movimientos', 'cash'), + -- ... mas permisos +``` + +--- + +## 5. DEPENDENCIAS + +### 5.1 Dependencias de Core + +| Modulo | Estado | Bloqueante | +|--------|--------|------------| +| MGN-001 Auth | 40% | SI | +| MGN-002 Users | 30% | SI | +| MGN-003 Roles | 0% | SI | +| MGN-004 Tenants | 0% | SI | + +### 5.2 Dependencias de Retail + +| Modulo | Tipo | +|--------|------| +| RT-002 POS | Depende de RT-001 | +| RT-003 Inventario | Depende de RT-001 | +| RT-004 Compras | Depende de RT-001 | +| RT-005 Clientes | Depende de RT-001 | +| RT-006 Precios | Depende de RT-001 | +| RT-007 Caja | Depende de RT-001 | +| RT-008 Reportes | Depende de RT-001 | +| RT-009 E-commerce | Depende de RT-001 | +| RT-010 Facturacion | Depende de RT-001 | + +--- + +## 6. ESPECIFICACIONES TECNICAS + +### 6.1 Autenticacion + +```yaml +auth_config: + jwt: + access_token_expiry: "15m" + refresh_token_expiry: "7d" + password: + hash_algorithm: "bcrypt" + rounds: 10 + min_length: 8 + rate_limiting: + login_attempts: 5 + lockout_duration: "15m" + session: + max_concurrent: 3 + idle_timeout: "30m" +``` + +### 6.2 RLS Policies + +```sql +-- Ya implementadas en core, se heredan +CREATE POLICY tenant_isolation ON auth.users + FOR ALL USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID); +``` + +--- + +## 7. CRITERIOS DE ACEPTACION + +### 7.1 Funcionales + +- [ ] Login con usuario y password funciona +- [ ] JWT se genera correctamente +- [ ] Refresh token funciona +- [ ] Roles se cargan con el usuario +- [ ] Permisos se validan en endpoints +- [ ] Multi-tenancy aisla datos + +### 7.2 No Funcionales + +- [ ] Login < 500ms +- [ ] Token validation < 50ms +- [ ] Session management sin memory leaks + +--- + +## 8. RIESGOS + +| Riesgo | Probabilidad | Impacto | Mitigacion | +|--------|--------------|---------|------------| +| Core auth incompleto | Alta | Critico | Completar core primero | +| Integracion fallida | Media | Alto | Tests de integracion | + +--- + +## 9. ESTIMACION + +| Tarea | SP | +|-------|---:| +| Configurar herencia modulos | 0 | +| Crear seeds roles retail | 0 | +| Crear seeds permisos retail | 0 | +| Tests de integracion | 0 | +| **TOTAL** | **0** | + +*Nota: SP = 0 porque es 100% herencia del core* + +--- + +## 10. REFERENCIAS + +| Documento | Ubicacion | +|-----------|-----------| +| Modulo Auth Core | erp-core/docs/01-fase-foundation/MGN-001-auth/ | +| Modulo Users Core | erp-core/docs/01-fase-foundation/MGN-002-users/ | +| Modulo Roles Core | erp-core/docs/01-fase-foundation/MGN-003-roles/ | +| DDL Auth | erp-core/database/ddl/01-auth.sql | + +--- + +**Estado:** ANALISIS COMPLETO +**Siguiente:** Esperar completitud de core auth diff --git a/orchestration/planes/fase-2-analisis-modulos/ANALISIS-RT-002-pos.md b/orchestration/planes/fase-2-analisis-modulos/ANALISIS-RT-002-pos.md new file mode 100644 index 0000000..15f2257 --- /dev/null +++ b/orchestration/planes/fase-2-analisis-modulos/ANALISIS-RT-002-pos.md @@ -0,0 +1,500 @@ +# ANALISIS MODULO RT-002: PUNTO DE VENTA (POS) + +**Fecha:** 2025-12-18 +**Fase:** 2 - Analisis por Modulo +**Modulo:** RT-002 POS +**Herencia:** 20% +**Story Points:** 55 +**Prioridad:** P0 (Critico) + +--- + +## 1. DESCRIPCION GENERAL + +### 1.1 Proposito +Terminal de punto de venta para operacion en tienda fisica con soporte offline, integracion de hardware y venta rapida en mostrador. + +### 1.2 Funcionalidades Principales + +| Funcionalidad | Descripcion | Criticidad | +|---------------|-------------|------------| +| Venta rapida | Escaneo y cobro < 30s | Critica | +| Modo offline | Operacion sin internet | Critica | +| Multi-pago | Efectivo, tarjeta, mixto | Alta | +| Descuentos | Manuales y automaticos | Alta | +| Hardware | Impresora, lector, cajon | Alta | +| Tickets | Impresion automatica | Alta | + +--- + +## 2. HERENCIA DEL CORE + +### 2.1 Componentes Heredados (20%) + +| Componente Core | Uso en POS | +|-----------------|------------| +| inventory.products | Catalogo de productos | +| sales.pricelists | Precios base | +| core.partners | Clientes | +| auth.users | Cajeros | + +### 2.2 Tablas Reutilizadas + +| Tabla Core | Relacion | +|------------|----------| +| inventory.products | FK en pos_order_lines.product_id | +| core.partners | FK en pos_orders.customer_id | +| auth.users | FK en pos_sessions.user_id | +| sales.pricelists | Consulta de precios | + +--- + +## 3. COMPONENTES NUEVOS + +### 3.1 Entidades (TypeORM) + +```typescript +// 1. POSSession - Sesion de caja +@Entity('pos_sessions', { schema: 'retail' }) +export class POSSession { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column('uuid') + tenantId: string; + + @ManyToOne(() => Branch) + branch: Branch; + + @ManyToOne(() => CashRegister) + cashRegister: CashRegister; + + @ManyToOne(() => User) + user: User; + + @Column({ type: 'enum', enum: POSSessionStatus }) + status: POSSessionStatus; + + @Column({ type: 'timestamptz' }) + openingDate: Date; + + @Column({ type: 'decimal', precision: 12, scale: 2 }) + openingBalance: number; + + @Column({ type: 'timestamptz', nullable: true }) + closingDate: Date; + + @Column({ type: 'decimal', precision: 12, scale: 2, nullable: true }) + closingBalance: number; + + @Column({ type: 'decimal', precision: 12, scale: 2, default: 0 }) + totalSales: number; + + @Column({ type: 'decimal', precision: 12, scale: 2, default: 0 }) + totalRefunds: number; +} + +// 2. POSOrder - Venta +@Entity('pos_orders', { schema: 'retail' }) +export class POSOrder { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column('uuid') + tenantId: string; + + @ManyToOne(() => POSSession) + session: POSSession; + + @Column() + orderNumber: string; + + @Column({ type: 'timestamptz' }) + orderDate: Date; + + @ManyToOne(() => Partner, { nullable: true }) + customer: Partner; + + @Column({ type: 'enum', enum: POSOrderStatus }) + status: POSOrderStatus; + + @Column({ type: 'decimal', precision: 12, scale: 2 }) + subtotal: number; + + @Column({ type: 'decimal', precision: 12, scale: 2 }) + discountAmount: number; + + @Column({ type: 'decimal', precision: 12, scale: 2 }) + taxAmount: number; + + @Column({ type: 'decimal', precision: 12, scale: 2 }) + total: number; + + @Column({ type: 'enum', enum: PaymentMethod }) + paymentMethod: PaymentMethod; + + @OneToMany(() => POSOrderLine, line => line.order) + lines: POSOrderLine[]; +} + +// 3. POSOrderLine - Linea de venta +@Entity('pos_order_lines', { schema: 'retail' }) +export class POSOrderLine { + @PrimaryGeneratedColumn('uuid') + id: string; + + @ManyToOne(() => POSOrder) + order: POSOrder; + + @ManyToOne(() => Product) + product: Product; + + @Column() + productName: string; + + @Column({ nullable: true }) + barcode: string; + + @Column({ type: 'decimal', precision: 12, scale: 4 }) + quantity: number; + + @Column({ type: 'decimal', precision: 12, scale: 2 }) + unitPrice: number; + + @Column({ type: 'decimal', precision: 5, scale: 2, default: 0 }) + discountPercent: number; + + @Column({ type: 'decimal', precision: 12, scale: 2 }) + subtotal: number; + + @Column({ type: 'decimal', precision: 12, scale: 2 }) + total: number; +} +``` + +### 3.2 Servicios Backend + +| Servicio | Metodos Principales | +|----------|-------------------| +| POSSessionService | openSession(), closeSession(), getActiveSession() | +| POSOrderService | createOrder(), addLine(), removeLine(), applyDiscount(), pay() | +| POSPaymentService | processPayment(), processMixedPayment(), refund() | +| POSPrintService | printTicket(), openCashDrawer() | +| POSSyncService | syncOfflineOrders(), resolveConflicts() | + +### 3.3 Controladores + +```typescript +@Controller('pos') +export class POSController { + // Sessions + @Post('sessions/open') + openSession(@Body() dto: OpenSessionDto): Promise; + + @Post('sessions/:id/close') + closeSession(@Param('id') id: string, @Body() dto: CloseSessionDto): Promise; + + @Get('sessions/active') + getActiveSession(): Promise; + + // Orders + @Post('orders') + createOrder(@Body() dto: CreateOrderDto): Promise; + + @Post('orders/:id/lines') + addLine(@Param('id') id: string, @Body() dto: AddLineDto): Promise; + + @Delete('orders/:id/lines/:lineId') + removeLine(@Param('id') id: string, @Param('lineId') lineId: string): Promise; + + @Post('orders/:id/discount') + applyDiscount(@Param('id') id: string, @Body() dto: DiscountDto): Promise; + + @Post('orders/:id/pay') + pay(@Param('id') id: string, @Body() dto: PaymentDto): Promise; + + // Products + @Get('products/search') + searchProducts(@Query('q') query: string): Promise; + + @Get('products/barcode/:code') + getByBarcode(@Param('code') code: string): Promise; + + // Sync + @Post('sync') + syncOfflineOrders(@Body() dto: SyncDto): Promise; +} +``` + +### 3.4 Frontend (PWA) + +| Pagina/Componente | Descripcion | +|-------------------|-------------| +| POSPage | Pantalla principal de venta | +| ProductSearch | Busqueda de productos | +| Cart | Carrito de compra | +| PaymentModal | Modal de pago | +| TicketPreview | Vista previa de ticket | +| SessionStatus | Estado de sesion | +| OfflineIndicator | Indicador modo offline | +| HardwareStatus | Estado de hardware | + +--- + +## 4. REQUERIMIENTOS TECNICOS + +### 4.1 PWA y Modo Offline + +```yaml +pwa_config: + manifest: + name: "Retail POS" + short_name: "POS" + display: "fullscreen" + orientation: "landscape" + + service_worker: + cache_strategy: "cache-first" + assets_to_cache: + - "/pos/**" + - "/api/products" + - "/api/pricelists" + + indexed_db: + stores: + - products + - prices + - customers + - offline_orders + - pending_sync + + sync: + strategy: "background-sync" + retry_interval: "30s" + max_retries: 10 +``` + +### 4.2 Performance + +| Operacion | Objetivo | Implementacion | +|-----------|----------|----------------| +| Busqueda producto | < 50ms | Indice + Cache Redis | +| Agregar al carrito | < 100ms | Operacion local | +| Calcular total | < 10ms | Calculo en memoria | +| Procesar pago | < 3s | Async + optimistic UI | + +### 4.3 Integracion Hardware + +```typescript +// Interfaz de Hardware +interface POSHardware { + printer: TicketPrinter; + scanner: BarcodeScanner; + drawer: CashDrawer; + cardReader?: CardReader; +} + +// Impresora ESC/POS +interface TicketPrinter { + connect(): Promise; + print(ticket: TicketData): Promise; + openDrawer(): Promise; + cut(): Promise; +} + +// Lector de codigo de barras +interface BarcodeScanner { + onScan(callback: (barcode: string) => void): void; +} +``` + +--- + +## 5. TABLAS DDL + +### 5.1 Tablas Nuevas + +```sql +-- Ya definidas en 03-retail-tables.sql +CREATE TABLE retail.pos_sessions (...); +CREATE TABLE retail.pos_orders (...); +CREATE TABLE retail.pos_order_lines (...); +CREATE TABLE retail.pos_payments (...); +CREATE TABLE retail.cash_registers (...); +``` + +### 5.2 ENUMs + +```sql +CREATE TYPE retail.pos_session_status AS ENUM ('opening', 'open', 'closing', 'closed'); +CREATE TYPE retail.pos_order_status AS ENUM ('draft', 'paid', 'done', 'cancelled', 'refunded'); +CREATE TYPE retail.payment_method AS ENUM ('cash', 'card', 'transfer', 'credit', 'mixed'); +``` + +### 5.3 Indices Criticos + +```sql +-- Performance para busqueda +CREATE INDEX idx_pos_orders_session ON retail.pos_orders(session_id); +CREATE INDEX idx_pos_orders_date ON retail.pos_orders(order_date); +CREATE INDEX idx_pos_order_lines_order ON retail.pos_order_lines(order_id); +CREATE INDEX idx_pos_order_lines_product ON retail.pos_order_lines(product_id); + +-- Busqueda por codigo de barras +CREATE INDEX idx_product_barcodes_code ON retail.product_barcodes(barcode); +``` + +--- + +## 6. DEPENDENCIAS + +### 6.1 Dependencias de Core + +| Modulo | Requerido Para | +|--------|---------------| +| MGN-001 Auth | Autenticacion cajeros | +| MGN-011 Inventory | Productos y stock | +| MGN-013 Sales | Precios | +| MGN-005 Catalogs | Partners (clientes) | + +### 6.2 Dependencias de Retail + +| Modulo | Tipo | +|--------|------| +| RT-001 Fundamentos | Prerequisito | +| RT-003 Inventario | Stock por sucursal | +| RT-006 Precios | Promociones | +| RT-005 Clientes | Programa lealtad | + +### 6.3 Dependencias Externas + +| Servicio | Proposito | +|----------|-----------| +| Redis | Cache de productos | +| WebSocket | Sync tiempo real | + +--- + +## 7. FLUJOS DE NEGOCIO + +### 7.1 Flujo de Venta + +``` +1. Cajero abre sesion (fondo inicial) + ↓ +2. Cliente presenta productos + ↓ +3. Escanear/buscar productos + ↓ +4. Agregar al carrito + ↓ +5. Aplicar descuentos (si aplica) + ↓ +6. Seleccionar forma de pago + ↓ +7. Procesar pago + ↓ +8. Imprimir ticket + ↓ +9. Abrir cajon (si efectivo) + ↓ +10. Entregar cambio y productos +``` + +### 7.2 Flujo Offline + +``` +1. Detectar perdida de conexion + ↓ +2. Activar modo offline + ↓ +3. Usar cache local (IndexedDB) + ↓ +4. Almacenar ventas en cola + ↓ +5. Detectar reconexion + ↓ +6. Sincronizar cola con servidor + ↓ +7. Resolver conflictos (si existen) + ↓ +8. Confirmar sincronizacion +``` + +--- + +## 8. CRITERIOS DE ACEPTACION + +### 8.1 Funcionales + +- [ ] Abrir sesion con fondo inicial +- [ ] Buscar productos por nombre/SKU +- [ ] Escanear codigo de barras +- [ ] Agregar/quitar productos del carrito +- [ ] Calcular subtotal, descuento, impuesto, total +- [ ] Aplicar descuento manual (con limite) +- [ ] Procesar pago efectivo con cambio +- [ ] Procesar pago tarjeta +- [ ] Procesar pago mixto +- [ ] Imprimir ticket automaticamente +- [ ] Cerrar sesion con arqueo + +### 8.2 Modo Offline + +- [ ] Funcionar sin conexion 24+ horas +- [ ] Cache de productos disponible +- [ ] Almacenar ventas localmente +- [ ] Sincronizar al reconectar +- [ ] No perder transacciones + +### 8.3 Performance + +- [ ] Busqueda < 50ms +- [ ] Calculo totales < 10ms +- [ ] Registro venta < 100ms +- [ ] Venta completa < 30s + +--- + +## 9. RIESGOS + +| Riesgo | Probabilidad | Impacto | Mitigacion | +|--------|--------------|---------|------------| +| PWA complejo | Alta | Alto | Spike tecnico primero | +| Hardware variado | Media | Medio | Drivers genericos | +| Sync conflicts | Media | Alto | Estrategia last-write-wins | +| Performance | Media | Critico | Indices + Cache | + +--- + +## 10. ESTIMACION DETALLADA + +| Componente | SP Backend | SP Frontend | Total | +|------------|-----------|-------------|-------| +| Entities + Migrations | 3 | - | 3 | +| POSSessionService | 5 | - | 5 | +| POSOrderService | 8 | - | 8 | +| POSPaymentService | 5 | - | 5 | +| POSController | 3 | - | 3 | +| PWA Setup | - | 5 | 5 | +| POSPage + Cart | - | 8 | 8 | +| PaymentModal | - | 3 | 3 | +| Offline Sync | 5 | 5 | 10 | +| Hardware Integration | 5 | - | 5 | +| **TOTAL** | **34** | **21** | **55** | + +--- + +## 11. REFERENCIAS + +| Documento | Ubicacion | +|-----------|-----------| +| Epica RT-002 | docs/08-epicas/EPIC-RT-002-pos.md | +| Modulo definicion | docs/02-definicion-modulos/RT-002-pos/ | +| DDL Retail | database/init/03-retail-tables.sql | +| Directiva POS | orchestration/directivas/DIRECTIVA-PUNTO-VENTA.md | + +--- + +**Estado:** ANALISIS COMPLETO +**Bloqueado por:** RT-001 Fundamentos, RT-006 Precios diff --git a/orchestration/planes/fase-2-analisis-modulos/ANALISIS-RT-003-inventario.md b/orchestration/planes/fase-2-analisis-modulos/ANALISIS-RT-003-inventario.md new file mode 100644 index 0000000..110eb6f --- /dev/null +++ b/orchestration/planes/fase-2-analisis-modulos/ANALISIS-RT-003-inventario.md @@ -0,0 +1,437 @@ +# ANALISIS MODULO RT-003: INVENTARIO MULTI-SUCURSAL + +**Fecha:** 2025-12-18 +**Fase:** 2 - Analisis por Modulo +**Modulo:** RT-003 Inventario +**Herencia:** 60% +**Story Points:** 42 +**Prioridad:** P0 + +--- + +## 1. DESCRIPCION GENERAL + +### 1.1 Proposito +Gestion de inventario distribuido entre multiples sucursales con transferencias, conteos ciclicos y alertas de reorden. + +### 1.2 Funcionalidades Principales + +| Funcionalidad | Descripcion | Criticidad | +|---------------|-------------|------------| +| Stock por sucursal | Inventario independiente | Critica | +| Transferencias | Entre sucursales | Alta | +| Alertas reorden | Stock minimo | Alta | +| Conteos ciclicos | ABC | Media | +| Kardex | Historial movimientos | Media | +| Reservas | Stock apartado | Media | + +--- + +## 2. HERENCIA DEL CORE + +### 2.1 Componentes Heredados (60%) + +| Componente Core | % Uso | Accion | +|-----------------|-------|--------| +| inventory.products | 100% | HEREDAR | +| inventory.product_variants | 100% | HEREDAR | +| inventory.warehouses | 100% | HEREDAR | +| inventory.locations | 100% | HEREDAR | +| inventory.lots | 100% | HEREDAR | +| inventory.stock_moves | 80% | EXTENDER | +| inventory.pickings | 60% | EXTENDER | + +### 2.2 Servicios a Heredar + +```typescript +import { ProductsService } from '@erp-core/inventory'; +import { WarehousesService } from '@erp-core/inventory'; +import { LocationsService } from '@erp-core/inventory'; +import { LotsService } from '@erp-core/inventory'; +``` + +### 2.3 Servicios a Extender + +```typescript +// Extender para multi-sucursal +class RetailInventoryService extends InventoryService { + // Stock por sucursal + async getStockByBranch(branchId: string, productId: string): Promise; + + // Transferencias + async createTransfer(dto: CreateTransferDto): Promise; + async confirmTransfer(id: string): Promise; + async receiveTransfer(id: string, dto: ReceiveDto): Promise; +} +``` + +--- + +## 3. COMPONENTES NUEVOS + +### 3.1 Entidades (TypeORM) + +```typescript +// 1. BranchStock - Stock por sucursal +@Entity('branch_stock', { schema: 'retail' }) +export class BranchStock { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column('uuid') + tenantId: string; + + @ManyToOne(() => Branch) + branch: Branch; + + @ManyToOne(() => Product) + product: Product; + + @Column({ type: 'decimal', precision: 12, scale: 4 }) + quantityOnHand: number; + + @Column({ type: 'decimal', precision: 12, scale: 4, default: 0 }) + quantityReserved: number; + + @Column({ type: 'decimal', precision: 12, scale: 4 }) + @Generated('quantityOnHand - quantityReserved') + quantityAvailable: number; + + @Column({ type: 'decimal', precision: 12, scale: 4, nullable: true }) + reorderPoint: number; + + @Column({ type: 'decimal', precision: 12, scale: 4, nullable: true }) + maxStock: number; + + @Column({ type: 'date', nullable: true }) + lastCountDate: Date; +} + +// 2. StockTransfer - Transferencia +@Entity('stock_transfers', { schema: 'retail' }) +export class StockTransfer { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column('uuid') + tenantId: string; + + @Column() + transferNumber: string; + + @ManyToOne(() => Branch) + sourceBranch: Branch; + + @ManyToOne(() => Branch) + destinationBranch: Branch; + + @Column({ type: 'enum', enum: TransferStatus }) + status: TransferStatus; + + @Column({ type: 'timestamptz' }) + requestDate: Date; + + @Column({ type: 'timestamptz', nullable: true }) + shipDate: Date; + + @Column({ type: 'timestamptz', nullable: true }) + receiveDate: Date; + + @ManyToOne(() => User) + requestedBy: User; + + @ManyToOne(() => User, { nullable: true }) + shippedBy: User; + + @ManyToOne(() => User, { nullable: true }) + receivedBy: User; + + @OneToMany(() => StockTransferLine, line => line.transfer) + lines: StockTransferLine[]; +} + +// 3. StockTransferLine +@Entity('stock_transfer_lines', { schema: 'retail' }) +export class StockTransferLine { + @PrimaryGeneratedColumn('uuid') + id: string; + + @ManyToOne(() => StockTransfer) + transfer: StockTransfer; + + @ManyToOne(() => Product) + product: Product; + + @Column({ type: 'decimal', precision: 12, scale: 4 }) + quantityRequested: number; + + @Column({ type: 'decimal', precision: 12, scale: 4, nullable: true }) + quantityShipped: number; + + @Column({ type: 'decimal', precision: 12, scale: 4, nullable: true }) + quantityReceived: number; +} +``` + +### 3.2 Servicios Backend + +| Servicio | Metodos Principales | +|----------|-------------------| +| BranchStockService | getStock(), updateStock(), reserveStock(), releaseStock() | +| TransferService | create(), confirm(), ship(), receive(), cancel() | +| ReorderService | checkReorderPoints(), generateSuggestions() | +| CycleCountService | createCount(), recordCount(), approveAdjustment() | +| KardexService | getMovements(), getProductHistory() | + +### 3.3 Controladores + +```typescript +@Controller('inventory') +export class RetailInventoryController { + // Stock por sucursal + @Get('branches/:branchId/stock') + getBranchStock(@Param('branchId') branchId: string): Promise; + + @Get('branches/:branchId/stock/:productId') + getProductStock(@Param('branchId') branchId: string, + @Param('productId') productId: string): Promise; + + @Get('stock/all/:productId') + getStockAllBranches(@Param('productId') productId: string): Promise; + + // Transferencias + @Post('transfers') + createTransfer(@Body() dto: CreateTransferDto): Promise; + + @Post('transfers/:id/confirm') + confirmTransfer(@Param('id') id: string): Promise; + + @Post('transfers/:id/ship') + shipTransfer(@Param('id') id: string, @Body() dto: ShipDto): Promise; + + @Post('transfers/:id/receive') + receiveTransfer(@Param('id') id: string, @Body() dto: ReceiveDto): Promise; + + // Alertas + @Get('alerts/reorder') + getReorderAlerts(): Promise; + + // Kardex + @Get('kardex/:productId') + getKardex(@Param('productId') productId: string, + @Query() filters: KardexFilters): Promise; +} +``` + +--- + +## 4. FLUJOS DE NEGOCIO + +### 4.1 Flujo de Transferencia + +``` +1. Sucursal destino solicita transferencia + ↓ +2. Estado: DRAFT → PENDING (pendiente aprobacion) + ↓ +3. Sucursal origen aprueba + ↓ +4. Estado: PENDING → APPROVED + ↓ +5. Sucursal origen prepara y envia + ↓ +6. Estado: APPROVED → IN_TRANSIT + ↓ +7. Stock origen: - cantidad_enviada + ↓ +8. Sucursal destino recibe + ↓ +9. Validar cantidades (diferencias) + ↓ +10. Estado: IN_TRANSIT → RECEIVED + ↓ +11. Stock destino: + cantidad_recibida +``` + +### 4.2 Flujo de Conteo Ciclico + +``` +1. Generar conteo (clasificacion ABC) + ↓ +2. Asignar a contador + ↓ +3. Conteo ciego (sin ver stock sistema) + ↓ +4. Registrar cantidades + ↓ +5. Comparar vs sistema + ↓ +6. Si diferencia: + a. Registrar motivo + b. Aprobar ajuste (supervisor) + c. Aplicar ajuste + ↓ +7. Actualizar fecha ultimo conteo +``` + +--- + +## 5. TABLAS DDL + +### 5.1 Tablas Nuevas + +```sql +-- Definidas en 03-retail-tables.sql +CREATE TABLE retail.branch_stock (...); +CREATE TABLE retail.stock_transfers (...); +CREATE TABLE retail.stock_transfer_lines (...); +``` + +### 5.2 Indices + +```sql +-- Stock por sucursal +CREATE UNIQUE INDEX idx_branch_stock_branch_product + ON retail.branch_stock(branch_id, product_id); + +-- Transferencias +CREATE INDEX idx_transfers_source ON retail.stock_transfers(source_branch_id); +CREATE INDEX idx_transfers_dest ON retail.stock_transfers(destination_branch_id); +CREATE INDEX idx_transfers_status ON retail.stock_transfers(status); +``` + +### 5.3 Triggers + +```sql +-- Actualizar stock al confirmar venta +CREATE OR REPLACE FUNCTION retail.update_stock_on_sale() +RETURNS TRIGGER AS $$ +BEGIN + UPDATE retail.branch_stock + SET quantity_on_hand = quantity_on_hand - NEW.quantity + WHERE branch_id = (SELECT branch_id FROM retail.pos_sessions WHERE id = + (SELECT session_id FROM retail.pos_orders WHERE id = NEW.order_id)) + AND product_id = NEW.product_id; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; +``` + +--- + +## 6. DEPENDENCIAS + +### 6.1 Dependencias de Core + +| Modulo | Estado | Requerido Para | +|--------|--------|---------------| +| MGN-011 Inventory | 60% | Base de productos y stock | +| MGN-005 Catalogs | 0% | Productos base | + +### 6.2 Dependencias de Retail + +| Modulo | Tipo | +|--------|------| +| RT-001 Fundamentos | Prerequisito | +| RT-004 Compras | Recepciones | + +### 6.3 Bloquea a + +| Modulo | Razon | +|--------|-------| +| RT-002 POS | Stock disponible | +| RT-009 E-commerce | Stock online | + +--- + +## 7. ESPECIFICACIONES APLICABLES + +### 7.1 Del Core + +| SPEC | Aplicacion | +|------|------------| +| SPEC-INVENTARIOS-CICLICOS | Conteos | +| SPEC-TRAZABILIDAD-LOTES-SERIES | Productos con lote | +| SPEC-VALORACION-INVENTARIO | Costeo | + +### 7.2 Configuracion + +```yaml +inventory_config: + reorder: + check_interval: "1h" + notification: ["email", "push"] + + cycle_count: + classification: + A: { frequency: "weekly", threshold: 0.8 } # 80% del valor + B: { frequency: "monthly", threshold: 0.15 } + C: { frequency: "quarterly", threshold: 0.05 } + + transfers: + require_approval: true + auto_reserve_on_request: true +``` + +--- + +## 8. CRITERIOS DE ACEPTACION + +### 8.1 Funcionales + +- [ ] Consultar stock por sucursal +- [ ] Ver stock en todas las sucursales +- [ ] Crear solicitud de transferencia +- [ ] Aprobar transferencia +- [ ] Enviar transferencia (actualiza stock origen) +- [ ] Recibir transferencia (actualiza stock destino) +- [ ] Registrar diferencias en recepcion +- [ ] Alertas de stock bajo automaticas +- [ ] Conteo ciclico con clasificacion ABC +- [ ] Ver kardex de movimientos + +### 8.2 Performance + +- [ ] Consulta stock < 500ms +- [ ] Listado sucursales < 1s + +--- + +## 9. RIESGOS + +| Riesgo | Probabilidad | Impacto | Mitigacion | +|--------|--------------|---------|------------| +| Inconsistencia stock | Media | Critico | Transacciones atomicas | +| Performance en listados | Media | Medio | Indices + paginacion | + +--- + +## 10. ESTIMACION DETALLADA + +| Componente | SP Backend | SP Frontend | Total | +|------------|-----------|-------------|-------| +| Entities + Migrations | 3 | - | 3 | +| BranchStockService | 5 | - | 5 | +| TransferService | 8 | - | 8 | +| ReorderService | 3 | - | 3 | +| CycleCountService | 5 | - | 5 | +| Controllers | 3 | - | 3 | +| Stock Pages | - | 8 | 8 | +| Transfer Pages | - | 5 | 5 | +| Alerts UI | - | 2 | 2 | +| **TOTAL** | **27** | **15** | **42** | + +--- + +## 11. REFERENCIAS + +| Documento | Ubicacion | +|-----------|-----------| +| Epica RT-003 | docs/08-epicas/EPIC-RT-003-inventario.md | +| Modulo Core Inventory | erp-core/backend/src/modules/inventory/ | +| SPEC Conteos | erp-core/docs/04-modelado/especificaciones-tecnicas/ | + +--- + +**Estado:** ANALISIS COMPLETO +**Bloqueado por:** RT-001 Fundamentos, MGN-011 Inventory (core) diff --git a/orchestration/planes/fase-2-analisis-modulos/ANALISIS-RT-004-compras.md b/orchestration/planes/fase-2-analisis-modulos/ANALISIS-RT-004-compras.md new file mode 100644 index 0000000..dc6aa17 --- /dev/null +++ b/orchestration/planes/fase-2-analisis-modulos/ANALISIS-RT-004-compras.md @@ -0,0 +1,423 @@ +# ANALISIS MODULO RT-004: COMPRAS Y REABASTECIMIENTO + +**Fecha:** 2025-12-18 +**Fase:** 2 - Analisis por Modulo +**Modulo:** RT-004 Compras +**Herencia:** 80% +**Story Points:** 38 +**Prioridad:** P0 + +--- + +## 1. DESCRIPCION GENERAL + +### 1.1 Proposito +Gestion centralizada de compras con sugerencias automaticas de reabastecimiento, ordenes de compra y recepcion de mercancia. + +### 1.2 Funcionalidades Principales + +| Funcionalidad | Descripcion | Criticidad | +|---------------|-------------|------------| +| Sugerencias compra | Automaticas por stock | Alta | +| Ordenes de compra | CRUD completo | Critica | +| Recepcion | Validacion contra OC | Critica | +| Proveedores | Catalogo y evaluacion | Media | +| Distribucion | A sucursales | Media | + +--- + +## 2. HERENCIA DEL CORE + +### 2.1 Componentes Heredados (80%) + +| Componente Core | % Uso | Accion | +|-----------------|-------|--------| +| purchase.purchase_orders | 100% | HEREDAR | +| purchase.purchase_order_lines | 100% | HEREDAR | +| purchase.rfqs | 100% | HEREDAR | +| core.partners (proveedores) | 100% | HEREDAR | +| inventory.pickings | 80% | EXTENDER para recepcion | + +### 2.2 Servicios a Heredar + +```typescript +import { PurchaseOrdersService } from '@erp-core/purchases'; +import { RFQsService } from '@erp-core/purchases'; +import { PartnersService } from '@erp-core/core'; // proveedores +``` + +### 2.3 Servicios a Extender + +```typescript +class RetailPurchaseService extends PurchaseOrdersService { + // Sugerencias automaticas + async generateSuggestions(): Promise; + + // Distribucion a sucursales + async distributeToBranches(poId: string, distribution: DistributionDto): Promise; +} +``` + +--- + +## 3. COMPONENTES NUEVOS + +### 3.1 Entidades Adicionales + +```typescript +// 1. PurchaseSuggestion - Sugerencia de compra +@Entity('purchase_suggestions', { schema: 'retail' }) +export class PurchaseSuggestion { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column('uuid') + tenantId: string; + + @ManyToOne(() => Product) + product: Product; + + @ManyToOne(() => Branch, { nullable: true }) + branch: Branch; + + @ManyToOne(() => Partner) + supplier: Partner; + + @Column({ type: 'decimal', precision: 12, scale: 4 }) + currentStock: number; + + @Column({ type: 'decimal', precision: 12, scale: 4 }) + reorderPoint: number; + + @Column({ type: 'decimal', precision: 12, scale: 4 }) + suggestedQuantity: number; + + @Column({ type: 'decimal', precision: 12, scale: 2 }) + estimatedCost: number; + + @Column({ type: 'boolean', default: false }) + isProcessed: boolean; + + @Column({ type: 'timestamptz' }) + generatedAt: Date; +} + +// 2. PurchaseDistribution - Distribucion a sucursales +@Entity('purchase_distributions', { schema: 'retail' }) +export class PurchaseDistribution { + @PrimaryGeneratedColumn('uuid') + id: string; + + @ManyToOne(() => PurchaseOrder) + purchaseOrder: PurchaseOrder; + + @ManyToOne(() => PurchaseOrderLine) + purchaseOrderLine: PurchaseOrderLine; + + @ManyToOne(() => Branch) + branch: Branch; + + @Column({ type: 'decimal', precision: 12, scale: 4 }) + quantity: number; + + @Column({ type: 'boolean', default: false }) + isReceived: boolean; +} +``` + +### 3.2 Servicios Backend + +| Servicio | Metodos Principales | +|----------|-------------------| +| PurchaseSuggestionService | generate(), getAll(), approve(), reject() | +| RetailPurchaseService | createFromSuggestions(), distribute() | +| ReceiptService | create(), validate(), confirm() | +| SupplierEvaluationService | evaluate(), getMetrics() | + +### 3.3 Controladores + +```typescript +@Controller('purchases') +export class RetailPurchaseController { + // Sugerencias + @Get('suggestions') + getSuggestions(): Promise; + + @Post('suggestions/generate') + generateSuggestions(): Promise; + + @Post('suggestions/approve') + approveSuggestions(@Body() ids: string[]): Promise; + + // Ordenes de compra (heredado + extendido) + @Post() + create(@Body() dto: CreatePurchaseOrderDto): Promise; + + @Post(':id/distribute') + distribute(@Param('id') id: string, @Body() dto: DistributionDto): Promise; + + @Post(':id/send') + sendToSupplier(@Param('id') id: string): Promise; + + // Recepcion + @Post(':id/receive') + receiveOrder(@Param('id') id: string, @Body() dto: ReceiveDto): Promise; + + // Proveedores + @Get('suppliers/:id/metrics') + getSupplierMetrics(@Param('id') id: string): Promise; +} +``` + +--- + +## 4. ALGORITMO DE SUGERENCIAS + +### 4.1 Logica de Reorden + +```typescript +interface ReorderAlgorithm { + // Parametros + stockActual: number; + stockMinimo: number; + stockMaximo: number; + ventasDiarias: number; // promedio ultimos 30 dias + leadTime: number; // dias de entrega proveedor + + // Calculo + puntoReorden = stockMinimo + (ventasDiarias * leadTime); + + // Condicion + if (stockActual <= puntoReorden) { + cantidadSugerida = stockMaximo - stockActual; + return { producto, cantidadSugerida, proveedor }; + } +} +``` + +### 4.2 Pseudocodigo + +```sql +-- Query para generar sugerencias +WITH ventas_promedio AS ( + SELECT + product_id, + AVG(quantity) as avg_daily_sales + FROM retail.pos_order_lines pol + JOIN retail.pos_orders po ON pol.order_id = po.id + WHERE po.order_date >= NOW() - INTERVAL '30 days' + GROUP BY product_id +), +stock_actual AS ( + SELECT + product_id, + SUM(quantity_on_hand) as total_stock + FROM retail.branch_stock + GROUP BY product_id +) +SELECT + p.id as product_id, + sa.total_stock, + p.reorder_point, + p.max_stock, + COALESCE(vp.avg_daily_sales, 0) as avg_sales, + s.lead_time_days, + (p.reorder_point + (COALESCE(vp.avg_daily_sales, 0) * s.lead_time_days)) as calculated_reorder, + (p.max_stock - sa.total_stock) as suggested_quantity +FROM inventory.products p +JOIN stock_actual sa ON p.id = sa.product_id +LEFT JOIN ventas_promedio vp ON p.id = vp.product_id +LEFT JOIN core.partners s ON p.default_supplier_id = s.id +WHERE sa.total_stock <= (p.reorder_point + (COALESCE(vp.avg_daily_sales, 0) * s.lead_time_days)); +``` + +--- + +## 5. FLUJOS DE NEGOCIO + +### 5.1 Flujo de Compra + +``` +1. Sistema genera sugerencias (cron) + ↓ +2. Comprador revisa sugerencias + ↓ +3. Aprobar sugerencias + ↓ +4. Crear orden de compra + ↓ +5. Asignar distribucion por sucursal + ↓ +6. Enviar OC al proveedor + ↓ +7. Estado: DRAFT → CONFIRMED → SENT + ↓ +8. Recepcion de mercancia + ↓ +9. Validar vs OC (diferencias) + ↓ +10. Estado: SENT → RECEIVED + ↓ +11. Actualizar stock por sucursal + ↓ +12. Generar factura proveedor (opcional) +``` + +### 5.2 Flujo de Recepcion + +``` +1. Proveedor entrega mercancia + ↓ +2. Buscar OC correspondiente + ↓ +3. Contar productos recibidos + ↓ +4. Registrar cantidades por linea + ↓ +5. Validar vs ordenado + ↓ +6. Si diferencia: + a. Faltante: registrar, notificar + b. Sobrante: rechazar o aceptar + ↓ +7. Confirmar recepcion + ↓ +8. Distribuir a sucursales + ↓ +9. Actualizar stock +``` + +--- + +## 6. TABLAS DDL + +### 6.1 Tablas Heredadas + +```sql +-- Del core (schema purchase) +purchase.purchase_orders +purchase.purchase_order_lines +purchase.rfqs +purchase.rfq_lines +``` + +### 6.2 Tablas Nuevas Retail + +```sql +-- Sugerencias +CREATE TABLE retail.purchase_suggestions ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + tenant_id UUID NOT NULL REFERENCES auth.tenants(id), + product_id UUID NOT NULL REFERENCES inventory.products(id), + branch_id UUID REFERENCES retail.branches(id), + supplier_id UUID REFERENCES core.partners(id), + current_stock DECIMAL(12,4) NOT NULL, + reorder_point DECIMAL(12,4) NOT NULL, + suggested_quantity DECIMAL(12,4) NOT NULL, + estimated_cost DECIMAL(12,2), + is_processed BOOLEAN DEFAULT FALSE, + generated_at TIMESTAMPTZ DEFAULT NOW(), + created_at TIMESTAMPTZ DEFAULT NOW() +); + +-- Distribucion +CREATE TABLE retail.purchase_distributions ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + tenant_id UUID NOT NULL REFERENCES auth.tenants(id), + purchase_order_id UUID NOT NULL REFERENCES purchase.purchase_orders(id), + purchase_order_line_id UUID NOT NULL REFERENCES purchase.purchase_order_lines(id), + branch_id UUID NOT NULL REFERENCES retail.branches(id), + quantity DECIMAL(12,4) NOT NULL, + is_received BOOLEAN DEFAULT FALSE, + created_at TIMESTAMPTZ DEFAULT NOW() +); +``` + +--- + +## 7. DEPENDENCIAS + +### 7.1 Dependencias de Core + +| Modulo | Estado | Requerido Para | +|--------|--------|---------------| +| MGN-012 Purchasing | 0% | Ordenes de compra | +| MGN-011 Inventory | 60% | Productos | +| MGN-005 Catalogs | 0% | Proveedores | + +### 7.2 Dependencias de Retail + +| Modulo | Tipo | +|--------|------| +| RT-001 Fundamentos | Prerequisito | +| RT-003 Inventario | Stock y recepciones | + +### 7.3 Bloquea a + +| Modulo | Razon | +|--------|-------| +| RT-003 Inventario | Recepciones actualizan stock | + +--- + +## 8. CRITERIOS DE ACEPTACION + +### 8.1 Funcionales + +- [ ] Generar sugerencias automaticas +- [ ] Ver sugerencias pendientes +- [ ] Aprobar/rechazar sugerencias +- [ ] Crear OC desde sugerencias +- [ ] Crear OC manual +- [ ] Asignar distribucion por sucursal +- [ ] Enviar OC a proveedor (email) +- [ ] Registrar recepcion +- [ ] Validar cantidades vs OC +- [ ] Registrar diferencias +- [ ] Actualizar stock al recibir +- [ ] Ver historial de compras por proveedor + +### 8.2 Performance + +- [ ] Generacion sugerencias < 30s +- [ ] Listado OC < 1s + +--- + +## 9. RIESGOS + +| Riesgo | Probabilidad | Impacto | Mitigacion | +|--------|--------------|---------|------------| +| Core purchase incompleto | Alta | Bloqueante | Implementar primero en core | +| Algoritmo ineficiente | Media | Medio | Indices y optimizacion query | + +--- + +## 10. ESTIMACION DETALLADA + +| Componente | SP Backend | SP Frontend | Total | +|------------|-----------|-------------|-------| +| Entities + Migrations | 3 | - | 3 | +| SuggestionService | 5 | - | 5 | +| RetailPurchaseService | 5 | - | 5 | +| ReceiptService | 5 | - | 5 | +| SupplierEvaluationService | 2 | - | 2 | +| Controllers | 3 | - | 3 | +| Suggestions UI | - | 5 | 5 | +| PO Pages | - | 5 | 5 | +| Receipt Pages | - | 5 | 5 | +| **TOTAL** | **23** | **15** | **38** | + +--- + +## 11. REFERENCIAS + +| Documento | Ubicacion | +|-----------|-----------| +| Epica RT-004 | docs/08-epicas/EPIC-RT-004-compras.md | +| Modulo Core Purchase | erp-core/backend/src/modules/purchases/ | + +--- + +**Estado:** ANALISIS COMPLETO +**Bloqueado por:** RT-001, RT-003, MGN-012 (core) diff --git a/orchestration/planes/fase-2-analisis-modulos/ANALISIS-RT-005-clientes.md b/orchestration/planes/fase-2-analisis-modulos/ANALISIS-RT-005-clientes.md new file mode 100644 index 0000000..9e008ba --- /dev/null +++ b/orchestration/planes/fase-2-analisis-modulos/ANALISIS-RT-005-clientes.md @@ -0,0 +1,498 @@ +# ANALISIS MODULO RT-005: CLIENTES Y PROGRAMA DE LEALTAD + +**Fecha:** 2025-12-18 +**Fase:** 2 - Analisis por Modulo +**Modulo:** RT-005 Clientes +**Herencia:** 40% +**Story Points:** 34 +**Prioridad:** P1 + +--- + +## 1. DESCRIPCION GENERAL + +### 1.1 Proposito +Gestion de clientes con programa de fidelizacion basado en puntos, niveles de membresia y recompensas. + +### 1.2 Funcionalidades Principales + +| Funcionalidad | Descripcion | Criticidad | +|---------------|-------------|------------| +| Registro cliente | Datos minimos en POS | Alta | +| Programa puntos | Acumulacion y canje | Alta | +| Niveles membresia | Bronce, Plata, Oro | Media | +| Historial compras | 3 anos | Media | +| Segmentacion | Por comportamiento | Baja | + +--- + +## 2. HERENCIA DEL CORE + +### 2.1 Componentes Heredados (40%) + +| Componente Core | % Uso | Accion | +|-----------------|-------|--------| +| core.partners | 100% | HEREDAR (clientes) | +| core.partner_contacts | 100% | HEREDAR | +| crm.leads | 20% | OPCIONAL | + +### 2.2 Servicios a Heredar + +```typescript +import { PartnersService } from '@erp-core/core'; +import { ContactsService } from '@erp-core/core'; +``` + +### 2.3 Servicios a Extender + +```typescript +class RetailCustomerService extends PartnersService { + // Busqueda rapida + async quickSearch(phone: string): Promise; + async quickSearch(email: string): Promise; + + // Programa lealtad + async getPoints(customerId: string): Promise; + async getLoyaltyCard(customerId: string): Promise; +} +``` + +--- + +## 3. COMPONENTES NUEVOS + +### 3.1 Entidades (TypeORM) + +```typescript +// 1. LoyaltyProgram - Programa de lealtad +@Entity('loyalty_programs', { schema: 'retail' }) +export class LoyaltyProgram { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column('uuid') + tenantId: string; + + @Column() + code: string; + + @Column() + name: string; + + @Column({ nullable: true }) + description: string; + + @Column({ type: 'decimal', precision: 5, scale: 2, default: 1 }) + pointsPerCurrency: number; // 1 punto por $10 + + @Column({ type: 'decimal', precision: 5, scale: 2, default: 0.01 }) + currencyPerPoint: number; // $0.10 por punto + + @Column({ type: 'int', default: 100 }) + minPointsRedeem: number; + + @Column({ type: 'int', nullable: true }) + pointsExpiryDays: number; // null = no expiran + + @Column({ type: 'boolean', default: true }) + isActive: boolean; +} + +// 2. LoyaltyCard - Tarjeta de cliente +@Entity('loyalty_cards', { schema: 'retail' }) +export class LoyaltyCard { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column('uuid') + tenantId: string; + + @ManyToOne(() => LoyaltyProgram) + program: LoyaltyProgram; + + @ManyToOne(() => Partner) + customer: Partner; + + @Column() + cardNumber: string; + + @Column({ type: 'date' }) + issueDate: Date; + + @Column({ type: 'int', default: 0 }) + pointsBalance: number; + + @Column({ type: 'int', default: 0 }) + pointsEarned: number; + + @Column({ type: 'int', default: 0 }) + pointsRedeemed: number; + + @Column({ type: 'int', default: 0 }) + pointsExpired: number; + + @Column({ type: 'boolean', default: true }) + isActive: boolean; +} + +// 3. LoyaltyTransaction - Movimiento de puntos +@Entity('loyalty_transactions', { schema: 'retail' }) +export class LoyaltyTransaction { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column('uuid') + tenantId: string; + + @ManyToOne(() => LoyaltyCard) + card: LoyaltyCard; + + @Column({ type: 'enum', enum: LoyaltyTransactionType }) + transactionType: LoyaltyTransactionType; // earn, redeem, expire, adjust + + @Column({ type: 'int' }) + pointsAmount: number; + + @Column({ nullable: true }) + referenceType: string; // pos_order, promotion, manual + + @Column({ type: 'uuid', nullable: true }) + referenceId: string; + + @Column({ nullable: true }) + notes: string; + + @Column({ type: 'timestamptz' }) + createdAt: Date; +} + +// 4. MembershipLevel - Niveles +@Entity('membership_levels', { schema: 'retail' }) +export class MembershipLevel { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column('uuid') + tenantId: string; + + @ManyToOne(() => LoyaltyProgram) + program: LoyaltyProgram; + + @Column() + name: string; // Bronce, Plata, Oro + + @Column({ type: 'int' }) + minPointsYear: number; // Puntos minimos para alcanzar + + @Column({ type: 'decimal', precision: 3, scale: 2, default: 1 }) + multiplier: number; // 1x, 1.5x, 2x + + @Column({ type: 'jsonb', nullable: true }) + benefits: object; // Beneficios adicionales +} +``` + +### 3.2 Servicios Backend + +| Servicio | Metodos Principales | +|----------|-------------------| +| RetailCustomerService | quickSearch(), register(), getHistory() | +| LoyaltyProgramService | create(), configure(), getActive() | +| LoyaltyCardService | issue(), activate(), getBalance() | +| LoyaltyTransactionService | earnPoints(), redeemPoints(), expire() | +| MembershipService | calculateLevel(), upgrade(), downgrade() | + +### 3.3 Controladores + +```typescript +@Controller('customers') +export class RetailCustomerController { + // Clientes + @Get('search') + quickSearch(@Query('q') query: string): Promise; + + @Post('quick-register') + quickRegister(@Body() dto: QuickRegisterDto): Promise; + + @Get(':id/history') + getPurchaseHistory(@Param('id') id: string): Promise; + + // Lealtad + @Get(':id/loyalty') + getLoyaltyInfo(@Param('id') id: string): Promise; + + @Get(':id/loyalty/card') + getLoyaltyCard(@Param('id') id: string): Promise; + + @Post(':id/loyalty/earn') + earnPoints(@Param('id') id: string, @Body() dto: EarnPointsDto): Promise; + + @Post(':id/loyalty/redeem') + redeemPoints(@Param('id') id: string, @Body() dto: RedeemPointsDto): Promise; + + @Get(':id/loyalty/transactions') + getTransactions(@Param('id') id: string): Promise; +} + +@Controller('loyalty') +export class LoyaltyController { + // Programas + @Get('programs') + getPrograms(): Promise; + + @Post('programs') + createProgram(@Body() dto: CreateProgramDto): Promise; + + // Tarjetas + @Post('cards/issue') + issueCard(@Body() dto: IssueCardDto): Promise; + + @Get('cards/:number') + getCardByNumber(@Param('number') number: string): Promise; +} +``` + +--- + +## 4. SISTEMA DE PUNTOS + +### 4.1 Configuracion + +```yaml +loyalty_config: + points: + earn_rate: 1 # 1 punto por cada $10 + redeem_rate: 100 # 100 puntos = $10 descuento + min_redeem: 100 # Minimo para canjear + max_discount_percent: 50 # Maximo descuento por puntos + expiry_days: null # No expiran (o 365) + + levels: + - name: "Bronce" + min_points_year: 0 + multiplier: 1.0 + benefits: [] + + - name: "Plata" + min_points_year: 1000 + multiplier: 1.5 + benefits: + - "Envio gratis e-commerce" + - "Ofertas exclusivas" + + - name: "Oro" + min_points_year: 5000 + multiplier: 2.0 + benefits: + - "Envio gratis e-commerce" + - "Ofertas exclusivas" + - "Acceso VIP preventas" + - "Descuento cumpleaños 20%" +``` + +### 4.2 Calculo de Puntos + +```typescript +// Al cerrar venta +function calculatePoints(order: POSOrder, card: LoyaltyCard): number { + const program = card.program; + const level = getMembershipLevel(card); + + // Base: 1 punto por cada $10 + const basePoints = Math.floor(order.total / program.pointsPerCurrency); + + // Multiplicador por nivel + const multipliedPoints = Math.floor(basePoints * level.multiplier); + + // Puntos extra por promociones + const bonusPoints = calculateBonusPoints(order); + + return multipliedPoints + bonusPoints; +} + +// Canje de puntos +function redeemPoints(points: number, program: LoyaltyProgram): number { + if (points < program.minPointsRedeem) { + throw new Error('Puntos insuficientes'); + } + + // Calcular descuento + const discount = points * program.currencyPerPoint; + return discount; +} +``` + +--- + +## 5. FLUJOS DE NEGOCIO + +### 5.1 Flujo en POS + +``` +1. Cliente se identifica (telefono/tarjeta) + ↓ +2. Buscar tarjeta de lealtad + ↓ +3. Mostrar puntos disponibles + ↓ +4. Realizar venta normal + ↓ +5. Preguntar si desea canjear puntos + ↓ +6. Si canjea: + a. Calcular descuento + b. Aplicar a orden + c. Registrar redeem + ↓ +7. Cerrar venta + ↓ +8. Calcular puntos ganados + ↓ +9. Registrar earn + ↓ +10. Imprimir puntos en ticket +``` + +### 5.2 Flujo de Registro + +``` +1. Cliente nuevo en POS + ↓ +2. Registro rapido (nombre, telefono) + ↓ +3. Crear partner en core + ↓ +4. Crear tarjeta lealtad + ↓ +5. Asignar nivel Bronce + ↓ +6. Opcional: puntos bienvenida +``` + +--- + +## 6. TABLAS DDL + +### 6.1 Tablas Definidas + +```sql +-- Ya en 03-retail-tables.sql +CREATE TABLE retail.loyalty_programs (...); +CREATE TABLE retail.loyalty_cards (...); +CREATE TABLE retail.loyalty_transactions (...); + +-- Agregar niveles +CREATE TABLE retail.membership_levels ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + tenant_id UUID NOT NULL REFERENCES auth.tenants(id), + program_id UUID NOT NULL REFERENCES retail.loyalty_programs(id), + name VARCHAR(50) NOT NULL, + min_points_year INT NOT NULL DEFAULT 0, + multiplier DECIMAL(3,2) NOT NULL DEFAULT 1.00, + benefits JSONB, + created_at TIMESTAMPTZ DEFAULT NOW() +); +``` + +### 6.2 Indices + +```sql +-- Busqueda rapida cliente +CREATE INDEX idx_partners_phone ON core.partners(phone); +CREATE INDEX idx_partners_email ON core.partners(email); + +-- Tarjetas +CREATE UNIQUE INDEX idx_loyalty_cards_number ON retail.loyalty_cards(tenant_id, card_number); +CREATE INDEX idx_loyalty_cards_customer ON retail.loyalty_cards(customer_id); + +-- Transacciones +CREATE INDEX idx_loyalty_transactions_card ON retail.loyalty_transactions(card_id); +CREATE INDEX idx_loyalty_transactions_date ON retail.loyalty_transactions(created_at); +``` + +--- + +## 7. DEPENDENCIAS + +### 7.1 Dependencias de Core + +| Modulo | Estado | Requerido Para | +|--------|--------|---------------| +| MGN-005 Catalogs | 0% | Partners (clientes) | +| MGN-014 CRM | 0% | Opcional | + +### 7.2 Dependencias de Retail + +| Modulo | Tipo | +|--------|------| +| RT-001 Fundamentos | Prerequisito | + +### 7.3 Bloquea a + +| Modulo | Razon | +|--------|-------| +| RT-002 POS | Integracion puntos | +| RT-009 E-commerce | Clientes y puntos | + +--- + +## 8. CRITERIOS DE ACEPTACION + +### 8.1 Funcionales + +- [ ] Buscar cliente por telefono +- [ ] Buscar cliente por email +- [ ] Registro rapido de cliente +- [ ] Crear programa de lealtad +- [ ] Emitir tarjeta a cliente +- [ ] Consultar balance de puntos +- [ ] Acumular puntos al comprar +- [ ] Canjear puntos por descuento +- [ ] Ver historial de transacciones +- [ ] Calcular nivel de membresia +- [ ] Ver historial de compras (3 anos) + +### 8.2 Performance + +- [ ] Busqueda cliente < 500ms +- [ ] Consulta puntos < 200ms + +--- + +## 9. RIESGOS + +| Riesgo | Probabilidad | Impacto | Mitigacion | +|--------|--------------|---------|------------| +| Fraude de puntos | Media | Alto | Auditoria + limites | +| Performance busqueda | Baja | Medio | Indices | + +--- + +## 10. ESTIMACION DETALLADA + +| Componente | SP Backend | SP Frontend | Total | +|------------|-----------|-------------|-------| +| Entities + Migrations | 3 | - | 3 | +| RetailCustomerService | 5 | - | 5 | +| LoyaltyProgramService | 3 | - | 3 | +| LoyaltyCardService | 5 | - | 5 | +| LoyaltyTransactionService | 3 | - | 3 | +| MembershipService | 2 | - | 2 | +| Controllers | 3 | - | 3 | +| Customer Pages | - | 5 | 5 | +| Loyalty Pages | - | 5 | 5 | +| **TOTAL** | **24** | **10** | **34** | + +--- + +## 11. REFERENCIAS + +| Documento | Ubicacion | +|-----------|-----------| +| Epica RT-005 | docs/08-epicas/EPIC-RT-005-clientes.md | +| Modulo Core Partners | erp-core/backend/src/modules/partners/ | + +--- + +**Estado:** ANALISIS COMPLETO +**Bloqueado por:** RT-001 Fundamentos diff --git a/orchestration/planes/fase-2-analisis-modulos/ANALISIS-RT-006-precios.md b/orchestration/planes/fase-2-analisis-modulos/ANALISIS-RT-006-precios.md new file mode 100644 index 0000000..1e78882 --- /dev/null +++ b/orchestration/planes/fase-2-analisis-modulos/ANALISIS-RT-006-precios.md @@ -0,0 +1,490 @@ +# ANALISIS MODULO RT-006: PRECIOS Y PROMOCIONES + +**Fecha:** 2025-12-18 +**Fase:** 2 - Analisis por Modulo +**Modulo:** RT-006 Precios +**Herencia:** 30% +**Story Points:** 36 +**Prioridad:** P0 + +--- + +## 1. DESCRIPCION GENERAL + +### 1.1 Proposito +Motor de reglas de precios con listas de precios, promociones, descuentos por volumen y sistema de cupones. + +### 1.2 Funcionalidades Principales + +| Funcionalidad | Descripcion | Criticidad | +|---------------|-------------|------------| +| Listas de precios | Por canal/sucursal | Critica | +| Promociones | Multiples tipos | Alta | +| Descuentos volumen | Por cantidad | Media | +| Cupones | Generacion y canje | Media | +| Motor reglas | Evaluacion rapida | Critica | + +--- + +## 2. HERENCIA DEL CORE + +### 2.1 Componentes Heredados (30%) + +| Componente Core | % Uso | Accion | +|-----------------|-------|--------| +| sales.pricelists | 100% | HEREDAR | +| sales.pricelist_items | 100% | HEREDAR | + +### 2.2 Servicios a Heredar + +```typescript +import { PricelistsService } from '@erp-core/sales'; +``` + +### 2.3 Servicios a Extender + +```typescript +class RetailPriceService extends PricelistsService { + // Motor de precios + async calculatePrice(productId: string, context: PriceContext): Promise; + + // Promociones + async getActivePromotions(branchId: string): Promise; + async applyPromotion(orderId: string, promotionId: string): Promise; +} +``` + +--- + +## 3. COMPONENTES NUEVOS + +### 3.1 Entidades (TypeORM) + +```typescript +// 1. Promotion - Promocion +@Entity('promotions', { schema: 'retail' }) +export class Promotion { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column('uuid') + tenantId: string; + + @Column() + code: string; + + @Column() + name: string; + + @Column({ nullable: true }) + description: string; + + @Column({ type: 'enum', enum: PromotionType }) + promotionType: PromotionType; + // percentage, fixed_amount, buy_x_get_y, bundle + + @Column({ type: 'decimal', precision: 10, scale: 2 }) + discountValue: number; // % o monto segun tipo + + @Column({ type: 'date' }) + startDate: Date; + + @Column({ type: 'date' }) + endDate: Date; + + @Column({ type: 'boolean', default: false }) + appliesToAll: boolean; + + @Column({ type: 'int', nullable: true }) + minQuantity: number; + + @Column({ type: 'decimal', precision: 12, scale: 2, nullable: true }) + minAmount: number; + + @Column({ type: 'uuid', array: true, nullable: true }) + branchIds: string[]; // null = todas + + @Column({ type: 'boolean', default: true }) + isActive: boolean; + + @Column({ type: 'int', nullable: true }) + maxUses: number; + + @Column({ type: 'int', default: 0 }) + currentUses: number; + + @OneToMany(() => PromotionProduct, pp => pp.promotion) + products: PromotionProduct[]; +} + +// 2. PromotionProduct - Productos en promocion +@Entity('promotion_products', { schema: 'retail' }) +export class PromotionProduct { + @PrimaryGeneratedColumn('uuid') + id: string; + + @ManyToOne(() => Promotion) + promotion: Promotion; + + @ManyToOne(() => Product) + product: Product; +} + +// 3. Coupon - Cupon +@Entity('coupons', { schema: 'retail' }) +export class Coupon { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column('uuid') + tenantId: string; + + @Column() + code: string; + + @Column({ type: 'enum', enum: CouponType }) + couponType: CouponType; // percentage, fixed_amount + + @Column({ type: 'decimal', precision: 10, scale: 2 }) + discountValue: number; + + @Column({ type: 'decimal', precision: 12, scale: 2, nullable: true }) + minPurchase: number; + + @Column({ type: 'decimal', precision: 12, scale: 2, nullable: true }) + maxDiscount: number; + + @Column({ type: 'date' }) + validFrom: Date; + + @Column({ type: 'date' }) + validUntil: Date; + + @Column({ type: 'int', default: 1 }) + maxUses: number; + + @Column({ type: 'int', default: 0 }) + timesUsed: number; + + @Column({ type: 'boolean', default: true }) + isActive: boolean; +} + +// 4. CouponRedemption - Uso de cupon +@Entity('coupon_redemptions', { schema: 'retail' }) +export class CouponRedemption { + @PrimaryGeneratedColumn('uuid') + id: string; + + @ManyToOne(() => Coupon) + coupon: Coupon; + + @ManyToOne(() => POSOrder) + order: POSOrder; + + @Column({ type: 'decimal', precision: 12, scale: 2 }) + discountApplied: number; + + @Column({ type: 'timestamptz' }) + redeemedAt: Date; +} +``` + +### 3.2 Motor de Precios + +```typescript +interface PriceContext { + productId: string; + quantity: number; + customerId?: string; + branchId: string; + channel: 'pos' | 'ecommerce'; + date: Date; +} + +interface PriceResult { + basePrice: number; + finalPrice: number; + discounts: DiscountApplied[]; + taxes: TaxApplied[]; + total: number; +} + +interface DiscountApplied { + type: 'pricelist' | 'promotion' | 'volume' | 'coupon' | 'loyalty'; + name: string; + amount: number; +} + +class PriceEngine { + async calculatePrice(context: PriceContext): Promise { + // 1. Obtener precio base del producto + const basePrice = await this.getBasePrice(context.productId); + + // 2. Aplicar lista de precios del canal + const pricelistPrice = await this.applyPricelist(basePrice, context); + + // 3. Evaluar promociones activas + const promotionDiscount = await this.evaluatePromotions(pricelistPrice, context); + + // 4. Evaluar descuento por volumen + const volumeDiscount = await this.evaluateVolumeDiscount(context); + + // 5. Calcular precio final + const finalPrice = pricelistPrice - promotionDiscount - volumeDiscount; + + // 6. Calcular impuestos + const taxes = await this.calculateTaxes(finalPrice, context); + + return { + basePrice, + finalPrice, + discounts: [...], + taxes, + total: finalPrice + taxes.total + }; + } +} +``` + +### 3.3 Servicios Backend + +| Servicio | Metodos Principales | +|----------|-------------------| +| PriceEngineService | calculatePrice(), evaluatePromotions() | +| PromotionService | create(), activate(), deactivate(), getActive() | +| CouponService | generate(), validate(), redeem() | +| VolumeDiscountService | configure(), calculate() | + +### 3.4 Controladores + +```typescript +@Controller('pricing') +export class PricingController { + // Calculo de precios + @Post('calculate') + calculatePrice(@Body() dto: PriceRequestDto): Promise; + + // Promociones + @Get('promotions') + getPromotions(@Query() filters: PromotionFilters): Promise; + + @Get('promotions/active') + getActivePromotions(@Query('branchId') branchId: string): Promise; + + @Post('promotions') + createPromotion(@Body() dto: CreatePromotionDto): Promise; + + @Put('promotions/:id') + updatePromotion(@Param('id') id: string, @Body() dto: UpdatePromotionDto): Promise; + + @Post('promotions/:id/activate') + activatePromotion(@Param('id') id: string): Promise; + + @Post('promotions/:id/deactivate') + deactivatePromotion(@Param('id') id: string): Promise; + + // Cupones + @Post('coupons/generate') + generateCoupons(@Body() dto: GenerateCouponsDto): Promise; + + @Get('coupons/:code/validate') + validateCoupon(@Param('code') code: string): Promise; + + @Post('coupons/:code/redeem') + redeemCoupon(@Param('code') code: string, @Body() dto: RedeemDto): Promise; +} +``` + +--- + +## 4. TIPOS DE PROMOCIONES + +### 4.1 Descuento Porcentual + +```yaml +tipo: percentage +ejemplo: + nombre: "20% en ropa" + descuento: 20 + aplica_a: [categoria: "ROPA"] + vigencia: "2025-12-01 al 2025-12-31" +``` + +### 4.2 Descuento Monto Fijo + +```yaml +tipo: fixed_amount +ejemplo: + nombre: "$100 en compras mayores a $500" + descuento: 100 + minimo_compra: 500 +``` + +### 4.3 Compra X Lleva Y (NxM) + +```yaml +tipo: buy_x_get_y +ejemplo: + nombre: "3x2 en bebidas" + compra: 3 + paga: 2 + aplica_a: [categoria: "BEBIDAS"] +``` + +### 4.4 Descuento por Volumen + +```yaml +tipo: volume +ejemplo: + nombre: "Descuento por volumen" + rangos: + - cantidad_min: 5, descuento: 5% + - cantidad_min: 10, descuento: 10% + - cantidad_min: 20, descuento: 15% +``` + +--- + +## 5. REGLAS DE EVALUACION + +### 5.1 Orden de Prioridad + +``` +1. Precio base del producto +2. Lista de precios del canal (si existe) +3. Promociones activas (por prioridad, NO acumulables) +4. Descuento por volumen (si aplica) +5. Cupon (si proporciona el cliente) +6. Puntos de lealtad (como descuento) +``` + +### 5.2 Conflicto de Promociones + +```typescript +// Solo aplica la mejor promocion (no acumulables) +function selectBestPromotion(promotions: Promotion[], orderTotal: number): Promotion { + return promotions + .filter(p => isApplicable(p, orderTotal)) + .sort((a, b) => calculateDiscount(b, orderTotal) - calculateDiscount(a, orderTotal)) + [0]; +} +``` + +--- + +## 6. TABLAS DDL + +### 6.1 Tablas Definidas + +```sql +-- Ya en 03-retail-tables.sql +CREATE TABLE retail.promotions (...); +CREATE TABLE retail.promotion_products (...); + +-- Agregar cupones +CREATE TABLE retail.coupons ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + tenant_id UUID NOT NULL REFERENCES auth.tenants(id), + code VARCHAR(20) NOT NULL, + coupon_type VARCHAR(20) NOT NULL, + discount_value DECIMAL(10,2) NOT NULL, + min_purchase DECIMAL(12,2), + max_discount DECIMAL(12,2), + valid_from DATE NOT NULL, + valid_until DATE NOT NULL, + max_uses INT DEFAULT 1, + times_used INT DEFAULT 0, + is_active BOOLEAN DEFAULT TRUE, + created_at TIMESTAMPTZ DEFAULT NOW(), + UNIQUE(tenant_id, code) +); + +CREATE TABLE retail.coupon_redemptions ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + tenant_id UUID NOT NULL REFERENCES auth.tenants(id), + coupon_id UUID NOT NULL REFERENCES retail.coupons(id), + order_id UUID NOT NULL REFERENCES retail.pos_orders(id), + discount_applied DECIMAL(12,2) NOT NULL, + redeemed_at TIMESTAMPTZ DEFAULT NOW() +); +``` + +### 6.2 Indices + +```sql +-- Promociones activas +CREATE INDEX idx_promotions_active ON retail.promotions(is_active, start_date, end_date); +CREATE INDEX idx_promotions_branch ON retail.promotions USING GIN(branch_ids); + +-- Cupones +CREATE UNIQUE INDEX idx_coupons_code ON retail.coupons(tenant_id, code); +CREATE INDEX idx_coupons_valid ON retail.coupons(valid_from, valid_until, is_active); +``` + +--- + +## 7. DEPENDENCIAS + +### 7.1 Dependencias de Core + +| Modulo | Estado | Requerido Para | +|--------|--------|---------------| +| MGN-013 Sales | 50% | Pricelists | +| SPEC-PRICING-RULES | Planificado | Motor de precios | + +### 7.2 Dependencias de Retail + +| Modulo | Tipo | +|--------|------| +| RT-001 Fundamentos | Prerequisito | + +### 7.3 Bloquea a + +| Modulo | Razon | +|--------|-------| +| RT-002 POS | Precios en ventas | +| RT-009 E-commerce | Precios online | + +--- + +## 8. CRITERIOS DE ACEPTACION + +### 8.1 Funcionales + +- [ ] Crear lista de precios +- [ ] Asignar precios por producto +- [ ] Crear promocion porcentual +- [ ] Crear promocion monto fijo +- [ ] Crear promocion NxM +- [ ] Configurar descuento por volumen +- [ ] Generar cupones +- [ ] Validar cupon +- [ ] Canjear cupon +- [ ] Calcular precio con todas las reglas +- [ ] Motor < 100ms + +### 8.2 Performance + +- [ ] Calculo precio < 100ms +- [ ] Soportar 100+ promociones activas + +--- + +## 9. ESTIMACION DETALLADA + +| Componente | SP Backend | SP Frontend | Total | +|------------|-----------|-------------|-------| +| Entities + Migrations | 3 | - | 3 | +| PriceEngineService | 8 | - | 8 | +| PromotionService | 5 | - | 5 | +| CouponService | 5 | - | 5 | +| Controllers | 3 | - | 3 | +| Promotion Pages | - | 8 | 8 | +| Coupon Pages | - | 4 | 4 | +| **TOTAL** | **24** | **12** | **36** | + +--- + +**Estado:** ANALISIS COMPLETO +**Bloqueado por:** RT-001, SPEC-PRICING-RULES diff --git a/orchestration/planes/fase-2-analisis-modulos/ANALISIS-RT-007-caja.md b/orchestration/planes/fase-2-analisis-modulos/ANALISIS-RT-007-caja.md new file mode 100644 index 0000000..3be0298 --- /dev/null +++ b/orchestration/planes/fase-2-analisis-modulos/ANALISIS-RT-007-caja.md @@ -0,0 +1,539 @@ +# ANALISIS MODULO RT-007: CAJA (ARQUEOS Y CORTES) + +**Fecha:** 2025-12-18 +**Fase:** 2 - Analisis por Modulo +**Modulo:** RT-007 Caja +**Herencia:** 10% +**Story Points:** 28 +**Prioridad:** P0 + +--- + +## 1. DESCRIPCION GENERAL + +### 1.1 Proposito +Control de efectivo con apertura/cierre de caja, movimientos de efectivo, arqueos y cortes con declaracion por denominacion. + +### 1.2 Funcionalidades Principales + +| Funcionalidad | Descripcion | Criticidad | +|---------------|-------------|------------| +| Apertura caja | Con fondo inicial | Critica | +| Cierre caja | Con conteo | Critica | +| Movimientos | Entradas/salidas | Alta | +| Arqueos | Parciales | Media | +| Declaracion | Por denominacion | Alta | +| Diferencias | Control | Alta | + +--- + +## 2. HERENCIA DEL CORE + +### 2.1 Componentes Heredados (10%) + +| Componente Core | % Uso | Accion | +|-----------------|-------|--------| +| financial.payments | 10% | Referencia | + +### 2.2 Observacion + +Este modulo es casi 100% nuevo. No existe gestion de caja en el core. Solo se reutilizan conceptos de pagos. + +--- + +## 3. COMPONENTES NUEVOS + +### 3.1 Entidades (TypeORM) + +```typescript +// 1. CashRegister - Caja registradora (ya existe) +@Entity('cash_registers', { schema: 'retail' }) +export class CashRegister { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column('uuid') + tenantId: string; + + @ManyToOne(() => Branch) + branch: Branch; + + @Column() + code: string; + + @Column() + name: string; + + @Column({ type: 'boolean', default: true }) + isActive: boolean; + + @Column({ type: 'enum', enum: PaymentMethod, nullable: true }) + defaultPaymentMethod: PaymentMethod; +} + +// 2. CashSession - Sesion de caja (extiende POSSession) +// Ya definida en RT-002, aqui se agregan campos de arqueo + +// 3. CashMovement - Movimiento de efectivo +@Entity('cash_movements', { schema: 'retail' }) +export class CashMovement { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column('uuid') + tenantId: string; + + @ManyToOne(() => POSSession) + session: POSSession; + + @Column({ type: 'enum', enum: CashMovementType }) + movementType: CashMovementType; // in, out + + @Column({ type: 'decimal', precision: 12, scale: 2 }) + amount: number; + + @Column() + reason: string; + + @Column({ nullable: true }) + notes: string; + + @ManyToOne(() => User, { nullable: true }) + authorizedBy: User; + + @Column({ type: 'timestamptz' }) + createdAt: Date; +} + +// 4. CashClosing - Corte de caja +@Entity('cash_closings', { schema: 'retail' }) +export class CashClosing { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column('uuid') + tenantId: string; + + @ManyToOne(() => POSSession) + session: POSSession; + + @Column({ type: 'timestamptz' }) + closingDate: Date; + + // Montos esperados (calculados) + @Column({ type: 'decimal', precision: 12, scale: 2 }) + expectedCash: number; + + @Column({ type: 'decimal', precision: 12, scale: 2 }) + expectedCard: number; + + @Column({ type: 'decimal', precision: 12, scale: 2 }) + expectedTransfer: number; + + // Montos declarados + @Column({ type: 'decimal', precision: 12, scale: 2 }) + declaredCash: number; + + @Column({ type: 'decimal', precision: 12, scale: 2 }) + declaredCard: number; + + @Column({ type: 'decimal', precision: 12, scale: 2 }) + declaredTransfer: number; + + // Diferencias + @Column({ type: 'decimal', precision: 12, scale: 2 }) + cashDifference: number; + + @Column({ type: 'decimal', precision: 12, scale: 2 }) + cardDifference: number; + + @Column({ type: 'decimal', precision: 12, scale: 2 }) + transferDifference: number; + + // Detalle de denominaciones + @Column({ type: 'jsonb', nullable: true }) + denominationDetail: DenominationDetail; + + @Column({ nullable: true }) + notes: string; + + @ManyToOne(() => User) + closedBy: User; + + @ManyToOne(() => User, { nullable: true }) + approvedBy: User; + + @Column({ type: 'boolean', default: false }) + isApproved: boolean; +} + +// 5. CashCount - Arqueo parcial +@Entity('cash_counts', { schema: 'retail' }) +export class CashCount { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column('uuid') + tenantId: string; + + @ManyToOne(() => POSSession) + session: POSSession; + + @Column({ type: 'timestamptz' }) + countDate: Date; + + @Column({ type: 'decimal', precision: 12, scale: 2 }) + expectedAmount: number; + + @Column({ type: 'decimal', precision: 12, scale: 2 }) + countedAmount: number; + + @Column({ type: 'decimal', precision: 12, scale: 2 }) + difference: number; + + @Column({ type: 'jsonb', nullable: true }) + denominationDetail: DenominationDetail; + + @ManyToOne(() => User) + countedBy: User; + + @Column({ nullable: true }) + notes: string; +} + +// Interfaz para denominaciones +interface DenominationDetail { + bills: { + '1000': number; + '500': number; + '200': number; + '100': number; + '50': number; + '20': number; + }; + coins: { + '20': number; + '10': number; + '5': number; + '2': number; + '1': number; + '0.50': number; + }; + total: number; +} +``` + +### 3.2 Servicios Backend + +| Servicio | Metodos Principales | +|----------|-------------------| +| CashRegisterService | getAll(), getByBranch(), activate(), deactivate() | +| CashSessionService | open(), close(), getActive(), getSummary() | +| CashMovementService | createIn(), createOut(), getBySession() | +| CashClosingService | prepare(), declare(), approve(), reject() | +| CashCountService | create(), compare() | + +### 3.3 Controladores + +```typescript +@Controller('cash') +export class CashController { + // Cajas registradoras + @Get('registers') + getRegisters(@Query('branchId') branchId: string): Promise; + + // Sesiones + @Post('sessions/open') + openSession(@Body() dto: OpenSessionDto): Promise; + + @Get('sessions/active') + getActiveSession(): Promise; + + @Get('sessions/:id/summary') + getSessionSummary(@Param('id') id: string): Promise; + + // Movimientos + @Post('movements') + createMovement(@Body() dto: CreateMovementDto): Promise; + + @Get('sessions/:id/movements') + getMovements(@Param('id') id: string): Promise; + + // Arqueos + @Post('sessions/:id/count') + createCount(@Param('id') id: string, @Body() dto: CountDto): Promise; + + // Corte + @Get('sessions/:id/closing/prepare') + prepareClosing(@Param('id') id: string): Promise; + + @Post('sessions/:id/closing') + closeSession(@Param('id') id: string, @Body() dto: ClosingDto): Promise; + + @Post('closings/:id/approve') + approveClosing(@Param('id') id: string): Promise; + + // Reportes + @Get('reports/daily') + getDailyReport(@Query('date') date: string): Promise; + + @Get('reports/differences') + getDifferencesReport(@Query() filters: DifferenceFilters): Promise; +} +``` + +--- + +## 4. FLUJOS DE NEGOCIO + +### 4.1 Flujo de Apertura + +``` +1. Cajero selecciona caja + ↓ +2. Verificar caja disponible (no en uso) + ↓ +3. Ingresar fondo inicial + ↓ +4. Sistema crea sesion (status: OPENING) + ↓ +5. Confirmar apertura + ↓ +6. Estado: OPENING → OPEN + ↓ +7. Cajero puede iniciar ventas +``` + +### 4.2 Flujo de Movimiento + +``` +1. Cajero solicita retiro/ingreso + ↓ +2. Ingresar monto y motivo + ↓ +3. Si retiro > limite: + a. Solicitar autorizacion supervisor + b. Supervisor aprueba + ↓ +4. Registrar movimiento + ↓ +5. Actualizar balance de sesion +``` + +### 4.3 Flujo de Corte + +``` +1. Cajero solicita cierre + ↓ +2. Sistema prepara resumen: + - Ventas en efectivo + - Ventas en tarjeta + - Ventas en transferencia + - Movimientos de efectivo + - Fondo inicial + - Esperado en efectivo + ↓ +3. Cajero cuenta efectivo + ↓ +4. Declarar por denominacion: + - Billetes: $1000, $500, $200, $100, $50, $20 + - Monedas: $20, $10, $5, $2, $1, $0.50 + ↓ +5. Sistema calcula total declarado + ↓ +6. Calcular diferencia + ↓ +7. Si diferencia > tolerancia: + a. Registrar motivo + b. Supervisor aprueba/rechaza + ↓ +8. Estado: OPEN → CLOSED + ↓ +9. Generar reporte de corte +``` + +--- + +## 5. CALCULOS + +### 5.1 Efectivo Esperado + +```typescript +function calculateExpectedCash(session: POSSession): number { + const openingBalance = session.openingBalance; + + // Ventas en efectivo + const cashSales = session.orders + .filter(o => o.status === 'done') + .reduce((sum, o) => { + const cashPayments = o.payments.filter(p => p.method === 'cash'); + return sum + cashPayments.reduce((s, p) => s + p.amount, 0); + }, 0); + + // Cambio dado + const changeGiven = session.orders + .filter(o => o.status === 'done') + .reduce((sum, o) => sum + (o.changeAmount || 0), 0); + + // Movimientos + const cashIn = session.movements + .filter(m => m.type === 'in') + .reduce((sum, m) => sum + m.amount, 0); + + const cashOut = session.movements + .filter(m => m.type === 'out') + .reduce((sum, m) => sum + m.amount, 0); + + return openingBalance + cashSales - changeGiven + cashIn - cashOut; +} +``` + +### 5.2 Declaracion por Denominacion + +```typescript +interface DenominationCount { + denomination: string; + quantity: number; + subtotal: number; +} + +function calculateDenominations(counts: DenominationCount[]): number { + return counts.reduce((total, c) => total + c.subtotal, 0); +} + +// Ejemplo +const declaration = [ + { denomination: '1000', quantity: 2, subtotal: 2000 }, + { denomination: '500', quantity: 5, subtotal: 2500 }, + { denomination: '200', quantity: 3, subtotal: 600 }, + { denomination: '100', quantity: 10, subtotal: 1000 }, + { denomination: '50', quantity: 8, subtotal: 400 }, + { denomination: '20', quantity: 15, subtotal: 300 }, + // Monedas + { denomination: '10', quantity: 20, subtotal: 200 }, + { denomination: '5', quantity: 10, subtotal: 50 }, + { denomination: '2', quantity: 5, subtotal: 10 }, + { denomination: '1', quantity: 10, subtotal: 10 }, + { denomination: '0.50', quantity: 20, subtotal: 10 }, +]; +// Total: $7,080 +``` + +--- + +## 6. TABLAS DDL + +### 6.1 Tablas + +```sql +-- Ya definidas parcialmente, agregar: +CREATE TABLE retail.cash_closings ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + tenant_id UUID NOT NULL REFERENCES auth.tenants(id), + session_id UUID NOT NULL REFERENCES retail.pos_sessions(id), + closing_date TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + -- Esperados + expected_cash DECIMAL(12,2) NOT NULL, + expected_card DECIMAL(12,2) NOT NULL DEFAULT 0, + expected_transfer DECIMAL(12,2) NOT NULL DEFAULT 0, + + -- Declarados + declared_cash DECIMAL(12,2) NOT NULL, + declared_card DECIMAL(12,2) NOT NULL DEFAULT 0, + declared_transfer DECIMAL(12,2) NOT NULL DEFAULT 0, + + -- Diferencias + cash_difference DECIMAL(12,2) GENERATED ALWAYS AS (declared_cash - expected_cash) STORED, + card_difference DECIMAL(12,2) GENERATED ALWAYS AS (declared_card - expected_card) STORED, + transfer_difference DECIMAL(12,2) GENERATED ALWAYS AS (declared_transfer - expected_transfer) STORED, + + -- Detalle + denomination_detail JSONB, + notes TEXT, + + -- Auditoria + closed_by UUID NOT NULL REFERENCES auth.users(id), + approved_by UUID REFERENCES auth.users(id), + is_approved BOOLEAN DEFAULT FALSE, + + created_at TIMESTAMPTZ DEFAULT NOW() +); + +CREATE TABLE retail.cash_counts ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + tenant_id UUID NOT NULL REFERENCES auth.tenants(id), + session_id UUID NOT NULL REFERENCES retail.pos_sessions(id), + count_date TIMESTAMPTZ NOT NULL DEFAULT NOW(), + expected_amount DECIMAL(12,2) NOT NULL, + counted_amount DECIMAL(12,2) NOT NULL, + difference DECIMAL(12,2) GENERATED ALWAYS AS (counted_amount - expected_amount) STORED, + denomination_detail JSONB, + counted_by UUID NOT NULL REFERENCES auth.users(id), + notes TEXT, + created_at TIMESTAMPTZ DEFAULT NOW() +); +``` + +--- + +## 7. DEPENDENCIAS + +### 7.1 Dependencias de Retail + +| Modulo | Tipo | +|--------|------| +| RT-001 Fundamentos | Prerequisito | +| RT-002 POS | Sesiones de caja | + +### 7.2 Bloquea a + +| Modulo | Razon | +|--------|-------| +| RT-008 Reportes | Reportes de caja | + +--- + +## 8. CRITERIOS DE ACEPTACION + +### 8.1 Funcionales + +- [ ] Abrir caja con fondo inicial +- [ ] Registrar movimiento de entrada +- [ ] Registrar movimiento de salida +- [ ] Requerir autorizacion para montos altos +- [ ] Realizar arqueo parcial +- [ ] Preparar cierre (mostrar esperados) +- [ ] Declarar por denominacion +- [ ] Calcular diferencias +- [ ] Aprobar cierre con diferencia +- [ ] Generar reporte de corte +- [ ] Ver historial de diferencias + +### 8.2 Auditoria + +- [ ] Registrar responsable de cada movimiento +- [ ] Registrar autorizador de retiros +- [ ] Registrar aprobador de diferencias + +--- + +## 9. ESTIMACION DETALLADA + +| Componente | SP Backend | SP Frontend | Total | +|------------|-----------|-------------|-------| +| Entities + Migrations | 3 | - | 3 | +| CashSessionService | 5 | - | 5 | +| CashMovementService | 3 | - | 3 | +| CashClosingService | 5 | - | 5 | +| CashCountService | 2 | - | 2 | +| Controllers | 3 | - | 3 | +| Opening UI | - | 2 | 2 | +| Movements UI | - | 2 | 2 | +| Closing UI | - | 3 | 3 | +| **TOTAL** | **21** | **7** | **28** | + +--- + +**Estado:** ANALISIS COMPLETO +**Bloqueado por:** RT-001, RT-002 diff --git a/orchestration/planes/fase-2-analisis-modulos/ANALISIS-RT-008-reportes.md b/orchestration/planes/fase-2-analisis-modulos/ANALISIS-RT-008-reportes.md new file mode 100644 index 0000000..4fbc769 --- /dev/null +++ b/orchestration/planes/fase-2-analisis-modulos/ANALISIS-RT-008-reportes.md @@ -0,0 +1,413 @@ +# ANALISIS MODULO RT-008: REPORTES Y DASHBOARD + +**Fecha:** 2025-12-18 +**Fase:** 2 - Analisis por Modulo +**Modulo:** RT-008 Reportes +**Herencia:** 70% +**Story Points:** 30 +**Prioridad:** P1 + +--- + +## 1. DESCRIPCION GENERAL + +### 1.1 Proposito +Sistema de analytics con dashboards en tiempo real, reportes de ventas, inventario, clientes y metricas operativas. + +### 1.2 Funcionalidades Principales + +| Funcionalidad | Descripcion | Criticidad | +|---------------|-------------|------------| +| Dashboard principal | KPIs tiempo real | Alta | +| Reportes ventas | Por periodo/sucursal | Alta | +| Analisis productos | Top vendidos, ABC | Media | +| Reportes inventario | Stock, valoracion | Media | +| Exportacion | Excel, PDF | Media | + +--- + +## 2. HERENCIA DEL CORE + +### 2.1 Componentes Heredados (70%) + +| Componente Core | % Uso | Accion | +|-----------------|-------|--------| +| MGN-009 Reports | 70% | EXTENDER | +| Analytics module | 100% | HEREDAR | + +### 2.2 Servicios a Heredar + +```typescript +import { ReportsService } from '@erp-core/reports'; +import { AnalyticsService } from '@erp-core/analytics'; +import { ExportService } from '@erp-core/reports'; +``` + +--- + +## 3. COMPONENTES NUEVOS + +### 3.1 Dashboard Principal + +```typescript +interface RetailDashboard { + // Metricas del dia + today: { + totalSales: number; + totalTransactions: number; + avgTicket: number; + newCustomers: number; + returningCustomers: number; + pointsRedeemed: number; + refunds: number; + }; + + // Comparativo + comparison: { + vsYesterday: PercentChange; + vsLastWeek: PercentChange; + vsLastMonth: PercentChange; + }; + + // Graficos + charts: { + salesByHour: TimeSeriesData[]; + salesByPaymentMethod: PieChartData[]; + topProducts: BarChartData[]; + topBranches: BarChartData[]; + }; + + // Alertas + alerts: { + lowStock: number; + pendingTransfers: number; + unapprovedClosings: number; + }; +} +``` + +### 3.2 Tipos de Reportes + +```typescript +// 1. Reporte de Ventas +interface SalesReport { + period: DateRange; + groupBy: 'day' | 'week' | 'month' | 'branch' | 'cashier'; + + summary: { + totalSales: number; + totalTransactions: number; + avgTicket: number; + totalDiscounts: number; + totalRefunds: number; + netSales: number; + }; + + byPaymentMethod: { + cash: number; + card: number; + transfer: number; + credit: number; + mixed: number; + }; + + details: SalesReportLine[]; +} + +// 2. Reporte de Productos +interface ProductReport { + period: DateRange; + branchId?: string; + + topSelling: ProductRanking[]; + slowMoving: ProductRanking[]; + noMovement: Product[]; + abcAnalysis: { + A: Product[]; // 80% ventas + B: Product[]; // 15% ventas + C: Product[]; // 5% ventas + }; +} + +// 3. Reporte de Inventario +interface InventoryReport { + branchId?: string; + + summary: { + totalProducts: number; + totalValue: number; + lowStockItems: number; + outOfStockItems: number; + }; + + byCategory: CategoryStock[]; + lowStockAlerts: StockAlert[]; + valuationMethod: 'FIFO' | 'LIFO' | 'AVCO'; +} + +// 4. Reporte de Clientes +interface CustomerReport { + period: DateRange; + + summary: { + totalCustomers: number; + newCustomers: number; + activeCustomers: number; + avgPurchaseFrequency: number; + }; + + topCustomers: CustomerRanking[]; + byMembershipLevel: MembershipBreakdown; + loyaltyMetrics: { + pointsIssued: number; + pointsRedeemed: number; + redemptionRate: number; + }; +} + +// 5. Reporte de Caja +interface CashReport { + period: DateRange; + branchId?: string; + + closings: CashClosingSummary[]; + differences: { + total: number; + count: number; + byReason: DifferenceBreakdown[]; + }; + movements: { + totalIn: number; + totalOut: number; + byReason: MovementBreakdown[]; + }; +} +``` + +### 3.3 Servicios Backend + +| Servicio | Metodos Principales | +|----------|-------------------| +| DashboardService | getDashboard(), refresh() | +| SalesReportService | generate(), getByPeriod(), export() | +| ProductReportService | getTopSelling(), getABCAnalysis() | +| InventoryReportService | getStockReport(), getValuation() | +| CustomerReportService | getCustomerMetrics(), getLoyaltyStats() | +| CashReportService | getClosingsReport(), getDifferences() | +| ExportService | toExcel(), toPDF() | + +### 3.4 Controladores + +```typescript +@Controller('reports') +export class ReportsController { + // Dashboard + @Get('dashboard') + getDashboard(@Query('branchId') branchId?: string): Promise; + + // Ventas + @Get('sales') + getSalesReport(@Query() filters: SalesFilters): Promise; + + @Get('sales/by-hour') + getSalesByHour(@Query('date') date: string): Promise; + + @Get('sales/by-cashier') + getSalesByCashier(@Query() filters: DateFilters): Promise; + + // Productos + @Get('products/top-selling') + getTopSelling(@Query() filters: ProductFilters): Promise; + + @Get('products/abc-analysis') + getABCAnalysis(@Query('branchId') branchId?: string): Promise; + + @Get('products/slow-moving') + getSlowMoving(@Query() filters: ProductFilters): Promise; + + // Inventario + @Get('inventory') + getInventoryReport(@Query('branchId') branchId?: string): Promise; + + @Get('inventory/valuation') + getValuation(@Query('branchId') branchId?: string): Promise; + + // Clientes + @Get('customers') + getCustomerReport(@Query() filters: CustomerFilters): Promise; + + @Get('customers/loyalty') + getLoyaltyMetrics(@Query() filters: DateFilters): Promise; + + // Caja + @Get('cash/closings') + getClosingsReport(@Query() filters: CashFilters): Promise; + + @Get('cash/differences') + getDifferencesReport(@Query() filters: CashFilters): Promise; + + // Exportacion + @Get(':type/export/excel') + exportToExcel(@Param('type') type: string, @Query() filters: any): Promise; + + @Get(':type/export/pdf') + exportToPDF(@Param('type') type: string, @Query() filters: any): Promise; +} +``` + +--- + +## 4. VISTAS MATERIALIZADAS + +### 4.1 Para Performance + +```sql +-- Vista materializada de ventas diarias +CREATE MATERIALIZED VIEW retail.mv_daily_sales AS +SELECT + DATE(po.order_date) as sale_date, + po.tenant_id, + ps.branch_id, + ps.user_id as cashier_id, + COUNT(*) as transaction_count, + SUM(po.total) as total_sales, + SUM(po.discount_amount) as total_discounts, + AVG(po.total) as avg_ticket, + SUM(CASE WHEN po.status = 'refunded' THEN po.total ELSE 0 END) as refunds +FROM retail.pos_orders po +JOIN retail.pos_sessions ps ON po.session_id = ps.id +WHERE po.status IN ('done', 'refunded') +GROUP BY DATE(po.order_date), po.tenant_id, ps.branch_id, ps.user_id; + +CREATE UNIQUE INDEX idx_mv_daily_sales ON retail.mv_daily_sales(sale_date, tenant_id, branch_id, cashier_id); + +-- Vista materializada de productos mas vendidos +CREATE MATERIALIZED VIEW retail.mv_product_sales AS +SELECT + pol.product_id, + pol.tenant_id, + ps.branch_id, + DATE_TRUNC('month', po.order_date) as sale_month, + SUM(pol.quantity) as qty_sold, + SUM(pol.total) as revenue, + COUNT(DISTINCT po.id) as order_count +FROM retail.pos_order_lines pol +JOIN retail.pos_orders po ON pol.order_id = po.id +JOIN retail.pos_sessions ps ON po.session_id = ps.id +WHERE po.status = 'done' +GROUP BY pol.product_id, pol.tenant_id, ps.branch_id, DATE_TRUNC('month', po.order_date); + +-- Refresh cada hora +-- REFRESH MATERIALIZED VIEW CONCURRENTLY retail.mv_daily_sales; +``` + +--- + +## 5. GRAFICOS Y VISUALIZACIONES + +### 5.1 Componentes Frontend + +| Componente | Tipo | Datos | +|------------|------|-------| +| SalesChart | Line | Ventas por hora/dia | +| PaymentPieChart | Pie | Por metodo de pago | +| TopProductsChart | Bar | Top 10 productos | +| BranchComparison | Bar | Ventas por sucursal | +| TrendIndicator | KPI | Variacion % | +| AlertsBadge | Badge | Alertas pendientes | + +### 5.2 Configuracion + +```typescript +interface ChartConfig { + refreshInterval: number; // segundos + defaultPeriod: 'today' | 'week' | 'month'; + colors: { + primary: string; + success: string; + warning: string; + danger: string; + }; +} + +const defaultConfig: ChartConfig = { + refreshInterval: 300, // 5 minutos + defaultPeriod: 'today', + colors: { + primary: '#3B82F6', + success: '#10B981', + warning: '#F59E0B', + danger: '#EF4444' + } +}; +``` + +--- + +## 6. DEPENDENCIAS + +### 6.1 Dependencias de Core + +| Modulo | Estado | Requerido Para | +|--------|--------|---------------| +| MGN-009 Reports | 0% | Base de reportes | + +### 6.2 Dependencias de Retail + +| Modulo | Tipo | +|--------|------| +| RT-001 Fundamentos | Prerequisito | +| RT-002 POS | Datos de ventas | +| RT-003 Inventario | Datos de stock | +| RT-005 Clientes | Datos de lealtad | +| RT-007 Caja | Datos de cortes | + +--- + +## 7. CRITERIOS DE ACEPTACION + +### 7.1 Dashboard + +- [ ] Cargar dashboard < 3s +- [ ] Auto-refresh cada 5 min +- [ ] Mostrar KPIs del dia +- [ ] Grafico de ventas por hora +- [ ] Top 5 productos +- [ ] Alertas visibles + +### 7.2 Reportes + +- [ ] Filtrar por sucursal +- [ ] Filtrar por periodo +- [ ] Comparar periodos +- [ ] Exportar a Excel +- [ ] Exportar a PDF + +### 7.3 Performance + +- [ ] Dashboard < 3s +- [ ] Reportes < 5s +- [ ] Vistas materializadas + +--- + +## 8. ESTIMACION DETALLADA + +| Componente | SP Backend | SP Frontend | Total | +|------------|-----------|-------------|-------| +| Dashboard Service | 3 | - | 3 | +| Sales Report Service | 3 | - | 3 | +| Product Report Service | 2 | - | 2 | +| Inventory Report Service | 2 | - | 2 | +| Customer Report Service | 2 | - | 2 | +| Cash Report Service | 2 | - | 2 | +| Export Service | 3 | - | 3 | +| Vistas Materializadas | 2 | - | 2 | +| Dashboard UI | - | 8 | 8 | +| Report Pages | - | 3 | 3 | +| **TOTAL** | **19** | **11** | **30** | + +--- + +**Estado:** ANALISIS COMPLETO +**Bloqueado por:** RT-002, RT-003, RT-005, RT-007 diff --git a/orchestration/planes/fase-2-analisis-modulos/ANALISIS-RT-009-ecommerce.md b/orchestration/planes/fase-2-analisis-modulos/ANALISIS-RT-009-ecommerce.md new file mode 100644 index 0000000..9bb320f --- /dev/null +++ b/orchestration/planes/fase-2-analisis-modulos/ANALISIS-RT-009-ecommerce.md @@ -0,0 +1,567 @@ +# ANALISIS MODULO RT-009: E-COMMERCE + +**Fecha:** 2025-12-18 +**Fase:** 2 - Analisis por Modulo +**Modulo:** RT-009 E-commerce +**Herencia:** 20% +**Story Points:** 55 +**Prioridad:** P2 + +--- + +## 1. DESCRIPCION GENERAL + +### 1.1 Proposito +Tienda online integrada con inventario, carrito de compras, checkout, pasarelas de pago y opciones de entrega. + +### 1.2 Funcionalidades Principales + +| Funcionalidad | Descripcion | Criticidad | +|---------------|-------------|------------| +| Catalogo online | Navegacion y busqueda | Alta | +| Carrito | Persistente | Alta | +| Checkout | Flujo completo | Critica | +| Pagos | Multiples pasarelas | Critica | +| Envio | Multiples opciones | Alta | +| Pedidos | Gestion backoffice | Alta | + +--- + +## 2. HERENCIA DEL CORE + +### 2.1 Componentes Heredados (20%) + +| Componente Core | % Uso | Accion | +|-----------------|-------|--------| +| inventory.products | 100% | HEREDAR | +| sales.pricelists | 100% | HEREDAR | +| core.partners | 100% | HEREDAR | + +### 2.2 Servicios a Heredar + +```typescript +import { ProductsService } from '@erp-core/inventory'; +import { PricelistsService } from '@erp-core/sales'; +import { PartnersService } from '@erp-core/core'; +``` + +--- + +## 3. COMPONENTES NUEVOS + +### 3.1 Entidades (TypeORM) + +```typescript +// 1. EcommerceOrder - Pedido online +@Entity('ecommerce_orders', { schema: 'retail' }) +export class EcommerceOrder { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column('uuid') + tenantId: string; + + @Column() + orderNumber: string; + + @ManyToOne(() => Partner) + customer: Partner; + + @Column({ type: 'enum', enum: EcommerceOrderStatus }) + status: EcommerceOrderStatus; + // pending, paid, preparing, shipped, ready_pickup, delivered, cancelled + + @Column({ type: 'timestamptz' }) + orderDate: Date; + + // Totales + @Column({ type: 'decimal', precision: 12, scale: 2 }) + subtotal: number; + + @Column({ type: 'decimal', precision: 12, scale: 2, default: 0 }) + discountAmount: number; + + @Column({ type: 'decimal', precision: 12, scale: 2 }) + shippingCost: number; + + @Column({ type: 'decimal', precision: 12, scale: 2 }) + taxAmount: number; + + @Column({ type: 'decimal', precision: 12, scale: 2 }) + total: number; + + // Pago + @Column({ type: 'enum', enum: PaymentStatus }) + paymentStatus: PaymentStatus; + + @Column({ nullable: true }) + paymentMethod: string; + + @Column({ nullable: true }) + paymentReference: string; + + // Entrega + @Column({ type: 'enum', enum: DeliveryMethod }) + deliveryMethod: DeliveryMethod; // shipping, pickup + + @ManyToOne(() => Branch, { nullable: true }) + pickupBranch: Branch; // Si es pickup + + @Column({ type: 'jsonb', nullable: true }) + shippingAddress: Address; + + @Column({ nullable: true }) + trackingNumber: string; + + @OneToMany(() => EcommerceOrderLine, line => line.order) + lines: EcommerceOrderLine[]; + + @Column({ nullable: true }) + notes: string; +} + +// 2. EcommerceOrderLine +@Entity('ecommerce_order_lines', { schema: 'retail' }) +export class EcommerceOrderLine { + @PrimaryGeneratedColumn('uuid') + id: string; + + @ManyToOne(() => EcommerceOrder) + order: EcommerceOrder; + + @ManyToOne(() => Product) + product: Product; + + @Column() + productName: string; + + @Column({ type: 'decimal', precision: 12, scale: 4 }) + quantity: number; + + @Column({ type: 'decimal', precision: 12, scale: 2 }) + unitPrice: number; + + @Column({ type: 'decimal', precision: 12, scale: 2 }) + total: number; +} + +// 3. Cart - Carrito (session-based) +@Entity('carts', { schema: 'retail' }) +export class Cart { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column('uuid') + tenantId: string; + + @ManyToOne(() => Partner, { nullable: true }) + customer: Partner; // null si guest + + @Column({ nullable: true }) + sessionId: string; // Para guests + + @OneToMany(() => CartItem, item => item.cart) + items: CartItem[]; + + @Column({ type: 'decimal', precision: 12, scale: 2, default: 0 }) + subtotal: number; + + @Column({ type: 'timestamptz' }) + createdAt: Date; + + @Column({ type: 'timestamptz' }) + updatedAt: Date; + + @Column({ type: 'timestamptz', nullable: true }) + expiresAt: Date; +} + +// 4. CartItem +@Entity('cart_items', { schema: 'retail' }) +export class CartItem { + @PrimaryGeneratedColumn('uuid') + id: string; + + @ManyToOne(() => Cart) + cart: Cart; + + @ManyToOne(() => Product) + product: Product; + + @Column({ type: 'decimal', precision: 12, scale: 4 }) + quantity: number; + + @Column({ type: 'decimal', precision: 12, scale: 2 }) + unitPrice: number; + + @Column({ type: 'decimal', precision: 12, scale: 2 }) + total: number; +} + +// 5. ShippingRate +@Entity('shipping_rates', { schema: 'retail' }) +export class ShippingRate { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column('uuid') + tenantId: string; + + @Column() + name: string; // "Envio estandar", "Express" + + @Column() + carrier: string; // "Fedex", "DHL", "Estafeta" + + @Column({ type: 'decimal', precision: 12, scale: 2 }) + baseRate: number; + + @Column({ type: 'decimal', precision: 12, scale: 2, nullable: true }) + freeShippingMinimum: number; + + @Column({ type: 'int', nullable: true }) + estimatedDays: number; + + @Column({ type: 'boolean', default: true }) + isActive: boolean; +} +``` + +### 3.2 Servicios Backend + +| Servicio | Metodos Principales | +|----------|-------------------| +| CatalogService | search(), getByCategory(), getProduct() | +| CartService | create(), addItem(), updateItem(), removeItem(), clear() | +| CheckoutService | validate(), calculateTotals(), processPayment() | +| PaymentGatewayService | createPayment(), capture(), refund() | +| ShippingService | calculateRates(), createShipment(), track() | +| EcommerceOrderService | create(), updateStatus(), getByCustomer() | + +### 3.3 Controladores + +```typescript +// API Publica (Storefront) +@Controller('store') +export class StorefrontController { + // Catalogo + @Get('products') + getProducts(@Query() filters: CatalogFilters): Promise; + + @Get('products/:id') + getProduct(@Param('id') id: string): Promise; + + @Get('products/search') + searchProducts(@Query('q') query: string): Promise; + + @Get('categories') + getCategories(): Promise; + + // Carrito + @Get('cart') + getCart(): Promise; + + @Post('cart/items') + addToCart(@Body() dto: AddToCartDto): Promise; + + @Put('cart/items/:id') + updateCartItem(@Param('id') id: string, @Body() dto: UpdateCartDto): Promise; + + @Delete('cart/items/:id') + removeFromCart(@Param('id') id: string): Promise; + + // Checkout + @Post('checkout/validate') + validateCheckout(@Body() dto: CheckoutDto): Promise; + + @Get('checkout/shipping-rates') + getShippingRates(@Query() dto: ShippingQuery): Promise; + + @Post('checkout/complete') + completeCheckout(@Body() dto: CompleteCheckoutDto): Promise; + + // Pagos + @Post('payments/create') + createPayment(@Body() dto: PaymentDto): Promise; + + @Post('payments/webhook') + handleWebhook(@Body() payload: any): Promise; + + // Pedidos (autenticado) + @Get('orders') + @UseGuards(AuthGuard) + getMyOrders(): Promise; + + @Get('orders/:id') + @UseGuards(AuthGuard) + getOrder(@Param('id') id: string): Promise; +} + +// API Backoffice +@Controller('ecommerce') +export class EcommerceController { + // Gestion de pedidos + @Get('orders') + getOrders(@Query() filters: OrderFilters): Promise; + + @Put('orders/:id/status') + updateStatus(@Param('id') id: string, @Body() dto: StatusDto): Promise; + + @Post('orders/:id/ship') + markAsShipped(@Param('id') id: string, @Body() dto: ShipDto): Promise; + + // Configuracion + @Get('shipping-rates') + getShippingRates(): Promise; + + @Post('shipping-rates') + createShippingRate(@Body() dto: CreateRateDto): Promise; +} +``` + +--- + +## 4. INTEGRACIONES EXTERNAS + +### 4.1 Pasarelas de Pago + +```typescript +interface PaymentGateway { + name: string; + createPayment(order: EcommerceOrder): Promise; + capturePayment(paymentId: string): Promise; + refund(paymentId: string, amount: number): Promise; +} + +// Implementaciones +class StripeGateway implements PaymentGateway { ... } +class ConektaGateway implements PaymentGateway { ... } +class MercadoPagoGateway implements PaymentGateway { ... } +``` + +### 4.2 Proveedores de Envio + +```typescript +interface ShippingProvider { + name: string; + calculateRate(origin: Address, destination: Address, weight: number): Promise; + createShipment(order: EcommerceOrder): Promise; + getTracking(trackingNumber: string): Promise; +} + +// Implementaciones +class FedexProvider implements ShippingProvider { ... } +class DHLProvider implements ShippingProvider { ... } +class EstafetaProvider implements ShippingProvider { ... } +``` + +--- + +## 5. FLUJOS DE NEGOCIO + +### 5.1 Flujo de Compra + +``` +1. Cliente navega catalogo + ↓ +2. Agrega productos al carrito + ↓ +3. Revisa carrito + ↓ +4. Inicia checkout + ↓ +5. Login/registro (o guest) + ↓ +6. Selecciona direccion de envio + ↓ +7. Selecciona metodo de envio + ↓ +8. Ve resumen con totales + ↓ +9. Selecciona metodo de pago + ↓ +10. Procesa pago + ↓ +11. Confirmacion de pedido + ↓ +12. Email de confirmacion +``` + +### 5.2 Flujo de Gestion de Pedido + +``` +1. Pedido creado (PENDING) + ↓ +2. Pago confirmado (PAID) + ↓ +3. Preparando (PREPARING) + - Reservar stock + - Imprimir etiqueta + ↓ +4. Enviado (SHIPPED) o Listo para recoger (READY_PICKUP) + - Tracking disponible + ↓ +5. Entregado (DELIVERED) + ↓ +6. Fin (o cancelado en cualquier punto) +``` + +--- + +## 6. TABLAS DDL + +### 6.1 Tablas Nuevas + +```sql +-- Pedidos +CREATE TABLE retail.ecommerce_orders ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + tenant_id UUID NOT NULL REFERENCES auth.tenants(id), + order_number VARCHAR(20) NOT NULL, + customer_id UUID NOT NULL REFERENCES core.partners(id), + status VARCHAR(20) NOT NULL DEFAULT 'pending', + order_date TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + subtotal DECIMAL(12,2) NOT NULL, + discount_amount DECIMAL(12,2) DEFAULT 0, + shipping_cost DECIMAL(12,2) NOT NULL DEFAULT 0, + tax_amount DECIMAL(12,2) NOT NULL DEFAULT 0, + total DECIMAL(12,2) NOT NULL, + + payment_status VARCHAR(20), + payment_method VARCHAR(50), + payment_reference VARCHAR(100), + + delivery_method VARCHAR(20) NOT NULL, + pickup_branch_id UUID REFERENCES retail.branches(id), + shipping_address JSONB, + tracking_number VARCHAR(50), + + notes TEXT, + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ, + + UNIQUE(tenant_id, order_number) +); + +-- Lineas +CREATE TABLE retail.ecommerce_order_lines ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + tenant_id UUID NOT NULL REFERENCES auth.tenants(id), + order_id UUID NOT NULL REFERENCES retail.ecommerce_orders(id) ON DELETE CASCADE, + product_id UUID NOT NULL REFERENCES inventory.products(id), + product_name VARCHAR(255) NOT NULL, + quantity DECIMAL(12,4) NOT NULL, + unit_price DECIMAL(12,2) NOT NULL, + total DECIMAL(12,2) NOT NULL +); + +-- Carritos +CREATE TABLE retail.carts ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + tenant_id UUID NOT NULL REFERENCES auth.tenants(id), + customer_id UUID REFERENCES core.partners(id), + session_id VARCHAR(100), + subtotal DECIMAL(12,2) DEFAULT 0, + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ, + expires_at TIMESTAMPTZ +); + +CREATE TABLE retail.cart_items ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + tenant_id UUID NOT NULL REFERENCES auth.tenants(id), + cart_id UUID NOT NULL REFERENCES retail.carts(id) ON DELETE CASCADE, + product_id UUID NOT NULL REFERENCES inventory.products(id), + quantity DECIMAL(12,4) NOT NULL, + unit_price DECIMAL(12,2) NOT NULL, + total DECIMAL(12,2) NOT NULL +); + +-- Tarifas de envio +CREATE TABLE retail.shipping_rates ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + tenant_id UUID NOT NULL REFERENCES auth.tenants(id), + name VARCHAR(100) NOT NULL, + carrier VARCHAR(50) NOT NULL, + base_rate DECIMAL(12,2) NOT NULL, + free_shipping_minimum DECIMAL(12,2), + estimated_days INT, + is_active BOOLEAN DEFAULT TRUE, + created_at TIMESTAMPTZ DEFAULT NOW() +); +``` + +--- + +## 7. DEPENDENCIAS + +### 7.1 Dependencias de Retail + +| Modulo | Tipo | +|--------|------| +| RT-001 Fundamentos | Prerequisito | +| RT-003 Inventario | Stock | +| RT-005 Clientes | Clientes y puntos | +| RT-006 Precios | Precios online | + +### 7.2 Dependencias Externas + +| Servicio | Proposito | +|----------|-----------| +| Stripe/Conekta | Pagos | +| Fedex/DHL | Envios | +| SendGrid | Emails | + +### 7.3 Bloquea a + +| Modulo | Razon | +|--------|-------| +| RT-010 Facturacion | Facturas de pedidos | + +--- + +## 8. CRITERIOS DE ACEPTACION + +### 8.1 Storefront + +- [ ] Navegar catalogo +- [ ] Buscar productos +- [ ] Ver detalle de producto +- [ ] Stock en tiempo real +- [ ] Agregar al carrito +- [ ] Carrito persistente +- [ ] Checkout como guest o registrado +- [ ] Multiples metodos de pago +- [ ] Envio o pickup +- [ ] Confirmacion por email + +### 8.2 Backoffice + +- [ ] Ver pedidos +- [ ] Actualizar estado +- [ ] Generar etiqueta envio +- [ ] Registrar tracking +- [ ] Cancelar pedido + +--- + +## 9. ESTIMACION DETALLADA + +| Componente | SP Backend | SP Frontend | Total | +|------------|-----------|-------------|-------| +| Entities + Migrations | 5 | - | 5 | +| CatalogService | 3 | - | 3 | +| CartService | 5 | - | 5 | +| CheckoutService | 5 | - | 5 | +| PaymentGatewayService | 8 | - | 8 | +| ShippingService | 5 | - | 5 | +| OrderService | 5 | - | 5 | +| Storefront UI | - | 13 | 13 | +| Backoffice UI | - | 6 | 6 | +| **TOTAL** | **36** | **19** | **55** | + +--- + +**Estado:** ANALISIS COMPLETO +**Bloqueado por:** RT-001, RT-003, RT-005, RT-006 diff --git a/orchestration/planes/fase-2-analisis-modulos/ANALISIS-RT-010-facturacion.md b/orchestration/planes/fase-2-analisis-modulos/ANALISIS-RT-010-facturacion.md new file mode 100644 index 0000000..aa59901 --- /dev/null +++ b/orchestration/planes/fase-2-analisis-modulos/ANALISIS-RT-010-facturacion.md @@ -0,0 +1,525 @@ +# ANALISIS MODULO RT-010: FACTURACION CFDI 4.0 + +**Fecha:** 2025-12-18 +**Fase:** 2 - Analisis por Modulo +**Modulo:** RT-010 Facturacion +**Herencia:** 60% +**Story Points:** 35 +**Prioridad:** P0 + +--- + +## 1. DESCRIPCION GENERAL + +### 1.1 Proposito +Generacion de comprobantes fiscales digitales (CFDI 4.0) para ventas en POS y e-commerce, con timbrado en tiempo real y portal de autofactura. + +### 1.2 Funcionalidades Principales + +| Funcionalidad | Descripcion | Criticidad | +|---------------|-------------|------------| +| Facturacion POS | Al cerrar venta | Critica | +| Publico general | RFC generico | Alta | +| Autofactura | Portal publico | Alta | +| Notas de credito | Por devolucion | Alta | +| Cancelacion | Dentro de plazo | Media | +| Reportes | Fiscales | Media | + +--- + +## 2. HERENCIA DEL CORE + +### 2.1 Componentes Heredados (60%) + +| Componente Core | % Uso | Accion | +|-----------------|-------|--------| +| financial.invoices | 80% | EXTENDER | +| financial.invoice_lines | 80% | EXTENDER | +| financial.payments | 100% | HEREDAR | +| core.partners | 100% | HEREDAR | + +### 2.2 Servicios a Heredar + +```typescript +import { InvoicesService } from '@erp-core/financial'; +import { PaymentsService } from '@erp-core/financial'; +import { PartnersService } from '@erp-core/core'; +``` + +### 2.3 Servicios a Extender + +```typescript +class CFDIInvoiceService extends InvoicesService { + // Generacion CFDI + async generateCFDI(posOrderId: string): Promise; + async timbrar(cfdi: CFDI): Promise; + + // Cancelacion + async cancelCFDI(invoiceId: string, motivo: string): Promise; +} +``` + +--- + +## 3. COMPONENTES NUEVOS + +### 3.1 Estructura CFDI 4.0 + +```typescript +interface CFDI40 { + // Comprobante + version: '4.0'; + serie: string; + folio: string; + fecha: Date; + formaPago: string; // 01 Efectivo, 04 Tarjeta, etc. + metodoPago: 'PUE' | 'PPD'; // Pago en una exhibicion / Parcialidades + tipoDeComprobante: 'I' | 'E' | 'T' | 'N' | 'P'; // Ingreso, Egreso, Traslado, Nomina, Pago + lugarExpedicion: string; // CP + + // Emisor + emisor: { + rfc: string; + nombre: string; + regimenFiscal: string; // 601, 612, etc. + }; + + // Receptor + receptor: { + rfc: string; + nombre: string; + domicilioFiscalReceptor: string; // CP + regimenFiscalReceptor: string; + usoCFDI: string; // G03 Gastos en general, etc. + }; + + // Conceptos + conceptos: CFDIConcepto[]; + + // Impuestos + impuestos: { + totalImpuestosTrasladados: number; + totalImpuestosRetenidos: number; + traslados: CFDITraslado[]; + retenciones?: CFDIRetencion[]; + }; + + // Totales + subTotal: number; + descuento?: number; + total: number; + + // Timbre (despues de timbrar) + timbreFiscalDigital?: { + version: '1.1'; + uuid: string; + fechaTimbrado: Date; + rfcProvCertif: string; + selloCFD: string; + selloSAT: string; + noCertificadoSAT: string; + }; +} + +interface CFDIConcepto { + claveProdServ: string; // Clave SAT (ej: 01010101) + noIdentificacion?: string; // SKU + cantidad: number; + claveUnidad: string; // E48, H87, etc. + unidad?: string; // Descripcion unidad + descripcion: string; + valorUnitario: number; + importe: number; + descuento?: number; + objetoImp: '01' | '02' | '03'; // No objeto, Si objeto, Si objeto no desglosado + impuestos?: { + traslados: CFDITraslado[]; + retenciones?: CFDIRetencion[]; + }; +} + +interface CFDITraslado { + base: number; + impuesto: '002'; // IVA + tipoFactor: 'Tasa' | 'Cuota' | 'Exento'; + tasaOCuota: number; // 0.160000 + importe: number; +} +``` + +### 3.2 Servicios Backend + +| Servicio | Metodos Principales | +|----------|-------------------| +| CFDIService | generate(), timbrar(), cancel(), validate() | +| CFDIBuilderService | fromPOSOrder(), fromEcommerceOrder() | +| PACService | timbrar(), consultar(), cancelar() | +| XMLService | buildXML(), parseXML(), validate() | +| PDFService | generatePDF() | +| AutoFacturaService | validateTicket(), generateFromTicket() | + +### 3.3 Controladores + +```typescript +@Controller('cfdi') +export class CFDIController { + // Generacion desde POS + @Post('pos/:orderId') + generateFromPOS(@Param('orderId') orderId: string, + @Body() dto: CFDIRequestDto): Promise; + + // Generacion desde E-commerce + @Post('ecommerce/:orderId') + generateFromEcommerce(@Param('orderId') orderId: string): Promise; + + // Factura publico general + @Post('public/:orderId') + generatePublicInvoice(@Param('orderId') orderId: string): Promise; + + // Consulta + @Get(':id') + getCFDI(@Param('id') id: string): Promise; + + @Get(':id/xml') + getXML(@Param('id') id: string): Promise; + + @Get(':id/pdf') + getPDF(@Param('id') id: string): Promise; + + // Cancelacion + @Post(':id/cancel') + cancel(@Param('id') id: string, @Body() dto: CancelDto): Promise; + + // Notas de credito + @Post('credit-note') + createCreditNote(@Body() dto: CreditNoteDto): Promise; + + // Reportes + @Get('report/monthly') + getMonthlyReport(@Query() filters: ReportFilters): Promise; +} + +// API Publica - Autofactura +@Controller('autofactura') +export class AutofacturaController { + @Get('validate/:ticketNumber') + validateTicket(@Param('ticketNumber') ticketNumber: string): Promise; + + @Post('generate') + generate(@Body() dto: AutofacturaDto): Promise; + + @Get('download/:uuid') + download(@Param('uuid') uuid: string): Promise<{ xml: string; pdf: Buffer }>; +} +``` + +--- + +## 4. INTEGRACION PAC + +### 4.1 Proveedores Soportados + +```typescript +interface PACProvider { + name: string; + timbrar(xml: string): Promise; + consultar(uuid: string): Promise; + cancelar(uuid: string, motivo: string): Promise; +} + +// Implementaciones +class FinkokPAC implements PACProvider { + private readonly wsdlUrl = 'https://demo-facturacion.finkok.com/servicios/soap/stamp.wsdl'; + // ... +} + +class FacturamaPAC implements PACProvider { + private readonly apiUrl = 'https://api.facturama.mx/3/cfdis'; + // ... +} + +class SWsapienPAC implements PACProvider { + private readonly apiUrl = 'https://services.test.sw.com.mx/cfdi40/issue'; + // ... +} +``` + +### 4.2 Configuracion + +```yaml +cfdi_config: + pac: + primary: "finkok" + backup: "facturama" + timeout: 10000 # 10 segundos + retries: 3 + + emisor: + rfc: "XAXX010101000" # Configurable por tenant + nombre: "EMPRESA DEMO SA DE CV" + regimen: "601" + cp: "06600" + + certificados: + cer_path: "/certs/{tenant}/cer.cer" + key_path: "/certs/{tenant}/key.key" + key_password: "${CFDI_KEY_PASSWORD}" + + almacenamiento: + xml_path: "/storage/cfdi/{tenant}/{year}/{month}/" + retention_years: 5 +``` + +--- + +## 5. FLUJOS DE NEGOCIO + +### 5.1 Facturacion en POS + +``` +1. Cliente solicita factura al cerrar venta + ↓ +2. Capturar datos fiscales: + - RFC + - Nombre o Razon Social + - Regimen Fiscal + - Uso CFDI + - CP Domicilio Fiscal + ↓ +3. Validar RFC contra lista SAT (opcional) + ↓ +4. Construir XML CFDI 4.0 + ↓ +5. Firmar con certificado + ↓ +6. Enviar a PAC para timbrado + ↓ +7. Si exito: + a. Guardar XML timbrado + b. Generar PDF + c. Enviar por email + d. Imprimir (opcional) + ↓ +8. Si fallo: + a. Reintentar con PAC backup + b. Si falla: marcar para reintento posterior +``` + +### 5.2 Autofactura + +``` +1. Cliente accede al portal publico + ↓ +2. Ingresa numero de ticket + ↓ +3. Sistema valida: + - Ticket existe + - No esta facturado + - Dentro de plazo (30 dias) + ↓ +4. Muestra detalle de compra + ↓ +5. Cliente ingresa datos fiscales + ↓ +6. Genera y timbra CFDI + ↓ +7. Descarga XML y PDF +``` + +### 5.3 Cancelacion + +``` +1. Solicitar cancelacion + ↓ +2. Validar: + - Dentro de plazo (30 dias) + - No tiene pagos aplicados + ↓ +3. Seleccionar motivo: + - 01: Con documento relacionado (sustitucion) + - 02: Sin documento relacionado + - 03: No se llevo a cabo + - 04: Error en datos + ↓ +4. Enviar solicitud a PAC + ↓ +5. Si requiere aceptacion receptor: + - Esperar aceptacion + ↓ +6. Confirmar cancelacion +``` + +--- + +## 6. TABLAS DDL + +### 6.1 Tablas Nuevas + +```sql +-- Configuracion CFDI por tenant +CREATE TABLE retail.cfdi_config ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + tenant_id UUID NOT NULL REFERENCES auth.tenants(id) UNIQUE, + emisor_rfc VARCHAR(13) NOT NULL, + emisor_nombre VARCHAR(255) NOT NULL, + emisor_regimen VARCHAR(3) NOT NULL, + emisor_cp VARCHAR(5) NOT NULL, + pac_provider VARCHAR(20) NOT NULL DEFAULT 'finkok', + pac_user VARCHAR(100), + pac_password_encrypted TEXT, + cer_path TEXT, + key_path TEXT, + key_password_encrypted TEXT, + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ +); + +-- CFDIs emitidos +CREATE TABLE retail.cfdis ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + tenant_id UUID NOT NULL REFERENCES auth.tenants(id), + + -- Relacion origen + source_type VARCHAR(20) NOT NULL, -- pos_order, ecommerce_order + source_id UUID NOT NULL, + + -- Datos comprobante + serie VARCHAR(10), + folio VARCHAR(20), + uuid VARCHAR(36), -- UUID del SAT + fecha_emision TIMESTAMPTZ NOT NULL, + tipo_comprobante CHAR(1) NOT NULL, -- I, E, T, N, P + forma_pago VARCHAR(2), + metodo_pago VARCHAR(3), + + -- Receptor + receptor_rfc VARCHAR(13) NOT NULL, + receptor_nombre VARCHAR(255) NOT NULL, + receptor_regimen VARCHAR(3), + receptor_cp VARCHAR(5), + uso_cfdi VARCHAR(4), + + -- Totales + subtotal DECIMAL(12,2) NOT NULL, + descuento DECIMAL(12,2) DEFAULT 0, + total_impuestos DECIMAL(12,2) NOT NULL, + total DECIMAL(12,2) NOT NULL, + + -- Estado + status VARCHAR(20) NOT NULL DEFAULT 'vigente', -- vigente, cancelado + cancel_date TIMESTAMPTZ, + cancel_reason VARCHAR(2), + + -- Archivos + xml_content TEXT, + xml_path TEXT, + pdf_path TEXT, + + -- Timbre + fecha_timbrado TIMESTAMPTZ, + rfc_pac VARCHAR(13), + sello_cfd TEXT, + sello_sat TEXT, + no_certificado_sat VARCHAR(20), + + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ, + + UNIQUE(tenant_id, uuid) +); + +-- Indices +CREATE INDEX idx_cfdis_source ON retail.cfdis(source_type, source_id); +CREATE INDEX idx_cfdis_uuid ON retail.cfdis(uuid); +CREATE INDEX idx_cfdis_fecha ON retail.cfdis(fecha_emision); +CREATE INDEX idx_cfdis_receptor ON retail.cfdis(receptor_rfc); +``` + +--- + +## 7. DEPENDENCIAS + +### 7.1 Dependencias de Core + +| Modulo | Estado | Requerido Para | +|--------|--------|---------------| +| MGN-010 Financial | 70% | Facturas base | + +### 7.2 Dependencias de Retail + +| Modulo | Tipo | +|--------|------| +| RT-001 Fundamentos | Prerequisito | +| RT-002 POS | Origen de facturas | +| RT-009 E-commerce | Origen de facturas | + +### 7.3 Dependencias Externas + +| Servicio | Proposito | +|----------|-----------| +| PAC (Finkok, Facturama) | Timbrado | +| SAT | Validacion RFC (opcional) | + +--- + +## 8. CRITERIOS DE ACEPTACION + +### 8.1 Funcionales + +- [ ] Generar factura desde POS +- [ ] Generar factura publico general +- [ ] Timbrar en < 5 segundos +- [ ] Retry automatico con PAC backup +- [ ] Generar PDF +- [ ] Enviar por email +- [ ] Portal de autofactura +- [ ] Cancelar CFDI +- [ ] Generar nota de credito +- [ ] Almacenar XMLs 5 anos + +### 8.2 Compliance + +- [ ] CFDI 4.0 valido +- [ ] Catalogo SAT actualizado +- [ ] Certificados vigentes +- [ ] Timbre valido + +--- + +## 9. ESTIMACION DETALLADA + +| Componente | SP Backend | SP Frontend | Total | +|------------|-----------|-------------|-------| +| Entities + Migrations | 3 | - | 3 | +| CFDIService | 5 | - | 5 | +| CFDIBuilderService | 5 | - | 5 | +| PACService | 5 | - | 5 | +| XMLService | 3 | - | 3 | +| PDFService | 2 | - | 2 | +| AutofacturaService | 3 | - | 3 | +| Controllers | 3 | - | 3 | +| Autofactura Portal | - | 4 | 4 | +| Config UI | - | 2 | 2 | +| **TOTAL** | **29** | **6** | **35** | + +--- + +## 10. CATALOGOS SAT REQUERIDOS + +| Catalogo | Uso | +|----------|-----| +| c_FormaPago | 01 Efectivo, 04 Tarjeta, etc. | +| c_MetodoPago | PUE, PPD | +| c_UsoCFDI | G01, G03, etc. | +| c_RegimenFiscal | 601, 612, etc. | +| c_ClaveProdServ | Claves de productos | +| c_ClaveUnidad | E48, H87, etc. | +| c_TipoDeComprobante | I, E, T, N, P | +| c_Impuesto | 002 IVA | +| c_TasaOCuota | 0.160000, 0.000000 | +| c_Moneda | MXN, USD | + +--- + +**Estado:** ANALISIS COMPLETO +**Bloqueado por:** RT-001, RT-002 o RT-009 diff --git a/orchestration/planes/fase-3-implementacion/PLAN-IMPL-BACKEND.md b/orchestration/planes/fase-3-implementacion/PLAN-IMPL-BACKEND.md new file mode 100644 index 0000000..cd46072 --- /dev/null +++ b/orchestration/planes/fase-3-implementacion/PLAN-IMPL-BACKEND.md @@ -0,0 +1,1991 @@ +# PLAN DE IMPLEMENTACION - BACKEND + +**Fecha:** 2025-12-18 +**Fase:** 3 - Plan de Implementaciones +**Capa:** Backend (Node.js + Express + TypeScript + TypeORM) + +--- + +## 1. RESUMEN EJECUTIVO + +### 1.1 Alcance +- **Servicios a heredar:** 12 +- **Servicios a extender:** 8 +- **Servicios nuevos:** 28 +- **Controllers nuevos:** 15 +- **Middleware nuevo:** 5 + +### 1.2 Arquitectura + +``` +┌─────────────────────────────────────────────────────────────┐ +│ RETAIL BACKEND │ +├─────────────────────────────────────────────────────────────┤ +│ │ +│ ┌─────────────┐ ┌─────────────┐ ┌─────────────────────┐ │ +│ │ Controllers │ │ Middleware │ │ WebSocket Gateway │ │ +│ └──────┬──────┘ └──────┬──────┘ └──────────┬──────────┘ │ +│ │ │ │ │ +│ ┌──────┴──────────────────────────────────────┴──────┐ │ +│ │ Services │ │ +│ │ ┌──────────────────────────────────────────────┐ │ │ +│ │ │ Retail Services (new) │ │ │ +│ │ │ POSService, CashService, LoyaltyService... │ │ │ +│ │ └──────────────────────────────────────────────┘ │ │ +│ │ ┌──────────────────────────────────────────────┐ │ │ +│ │ │ Extended Services │ │ │ +│ │ │ RetailProductsService extends ProductsService│ │ │ +│ │ └──────────────────────────────────────────────┘ │ │ +│ │ ┌──────────────────────────────────────────────┐ │ │ +│ │ │ Core Services (inherited) │ │ │ +│ │ │ AuthService, PartnersService, TaxesService │ │ │ +│ │ └──────────────────────────────────────────────┘ │ │ +│ └────────────────────────┬───────────────────────────┘ │ +│ │ │ +│ ┌────────────────────────┴───────────────────────────┐ │ +│ │ Repositories │ │ +│ │ TypeORM EntityRepository │ │ +│ └────────────────────────┬───────────────────────────┘ │ +│ │ │ +│ ┌────────────────────────┴───────────────────────────┐ │ +│ │ PostgreSQL + RLS │ │ +│ └─────────────────────────────────────────────────────┘ │ +└─────────────────────────────────────────────────────────────┘ +``` + +--- + +## 2. ESTRUCTURA DE PROYECTO + +### 2.1 Estructura de Carpetas + +``` +retail/backend/ +├── src/ +│ ├── config/ +│ │ ├── database.ts +│ │ ├── redis.ts +│ │ └── app.ts +│ │ +│ ├── modules/ +│ │ ├── auth/ # Heredado + extensiones +│ │ │ ├── entities/ +│ │ │ ├── services/ +│ │ │ ├── controllers/ +│ │ │ └── middleware/ +│ │ │ +│ │ ├── branches/ # NUEVO +│ │ │ ├── entities/ +│ │ │ │ ├── branch.entity.ts +│ │ │ │ ├── cash-register.entity.ts +│ │ │ │ └── branch-user.entity.ts +│ │ │ ├── services/ +│ │ │ │ └── branches.service.ts +│ │ │ ├── controllers/ +│ │ │ │ └── branches.controller.ts +│ │ │ └── dto/ +│ │ │ +│ │ ├── pos/ # NUEVO +│ │ │ ├── entities/ +│ │ │ │ ├── pos-session.entity.ts +│ │ │ │ ├── pos-order.entity.ts +│ │ │ │ ├── pos-order-line.entity.ts +│ │ │ │ └── pos-payment.entity.ts +│ │ │ ├── services/ +│ │ │ │ ├── pos-session.service.ts +│ │ │ │ ├── pos-order.service.ts +│ │ │ │ └── pos-sync.service.ts +│ │ │ ├── controllers/ +│ │ │ │ └── pos.controller.ts +│ │ │ └── gateway/ +│ │ │ └── pos.gateway.ts +│ │ │ +│ │ ├── cash/ # NUEVO +│ │ │ ├── entities/ +│ │ │ │ ├── cash-movement.entity.ts +│ │ │ │ ├── cash-closing.entity.ts +│ │ │ │ └── cash-count.entity.ts +│ │ │ ├── services/ +│ │ │ │ ├── cash-session.service.ts +│ │ │ │ └── cash-closing.service.ts +│ │ │ └── controllers/ +│ │ │ +│ │ ├── inventory/ # EXTENDIDO +│ │ │ ├── entities/ +│ │ │ │ ├── stock-transfer.entity.ts +│ │ │ │ └── stock-adjustment.entity.ts +│ │ │ ├── services/ +│ │ │ │ ├── retail-stock.service.ts # extiende +│ │ │ │ ├── transfers.service.ts +│ │ │ │ └── adjustments.service.ts +│ │ │ └── controllers/ +│ │ │ +│ │ ├── customers/ # EXTENDIDO +│ │ │ ├── entities/ +│ │ │ │ ├── loyalty-program.entity.ts +│ │ │ │ ├── membership-level.entity.ts +│ │ │ │ ├── loyalty-transaction.entity.ts +│ │ │ │ └── customer-membership.entity.ts +│ │ │ ├── services/ +│ │ │ │ ├── retail-customers.service.ts +│ │ │ │ └── loyalty.service.ts +│ │ │ └── controllers/ +│ │ │ +│ │ ├── pricing/ # EXTENDIDO +│ │ │ ├── entities/ +│ │ │ │ ├── promotion.entity.ts +│ │ │ │ ├── coupon.entity.ts +│ │ │ │ └── coupon-redemption.entity.ts +│ │ │ ├── services/ +│ │ │ │ ├── price-engine.service.ts +│ │ │ │ ├── promotions.service.ts +│ │ │ │ └── coupons.service.ts +│ │ │ └── controllers/ +│ │ │ +│ │ ├── purchases/ # EXTENDIDO +│ │ │ ├── entities/ +│ │ │ │ ├── purchase-suggestion.entity.ts +│ │ │ │ ├── supplier-order.entity.ts +│ │ │ │ └── goods-receipt.entity.ts +│ │ │ ├── services/ +│ │ │ │ ├── purchase-suggestions.service.ts +│ │ │ │ └── supplier-orders.service.ts +│ │ │ └── controllers/ +│ │ │ +│ │ ├── invoicing/ # NUEVO +│ │ │ ├── entities/ +│ │ │ │ ├── cfdi-config.entity.ts +│ │ │ │ └── cfdi.entity.ts +│ │ │ ├── services/ +│ │ │ │ ├── cfdi.service.ts +│ │ │ │ ├── cfdi-builder.service.ts +│ │ │ │ ├── pac.service.ts +│ │ │ │ ├── xml.service.ts +│ │ │ │ └── pdf.service.ts +│ │ │ └── controllers/ +│ │ │ ├── cfdi.controller.ts +│ │ │ └── autofactura.controller.ts +│ │ │ +│ │ ├── ecommerce/ # NUEVO +│ │ │ ├── entities/ +│ │ │ │ ├── cart.entity.ts +│ │ │ │ ├── ecommerce-order.entity.ts +│ │ │ │ └── shipping-rate.entity.ts +│ │ │ ├── services/ +│ │ │ │ ├── catalog.service.ts +│ │ │ │ ├── cart.service.ts +│ │ │ │ ├── checkout.service.ts +│ │ │ │ ├── payment-gateway.service.ts +│ │ │ │ └── shipping.service.ts +│ │ │ └── controllers/ +│ │ │ ├── storefront.controller.ts +│ │ │ └── ecommerce-admin.controller.ts +│ │ │ +│ │ └── reports/ # EXTENDIDO +│ │ ├── services/ +│ │ │ ├── dashboard.service.ts +│ │ │ ├── sales-report.service.ts +│ │ │ ├── product-report.service.ts +│ │ │ └── cash-report.service.ts +│ │ └── controllers/ +│ │ +│ ├── shared/ +│ │ ├── entities/ +│ │ │ └── base.entity.ts +│ │ ├── services/ +│ │ │ └── base.service.ts +│ │ ├── dto/ +│ │ │ └── pagination.dto.ts +│ │ ├── interfaces/ +│ │ │ └── tenant-context.interface.ts +│ │ └── utils/ +│ │ ├── sequence.util.ts +│ │ └── decimal.util.ts +│ │ +│ ├── middleware/ +│ │ ├── tenant.middleware.ts +│ │ ├── auth.middleware.ts +│ │ ├── branch.middleware.ts +│ │ └── error.middleware.ts +│ │ +│ ├── integrations/ +│ │ ├── pac/ +│ │ │ ├── finkok.provider.ts +│ │ │ ├── facturama.provider.ts +│ │ │ └── pac.interface.ts +│ │ ├── payments/ +│ │ │ ├── stripe.provider.ts +│ │ │ ├── conekta.provider.ts +│ │ │ └── payment.interface.ts +│ │ └── shipping/ +│ │ ├── fedex.provider.ts +│ │ └── shipping.interface.ts +│ │ +│ ├── migrations/ +│ │ └── ... +│ │ +│ └── server.ts +│ +├── package.json +├── tsconfig.json +└── .env.example +``` + +--- + +## 3. SERVICIOS POR MODULO + +### 3.1 RT-001 Fundamentos (Herencia 100%) + +```typescript +// HEREDADOS - Solo configurar importaciones +import { AuthService } from '@erp-core/auth'; +import { UsersService } from '@erp-core/users'; +import { TenantsService } from '@erp-core/tenants'; +import { RolesService } from '@erp-core/roles'; +``` + +**Servicios:** +| Servicio | Accion | Fuente | +|----------|--------|--------| +| AuthService | HEREDAR | @erp-core/auth | +| UsersService | HEREDAR | @erp-core/users | +| TenantsService | HEREDAR | @erp-core/tenants | +| RolesService | HEREDAR | @erp-core/roles | + +--- + +### 3.2 RT-002 POS (20% herencia) + +**Servicios Nuevos:** + +```typescript +// 1. POSSessionService +@Injectable() +export class POSSessionService { + constructor( + @InjectRepository(POSSession) + private sessionRepo: Repository, + private cashRegisterService: CashRegisterService, + ) {} + + async openSession(dto: OpenSessionDto): Promise { + // 1. Verificar caja disponible + // 2. Crear sesion + // 3. Registrar apertura + return session; + } + + async closeSession(sessionId: string, dto: CloseSessionDto): Promise { + // 1. Calcular totales esperados + // 2. Validar declaracion + // 3. Crear corte + // 4. Cerrar sesion + return session; + } + + async getActiveSession(userId: string): Promise { + return this.sessionRepo.findOne({ + where: { userId, status: In(['opening', 'open']) } + }); + } +} + +// 2. POSOrderService +@Injectable() +export class POSOrderService { + constructor( + @InjectRepository(POSOrder) + private orderRepo: Repository, + private priceEngine: PriceEngineService, + private stockService: RetailStockService, + private loyaltyService: LoyaltyService, + ) {} + + async createOrder(sessionId: string): Promise { + const orderNumber = await this.generateOrderNumber(); + return this.orderRepo.save({ + sessionId, + orderNumber, + status: 'draft', + }); + } + + async addLine(orderId: string, dto: AddLineDto): Promise { + // 1. Calcular precio con motor de precios + const priceResult = await this.priceEngine.calculatePrice({ + productId: dto.productId, + quantity: dto.quantity, + branchId: order.session.branchId, + }); + + // 2. Crear linea + // 3. Recalcular totales + return line; + } + + async confirmOrder(orderId: string, dto: ConfirmOrderDto): Promise { + // 1. Validar stock + // 2. Procesar pagos + // 3. Descontar inventario + // 4. Otorgar puntos lealtad + // 5. Aplicar cupon si existe + // 6. Marcar como done + return order; + } + + async refundOrder(orderId: string, dto: RefundDto): Promise { + // 1. Validar orden + // 2. Revertir inventario + // 3. Revertir puntos + // 4. Crear registro de devolucion + return order; + } +} + +// 3. POSSyncService (para offline) +@Injectable() +export class POSSyncService { + constructor( + private orderService: POSOrderService, + private redis: Redis, + ) {} + + async syncOfflineOrders(orders: OfflineOrder[]): Promise { + const results: SyncResult = { synced: [], failed: [] }; + + for (const offlineOrder of orders) { + try { + // Verificar si ya fue sincronizada + const exists = await this.orderService.findByOfflineId(offlineOrder.offlineId); + if (exists) { + results.synced.push({ offlineId: offlineOrder.offlineId, orderId: exists.id }); + continue; + } + + // Crear orden + const order = await this.orderService.createFromOffline(offlineOrder); + results.synced.push({ offlineId: offlineOrder.offlineId, orderId: order.id }); + + } catch (error) { + results.failed.push({ offlineId: offlineOrder.offlineId, error: error.message }); + } + } + + return results; + } +} +``` + +**Servicios:** +| Servicio | Accion | Dependencias | +|----------|--------|--------------| +| POSSessionService | NUEVO | CashRegisterService | +| POSOrderService | NUEVO | PriceEngineService, RetailStockService, LoyaltyService | +| POSPaymentService | NUEVO | - | +| POSSyncService | NUEVO | POSOrderService, Redis | + +--- + +### 3.3 RT-003 Inventario (60% herencia) + +**Servicios Extendidos:** + +```typescript +// 1. RetailStockService (extiende StockService del core) +@Injectable() +export class RetailStockService extends StockService { + constructor( + @InjectRepository(StockQuant) + stockQuantRepo: Repository, + @InjectRepository(Branch) + private branchRepo: Repository, + ) { + super(stockQuantRepo); + } + + // Metodos heredados: getStock, reserveStock, etc. + + // Metodos nuevos para retail + async getStockByBranch(branchId: string, productId?: string): Promise { + const branch = await this.branchRepo.findOne({ + where: { id: branchId }, + relations: ['warehouse'], + }); + + return this.stockQuantRepo.find({ + where: { + warehouseId: branch.warehouse.id, + ...(productId && { productId }), + }, + }); + } + + async getMultiBranchStock(productId: string): Promise { + // Stock del producto en todas las sucursales + return this.stockQuantRepo + .createQueryBuilder('sq') + .select(['b.id as branchId', 'b.name as branchName', 'sq.quantity']) + .innerJoin('retail.branches', 'b', 'b.warehouse_id = sq.warehouse_id') + .where('sq.product_id = :productId', { productId }) + .getRawMany(); + } + + async decrementStock(branchId: string, productId: string, quantity: number): Promise { + const branch = await this.branchRepo.findOne({ where: { id: branchId } }); + await this.stockQuantRepo.decrement( + { warehouseId: branch.warehouseId, productId }, + 'quantity', + quantity + ); + } +} + +// 2. TransfersService +@Injectable() +export class TransfersService { + async createTransfer(dto: CreateTransferDto): Promise { + const transfer = await this.transferRepo.save({ + ...dto, + transferNumber: await this.generateNumber('TRF'), + status: 'draft', + }); + return transfer; + } + + async confirmTransfer(id: string): Promise { + // 1. Validar stock en origen + // 2. Reservar stock + // 3. Cambiar status a 'in_transit' + return transfer; + } + + async receiveTransfer(id: string, dto: ReceiveDto): Promise { + // 1. Descontar de origen + // 2. Agregar a destino + // 3. Registrar diferencias si las hay + // 4. Cambiar status a 'received' + return transfer; + } +} + +// 3. AdjustmentsService +@Injectable() +export class AdjustmentsService { + async createAdjustment(dto: CreateAdjustmentDto): Promise { + // Similar a transferencias + } + + async confirmAdjustment(id: string): Promise { + // Aplicar diferencias al stock + } +} +``` + +**Servicios:** +| Servicio | Accion | Hereda de | +|----------|--------|-----------| +| RetailStockService | EXTENDER | StockService | +| TransfersService | NUEVO | - | +| AdjustmentsService | NUEVO | - | + +--- + +### 3.4 RT-004 Compras (80% herencia) + +**Servicios Extendidos:** + +```typescript +// 1. RetailPurchaseService (extiende PurchaseService) +@Injectable() +export class RetailPurchaseService extends PurchaseService { + // Hereda: createOrder, confirmOrder, receiveGoods + + async suggestRestock(branchId: string): Promise { + // Algoritmo de sugerencia: + // 1. Productos con stock < minimo + // 2. Prioridad basada en dias de stock + // 3. Cantidad sugerida basada en historico de ventas + + const suggestions = await this.stockRepo + .createQueryBuilder('sq') + .select([ + 'sq.product_id', + 'sq.quantity as current_stock', + 'p.min_stock', + 'p.max_stock', + 'p.default_supplier_id', + ]) + .innerJoin('inventory.products', 'p', 'p.id = sq.product_id') + .innerJoin('retail.branches', 'b', 'b.warehouse_id = sq.warehouse_id') + .where('b.id = :branchId', { branchId }) + .andWhere('sq.quantity <= p.min_stock') + .getRawMany(); + + return suggestions.map(s => ({ + productId: s.product_id, + currentStock: s.current_stock, + minStock: s.min_stock, + maxStock: s.max_stock, + suggestedQty: s.max_stock - s.current_stock, + supplierId: s.default_supplier_id, + priority: this.calculatePriority(s), + })); + } + + private calculatePriority(s: any): 'critical' | 'high' | 'medium' | 'low' { + const daysOfStock = s.current_stock / s.avg_daily_sales; + if (daysOfStock <= 0) return 'critical'; + if (daysOfStock <= 3) return 'high'; + if (daysOfStock <= 7) return 'medium'; + return 'low'; + } +} + +// 2. GoodsReceiptService +@Injectable() +export class GoodsReceiptService { + async createReceipt(dto: CreateReceiptDto): Promise { + // 1. Crear recepcion + // 2. Vincular con orden de compra si existe + return receipt; + } + + async confirmReceipt(id: string): Promise { + // 1. Agregar stock al warehouse de la sucursal + // 2. Actualizar costos si es necesario + // 3. Actualizar orden de compra + return receipt; + } +} +``` + +--- + +### 3.5 RT-005 Clientes (40% herencia) + +**Servicios Extendidos:** + +```typescript +// 1. RetailCustomersService (extiende PartnersService) +@Injectable() +export class RetailCustomersService extends PartnersService { + constructor( + partnersRepo: Repository, + private membershipRepo: Repository, + private loyaltyService: LoyaltyService, + ) { + super(partnersRepo); + } + + async createCustomer(dto: CreateCustomerDto): Promise { + // 1. Crear partner base + const customer = await super.create({ ...dto, type: 'customer' }); + + // 2. Inscribir en programa de lealtad si esta activo + if (dto.enrollInLoyalty) { + await this.loyaltyService.enrollCustomer(customer.id); + } + + return customer; + } + + async getCustomerWithMembership(customerId: string): Promise { + const customer = await this.findById(customerId); + const membership = await this.membershipRepo.findOne({ + where: { customerId }, + relations: ['program', 'level'], + }); + + return { ...customer, membership }; + } +} + +// 2. LoyaltyService +@Injectable() +export class LoyaltyService { + async enrollCustomer(customerId: string, programId?: string): Promise { + const program = programId + ? await this.programRepo.findOne({ where: { id: programId } }) + : await this.programRepo.findOne({ where: { isActive: true } }); + + const membershipNumber = await this.generateMembershipNumber(); + + return this.membershipRepo.save({ + customerId, + programId: program.id, + membershipNumber, + currentPoints: 0, + lifetimePoints: 0, + status: 'active', + }); + } + + async earnPoints(customerId: string, orderId: string, amount: number): Promise { + const membership = await this.membershipRepo.findOne({ + where: { customerId }, + relations: ['program', 'level'], + }); + + if (!membership) return null; + + // Calcular puntos con multiplicador de nivel + const basePoints = Math.floor(amount * membership.program.pointsPerCurrency); + const multiplier = membership.level?.pointsMultiplier || 1; + const points = Math.floor(basePoints * multiplier); + + // Crear transaccion + const transaction = await this.transactionRepo.save({ + customerId, + programId: membership.programId, + orderId, + transactionType: 'earn', + points, + balanceAfter: membership.currentPoints + points, + expiresAt: this.calculateExpiry(), + }); + + // Actualizar balance + await this.membershipRepo.update(membership.id, { + currentPoints: () => `current_points + ${points}`, + lifetimePoints: () => `lifetime_points + ${points}`, + lastActivityAt: new Date(), + }); + + // Verificar upgrade de nivel + await this.checkLevelUpgrade(membership); + + return transaction; + } + + async redeemPoints(customerId: string, orderId: string, points: number): Promise { + const membership = await this.membershipRepo.findOne({ + where: { customerId }, + relations: ['program'], + }); + + // Validaciones + if (points > membership.currentPoints) { + throw new BadRequestException('Insufficient points'); + } + if (points < membership.program.minPointsRedeem) { + throw new BadRequestException(`Minimum redemption is ${membership.program.minPointsRedeem} points`); + } + + // Calcular descuento + const discount = points * membership.program.currencyPerPoint; + + // Crear transaccion + await this.transactionRepo.save({ + customerId, + programId: membership.programId, + orderId, + transactionType: 'redeem', + points: -points, + balanceAfter: membership.currentPoints - points, + }); + + // Actualizar balance + await this.membershipRepo.decrement({ id: membership.id }, 'currentPoints', points); + + return { points, discount }; + } + + private async checkLevelUpgrade(membership: CustomerMembership): Promise { + const newLevel = await this.levelRepo + .createQueryBuilder('level') + .where('level.program_id = :programId', { programId: membership.programId }) + .andWhere('level.min_points <= :points', { points: membership.lifetimePoints }) + .orderBy('level.min_points', 'DESC') + .getOne(); + + if (newLevel && newLevel.id !== membership.levelId) { + await this.membershipRepo.update(membership.id, { levelId: newLevel.id }); + // TODO: Notificar al cliente del upgrade + } + } +} +``` + +--- + +### 3.6 RT-006 Precios (30% herencia) + +**Servicios Nuevos y Extendidos:** + +```typescript +// 1. PriceEngineService (CORE - Motor de precios) +@Injectable() +export class PriceEngineService { + constructor( + private pricelistService: PricelistsService, + private promotionsService: PromotionsService, + private couponsService: CouponsService, + private loyaltyService: LoyaltyService, + ) {} + + async calculatePrice(context: PriceContext): Promise { + const { productId, quantity, branchId, customerId, couponCode } = context; + + // 1. Precio base del producto + const product = await this.productRepo.findOne({ where: { id: productId } }); + const basePrice = product.salePrice; + + // 2. Aplicar lista de precios (si existe para el canal) + const pricelistPrice = await this.applyPricelist(basePrice, context); + + // 3. Evaluar promociones activas + const promotions = await this.promotionsService.getActiveForProduct(productId, branchId); + const bestPromotion = this.selectBestPromotion(promotions, pricelistPrice, quantity); + const promotionDiscount = bestPromotion?.discount || 0; + + // 4. Calcular precio despues de promocion + let finalPrice = pricelistPrice - promotionDiscount; + + // 5. Aplicar cupon si existe + let couponDiscount = 0; + if (couponCode) { + const couponResult = await this.couponsService.calculateDiscount(couponCode, finalPrice); + couponDiscount = couponResult.discount; + finalPrice -= couponDiscount; + } + + // 6. Calcular descuento por puntos de lealtad (si aplica) + let loyaltyDiscount = 0; + if (context.loyaltyPointsToUse && customerId) { + const loyaltyResult = await this.loyaltyService.calculateRedemption( + customerId, + context.loyaltyPointsToUse + ); + loyaltyDiscount = loyaltyResult.discount; + finalPrice -= loyaltyDiscount; + } + + // 7. Calcular impuestos + const taxes = await this.calculateTaxes(finalPrice, product.taxIds); + + return { + basePrice, + pricelistPrice, + discounts: [ + ...(bestPromotion ? [{ type: 'promotion', name: bestPromotion.name, amount: promotionDiscount }] : []), + ...(couponDiscount > 0 ? [{ type: 'coupon', name: couponCode, amount: couponDiscount }] : []), + ...(loyaltyDiscount > 0 ? [{ type: 'loyalty', name: 'Puntos', amount: loyaltyDiscount }] : []), + ], + subtotal: finalPrice, + taxes, + total: finalPrice + taxes.reduce((sum, t) => sum + t.amount, 0), + }; + } + + private selectBestPromotion( + promotions: Promotion[], + price: number, + quantity: number + ): Promotion | null { + const applicable = promotions.filter(p => this.isPromotionApplicable(p, price, quantity)); + + if (applicable.length === 0) return null; + + // Ordenar por descuento mayor + return applicable.sort((a, b) => { + const discountA = this.calculatePromotionDiscount(a, price, quantity); + const discountB = this.calculatePromotionDiscount(b, price, quantity); + return discountB - discountA; + })[0]; + } + + private calculatePromotionDiscount(promo: Promotion, price: number, qty: number): number { + switch (promo.promotionType) { + case 'percentage': + return price * (promo.discountValue / 100); + case 'fixed_amount': + return Math.min(promo.discountValue, price); + case 'buy_x_get_y': + const freeItems = Math.floor(qty / promo.buyQuantity) * (promo.buyQuantity - promo.getQuantity); + return (price / qty) * freeItems; + default: + return 0; + } + } +} + +// 2. PromotionsService +@Injectable() +export class PromotionsService { + async create(dto: CreatePromotionDto): Promise { + const promotion = await this.promotionRepo.save({ + ...dto, + code: dto.code || this.generateCode(), + }); + + // Agregar productos si se especificaron + if (dto.productIds?.length) { + await this.addProducts(promotion.id, dto.productIds); + } + + return promotion; + } + + async getActiveForProduct(productId: string, branchId: string): Promise { + const today = new Date(); + + return this.promotionRepo + .createQueryBuilder('p') + .leftJoin('retail.promotion_products', 'pp', 'pp.promotion_id = p.id') + .where('p.is_active = true') + .andWhere('p.start_date <= :today', { today }) + .andWhere('p.end_date >= :today', { today }) + .andWhere('(p.applies_to_all = true OR pp.product_id = :productId)', { productId }) + .andWhere('(p.branch_ids IS NULL OR :branchId = ANY(p.branch_ids))', { branchId }) + .andWhere('(p.max_uses IS NULL OR p.current_uses < p.max_uses)') + .getMany(); + } + + async incrementUse(promotionId: string): Promise { + await this.promotionRepo.increment({ id: promotionId }, 'currentUses', 1); + } +} + +// 3. CouponsService +@Injectable() +export class CouponsService { + async generate(dto: GenerateCouponsDto): Promise { + const coupons: Coupon[] = []; + + for (let i = 0; i < dto.quantity; i++) { + coupons.push(await this.couponRepo.save({ + code: this.generateCode(dto.prefix), + couponType: dto.couponType, + discountValue: dto.discountValue, + minPurchase: dto.minPurchase, + maxDiscount: dto.maxDiscount, + validFrom: dto.validFrom, + validUntil: dto.validUntil, + maxUses: dto.maxUses || 1, + customerId: dto.customerId, + })); + } + + return coupons; + } + + async validate(code: string, orderTotal: number): Promise { + const coupon = await this.couponRepo.findOne({ where: { code } }); + + if (!coupon) return { valid: false, error: 'Cupon no encontrado' }; + if (!coupon.isActive) return { valid: false, error: 'Cupon inactivo' }; + if (coupon.timesUsed >= coupon.maxUses) return { valid: false, error: 'Cupon agotado' }; + if (new Date() < coupon.validFrom) return { valid: false, error: 'Cupon no vigente' }; + if (new Date() > coupon.validUntil) return { valid: false, error: 'Cupon expirado' }; + if (coupon.minPurchase && orderTotal < coupon.minPurchase) { + return { valid: false, error: `Compra minima: $${coupon.minPurchase}` }; + } + + const discount = this.calculateDiscount(coupon, orderTotal); + + return { valid: true, coupon, discount }; + } + + async redeem(code: string, orderId: string, discount: number): Promise { + const coupon = await this.couponRepo.findOne({ where: { code } }); + + await this.couponRepo.increment({ id: coupon.id }, 'timesUsed', 1); + + return this.redemptionRepo.save({ + couponId: coupon.id, + orderId, + discountApplied: discount, + }); + } + + private calculateDiscount(coupon: Coupon, total: number): number { + let discount = coupon.couponType === 'percentage' + ? total * (coupon.discountValue / 100) + : coupon.discountValue; + + if (coupon.maxDiscount) { + discount = Math.min(discount, coupon.maxDiscount); + } + + return discount; + } +} +``` + +--- + +### 3.7 RT-007 Caja (10% herencia) + +**Servicios Nuevos:** + +```typescript +// 1. CashSessionService +@Injectable() +export class CashSessionService { + async getSummary(sessionId: string): Promise { + const session = await this.sessionRepo.findOne({ + where: { id: sessionId }, + relations: ['orders', 'orders.payments', 'movements'], + }); + + // Calcular esperados + const cashSales = session.orders + .filter(o => o.status === 'done') + .flatMap(o => o.payments) + .filter(p => p.paymentMethod === 'cash') + .reduce((sum, p) => sum + Number(p.amount), 0); + + const changeGiven = session.orders + .filter(o => o.status === 'done') + .flatMap(o => o.payments) + .filter(p => p.paymentMethod === 'cash') + .reduce((sum, p) => sum + Number(p.changeAmount || 0), 0); + + const cashIn = session.movements + .filter(m => m.movementType === 'in') + .reduce((sum, m) => sum + Number(m.amount), 0); + + const cashOut = session.movements + .filter(m => m.movementType === 'out') + .reduce((sum, m) => sum + Number(m.amount), 0); + + const cardSales = session.orders + .filter(o => o.status === 'done') + .flatMap(o => o.payments) + .filter(p => p.paymentMethod === 'card') + .reduce((sum, p) => sum + Number(p.amount), 0); + + const transferSales = session.orders + .filter(o => o.status === 'done') + .flatMap(o => o.payments) + .filter(p => p.paymentMethod === 'transfer') + .reduce((sum, p) => sum + Number(p.amount), 0); + + const expectedCash = Number(session.openingBalance) + cashSales - changeGiven + cashIn - cashOut; + + return { + sessionId, + openingBalance: session.openingBalance, + cashSales, + changeGiven, + cashIn, + cashOut, + expectedCash, + cardSales, + transferSales, + totalOrders: session.orders.filter(o => o.status === 'done').length, + }; + } +} + +// 2. CashMovementService +@Injectable() +export class CashMovementService { + async createMovement(dto: CreateMovementDto): Promise { + const session = await this.sessionRepo.findOne({ + where: { id: dto.sessionId, status: 'open' }, + }); + + if (!session) { + throw new BadRequestException('Session not found or not open'); + } + + // Si es retiro alto, requerir autorizacion + if (dto.movementType === 'out' && dto.amount > this.config.maxWithdrawalWithoutAuth) { + if (!dto.authorizedBy) { + throw new BadRequestException('Authorization required for this amount'); + } + // Validar que el autorizador tenga permiso + await this.validateAuthorizer(dto.authorizedBy); + } + + return this.movementRepo.save({ + sessionId: dto.sessionId, + movementType: dto.movementType, + amount: dto.amount, + reason: dto.reason, + notes: dto.notes, + authorizedBy: dto.authorizedBy, + }); + } +} + +// 3. CashClosingService +@Injectable() +export class CashClosingService { + async prepareClosing(sessionId: string): Promise { + const summary = await this.sessionService.getSummary(sessionId); + + return { + ...summary, + denominations: this.getDenominationTemplate(), + }; + } + + async createClosing(sessionId: string, dto: CreateClosingDto): Promise { + const summary = await this.sessionService.getSummary(sessionId); + + // Calcular total declarado de denominaciones + const declaredFromDenominations = this.calculateDenominations(dto.denominationDetail); + + // Validar que coincida con el declarado + if (Math.abs(declaredFromDenominations - dto.declaredCash) > 0.01) { + throw new BadRequestException('Denomination detail does not match declared cash'); + } + + const closing = await this.closingRepo.save({ + sessionId, + closingDate: new Date(), + expectedCash: summary.expectedCash, + expectedCard: summary.cardSales, + expectedTransfer: summary.transferSales, + declaredCash: dto.declaredCash, + declaredCard: dto.declaredCard, + declaredTransfer: dto.declaredTransfer, + denominationDetail: dto.denominationDetail, + notes: dto.notes, + closedBy: dto.userId, + }); + + // Cerrar sesion + await this.sessionRepo.update(sessionId, { + status: 'closed', + closingBalance: dto.declaredCash, + closedAt: new Date(), + }); + + // Si hay diferencia significativa, marcar para aprobacion + const tolerance = this.config.cashDifferenceTolerance || 10; + if (Math.abs(closing.cashDifference) > tolerance) { + // TODO: Notificar a supervisor + } + + return closing; + } + + private calculateDenominations(detail: DenominationDetail): number { + let total = 0; + + for (const [denom, count] of Object.entries(detail.bills)) { + total += Number(denom) * count; + } + for (const [denom, count] of Object.entries(detail.coins)) { + total += Number(denom) * count; + } + + return total; + } + + private getDenominationTemplate(): DenominationTemplate { + return { + bills: { '1000': 0, '500': 0, '200': 0, '100': 0, '50': 0, '20': 0 }, + coins: { '20': 0, '10': 0, '5': 0, '2': 0, '1': 0, '0.50': 0 }, + }; + } +} +``` + +--- + +### 3.8 RT-008 Reportes (70% herencia) + +**Servicios Extendidos:** + +```typescript +// 1. DashboardService +@Injectable() +export class DashboardService { + async getDashboard(branchId?: string): Promise { + const today = new Date(); + today.setHours(0, 0, 0, 0); + + const baseWhere = branchId ? { branchId } : {}; + + // KPIs del dia + const todayStats = await this.getStatsForPeriod(today, new Date(), branchId); + + // Comparativos + const yesterday = new Date(today); + yesterday.setDate(yesterday.getDate() - 1); + const lastWeek = new Date(today); + lastWeek.setDate(lastWeek.getDate() - 7); + const lastMonth = new Date(today); + lastMonth.setMonth(lastMonth.getMonth() - 1); + + const yesterdayStats = await this.getStatsForPeriod(yesterday, today, branchId); + const lastWeekStats = await this.getStatsForPeriod(lastWeek, new Date(lastWeek.getTime() + 86400000), branchId); + + // Graficos + const salesByHour = await this.getSalesByHour(today, branchId); + const salesByPaymentMethod = await this.getSalesByPaymentMethod(today, branchId); + const topProducts = await this.getTopProducts(today, branchId, 10); + + // Alertas + const lowStockCount = await this.stockService.getLowStockCount(branchId); + const pendingTransfers = await this.transferService.getPendingCount(branchId); + + return { + today: todayStats, + comparison: { + vsYesterday: this.calculateChange(todayStats.totalSales, yesterdayStats.totalSales), + vsLastWeek: this.calculateChange(todayStats.totalSales, lastWeekStats.totalSales), + }, + charts: { + salesByHour, + salesByPaymentMethod, + topProducts, + }, + alerts: { + lowStock: lowStockCount, + pendingTransfers, + }, + }; + } + + private async getStatsForPeriod(from: Date, to: Date, branchId?: string) { + // Usar vista materializada si es de dias anteriores + const result = await this.orderRepo + .createQueryBuilder('o') + .select([ + 'SUM(o.total) as totalSales', + 'COUNT(*) as totalTransactions', + 'AVG(o.total) as avgTicket', + ]) + .innerJoin('retail.pos_sessions', 's', 's.id = o.session_id') + .where('o.order_date >= :from', { from }) + .andWhere('o.order_date < :to', { to }) + .andWhere('o.status = :status', { status: 'done' }) + .andWhere(branchId ? 's.branch_id = :branchId' : '1=1', { branchId }) + .getRawOne(); + + return { + totalSales: Number(result.totalSales) || 0, + totalTransactions: Number(result.totalTransactions) || 0, + avgTicket: Number(result.avgTicket) || 0, + }; + } +} + +// 2. SalesReportService +@Injectable() +export class SalesReportService extends ReportsService { + async generate(filters: SalesReportFilters): Promise { + const { from, to, branchId, groupBy } = filters; + + let query = this.orderRepo + .createQueryBuilder('o') + .innerJoin('retail.pos_sessions', 's', 's.id = o.session_id') + .where('o.order_date >= :from', { from }) + .andWhere('o.order_date <= :to', { to }) + .andWhere('o.status = :status', { status: 'done' }); + + if (branchId) { + query = query.andWhere('s.branch_id = :branchId', { branchId }); + } + + // Agrupar segun criterio + switch (groupBy) { + case 'day': + query = query.select([ + 'DATE(o.order_date) as date', + 'SUM(o.total) as totalSales', + 'COUNT(*) as transactions', + ]).groupBy('DATE(o.order_date)'); + break; + case 'branch': + query = query.select([ + 's.branch_id', + 'b.name as branchName', + 'SUM(o.total) as totalSales', + 'COUNT(*) as transactions', + ]) + .innerJoin('retail.branches', 'b', 'b.id = s.branch_id') + .groupBy('s.branch_id, b.name'); + break; + // ... otros casos + } + + const details = await query.getRawMany(); + + // Calcular sumario + const summary = { + totalSales: details.reduce((sum, d) => sum + Number(d.totalSales), 0), + totalTransactions: details.reduce((sum, d) => sum + Number(d.transactions), 0), + avgTicket: 0, + }; + summary.avgTicket = summary.totalSales / summary.totalTransactions || 0; + + return { + period: { from, to }, + groupBy, + summary, + details, + }; + } +} +``` + +--- + +### 3.9 RT-009 E-commerce (20% herencia) + +**Servicios Nuevos:** + +```typescript +// 1. CatalogService +@Injectable() +export class CatalogService { + async search(filters: CatalogFilters): Promise { + let query = this.productRepo + .createQueryBuilder('p') + .where('p.is_active = true') + .andWhere('p.is_sellable = true'); + + if (filters.categoryId) { + query = query.andWhere('p.category_id = :categoryId', { categoryId: filters.categoryId }); + } + + if (filters.search) { + query = query.andWhere( + '(p.name ILIKE :search OR p.default_code ILIKE :search)', + { search: `%${filters.search}%` } + ); + } + + if (filters.minPrice) { + query = query.andWhere('p.sale_price >= :minPrice', { minPrice: filters.minPrice }); + } + + if (filters.maxPrice) { + query = query.andWhere('p.sale_price <= :maxPrice', { maxPrice: filters.maxPrice }); + } + + // Ordenamiento + const orderBy = filters.sortBy || 'name'; + const orderDir = filters.sortDir || 'ASC'; + query = query.orderBy(`p.${orderBy}`, orderDir); + + // Paginacion + const [items, total] = await query + .skip(filters.offset || 0) + .take(filters.limit || 20) + .getManyAndCount(); + + return { items, total, page: Math.floor((filters.offset || 0) / (filters.limit || 20)) + 1 }; + } + + async getProduct(productId: string): Promise { + const product = await this.productRepo.findOne({ + where: { id: productId }, + relations: ['category', 'images'], + }); + + // Stock disponible + const stock = await this.stockService.getAvailableStock(productId); + + // Promociones activas + const promotions = await this.promotionsService.getActiveForProduct(productId); + + return { ...product, stock, promotions }; + } +} + +// 2. CartService +@Injectable() +export class CartService { + async getOrCreateCart(customerId?: string, sessionId?: string): Promise { + let cart: Cart; + + if (customerId) { + cart = await this.cartRepo.findOne({ + where: { customerId }, + relations: ['items', 'items.product'], + }); + } else if (sessionId) { + cart = await this.cartRepo.findOne({ + where: { sessionId }, + relations: ['items', 'items.product'], + }); + } + + if (!cart) { + cart = await this.cartRepo.save({ + customerId, + sessionId, + subtotal: 0, + expiresAt: this.calculateExpiry(), + }); + cart.items = []; + } + + return cart; + } + + async addItem(cartId: string, dto: AddItemDto): Promise { + const cart = await this.cartRepo.findOne({ + where: { id: cartId }, + relations: ['items'], + }); + + // Verificar stock + const stock = await this.stockService.getAvailableStock(dto.productId); + if (stock < dto.quantity) { + throw new BadRequestException('Insufficient stock'); + } + + // Obtener precio + const product = await this.productRepo.findOne({ where: { id: dto.productId } }); + const price = product.salePrice; + + // Buscar si ya existe en carrito + const existingItem = cart.items.find(i => i.productId === dto.productId); + + if (existingItem) { + existingItem.quantity += dto.quantity; + existingItem.total = existingItem.quantity * existingItem.unitPrice; + await this.cartItemRepo.save(existingItem); + } else { + await this.cartItemRepo.save({ + cartId, + productId: dto.productId, + quantity: dto.quantity, + unitPrice: price, + total: dto.quantity * price, + }); + } + + // Recalcular subtotal + await this.recalculateSubtotal(cartId); + + return this.getCart(cartId); + } + + async updateItem(cartId: string, itemId: string, quantity: number): Promise { + if (quantity <= 0) { + await this.cartItemRepo.delete(itemId); + } else { + const item = await this.cartItemRepo.findOne({ where: { id: itemId } }); + item.quantity = quantity; + item.total = quantity * item.unitPrice; + await this.cartItemRepo.save(item); + } + + await this.recalculateSubtotal(cartId); + return this.getCart(cartId); + } + + private async recalculateSubtotal(cartId: string): Promise { + const items = await this.cartItemRepo.find({ where: { cartId } }); + const subtotal = items.reduce((sum, i) => sum + Number(i.total), 0); + await this.cartRepo.update(cartId, { subtotal, updatedAt: new Date() }); + } +} + +// 3. CheckoutService +@Injectable() +export class CheckoutService { + async validate(cartId: string, dto: CheckoutDto): Promise { + const cart = await this.cartService.getCart(cartId); + + const errors: string[] = []; + + // Validar items + for (const item of cart.items) { + const stock = await this.stockService.getAvailableStock(item.productId); + if (stock < item.quantity) { + errors.push(`Stock insuficiente para ${item.product.name}`); + } + } + + // Validar direccion si es envio + if (dto.deliveryMethod === 'shipping' && !dto.shippingAddress) { + errors.push('Direccion de envio requerida'); + } + + // Validar sucursal si es pickup + if (dto.deliveryMethod === 'pickup' && !dto.pickupBranchId) { + errors.push('Sucursal de recoleccion requerida'); + } + + return { + valid: errors.length === 0, + errors, + summary: await this.calculateTotals(cart, dto), + }; + } + + async complete(cartId: string, dto: CompleteCheckoutDto): Promise { + const cart = await this.cartService.getCart(cartId); + + // Validar una vez mas + const validation = await this.validate(cartId, dto); + if (!validation.valid) { + throw new BadRequestException(validation.errors.join(', ')); + } + + // Crear orden + const order = await this.orderRepo.save({ + orderNumber: await this.generateOrderNumber(), + customerId: cart.customerId || dto.customerId, + status: 'pending', + orderDate: new Date(), + subtotal: cart.subtotal, + discountAmount: validation.summary.discountAmount, + shippingCost: validation.summary.shippingCost, + taxAmount: validation.summary.taxAmount, + total: validation.summary.total, + paymentStatus: 'pending', + deliveryMethod: dto.deliveryMethod, + pickupBranchId: dto.pickupBranchId, + shippingAddress: dto.shippingAddress, + }); + + // Crear lineas + for (const item of cart.items) { + await this.orderLineRepo.save({ + orderId: order.id, + productId: item.productId, + productName: item.product.name, + quantity: item.quantity, + unitPrice: item.unitPrice, + total: item.total, + }); + } + + // Reservar stock + for (const item of cart.items) { + await this.stockService.reserveStock(item.productId, item.quantity); + } + + // Limpiar carrito + await this.cartService.clear(cartId); + + return order; + } +} + +// 4. PaymentGatewayService +@Injectable() +export class PaymentGatewayService { + private gateways: Map; + + constructor( + private stripeGateway: StripeGateway, + private conektaGateway: ConektaGateway, + private mercadoPagoGateway: MercadoPagoGateway, + ) { + this.gateways = new Map([ + ['stripe', stripeGateway], + ['conekta', conektaGateway], + ['mercadopago', mercadoPagoGateway], + ]); + } + + async createPayment(orderId: string, gateway: string): Promise { + const order = await this.orderRepo.findOne({ where: { id: orderId } }); + const provider = this.gateways.get(gateway); + + if (!provider) { + throw new BadRequestException(`Gateway ${gateway} not supported`); + } + + return provider.createPayment(order); + } + + async handleWebhook(gateway: string, payload: any): Promise { + const provider = this.gateways.get(gateway); + const event = await provider.parseWebhook(payload); + + switch (event.type) { + case 'payment.succeeded': + await this.handlePaymentSuccess(event.orderId); + break; + case 'payment.failed': + await this.handlePaymentFailed(event.orderId); + break; + } + } + + private async handlePaymentSuccess(orderId: string): Promise { + await this.orderRepo.update(orderId, { + paymentStatus: 'paid', + status: 'paid', + }); + + // Confirmar stock (quitar reserva y decrementar) + const lines = await this.orderLineRepo.find({ where: { orderId } }); + for (const line of lines) { + await this.stockService.confirmReservation(line.productId, line.quantity); + } + + // TODO: Enviar email de confirmacion + } +} +``` + +--- + +### 3.10 RT-010 Facturacion (60% herencia) + +**Servicios Nuevos:** + +```typescript +// 1. CFDIService +@Injectable() +export class CFDIService { + constructor( + private builderService: CFDIBuilderService, + private pacService: PACService, + private xmlService: XMLService, + private pdfService: PDFService, + ) {} + + async generateFromPOS(orderId: string, dto: CFDIRequestDto): Promise { + // 1. Obtener orden + const order = await this.posOrderRepo.findOne({ + where: { id: orderId }, + relations: ['lines', 'lines.product'], + }); + + // 2. Construir CFDI + const cfdiData = await this.builderService.fromPOSOrder(order, dto); + + // 3. Generar XML + const xml = this.xmlService.buildXML(cfdiData); + + // 4. Firmar con certificado + const signedXml = await this.xmlService.sign(xml); + + // 5. Timbrar con PAC + const timbrado = await this.pacService.timbrar(signedXml); + + // 6. Guardar CFDI + const cfdi = await this.cfdiRepo.save({ + sourceType: 'pos_order', + sourceId: orderId, + serie: cfdiData.serie, + folio: cfdiData.folio, + uuid: timbrado.uuid, + fechaEmision: new Date(), + tipoComprobante: 'I', + formaPago: dto.formaPago, + metodoPago: dto.metodoPago, + receptorRfc: dto.receptorRfc, + receptorNombre: dto.receptorNombre, + receptorRegimen: dto.receptorRegimen, + receptorCp: dto.receptorCp, + usoCfdi: dto.usoCfdi, + subtotal: order.subtotal, + descuento: order.discountAmount, + totalImpuestos: order.taxAmount, + total: order.total, + status: 'vigente', + xmlContent: timbrado.xml, + fechaTimbrado: timbrado.fechaTimbrado, + rfcPac: timbrado.rfcProvCertif, + selloCfd: timbrado.selloCFD, + selloSat: timbrado.selloSAT, + noCertificadoSat: timbrado.noCertificadoSAT, + }); + + // 7. Generar PDF + const pdf = await this.pdfService.generate(cfdi); + await this.cfdiRepo.update(cfdi.id, { pdfPath: pdf.path }); + + // 8. Marcar orden como facturada + await this.posOrderRepo.update(orderId, { + isInvoiced: true, + invoiceId: cfdi.id, + }); + + return cfdi; + } + + async generatePublicInvoice(orderId: string): Promise { + // Factura a publico general + return this.generateFromPOS(orderId, { + receptorRfc: 'XAXX010101000', + receptorNombre: 'PUBLICO EN GENERAL', + receptorRegimen: '616', + receptorCp: this.config.emisorCp, + usoCfdi: 'S01', + formaPago: '99', + metodoPago: 'PUE', + }); + } + + async cancel(cfdiId: string, dto: CancelDto): Promise { + const cfdi = await this.cfdiRepo.findOne({ where: { id: cfdiId } }); + + // Validar que se puede cancelar + if (cfdi.status !== 'vigente') { + throw new BadRequestException('CFDI ya esta cancelado'); + } + + // Enviar cancelacion al PAC + const result = await this.pacService.cancelar(cfdi.uuid, dto.motivo); + + if (result.success) { + await this.cfdiRepo.update(cfdiId, { + status: 'cancelado', + cancelDate: new Date(), + cancelReason: dto.motivo, + }); + } + + return result; + } +} + +// 2. CFDIBuilderService +@Injectable() +export class CFDIBuilderService { + async fromPOSOrder(order: POSOrder, dto: CFDIRequestDto): Promise { + const config = await this.getConfig(); + + return { + version: '4.0', + serie: config.serieFactura, + folio: await this.getNextFolio(), + fecha: new Date(), + formaPago: dto.formaPago, + metodoPago: dto.metodoPago, + tipoDeComprobante: 'I', + lugarExpedicion: config.emisorCp, + + emisor: { + rfc: config.emisorRfc, + nombre: config.emisorNombre, + regimenFiscal: config.emisorRegimen, + }, + + receptor: { + rfc: dto.receptorRfc, + nombre: dto.receptorNombre, + domicilioFiscalReceptor: dto.receptorCp, + regimenFiscalReceptor: dto.receptorRegimen, + usoCFDI: dto.usoCfdi, + }, + + conceptos: order.lines.map(line => ({ + claveProdServ: line.product.satProductCode || '01010101', + noIdentificacion: line.product.defaultCode, + cantidad: line.quantity, + claveUnidad: line.product.satUnitCode || 'H87', + unidad: line.product.uom?.name || 'Pieza', + descripcion: line.productName, + valorUnitario: Number(line.unitPrice), + importe: Number(line.total) - Number(line.taxAmount), + objetoImp: '02', + impuestos: { + traslados: [{ + base: Number(line.total) - Number(line.taxAmount), + impuesto: '002', + tipoFactor: 'Tasa', + tasaOCuota: 0.16, + importe: Number(line.taxAmount), + }], + }, + })), + + impuestos: { + totalImpuestosTrasladados: Number(order.taxAmount), + totalImpuestosRetenidos: 0, + traslados: [{ + base: Number(order.subtotal) - Number(order.discountAmount), + impuesto: '002', + tipoFactor: 'Tasa', + tasaOCuota: 0.16, + importe: Number(order.taxAmount), + }], + }, + + subTotal: Number(order.subtotal), + descuento: Number(order.discountAmount), + total: Number(order.total), + }; + } +} + +// 3. PACService +@Injectable() +export class PACService { + private providers: Map; + + constructor( + private finkokProvider: FinkokPAC, + private facturamaProvider: FacturamaPAC, + ) { + this.providers = new Map([ + ['finkok', finkokProvider], + ['facturama', facturamaProvider], + ]); + } + + async timbrar(xml: string): Promise { + const config = await this.getConfig(); + const primaryProvider = this.providers.get(config.pacProvider); + const backupProvider = this.providers.get(config.pacBackup); + + try { + return await primaryProvider.timbrar(xml); + } catch (error) { + if (backupProvider) { + return await backupProvider.timbrar(xml); + } + throw error; + } + } + + async cancelar(uuid: string, motivo: string): Promise { + const config = await this.getConfig(); + const provider = this.providers.get(config.pacProvider); + return provider.cancelar(uuid, motivo); + } +} + +// 4. AutofacturaService +@Injectable() +export class AutofacturaService { + async validateTicket(ticketNumber: string): Promise { + const order = await this.posOrderRepo.findOne({ + where: { orderNumber: ticketNumber }, + relations: ['lines'], + }); + + if (!order) { + return { valid: false, error: 'Ticket no encontrado' }; + } + + if (order.isInvoiced) { + return { valid: false, error: 'Ticket ya fue facturado' }; + } + + // Validar plazo (30 dias por defecto) + const config = await this.getConfig(); + const maxDays = config.autofacturaDias || 30; + const daysSinceOrder = this.daysBetween(order.orderDate, new Date()); + + if (daysSinceOrder > maxDays) { + return { valid: false, error: `Plazo de ${maxDays} dias excedido` }; + } + + return { + valid: true, + order: { + orderNumber: order.orderNumber, + orderDate: order.orderDate, + subtotal: order.subtotal, + tax: order.taxAmount, + total: order.total, + items: order.lines.map(l => ({ + name: l.productName, + quantity: l.quantity, + unitPrice: l.unitPrice, + total: l.total, + })), + }, + }; + } + + async generateFromTicket(dto: AutofacturaDto): Promise { + // Validar ticket + const validation = await this.validateTicket(dto.ticketNumber); + if (!validation.valid) { + throw new BadRequestException(validation.error); + } + + // Buscar orden + const order = await this.posOrderRepo.findOne({ + where: { orderNumber: dto.ticketNumber }, + }); + + // Generar factura + return this.cfdiService.generateFromPOS(order.id, { + receptorRfc: dto.rfc, + receptorNombre: dto.nombre, + receptorRegimen: dto.regimenFiscal, + receptorCp: dto.codigoPostal, + usoCfdi: dto.usoCfdi, + formaPago: dto.formaPago || '99', + metodoPago: 'PUE', + }); + } +} +``` + +--- + +## 4. MIDDLEWARE + +### 4.1 TenantMiddleware + +```typescript +@Injectable() +export class TenantMiddleware implements NestMiddleware { + async use(req: Request, res: Response, next: NextFunction) { + const tenantId = req.headers['x-tenant-id'] as string; + + if (!tenantId) { + throw new UnauthorizedException('Tenant ID required'); + } + + // Validar que el tenant existe + const tenant = await this.tenantRepo.findOne({ where: { id: tenantId } }); + if (!tenant || !tenant.isActive) { + throw new UnauthorizedException('Invalid tenant'); + } + + // Establecer variable de sesion para RLS + await this.dataSource.query( + `SELECT set_config('app.current_tenant_id', $1, false)`, + [tenantId] + ); + + req['tenant'] = tenant; + next(); + } +} +``` + +### 4.2 BranchMiddleware + +```typescript +@Injectable() +export class BranchMiddleware implements NestMiddleware { + async use(req: Request, res: Response, next: NextFunction) { + const branchId = req.headers['x-branch-id'] as string; + + if (branchId) { + const branch = await this.branchRepo.findOne({ + where: { id: branchId }, + }); + + if (!branch || !branch.isActive) { + throw new UnauthorizedException('Invalid branch'); + } + + req['branch'] = branch; + } + + next(); + } +} +``` + +--- + +## 5. WEBSOCKET GATEWAY (Para sincronizacion POS) + +```typescript +@WebSocketGateway({ + namespace: 'pos', + cors: { origin: '*' }, +}) +export class POSGateway { + @WebSocketServer() + server: Server; + + @SubscribeMessage('sync:orders') + async handleSyncOrders( + @MessageBody() data: { orders: OfflineOrder[] }, + @ConnectedSocket() client: Socket, + ) { + const result = await this.syncService.syncOfflineOrders(data.orders); + client.emit('sync:result', result); + } + + @SubscribeMessage('order:created') + async handleOrderCreated( + @MessageBody() data: { orderId: string; branchId: string }, + ) { + // Notificar a otros clientes de la misma sucursal + this.server.to(`branch:${data.branchId}`).emit('order:new', data); + } + + handleConnection(client: Socket) { + const branchId = client.handshake.query.branchId as string; + if (branchId) { + client.join(`branch:${branchId}`); + } + } +} +``` + +--- + +## 6. CHECKLIST DE IMPLEMENTACION + +### Modulo RT-001 (Fundamentos) +- [ ] Configurar importaciones de @erp-core +- [ ] Verificar middleware de tenant +- [ ] Configurar autenticacion + +### Modulo RT-002 (POS) +- [ ] POSSessionService +- [ ] POSOrderService +- [ ] POSPaymentService +- [ ] POSSyncService +- [ ] POSController +- [ ] POSGateway (WebSocket) + +### Modulo RT-003 (Inventario) +- [ ] RetailStockService +- [ ] TransfersService +- [ ] AdjustmentsService +- [ ] InventoryController + +### Modulo RT-004 (Compras) +- [ ] RetailPurchaseService +- [ ] GoodsReceiptService +- [ ] PurchaseController + +### Modulo RT-005 (Clientes) +- [ ] RetailCustomersService +- [ ] LoyaltyService +- [ ] CustomersController + +### Modulo RT-006 (Precios) +- [ ] PriceEngineService +- [ ] PromotionsService +- [ ] CouponsService +- [ ] PricingController + +### Modulo RT-007 (Caja) +- [ ] CashSessionService +- [ ] CashMovementService +- [ ] CashClosingService +- [ ] CashController + +### Modulo RT-008 (Reportes) +- [ ] DashboardService +- [ ] SalesReportService +- [ ] ProductReportService +- [ ] CashReportService +- [ ] ReportsController + +### Modulo RT-009 (E-commerce) +- [ ] CatalogService +- [ ] CartService +- [ ] CheckoutService +- [ ] PaymentGatewayService +- [ ] ShippingService +- [ ] StorefrontController +- [ ] EcommerceAdminController + +### Modulo RT-010 (Facturacion) +- [ ] CFDIService +- [ ] CFDIBuilderService +- [ ] PACService +- [ ] XMLService +- [ ] PDFService +- [ ] AutofacturaService +- [ ] CFDIController +- [ ] AutofacturaController + +--- + +**Estado:** PLAN COMPLETO +**Total servicios:** 48 (12 heredados, 8 extendidos, 28 nuevos) +**Total controllers:** 15 diff --git a/orchestration/planes/fase-3-implementacion/PLAN-IMPL-DATABASE.md b/orchestration/planes/fase-3-implementacion/PLAN-IMPL-DATABASE.md new file mode 100644 index 0000000..e48324e --- /dev/null +++ b/orchestration/planes/fase-3-implementacion/PLAN-IMPL-DATABASE.md @@ -0,0 +1,1371 @@ +# PLAN DE IMPLEMENTACION - DATABASE + +**Fecha:** 2025-12-18 +**Fase:** 3 - Plan de Implementaciones +**Capa:** Base de Datos (PostgreSQL 15+) + +--- + +## 1. RESUMEN EJECUTIVO + +### 1.1 Alcance +- **Tablas heredadas de core:** 144 +- **Tablas nuevas retail:** 26 +- **Schemas afectados:** auth, core, financial, inventory, sales, retail (nuevo) +- **Migraciones requeridas:** 15 + +### 1.2 Prerrequisitos Bloqueantes + +| ID | Modulo Core | Estado | Accion Requerida | +|----|-------------|--------|------------------| +| GAP-BLK-001 | MGN-001 Auth | 40% | Completar al 100% | +| GAP-BLK-002 | MGN-005 Catalogs | 0% | Implementar | +| GAP-BLK-003 | MGN-010 Financial | 70% | Migrar a TypeORM | +| GAP-BLK-004 | MGN-011 Inventory | 60% | Completar | +| GAP-BLK-005 | MGN-013 Sales | 50% | Completar | + +--- + +## 2. ORDEN DE IMPLEMENTACION + +### 2.1 Secuencia de Dependencias + +``` +SPRINT 1 (Prerrequisitos Core) + │ + ├── 1.1 auth.tenants (verificar/completar) + ├── 1.2 auth.users (verificar/completar) + ├── 1.3 auth.roles (verificar/completar) + └── 1.4 auth.user_roles (verificar/completar) + │ + v +SPRINT 2 (Catalogos Core) + │ + ├── 2.1 core.countries + ├── 2.2 core.currencies + ├── 2.3 core.uom_categories + ├── 2.4 core.uom + └── 2.5 core.sequences + │ + v +SPRINT 3 (Financial Core) + │ + ├── 3.1 financial.tax_groups + ├── 3.2 financial.taxes + ├── 3.3 financial.accounts + ├── 3.4 financial.journals + └── 3.5 financial.payment_methods + │ + v +SPRINT 4 (Inventory Core) + │ + ├── 4.1 inventory.product_categories + ├── 4.2 inventory.products + ├── 4.3 inventory.warehouses + └── 4.4 inventory.stock_quants + │ + v +SPRINT 5 (Partners/Sales Core) + │ + ├── 5.1 core.partners + ├── 5.2 sales.pricelists + └── 5.3 sales.pricelist_items + │ + v +SPRINT 6 (Retail Schema - Fundamentos) + │ + ├── 6.1 retail.branches + ├── 6.2 retail.cash_registers + └── 6.3 retail.branch_users + │ + v +SPRINT 7 (Retail - POS) + │ + ├── 7.1 retail.pos_sessions + ├── 7.2 retail.pos_orders + ├── 7.3 retail.pos_order_lines + └── 7.4 retail.pos_payments + │ + v +SPRINT 8 (Retail - Caja) + │ + ├── 8.1 retail.cash_movements + ├── 8.2 retail.cash_closings + └── 8.3 retail.cash_counts + │ + v +SPRINT 9 (Retail - Inventario) + │ + ├── 9.1 retail.branch_stock (vista/extension) + ├── 9.2 retail.stock_transfers + └── 9.3 retail.stock_adjustments + │ + v +SPRINT 10 (Retail - Clientes/Lealtad) + │ + ├── 10.1 retail.loyalty_programs + ├── 10.2 retail.membership_levels + ├── 10.3 retail.loyalty_transactions + └── 10.4 retail.customer_memberships + │ + v +SPRINT 11 (Retail - Precios/Promociones) + │ + ├── 11.1 retail.promotions + ├── 11.2 retail.promotion_products + ├── 11.3 retail.coupons + └── 11.4 retail.coupon_redemptions + │ + v +SPRINT 12 (Retail - Compras) + │ + ├── 12.1 retail.purchase_suggestions + ├── 12.2 retail.supplier_orders + └── 12.3 retail.goods_receipts + │ + v +SPRINT 13 (Retail - Facturacion) + │ + ├── 13.1 retail.cfdi_config + └── 13.2 retail.cfdis + │ + v +SPRINT 14 (Retail - E-commerce) + │ + ├── 14.1 retail.carts + ├── 14.2 retail.cart_items + ├── 14.3 retail.ecommerce_orders + ├── 14.4 retail.ecommerce_order_lines + └── 14.5 retail.shipping_rates + │ + v +SPRINT 15 (Vistas Materializadas y Optimizacion) + │ + ├── 15.1 retail.mv_daily_sales + ├── 15.2 retail.mv_product_sales + └── 15.3 Indices adicionales +``` + +--- + +## 3. DDL DETALLADO POR SPRINT + +### 3.1 SPRINT 6 - Retail Schema Fundamentos + +```sql +-- ===================================================== +-- RETAIL SCHEMA - FUNDAMENTOS +-- Sprint: 6 +-- Dependencias: auth.*, core.*, inventory.warehouses +-- ===================================================== + +-- Crear schema +CREATE SCHEMA IF NOT EXISTS retail; + +-- 6.1 Sucursales +CREATE TABLE retail.branches ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + tenant_id UUID NOT NULL REFERENCES auth.tenants(id), + code VARCHAR(10) NOT NULL, + name VARCHAR(100) NOT NULL, + address TEXT, + phone VARCHAR(20), + email VARCHAR(100), + warehouse_id UUID REFERENCES inventory.warehouses(id), + is_active BOOLEAN DEFAULT TRUE, + is_main BOOLEAN DEFAULT FALSE, + config JSONB DEFAULT '{}', + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ, + UNIQUE(tenant_id, code) +); + +-- RLS Policy +ALTER TABLE retail.branches ENABLE ROW LEVEL SECURITY; + +CREATE POLICY tenant_isolation ON retail.branches + USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID); + +-- 6.2 Cajas Registradoras +CREATE TABLE retail.cash_registers ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + tenant_id UUID NOT NULL REFERENCES auth.tenants(id), + branch_id UUID NOT NULL REFERENCES retail.branches(id), + code VARCHAR(10) NOT NULL, + name VARCHAR(50) NOT NULL, + is_active BOOLEAN DEFAULT TRUE, + default_payment_method VARCHAR(20), + config JSONB DEFAULT '{}', + created_at TIMESTAMPTZ DEFAULT NOW(), + UNIQUE(tenant_id, code) +); + +ALTER TABLE retail.cash_registers ENABLE ROW LEVEL SECURITY; + +CREATE POLICY tenant_isolation ON retail.cash_registers + USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID); + +-- 6.3 Usuarios por Sucursal +CREATE TABLE retail.branch_users ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + tenant_id UUID NOT NULL REFERENCES auth.tenants(id), + branch_id UUID NOT NULL REFERENCES retail.branches(id), + user_id UUID NOT NULL REFERENCES auth.users(id), + role VARCHAR(20) NOT NULL, -- manager, cashier, inventory + is_primary BOOLEAN DEFAULT FALSE, + created_at TIMESTAMPTZ DEFAULT NOW(), + UNIQUE(tenant_id, branch_id, user_id) +); + +ALTER TABLE retail.branch_users ENABLE ROW LEVEL SECURITY; + +CREATE POLICY tenant_isolation ON retail.branch_users + USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID); + +-- Indices +CREATE INDEX idx_branches_tenant ON retail.branches(tenant_id); +CREATE INDEX idx_cash_registers_branch ON retail.cash_registers(branch_id); +CREATE INDEX idx_branch_users_user ON retail.branch_users(user_id); +``` + +### 3.2 SPRINT 7 - Retail POS + +```sql +-- ===================================================== +-- RETAIL SCHEMA - POS +-- Sprint: 7 +-- Dependencias: retail.branches, retail.cash_registers +-- ===================================================== + +-- 7.1 Sesiones POS +CREATE TABLE retail.pos_sessions ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + tenant_id UUID NOT NULL REFERENCES auth.tenants(id), + branch_id UUID NOT NULL REFERENCES retail.branches(id), + register_id UUID NOT NULL REFERENCES retail.cash_registers(id), + user_id UUID NOT NULL REFERENCES auth.users(id), + status VARCHAR(20) NOT NULL DEFAULT 'opening', + -- opening, open, closing, closed + opening_balance DECIMAL(12,2) NOT NULL DEFAULT 0, + closing_balance DECIMAL(12,2), + opened_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + closed_at TIMESTAMPTZ, + notes TEXT, + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ +); + +ALTER TABLE retail.pos_sessions ENABLE ROW LEVEL SECURITY; + +CREATE POLICY tenant_isolation ON retail.pos_sessions + USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID); + +-- 7.2 Ordenes POS +CREATE TABLE retail.pos_orders ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + tenant_id UUID NOT NULL REFERENCES auth.tenants(id), + session_id UUID NOT NULL REFERENCES retail.pos_sessions(id), + order_number VARCHAR(20) NOT NULL, + customer_id UUID REFERENCES core.partners(id), + status VARCHAR(20) NOT NULL DEFAULT 'draft', + -- draft, done, refunded, cancelled + order_date TIMESTAMPTZ NOT NULL DEFAULT NOW(), + subtotal DECIMAL(12,2) NOT NULL DEFAULT 0, + discount_amount DECIMAL(12,2) NOT NULL DEFAULT 0, + tax_amount DECIMAL(12,2) NOT NULL DEFAULT 0, + total DECIMAL(12,2) NOT NULL DEFAULT 0, + notes TEXT, + -- Campos offline + offline_id VARCHAR(50), + synced_at TIMESTAMPTZ, + -- Facturacion + is_invoiced BOOLEAN DEFAULT FALSE, + invoice_id UUID, + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ, + UNIQUE(tenant_id, order_number) +); + +ALTER TABLE retail.pos_orders ENABLE ROW LEVEL SECURITY; + +CREATE POLICY tenant_isolation ON retail.pos_orders + USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID); + +-- 7.3 Lineas de Orden POS +CREATE TABLE retail.pos_order_lines ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + tenant_id UUID NOT NULL REFERENCES auth.tenants(id), + order_id UUID NOT NULL REFERENCES retail.pos_orders(id) ON DELETE CASCADE, + product_id UUID NOT NULL REFERENCES inventory.products(id), + product_name VARCHAR(255) NOT NULL, + quantity DECIMAL(12,4) NOT NULL, + unit_price DECIMAL(12,2) NOT NULL, + discount_percent DECIMAL(5,2) DEFAULT 0, + discount_amount DECIMAL(12,2) DEFAULT 0, + tax_amount DECIMAL(12,2) DEFAULT 0, + total DECIMAL(12,2) NOT NULL, + notes TEXT +); + +ALTER TABLE retail.pos_order_lines ENABLE ROW LEVEL SECURITY; + +CREATE POLICY tenant_isolation ON retail.pos_order_lines + USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID); + +-- 7.4 Pagos POS +CREATE TABLE retail.pos_payments ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + tenant_id UUID NOT NULL REFERENCES auth.tenants(id), + order_id UUID NOT NULL REFERENCES retail.pos_orders(id) ON DELETE CASCADE, + payment_method VARCHAR(20) NOT NULL, + -- cash, card, transfer, credit, mixed + amount DECIMAL(12,2) NOT NULL, + received_amount DECIMAL(12,2), + change_amount DECIMAL(12,2) DEFAULT 0, + reference VARCHAR(100), + card_last_four VARCHAR(4), + authorization_code VARCHAR(20), + created_at TIMESTAMPTZ DEFAULT NOW() +); + +ALTER TABLE retail.pos_payments ENABLE ROW LEVEL SECURITY; + +CREATE POLICY tenant_isolation ON retail.pos_payments + USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID); + +-- Indices +CREATE INDEX idx_pos_sessions_branch ON retail.pos_sessions(branch_id); +CREATE INDEX idx_pos_sessions_user ON retail.pos_sessions(user_id); +CREATE INDEX idx_pos_sessions_status ON retail.pos_sessions(status); +CREATE INDEX idx_pos_orders_session ON retail.pos_orders(session_id); +CREATE INDEX idx_pos_orders_customer ON retail.pos_orders(customer_id); +CREATE INDEX idx_pos_orders_date ON retail.pos_orders(order_date); +CREATE INDEX idx_pos_orders_offline ON retail.pos_orders(offline_id) WHERE offline_id IS NOT NULL; +CREATE INDEX idx_pos_order_lines_product ON retail.pos_order_lines(product_id); +CREATE INDEX idx_pos_payments_order ON retail.pos_payments(order_id); +``` + +### 3.3 SPRINT 8 - Retail Caja + +```sql +-- ===================================================== +-- RETAIL SCHEMA - CAJA +-- Sprint: 8 +-- Dependencias: retail.pos_sessions +-- ===================================================== + +-- 8.1 Movimientos de Caja +CREATE TABLE retail.cash_movements ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + tenant_id UUID NOT NULL REFERENCES auth.tenants(id), + session_id UUID NOT NULL REFERENCES retail.pos_sessions(id), + movement_type VARCHAR(10) NOT NULL, -- in, out + amount DECIMAL(12,2) NOT NULL, + reason VARCHAR(100) NOT NULL, + notes TEXT, + authorized_by UUID REFERENCES auth.users(id), + created_at TIMESTAMPTZ DEFAULT NOW() +); + +ALTER TABLE retail.cash_movements ENABLE ROW LEVEL SECURITY; + +CREATE POLICY tenant_isolation ON retail.cash_movements + USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID); + +-- 8.2 Cortes de Caja +CREATE TABLE retail.cash_closings ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + tenant_id UUID NOT NULL REFERENCES auth.tenants(id), + session_id UUID NOT NULL REFERENCES retail.pos_sessions(id) UNIQUE, + closing_date TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + -- Montos esperados (calculados) + expected_cash DECIMAL(12,2) NOT NULL, + expected_card DECIMAL(12,2) NOT NULL DEFAULT 0, + expected_transfer DECIMAL(12,2) NOT NULL DEFAULT 0, + + -- Montos declarados + declared_cash DECIMAL(12,2) NOT NULL, + declared_card DECIMAL(12,2) NOT NULL DEFAULT 0, + declared_transfer DECIMAL(12,2) NOT NULL DEFAULT 0, + + -- Diferencias (columnas generadas) + cash_difference DECIMAL(12,2) GENERATED ALWAYS AS (declared_cash - expected_cash) STORED, + card_difference DECIMAL(12,2) GENERATED ALWAYS AS (declared_card - expected_card) STORED, + transfer_difference DECIMAL(12,2) GENERATED ALWAYS AS (declared_transfer - expected_transfer) STORED, + + -- Detalle de denominaciones + denomination_detail JSONB, + notes TEXT, + + -- Auditoria + closed_by UUID NOT NULL REFERENCES auth.users(id), + approved_by UUID REFERENCES auth.users(id), + is_approved BOOLEAN DEFAULT FALSE, + + created_at TIMESTAMPTZ DEFAULT NOW() +); + +ALTER TABLE retail.cash_closings ENABLE ROW LEVEL SECURITY; + +CREATE POLICY tenant_isolation ON retail.cash_closings + USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID); + +-- 8.3 Arqueos Parciales +CREATE TABLE retail.cash_counts ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + tenant_id UUID NOT NULL REFERENCES auth.tenants(id), + session_id UUID NOT NULL REFERENCES retail.pos_sessions(id), + count_date TIMESTAMPTZ NOT NULL DEFAULT NOW(), + expected_amount DECIMAL(12,2) NOT NULL, + counted_amount DECIMAL(12,2) NOT NULL, + difference DECIMAL(12,2) GENERATED ALWAYS AS (counted_amount - expected_amount) STORED, + denomination_detail JSONB, + counted_by UUID NOT NULL REFERENCES auth.users(id), + notes TEXT, + created_at TIMESTAMPTZ DEFAULT NOW() +); + +ALTER TABLE retail.cash_counts ENABLE ROW LEVEL SECURITY; + +CREATE POLICY tenant_isolation ON retail.cash_counts + USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID); + +-- Indices +CREATE INDEX idx_cash_movements_session ON retail.cash_movements(session_id); +CREATE INDEX idx_cash_closings_date ON retail.cash_closings(closing_date); +CREATE INDEX idx_cash_counts_session ON retail.cash_counts(session_id); +``` + +### 3.4 SPRINT 9 - Retail Inventario + +```sql +-- ===================================================== +-- RETAIL SCHEMA - INVENTARIO MULTI-SUCURSAL +-- Sprint: 9 +-- Dependencias: retail.branches, inventory.* +-- ===================================================== + +-- 9.1 Vista de Stock por Sucursal (extiende inventory.stock_quants) +CREATE OR REPLACE VIEW retail.branch_stock AS +SELECT + sq.id, + sq.tenant_id, + sq.product_id, + sq.warehouse_id, + b.id as branch_id, + b.code as branch_code, + b.name as branch_name, + sq.quantity, + sq.reserved_quantity, + (sq.quantity - sq.reserved_quantity) as available_quantity, + p.default_code as product_code, + p.name as product_name, + p.sale_price, + sq.updated_at +FROM inventory.stock_quants sq +JOIN retail.branches b ON b.warehouse_id = sq.warehouse_id +JOIN inventory.products p ON p.id = sq.product_id +WHERE sq.tenant_id = current_setting('app.current_tenant_id', true)::UUID; + +-- 9.2 Transferencias entre Sucursales +CREATE TABLE retail.stock_transfers ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + tenant_id UUID NOT NULL REFERENCES auth.tenants(id), + transfer_number VARCHAR(20) NOT NULL, + source_branch_id UUID NOT NULL REFERENCES retail.branches(id), + dest_branch_id UUID NOT NULL REFERENCES retail.branches(id), + status VARCHAR(20) NOT NULL DEFAULT 'draft', + -- draft, confirmed, in_transit, received, cancelled + requested_by UUID NOT NULL REFERENCES auth.users(id), + confirmed_by UUID REFERENCES auth.users(id), + received_by UUID REFERENCES auth.users(id), + requested_date TIMESTAMPTZ NOT NULL DEFAULT NOW(), + confirmed_date TIMESTAMPTZ, + received_date TIMESTAMPTZ, + notes TEXT, + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ, + UNIQUE(tenant_id, transfer_number) +); + +ALTER TABLE retail.stock_transfers ENABLE ROW LEVEL SECURITY; + +CREATE POLICY tenant_isolation ON retail.stock_transfers + USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID); + +-- 9.2.1 Lineas de Transferencia +CREATE TABLE retail.stock_transfer_lines ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + tenant_id UUID NOT NULL REFERENCES auth.tenants(id), + transfer_id UUID NOT NULL REFERENCES retail.stock_transfers(id) ON DELETE CASCADE, + product_id UUID NOT NULL REFERENCES inventory.products(id), + requested_qty DECIMAL(12,4) NOT NULL, + sent_qty DECIMAL(12,4), + received_qty DECIMAL(12,4), + notes TEXT +); + +ALTER TABLE retail.stock_transfer_lines ENABLE ROW LEVEL SECURITY; + +CREATE POLICY tenant_isolation ON retail.stock_transfer_lines + USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID); + +-- 9.3 Ajustes de Inventario +CREATE TABLE retail.stock_adjustments ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + tenant_id UUID NOT NULL REFERENCES auth.tenants(id), + adjustment_number VARCHAR(20) NOT NULL, + branch_id UUID NOT NULL REFERENCES retail.branches(id), + adjustment_type VARCHAR(20) NOT NULL, + -- count, damage, theft, expiry, correction + status VARCHAR(20) NOT NULL DEFAULT 'draft', + -- draft, confirmed, cancelled + adjusted_by UUID NOT NULL REFERENCES auth.users(id), + approved_by UUID REFERENCES auth.users(id), + adjustment_date TIMESTAMPTZ NOT NULL DEFAULT NOW(), + notes TEXT, + created_at TIMESTAMPTZ DEFAULT NOW(), + UNIQUE(tenant_id, adjustment_number) +); + +ALTER TABLE retail.stock_adjustments ENABLE ROW LEVEL SECURITY; + +CREATE POLICY tenant_isolation ON retail.stock_adjustments + USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID); + +-- 9.3.1 Lineas de Ajuste +CREATE TABLE retail.stock_adjustment_lines ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + tenant_id UUID NOT NULL REFERENCES auth.tenants(id), + adjustment_id UUID NOT NULL REFERENCES retail.stock_adjustments(id) ON DELETE CASCADE, + product_id UUID NOT NULL REFERENCES inventory.products(id), + theoretical_qty DECIMAL(12,4) NOT NULL, + actual_qty DECIMAL(12,4) NOT NULL, + difference DECIMAL(12,4) GENERATED ALWAYS AS (actual_qty - theoretical_qty) STORED, + reason VARCHAR(100), + notes TEXT +); + +ALTER TABLE retail.stock_adjustment_lines ENABLE ROW LEVEL SECURITY; + +CREATE POLICY tenant_isolation ON retail.stock_adjustment_lines + USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID); + +-- Indices +CREATE INDEX idx_stock_transfers_source ON retail.stock_transfers(source_branch_id); +CREATE INDEX idx_stock_transfers_dest ON retail.stock_transfers(dest_branch_id); +CREATE INDEX idx_stock_transfers_status ON retail.stock_transfers(status); +CREATE INDEX idx_stock_adjustments_branch ON retail.stock_adjustments(branch_id); +``` + +### 3.5 SPRINT 10 - Retail Clientes y Lealtad + +```sql +-- ===================================================== +-- RETAIL SCHEMA - CLIENTES Y LEALTAD +-- Sprint: 10 +-- Dependencias: core.partners +-- ===================================================== + +-- 10.1 Programas de Lealtad +CREATE TABLE retail.loyalty_programs ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + tenant_id UUID NOT NULL REFERENCES auth.tenants(id), + code VARCHAR(20) NOT NULL, + name VARCHAR(100) NOT NULL, + description TEXT, + points_per_currency DECIMAL(10,4) NOT NULL DEFAULT 1, + -- 1 punto por cada $1 + currency_per_point DECIMAL(10,4) NOT NULL DEFAULT 0.01, + -- $0.01 por punto al canjear + min_points_redeem INT NOT NULL DEFAULT 100, + max_discount_percent DECIMAL(5,2) DEFAULT 100, + is_active BOOLEAN DEFAULT TRUE, + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ, + UNIQUE(tenant_id, code) +); + +ALTER TABLE retail.loyalty_programs ENABLE ROW LEVEL SECURITY; + +CREATE POLICY tenant_isolation ON retail.loyalty_programs + USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID); + +-- 10.2 Niveles de Membresia +CREATE TABLE retail.membership_levels ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + tenant_id UUID NOT NULL REFERENCES auth.tenants(id), + program_id UUID NOT NULL REFERENCES retail.loyalty_programs(id), + code VARCHAR(20) NOT NULL, + name VARCHAR(50) NOT NULL, + min_points INT NOT NULL DEFAULT 0, + discount_percent DECIMAL(5,2) DEFAULT 0, + points_multiplier DECIMAL(5,2) DEFAULT 1, + benefits JSONB, + color VARCHAR(7), -- hex color + sort_order INT DEFAULT 0, + UNIQUE(tenant_id, program_id, code) +); + +ALTER TABLE retail.membership_levels ENABLE ROW LEVEL SECURITY; + +CREATE POLICY tenant_isolation ON retail.membership_levels + USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID); + +-- 10.3 Transacciones de Puntos +CREATE TABLE retail.loyalty_transactions ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + tenant_id UUID NOT NULL REFERENCES auth.tenants(id), + customer_id UUID NOT NULL REFERENCES core.partners(id), + program_id UUID NOT NULL REFERENCES retail.loyalty_programs(id), + order_id UUID REFERENCES retail.pos_orders(id), + transaction_type VARCHAR(20) NOT NULL, + -- earn, redeem, expire, adjust, bonus + points INT NOT NULL, + balance_after INT NOT NULL, + description TEXT, + expires_at DATE, + created_at TIMESTAMPTZ DEFAULT NOW() +); + +ALTER TABLE retail.loyalty_transactions ENABLE ROW LEVEL SECURITY; + +CREATE POLICY tenant_isolation ON retail.loyalty_transactions + USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID); + +-- 10.4 Membresias de Clientes +CREATE TABLE retail.customer_memberships ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + tenant_id UUID NOT NULL REFERENCES auth.tenants(id), + customer_id UUID NOT NULL REFERENCES core.partners(id), + program_id UUID NOT NULL REFERENCES retail.loyalty_programs(id), + level_id UUID REFERENCES retail.membership_levels(id), + membership_number VARCHAR(20) NOT NULL, + current_points INT NOT NULL DEFAULT 0, + lifetime_points INT NOT NULL DEFAULT 0, + enrolled_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + last_activity_at TIMESTAMPTZ, + status VARCHAR(20) DEFAULT 'active', + -- active, inactive, suspended + UNIQUE(tenant_id, membership_number), + UNIQUE(tenant_id, customer_id, program_id) +); + +ALTER TABLE retail.customer_memberships ENABLE ROW LEVEL SECURITY; + +CREATE POLICY tenant_isolation ON retail.customer_memberships + USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID); + +-- Indices +CREATE INDEX idx_loyalty_programs_active ON retail.loyalty_programs(is_active); +CREATE INDEX idx_membership_levels_program ON retail.membership_levels(program_id); +CREATE INDEX idx_loyalty_trans_customer ON retail.loyalty_transactions(customer_id); +CREATE INDEX idx_loyalty_trans_date ON retail.loyalty_transactions(created_at); +CREATE INDEX idx_customer_memberships_customer ON retail.customer_memberships(customer_id); +``` + +### 3.6 SPRINT 11 - Retail Precios y Promociones + +```sql +-- ===================================================== +-- RETAIL SCHEMA - PRECIOS Y PROMOCIONES +-- Sprint: 11 +-- Dependencias: retail.branches, inventory.products +-- ===================================================== + +-- 11.1 Promociones +CREATE TABLE retail.promotions ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + tenant_id UUID NOT NULL REFERENCES auth.tenants(id), + code VARCHAR(20) NOT NULL, + name VARCHAR(100) NOT NULL, + description TEXT, + promotion_type VARCHAR(30) NOT NULL, + -- percentage, fixed_amount, buy_x_get_y, bundle + discount_value DECIMAL(10,2) NOT NULL, + start_date DATE NOT NULL, + end_date DATE NOT NULL, + applies_to_all BOOLEAN DEFAULT FALSE, + min_quantity INT, + min_amount DECIMAL(12,2), + branch_ids UUID[], -- null = todas + is_active BOOLEAN DEFAULT TRUE, + max_uses INT, + current_uses INT DEFAULT 0, + -- Para NxM + buy_quantity INT, + get_quantity INT, + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ, + UNIQUE(tenant_id, code), + CONSTRAINT valid_dates CHECK (end_date >= start_date) +); + +ALTER TABLE retail.promotions ENABLE ROW LEVEL SECURITY; + +CREATE POLICY tenant_isolation ON retail.promotions + USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID); + +-- 11.2 Productos en Promocion +CREATE TABLE retail.promotion_products ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + tenant_id UUID NOT NULL REFERENCES auth.tenants(id), + promotion_id UUID NOT NULL REFERENCES retail.promotions(id) ON DELETE CASCADE, + product_id UUID REFERENCES inventory.products(id), + category_id UUID REFERENCES inventory.product_categories(id), + -- Al menos uno debe tener valor + CONSTRAINT product_or_category CHECK (product_id IS NOT NULL OR category_id IS NOT NULL) +); + +ALTER TABLE retail.promotion_products ENABLE ROW LEVEL SECURITY; + +CREATE POLICY tenant_isolation ON retail.promotion_products + USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID); + +-- 11.3 Cupones +CREATE TABLE retail.coupons ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + tenant_id UUID NOT NULL REFERENCES auth.tenants(id), + code VARCHAR(20) NOT NULL, + coupon_type VARCHAR(20) NOT NULL, + -- percentage, fixed_amount + discount_value DECIMAL(10,2) NOT NULL, + min_purchase DECIMAL(12,2), + max_discount DECIMAL(12,2), + valid_from DATE NOT NULL, + valid_until DATE NOT NULL, + max_uses INT DEFAULT 1, + times_used INT DEFAULT 0, + is_active BOOLEAN DEFAULT TRUE, + customer_id UUID REFERENCES core.partners(id), -- null = cualquier cliente + created_at TIMESTAMPTZ DEFAULT NOW(), + UNIQUE(tenant_id, code), + CONSTRAINT valid_dates CHECK (valid_until >= valid_from) +); + +ALTER TABLE retail.coupons ENABLE ROW LEVEL SECURITY; + +CREATE POLICY tenant_isolation ON retail.coupons + USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID); + +-- 11.4 Canjes de Cupones +CREATE TABLE retail.coupon_redemptions ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + tenant_id UUID NOT NULL REFERENCES auth.tenants(id), + coupon_id UUID NOT NULL REFERENCES retail.coupons(id), + order_id UUID NOT NULL REFERENCES retail.pos_orders(id), + discount_applied DECIMAL(12,2) NOT NULL, + redeemed_at TIMESTAMPTZ DEFAULT NOW() +); + +ALTER TABLE retail.coupon_redemptions ENABLE ROW LEVEL SECURITY; + +CREATE POLICY tenant_isolation ON retail.coupon_redemptions + USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID); + +-- Indices +CREATE INDEX idx_promotions_active ON retail.promotions(is_active, start_date, end_date); +CREATE INDEX idx_promotions_branches ON retail.promotions USING GIN(branch_ids); +CREATE INDEX idx_promotion_products_promotion ON retail.promotion_products(promotion_id); +CREATE INDEX idx_promotion_products_product ON retail.promotion_products(product_id); +CREATE INDEX idx_coupons_code ON retail.coupons(code); +CREATE INDEX idx_coupons_valid ON retail.coupons(valid_from, valid_until, is_active); +``` + +### 3.7 SPRINT 12 - Retail Compras + +```sql +-- ===================================================== +-- RETAIL SCHEMA - COMPRAS +-- Sprint: 12 +-- Dependencias: retail.branches, inventory.products, core.partners +-- ===================================================== + +-- 12.1 Sugerencias de Compra +CREATE TABLE retail.purchase_suggestions ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + tenant_id UUID NOT NULL REFERENCES auth.tenants(id), + branch_id UUID NOT NULL REFERENCES retail.branches(id), + product_id UUID NOT NULL REFERENCES inventory.products(id), + supplier_id UUID REFERENCES core.partners(id), + current_stock DECIMAL(12,4) NOT NULL, + min_stock DECIMAL(12,4) NOT NULL, + max_stock DECIMAL(12,4) NOT NULL, + suggested_qty DECIMAL(12,4) NOT NULL, + avg_daily_sales DECIMAL(12,4), + days_of_stock DECIMAL(8,2), + priority VARCHAR(10) NOT NULL DEFAULT 'medium', + -- critical, high, medium, low + status VARCHAR(20) NOT NULL DEFAULT 'pending', + -- pending, ordered, ignored + generated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE(tenant_id, branch_id, product_id, generated_at::DATE) +); + +ALTER TABLE retail.purchase_suggestions ENABLE ROW LEVEL SECURITY; + +CREATE POLICY tenant_isolation ON retail.purchase_suggestions + USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID); + +-- 12.2 Ordenes a Proveedores +CREATE TABLE retail.supplier_orders ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + tenant_id UUID NOT NULL REFERENCES auth.tenants(id), + order_number VARCHAR(20) NOT NULL, + supplier_id UUID NOT NULL REFERENCES core.partners(id), + branch_id UUID NOT NULL REFERENCES retail.branches(id), + status VARCHAR(20) NOT NULL DEFAULT 'draft', + -- draft, sent, confirmed, partial, received, cancelled + order_date TIMESTAMPTZ NOT NULL DEFAULT NOW(), + expected_date DATE, + subtotal DECIMAL(12,2) NOT NULL DEFAULT 0, + tax_amount DECIMAL(12,2) NOT NULL DEFAULT 0, + total DECIMAL(12,2) NOT NULL DEFAULT 0, + notes TEXT, + created_by UUID NOT NULL REFERENCES auth.users(id), + approved_by UUID REFERENCES auth.users(id), + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ, + UNIQUE(tenant_id, order_number) +); + +ALTER TABLE retail.supplier_orders ENABLE ROW LEVEL SECURITY; + +CREATE POLICY tenant_isolation ON retail.supplier_orders + USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID); + +-- 12.2.1 Lineas de Orden a Proveedor +CREATE TABLE retail.supplier_order_lines ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + tenant_id UUID NOT NULL REFERENCES auth.tenants(id), + order_id UUID NOT NULL REFERENCES retail.supplier_orders(id) ON DELETE CASCADE, + product_id UUID NOT NULL REFERENCES inventory.products(id), + quantity DECIMAL(12,4) NOT NULL, + unit_price DECIMAL(12,2) NOT NULL, + tax_percent DECIMAL(5,2) DEFAULT 0, + total DECIMAL(12,2) NOT NULL, + received_qty DECIMAL(12,4) DEFAULT 0 +); + +ALTER TABLE retail.supplier_order_lines ENABLE ROW LEVEL SECURITY; + +CREATE POLICY tenant_isolation ON retail.supplier_order_lines + USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID); + +-- 12.3 Recepciones de Mercancia +CREATE TABLE retail.goods_receipts ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + tenant_id UUID NOT NULL REFERENCES auth.tenants(id), + receipt_number VARCHAR(20) NOT NULL, + supplier_order_id UUID REFERENCES retail.supplier_orders(id), + branch_id UUID NOT NULL REFERENCES retail.branches(id), + supplier_id UUID NOT NULL REFERENCES core.partners(id), + status VARCHAR(20) NOT NULL DEFAULT 'draft', + -- draft, confirmed, cancelled + receipt_date TIMESTAMPTZ NOT NULL DEFAULT NOW(), + supplier_invoice VARCHAR(50), + notes TEXT, + received_by UUID NOT NULL REFERENCES auth.users(id), + created_at TIMESTAMPTZ DEFAULT NOW(), + UNIQUE(tenant_id, receipt_number) +); + +ALTER TABLE retail.goods_receipts ENABLE ROW LEVEL SECURITY; + +CREATE POLICY tenant_isolation ON retail.goods_receipts + USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID); + +-- 12.3.1 Lineas de Recepcion +CREATE TABLE retail.goods_receipt_lines ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + tenant_id UUID NOT NULL REFERENCES auth.tenants(id), + receipt_id UUID NOT NULL REFERENCES retail.goods_receipts(id) ON DELETE CASCADE, + product_id UUID NOT NULL REFERENCES inventory.products(id), + expected_qty DECIMAL(12,4), + received_qty DECIMAL(12,4) NOT NULL, + unit_cost DECIMAL(12,2), + notes TEXT +); + +ALTER TABLE retail.goods_receipt_lines ENABLE ROW LEVEL SECURITY; + +CREATE POLICY tenant_isolation ON retail.goods_receipt_lines + USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID); + +-- Indices +CREATE INDEX idx_purchase_suggestions_branch ON retail.purchase_suggestions(branch_id); +CREATE INDEX idx_purchase_suggestions_status ON retail.purchase_suggestions(status); +CREATE INDEX idx_purchase_suggestions_priority ON retail.purchase_suggestions(priority); +CREATE INDEX idx_supplier_orders_supplier ON retail.supplier_orders(supplier_id); +CREATE INDEX idx_supplier_orders_status ON retail.supplier_orders(status); +CREATE INDEX idx_goods_receipts_order ON retail.goods_receipts(supplier_order_id); +CREATE INDEX idx_goods_receipts_branch ON retail.goods_receipts(branch_id); +``` + +### 3.8 SPRINT 13 - Retail Facturacion CFDI + +```sql +-- ===================================================== +-- RETAIL SCHEMA - FACTURACION CFDI 4.0 +-- Sprint: 13 +-- Dependencias: retail.pos_orders, core.partners +-- ===================================================== + +-- 13.1 Configuracion CFDI por Tenant +CREATE TABLE retail.cfdi_config ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + tenant_id UUID NOT NULL REFERENCES auth.tenants(id) UNIQUE, + emisor_rfc VARCHAR(13) NOT NULL, + emisor_nombre VARCHAR(255) NOT NULL, + emisor_regimen VARCHAR(3) NOT NULL, + emisor_cp VARCHAR(5) NOT NULL, + pac_provider VARCHAR(20) NOT NULL DEFAULT 'finkok', + pac_user VARCHAR(100), + pac_password_encrypted TEXT, + cer_path TEXT, + key_path TEXT, + key_password_encrypted TEXT, + serie_factura VARCHAR(10) DEFAULT 'A', + serie_nota_credito VARCHAR(10) DEFAULT 'NC', + folio_actual INT DEFAULT 1, + autofactura_enabled BOOLEAN DEFAULT TRUE, + autofactura_dias INT DEFAULT 30, + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ +); + +ALTER TABLE retail.cfdi_config ENABLE ROW LEVEL SECURITY; + +CREATE POLICY tenant_isolation ON retail.cfdi_config + USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID); + +-- 13.2 CFDIs Emitidos +CREATE TABLE retail.cfdis ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + tenant_id UUID NOT NULL REFERENCES auth.tenants(id), + + -- Relacion origen + source_type VARCHAR(20) NOT NULL, -- pos_order, ecommerce_order + source_id UUID NOT NULL, + + -- Datos comprobante + serie VARCHAR(10), + folio VARCHAR(20), + uuid VARCHAR(36), -- UUID del SAT + fecha_emision TIMESTAMPTZ NOT NULL, + tipo_comprobante CHAR(1) NOT NULL, -- I, E, T, N, P + forma_pago VARCHAR(2), + metodo_pago VARCHAR(3), + + -- Receptor + receptor_rfc VARCHAR(13) NOT NULL, + receptor_nombre VARCHAR(255) NOT NULL, + receptor_regimen VARCHAR(3), + receptor_cp VARCHAR(5), + uso_cfdi VARCHAR(4), + + -- Totales + subtotal DECIMAL(12,2) NOT NULL, + descuento DECIMAL(12,2) DEFAULT 0, + total_impuestos DECIMAL(12,2) NOT NULL, + total DECIMAL(12,2) NOT NULL, + + -- Estado + status VARCHAR(20) NOT NULL DEFAULT 'vigente', + -- vigente, cancelado, pendiente + cancel_date TIMESTAMPTZ, + cancel_reason VARCHAR(2), + + -- Archivos + xml_content TEXT, + xml_path TEXT, + pdf_path TEXT, + + -- Timbre + fecha_timbrado TIMESTAMPTZ, + rfc_pac VARCHAR(13), + sello_cfd TEXT, + sello_sat TEXT, + no_certificado_sat VARCHAR(20), + + -- CFDI relacionado (para notas de credito) + cfdi_relacionado_uuid VARCHAR(36), + tipo_relacion VARCHAR(2), + + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ, + + UNIQUE(tenant_id, uuid) +); + +ALTER TABLE retail.cfdis ENABLE ROW LEVEL SECURITY; + +CREATE POLICY tenant_isolation ON retail.cfdis + USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID); + +-- Indices +CREATE INDEX idx_cfdis_source ON retail.cfdis(source_type, source_id); +CREATE INDEX idx_cfdis_uuid ON retail.cfdis(uuid); +CREATE INDEX idx_cfdis_fecha ON retail.cfdis(fecha_emision); +CREATE INDEX idx_cfdis_receptor ON retail.cfdis(receptor_rfc); +CREATE INDEX idx_cfdis_status ON retail.cfdis(status); +``` + +### 3.9 SPRINT 14 - Retail E-commerce + +```sql +-- ===================================================== +-- RETAIL SCHEMA - E-COMMERCE +-- Sprint: 14 +-- Dependencias: retail.branches, inventory.products, core.partners +-- ===================================================== + +-- 14.1 Carritos +CREATE TABLE retail.carts ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + tenant_id UUID NOT NULL REFERENCES auth.tenants(id), + customer_id UUID REFERENCES core.partners(id), + session_id VARCHAR(100), -- Para guests + subtotal DECIMAL(12,2) DEFAULT 0, + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ, + expires_at TIMESTAMPTZ, + CONSTRAINT customer_or_session CHECK (customer_id IS NOT NULL OR session_id IS NOT NULL) +); + +ALTER TABLE retail.carts ENABLE ROW LEVEL SECURITY; + +CREATE POLICY tenant_isolation ON retail.carts + USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID); + +-- 14.2 Items del Carrito +CREATE TABLE retail.cart_items ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + tenant_id UUID NOT NULL REFERENCES auth.tenants(id), + cart_id UUID NOT NULL REFERENCES retail.carts(id) ON DELETE CASCADE, + product_id UUID NOT NULL REFERENCES inventory.products(id), + quantity DECIMAL(12,4) NOT NULL, + unit_price DECIMAL(12,2) NOT NULL, + total DECIMAL(12,2) NOT NULL, + added_at TIMESTAMPTZ DEFAULT NOW() +); + +ALTER TABLE retail.cart_items ENABLE ROW LEVEL SECURITY; + +CREATE POLICY tenant_isolation ON retail.cart_items + USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID); + +-- 14.3 Pedidos E-commerce +CREATE TABLE retail.ecommerce_orders ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + tenant_id UUID NOT NULL REFERENCES auth.tenants(id), + order_number VARCHAR(20) NOT NULL, + customer_id UUID NOT NULL REFERENCES core.partners(id), + status VARCHAR(20) NOT NULL DEFAULT 'pending', + -- pending, paid, preparing, shipped, ready_pickup, delivered, cancelled + order_date TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + -- Totales + subtotal DECIMAL(12,2) NOT NULL, + discount_amount DECIMAL(12,2) DEFAULT 0, + shipping_cost DECIMAL(12,2) NOT NULL DEFAULT 0, + tax_amount DECIMAL(12,2) NOT NULL DEFAULT 0, + total DECIMAL(12,2) NOT NULL, + + -- Pago + payment_status VARCHAR(20), -- pending, paid, failed, refunded + payment_method VARCHAR(50), + payment_reference VARCHAR(100), + + -- Entrega + delivery_method VARCHAR(20) NOT NULL, -- shipping, pickup + pickup_branch_id UUID REFERENCES retail.branches(id), + shipping_address JSONB, + tracking_number VARCHAR(50), + + -- Facturacion + is_invoiced BOOLEAN DEFAULT FALSE, + invoice_id UUID, + + notes TEXT, + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ, + + UNIQUE(tenant_id, order_number) +); + +ALTER TABLE retail.ecommerce_orders ENABLE ROW LEVEL SECURITY; + +CREATE POLICY tenant_isolation ON retail.ecommerce_orders + USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID); + +-- 14.4 Lineas de Pedido E-commerce +CREATE TABLE retail.ecommerce_order_lines ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + tenant_id UUID NOT NULL REFERENCES auth.tenants(id), + order_id UUID NOT NULL REFERENCES retail.ecommerce_orders(id) ON DELETE CASCADE, + product_id UUID NOT NULL REFERENCES inventory.products(id), + product_name VARCHAR(255) NOT NULL, + quantity DECIMAL(12,4) NOT NULL, + unit_price DECIMAL(12,2) NOT NULL, + total DECIMAL(12,2) NOT NULL +); + +ALTER TABLE retail.ecommerce_order_lines ENABLE ROW LEVEL SECURITY; + +CREATE POLICY tenant_isolation ON retail.ecommerce_order_lines + USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID); + +-- 14.5 Tarifas de Envio +CREATE TABLE retail.shipping_rates ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + tenant_id UUID NOT NULL REFERENCES auth.tenants(id), + name VARCHAR(100) NOT NULL, + carrier VARCHAR(50) NOT NULL, + base_rate DECIMAL(12,2) NOT NULL, + free_shipping_minimum DECIMAL(12,2), + estimated_days INT, + is_active BOOLEAN DEFAULT TRUE, + created_at TIMESTAMPTZ DEFAULT NOW() +); + +ALTER TABLE retail.shipping_rates ENABLE ROW LEVEL SECURITY; + +CREATE POLICY tenant_isolation ON retail.shipping_rates + USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID); + +-- Indices +CREATE INDEX idx_carts_customer ON retail.carts(customer_id); +CREATE INDEX idx_carts_session ON retail.carts(session_id); +CREATE INDEX idx_carts_expires ON retail.carts(expires_at); +CREATE INDEX idx_cart_items_cart ON retail.cart_items(cart_id); +CREATE INDEX idx_ecommerce_orders_customer ON retail.ecommerce_orders(customer_id); +CREATE INDEX idx_ecommerce_orders_status ON retail.ecommerce_orders(status); +CREATE INDEX idx_ecommerce_orders_date ON retail.ecommerce_orders(order_date); +``` + +### 3.10 SPRINT 15 - Vistas Materializadas + +```sql +-- ===================================================== +-- RETAIL SCHEMA - VISTAS MATERIALIZADAS +-- Sprint: 15 +-- Para performance en reportes +-- ===================================================== + +-- 15.1 Ventas Diarias +CREATE MATERIALIZED VIEW retail.mv_daily_sales AS +SELECT + DATE(po.order_date) as sale_date, + po.tenant_id, + ps.branch_id, + ps.user_id as cashier_id, + COUNT(*) as transaction_count, + SUM(po.total) as total_sales, + SUM(po.discount_amount) as total_discounts, + AVG(po.total) as avg_ticket, + SUM(CASE WHEN po.status = 'refunded' THEN po.total ELSE 0 END) as refunds +FROM retail.pos_orders po +JOIN retail.pos_sessions ps ON po.session_id = ps.id +WHERE po.status IN ('done', 'refunded') +GROUP BY DATE(po.order_date), po.tenant_id, ps.branch_id, ps.user_id; + +CREATE UNIQUE INDEX idx_mv_daily_sales +ON retail.mv_daily_sales(sale_date, tenant_id, branch_id, cashier_id); + +-- 15.2 Ventas por Producto +CREATE MATERIALIZED VIEW retail.mv_product_sales AS +SELECT + pol.product_id, + pol.tenant_id, + ps.branch_id, + DATE_TRUNC('month', po.order_date) as sale_month, + SUM(pol.quantity) as qty_sold, + SUM(pol.total) as revenue, + COUNT(DISTINCT po.id) as order_count +FROM retail.pos_order_lines pol +JOIN retail.pos_orders po ON pol.order_id = po.id +JOIN retail.pos_sessions ps ON po.session_id = ps.id +WHERE po.status = 'done' +GROUP BY pol.product_id, pol.tenant_id, ps.branch_id, DATE_TRUNC('month', po.order_date); + +CREATE UNIQUE INDEX idx_mv_product_sales +ON retail.mv_product_sales(product_id, tenant_id, branch_id, sale_month); + +-- 15.3 Stock Actual por Sucursal +CREATE MATERIALIZED VIEW retail.mv_branch_stock_summary AS +SELECT + b.tenant_id, + b.id as branch_id, + b.code as branch_code, + b.name as branch_name, + COUNT(DISTINCT sq.product_id) as product_count, + SUM(sq.quantity) as total_stock, + SUM(sq.quantity * p.cost_price) as stock_value, + COUNT(CASE WHEN sq.quantity <= p.min_stock THEN 1 END) as low_stock_count, + COUNT(CASE WHEN sq.quantity = 0 THEN 1 END) as out_of_stock_count +FROM retail.branches b +JOIN inventory.stock_quants sq ON sq.warehouse_id = b.warehouse_id +JOIN inventory.products p ON p.id = sq.product_id +GROUP BY b.tenant_id, b.id, b.code, b.name; + +CREATE UNIQUE INDEX idx_mv_branch_stock_summary +ON retail.mv_branch_stock_summary(tenant_id, branch_id); + +-- Funcion para refrescar vistas +CREATE OR REPLACE FUNCTION retail.refresh_materialized_views() +RETURNS void AS $$ +BEGIN + REFRESH MATERIALIZED VIEW CONCURRENTLY retail.mv_daily_sales; + REFRESH MATERIALIZED VIEW CONCURRENTLY retail.mv_product_sales; + REFRESH MATERIALIZED VIEW CONCURRENTLY retail.mv_branch_stock_summary; +END; +$$ LANGUAGE plpgsql; + +-- Comentario: Programar cron job para ejecutar cada hora +-- SELECT cron.schedule('refresh-retail-views', '0 * * * *', 'SELECT retail.refresh_materialized_views()'); +``` + +--- + +## 4. MIGRACIONES TYPEORM + +### 4.1 Estructura de Archivos + +``` +src/migrations/ +├── 1734500000000-CreateRetailSchema.ts +├── 1734500000001-CreateBranchTables.ts +├── 1734500000002-CreatePOSTables.ts +├── 1734500000003-CreateCashTables.ts +├── 1734500000004-CreateInventoryTables.ts +├── 1734500000005-CreateLoyaltyTables.ts +├── 1734500000006-CreatePromotionTables.ts +├── 1734500000007-CreatePurchaseTables.ts +├── 1734500000008-CreateCFDITables.ts +├── 1734500000009-CreateEcommerceTables.ts +├── 1734500000010-CreateMaterializedViews.ts +├── 1734500000011-CreateIndices.ts +└── 1734500000012-SeedCatalogs.ts +``` + +### 4.2 Ejemplo de Migracion + +```typescript +// 1734500000002-CreatePOSTables.ts +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class CreatePOSTables1734500000002 implements MigrationInterface { + name = 'CreatePOSTables1734500000002'; + + public async up(queryRunner: QueryRunner): Promise { + // pos_sessions + await queryRunner.query(` + CREATE TABLE retail.pos_sessions ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + tenant_id UUID NOT NULL REFERENCES auth.tenants(id), + branch_id UUID NOT NULL REFERENCES retail.branches(id), + register_id UUID NOT NULL REFERENCES retail.cash_registers(id), + user_id UUID NOT NULL REFERENCES auth.users(id), + status VARCHAR(20) NOT NULL DEFAULT 'opening', + opening_balance DECIMAL(12,2) NOT NULL DEFAULT 0, + closing_balance DECIMAL(12,2), + opened_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + closed_at TIMESTAMPTZ, + notes TEXT, + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ + ) + `); + + // RLS + await queryRunner.query(` + ALTER TABLE retail.pos_sessions ENABLE ROW LEVEL SECURITY + `); + + await queryRunner.query(` + CREATE POLICY tenant_isolation ON retail.pos_sessions + USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID) + `); + + // Similar para pos_orders, pos_order_lines, pos_payments... + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE IF EXISTS retail.pos_payments`); + await queryRunner.query(`DROP TABLE IF EXISTS retail.pos_order_lines`); + await queryRunner.query(`DROP TABLE IF EXISTS retail.pos_orders`); + await queryRunner.query(`DROP TABLE IF EXISTS retail.pos_sessions`); + } +} +``` + +--- + +## 5. SCRIPTS DE SEED + +### 5.1 Catalogos SAT (para CFDI) + +```sql +-- Catalogos SAT +INSERT INTO retail.sat_forma_pago (clave, descripcion) VALUES +('01', 'Efectivo'), +('02', 'Cheque nominativo'), +('03', 'Transferencia electrónica de fondos'), +('04', 'Tarjeta de crédito'), +('28', 'Tarjeta de débito'), +('99', 'Por definir'); + +INSERT INTO retail.sat_metodo_pago (clave, descripcion) VALUES +('PUE', 'Pago en una sola exhibición'), +('PPD', 'Pago en parcialidades o diferido'); + +INSERT INTO retail.sat_uso_cfdi (clave, descripcion, persona_fisica, persona_moral) VALUES +('G01', 'Adquisición de mercancías', true, true), +('G03', 'Gastos en general', true, true), +('P01', 'Por definir', true, true); + +INSERT INTO retail.sat_regimen_fiscal (clave, descripcion, persona_fisica, persona_moral) VALUES +('601', 'General de Ley Personas Morales', false, true), +('612', 'Personas Físicas con Actividades Empresariales y Profesionales', true, false), +('621', 'Incorporación Fiscal', true, false), +('626', 'Régimen Simplificado de Confianza', true, true); +``` + +--- + +## 6. CHECKLIST DE IMPLEMENTACION + +### Sprint 1-5: Core Prerequisites +- [ ] Verificar schema auth completo +- [ ] Completar MGN-001 Auth al 100% +- [ ] Implementar MGN-005 Catalogs +- [ ] Migrar MGN-010 Financial a TypeORM +- [ ] Completar MGN-011 Inventory +- [ ] Completar MGN-013 Sales + +### Sprint 6-15: Retail Schema +- [ ] Crear schema retail +- [ ] Sprint 6: Branches y cajas +- [ ] Sprint 7: POS +- [ ] Sprint 8: Caja +- [ ] Sprint 9: Inventario multi-sucursal +- [ ] Sprint 10: Lealtad +- [ ] Sprint 11: Promociones +- [ ] Sprint 12: Compras +- [ ] Sprint 13: CFDI +- [ ] Sprint 14: E-commerce +- [ ] Sprint 15: Vistas materializadas + +### Post-implementacion +- [ ] Ejecutar todas las migraciones +- [ ] Verificar RLS en todas las tablas +- [ ] Cargar seeds de catalogos +- [ ] Validar indices de performance +- [ ] Configurar cron de refresh de vistas + +--- + +**Estado:** PLAN COMPLETO +**Fecha estimada:** 15 sprints (7.5 semanas con sprints de 3 dias) diff --git a/orchestration/planes/fase-3-implementacion/PLAN-IMPL-FRONTEND.md b/orchestration/planes/fase-3-implementacion/PLAN-IMPL-FRONTEND.md new file mode 100644 index 0000000..f781795 --- /dev/null +++ b/orchestration/planes/fase-3-implementacion/PLAN-IMPL-FRONTEND.md @@ -0,0 +1,1680 @@ +# PLAN DE IMPLEMENTACION - FRONTEND + +**Fecha:** 2025-12-18 +**Fase:** 3 - Plan de Implementaciones +**Capa:** Frontend (React + TypeScript + Vite + Tailwind CSS) + +--- + +## 1. RESUMEN EJECUTIVO + +### 1.1 Alcance +- **Aplicaciones:** 3 (Backoffice, POS, Storefront) +- **Paginas totales:** ~65 +- **Componentes compartidos:** ~40 +- **PWA:** Si (POS con offline-first) + +### 1.2 Arquitectura de Aplicaciones + +``` +┌─────────────────────────────────────────────────────────────────────────┐ +│ RETAIL FRONTEND APPS │ +├─────────────────────────────────────────────────────────────────────────┤ +│ │ +│ ┌─────────────────────┐ ┌────────────────┐ ┌────────────────────┐ │ +│ │ BACKOFFICE │ │ POS │ │ STOREFRONT │ │ +│ │ (Admin Dashboard) │ │ (Punto Venta) │ │ (E-commerce) │ │ +│ ├─────────────────────┤ ├────────────────┤ ├────────────────────┤ │ +│ │ - Dashboard │ │ - Ventas │ │ - Catalogo │ │ +│ │ - Inventario │ │ - Cobro │ │ - Carrito │ │ +│ │ - Productos │ │ - Caja │ │ - Checkout │ │ +│ │ - Clientes │ │ - Offline │ │ - Mi cuenta │ │ +│ │ - Promociones │ │ │ │ │ │ +│ │ - Reportes │ │ │ │ │ │ +│ │ - Configuracion │ │ │ │ │ │ +│ └─────────────────────┘ └────────────────┘ └────────────────────┘ │ +│ │ │ │ │ +│ └──────────────────────┴──────────────────────┘ │ +│ │ │ +│ ┌───────────────────────────────┴─────────────────────────────────┐ │ +│ │ SHARED LIBRARIES │ │ +│ │ @retail/ui-components @retail/api-client @retail/hooks │ │ +│ └─────────────────────────────────────────────────────────────────┘ │ +│ │ +└─────────────────────────────────────────────────────────────────────────┘ +``` + +--- + +## 2. ESTRUCTURA DE PROYECTO + +### 2.1 Monorepo Structure + +``` +retail/frontend/ +├── packages/ +│ ├── ui-components/ # Componentes compartidos +│ │ ├── src/ +│ │ │ ├── components/ +│ │ │ │ ├── Button/ +│ │ │ │ ├── Input/ +│ │ │ │ ├── Modal/ +│ │ │ │ ├── Table/ +│ │ │ │ ├── Card/ +│ │ │ │ ├── Badge/ +│ │ │ │ ├── Toast/ +│ │ │ │ ├── Spinner/ +│ │ │ │ └── index.ts +│ │ │ ├── layouts/ +│ │ │ │ ├── AdminLayout/ +│ │ │ │ ├── POSLayout/ +│ │ │ │ └── StoreLayout/ +│ │ │ └── index.ts +│ │ └── package.json +│ │ +│ ├── api-client/ # Cliente API tipado +│ │ ├── src/ +│ │ │ ├── client.ts +│ │ │ ├── endpoints/ +│ │ │ │ ├── auth.ts +│ │ │ │ ├── products.ts +│ │ │ │ ├── orders.ts +│ │ │ │ ├── customers.ts +│ │ │ │ └── ... +│ │ │ └── types/ +│ │ │ └── index.ts +│ │ └── package.json +│ │ +│ └── hooks/ # Hooks compartidos +│ ├── src/ +│ │ ├── useAuth.ts +│ │ ├── useTenant.ts +│ │ ├── useBranch.ts +│ │ ├── useToast.ts +│ │ ├── useDebounce.ts +│ │ └── index.ts +│ └── package.json +│ +├── apps/ +│ ├── backoffice/ # App Admin +│ │ ├── src/ +│ │ │ ├── pages/ +│ │ │ ├── components/ +│ │ │ ├── store/ +│ │ │ ├── routes/ +│ │ │ └── main.tsx +│ │ ├── public/ +│ │ ├── index.html +│ │ └── vite.config.ts +│ │ +│ ├── pos/ # App POS (PWA) +│ │ ├── src/ +│ │ │ ├── pages/ +│ │ │ ├── components/ +│ │ │ ├── store/ +│ │ │ ├── offline/ +│ │ │ │ ├── db.ts # IndexedDB +│ │ │ │ ├── sync.ts +│ │ │ │ └── queue.ts +│ │ │ ├── hardware/ +│ │ │ │ ├── printer.ts +│ │ │ │ ├── scanner.ts +│ │ │ │ └── drawer.ts +│ │ │ ├── sw.ts # Service Worker +│ │ │ └── main.tsx +│ │ ├── public/ +│ │ │ ├── manifest.json +│ │ │ └── icons/ +│ │ └── vite.config.ts +│ │ +│ └── storefront/ # App E-commerce +│ ├── src/ +│ │ ├── pages/ +│ │ ├── components/ +│ │ ├── store/ +│ │ └── main.tsx +│ └── vite.config.ts +│ +├── package.json +├── pnpm-workspace.yaml +└── tsconfig.base.json +``` + +--- + +## 3. APP: BACKOFFICE (Admin Dashboard) + +### 3.1 Paginas por Modulo + +#### Dashboard (RT-008) +| Pagina | Ruta | Descripcion | +|--------|------|-------------| +| Dashboard | `/` | KPIs, graficos, alertas | + +#### Inventario (RT-003) +| Pagina | Ruta | Descripcion | +|--------|------|-------------| +| Stock por Sucursal | `/inventory` | Vista de stock multi-sucursal | +| Transferencias | `/inventory/transfers` | Lista de transferencias | +| Nueva Transferencia | `/inventory/transfers/new` | Crear transferencia | +| Detalle Transferencia | `/inventory/transfers/:id` | Ver/editar transferencia | +| Ajustes | `/inventory/adjustments` | Lista de ajustes | +| Nuevo Ajuste | `/inventory/adjustments/new` | Crear ajuste | + +#### Productos (heredado core) +| Pagina | Ruta | Descripcion | +|--------|------|-------------| +| Lista Productos | `/products` | Catalogo de productos | +| Nuevo Producto | `/products/new` | Crear producto | +| Detalle Producto | `/products/:id` | Ver/editar producto | +| Categorias | `/products/categories` | Gestionar categorias | + +#### Clientes (RT-005) +| Pagina | Ruta | Descripcion | +|--------|------|-------------| +| Lista Clientes | `/customers` | Lista de clientes | +| Nuevo Cliente | `/customers/new` | Crear cliente | +| Detalle Cliente | `/customers/:id` | Ver/editar cliente | +| Programa Lealtad | `/customers/loyalty` | Config programa | +| Niveles Membresia | `/customers/loyalty/levels` | Gestionar niveles | + +#### Precios (RT-006) +| Pagina | Ruta | Descripcion | +|--------|------|-------------| +| Listas de Precios | `/pricing/pricelists` | Gestionar listas | +| Promociones | `/pricing/promotions` | Lista promociones | +| Nueva Promocion | `/pricing/promotions/new` | Crear promocion | +| Detalle Promocion | `/pricing/promotions/:id` | Ver/editar | +| Cupones | `/pricing/coupons` | Lista cupones | +| Generar Cupones | `/pricing/coupons/generate` | Generar batch | + +#### Compras (RT-004) +| Pagina | Ruta | Descripcion | +|--------|------|-------------| +| Sugerencias Compra | `/purchases/suggestions` | Ver sugerencias | +| Ordenes Proveedor | `/purchases/orders` | Lista ordenes | +| Nueva Orden | `/purchases/orders/new` | Crear orden | +| Recepciones | `/purchases/receipts` | Lista recepciones | +| Nueva Recepcion | `/purchases/receipts/new` | Crear recepcion | + +#### E-commerce (RT-009) +| Pagina | Ruta | Descripcion | +|--------|------|-------------| +| Pedidos Online | `/ecommerce/orders` | Lista pedidos | +| Detalle Pedido | `/ecommerce/orders/:id` | Ver/gestionar | +| Tarifas Envio | `/ecommerce/shipping` | Configurar envios | + +#### Facturacion (RT-010) +| Pagina | Ruta | Descripcion | +|--------|------|-------------| +| Facturas | `/invoicing` | Lista facturas | +| Detalle Factura | `/invoicing/:id` | Ver factura | +| Configuracion CFDI | `/invoicing/config` | Config emisor/PAC | + +#### Reportes (RT-008) +| Pagina | Ruta | Descripcion | +|--------|------|-------------| +| Reporte Ventas | `/reports/sales` | Reporte de ventas | +| Reporte Productos | `/reports/products` | Top vendidos, ABC | +| Reporte Inventario | `/reports/inventory` | Stock, valoracion | +| Reporte Clientes | `/reports/customers` | Metricas clientes | +| Reporte Caja | `/reports/cash` | Cortes, diferencias | + +#### Configuracion +| Pagina | Ruta | Descripcion | +|--------|------|-------------| +| Sucursales | `/settings/branches` | Gestionar sucursales | +| Cajas | `/settings/registers` | Cajas registradoras | +| Usuarios | `/settings/users` | Usuarios y permisos | + +### 3.2 Componentes Especificos + +```typescript +// Dashboard Components +components/ +├── dashboard/ +│ ├── KPICard.tsx +│ ├── SalesChart.tsx +│ ├── TopProductsChart.tsx +│ ├── PaymentMethodPie.tsx +│ ├── AlertsBadge.tsx +│ └── QuickActions.tsx + +// Inventory Components +├── inventory/ +│ ├── StockTable.tsx +│ ├── BranchStockCard.tsx +│ ├── TransferForm.tsx +│ ├── TransferTimeline.tsx +│ ├── AdjustmentForm.tsx +│ └── ProductSelector.tsx + +// Customer Components +├── customers/ +│ ├── CustomerForm.tsx +│ ├── MembershipCard.tsx +│ ├── PointsHistory.tsx +│ ├── LoyaltyProgramForm.tsx +│ └── LevelBadge.tsx + +// Pricing Components +├── pricing/ +│ ├── PromotionForm.tsx +│ ├── PromotionCard.tsx +│ ├── CouponGenerator.tsx +│ ├── CouponCard.tsx +│ └── PricelistEditor.tsx + +// Reports Components +├── reports/ +│ ├── DateRangePicker.tsx +│ ├── BranchSelector.tsx +│ ├── ExportButtons.tsx +│ ├── ReportTable.tsx +│ └── ChartWrapper.tsx +``` + +### 3.3 Estado Global (Zustand) + +```typescript +// stores/index.ts +export { useAuthStore } from './authStore'; +export { useTenantStore } from './tenantStore'; +export { useBranchStore } from './branchStore'; +export { useUIStore } from './uiStore'; + +// stores/branchStore.ts +interface BranchState { + currentBranch: Branch | null; + branches: Branch[]; + setBranch: (branch: Branch) => void; + loadBranches: () => Promise; +} + +export const useBranchStore = create((set) => ({ + currentBranch: null, + branches: [], + setBranch: (branch) => { + set({ currentBranch: branch }); + localStorage.setItem('currentBranchId', branch.id); + api.setHeader('x-branch-id', branch.id); + }, + loadBranches: async () => { + const branches = await api.branches.list(); + set({ branches }); + }, +})); +``` + +--- + +## 4. APP: POS (Punto de Venta - PWA) + +### 4.1 Paginas + +| Pagina | Ruta | Descripcion | +|--------|------|-------------| +| Login | `/login` | Autenticacion | +| Seleccion Caja | `/select-register` | Elegir caja | +| Apertura | `/open-session` | Abrir caja | +| Ventas | `/sales` | Pantalla principal POS | +| Cobro | `/sales/checkout` | Pantalla de cobro | +| Buscar Producto | `/sales/search` | Busqueda rapida | +| Movimientos Caja | `/cash/movements` | Entradas/salidas | +| Arqueo | `/cash/count` | Arqueo parcial | +| Cierre | `/cash/close` | Corte de caja | +| Historial | `/history` | Ventas del dia | +| Detalle Venta | `/history/:id` | Ver venta | +| Facturar | `/invoice/:orderId` | Generar factura | +| Offline Queue | `/offline` | Cola de sincronizacion | + +### 4.2 Layout POS + +```tsx +// layouts/POSLayout.tsx +const POSLayout: React.FC = ({ children }) => { + const { session, isOffline } = usePOSStore(); + const { syncPending } = useOfflineStore(); + + return ( +
+ {/* Header */} +
+
+ + {session?.branch.name} + Caja: {session?.register.name} +
+ +
+ {/* Indicador Offline */} + {isOffline && ( + + + Modo Offline + + )} + + {/* Pendientes de sync */} + {syncPending > 0 && ( + + {syncPending} pendientes + + )} + + +
+
+ + {/* Main content */} +
+ {children} +
+ + {/* Footer con acciones rapidas */} +
+
+ } label="Historial" to="/history" /> + } label="Movimientos" to="/cash/movements" /> + } label="Arqueo" to="/cash/count" /> +
+ +
+
+ ); +}; +``` + +### 4.3 Pantalla Principal de Ventas + +```tsx +// pages/Sales.tsx +const SalesPage: React.FC = () => { + const { currentOrder, addLine, removeLine, setCustomer } = usePOSStore(); + + return ( +
+ {/* Panel izquierdo - Busqueda y productos */} +
+ {/* Barra de busqueda */} +
+ +
+ + {/* Grid de categorias y productos */} +
+ + +
+ + {/* Teclado numerico (opcional, para touch) */} + {showNumpad && } +
+ + {/* Panel derecho - Carrito */} +
+ {/* Cliente */} +
+ +
+ + {/* Lineas de orden */} +
+ +
+ + {/* Totales */} +
+ +
+ + {/* Botones de accion */} +
+ + +
+
+
+ ); +}; +``` + +### 4.4 Pantalla de Cobro + +```tsx +// pages/Checkout.tsx +const CheckoutPage: React.FC = () => { + const { currentOrder, processPayment } = usePOSStore(); + const [payments, setPayments] = useState([]); + const [amountDue, setAmountDue] = useState(currentOrder?.total || 0); + + const handleAddPayment = (method: PaymentMethod, amount: number) => { + setPayments([...payments, { method, amount }]); + setAmountDue(prev => Math.max(0, prev - amount)); + }; + + const handleComplete = async () => { + await processPayment(payments); + // Imprimir ticket + await printer.printReceipt(currentOrder); + // Abrir cajon + await drawer.open(); + // Regresar a ventas + navigate('/sales'); + }; + + return ( +
+ {/* Resumen de orden */} +
+

Resumen

+ + +
+

Pagos

+ +
+ +
+
+ Por pagar: + 0 ? 'text-yellow-400' : 'text-green-400'}> + ${amountDue.toFixed(2)} + +
+
+
+ + {/* Metodos de pago */} +
+

Metodo de Pago

+ +
+ } + label="Efectivo" + onClick={() => setSelectedMethod('cash')} + selected={selectedMethod === 'cash'} + /> + } + label="Tarjeta" + onClick={() => setSelectedMethod('card')} + selected={selectedMethod === 'card'} + /> + } + label="Transferencia" + onClick={() => setSelectedMethod('transfer')} + selected={selectedMethod === 'transfer'} + /> + } + label="Puntos" + onClick={() => setSelectedMethod('points')} + selected={selectedMethod === 'points'} + disabled={!currentOrder?.customer} + /> +
+ + {/* Input de monto o teclado numerico */} + {selectedMethod === 'cash' && ( + handleAddPayment('cash', amount)} + /> + )} + + {selectedMethod === 'card' && ( + handleAddPayment('card', amount, ref)} + /> + )} + + {selectedMethod === 'points' && ( + handleAddPayment('points', discount)} + /> + )} + + {/* Boton finalizar */} +
+ +
+
+
+ ); +}; +``` + +### 4.5 Offline Support (PWA) + +```typescript +// offline/db.ts - IndexedDB con Dexie +import Dexie, { Table } from 'dexie'; + +export interface OfflineOrder { + offlineId: string; + orderData: any; + createdAt: Date; + syncStatus: 'pending' | 'syncing' | 'synced' | 'error'; + errorMessage?: string; +} + +export interface CachedProduct { + id: string; + data: any; + updatedAt: Date; +} + +class RetailDatabase extends Dexie { + offlineOrders!: Table; + cachedProducts!: Table; + cachedCustomers!: Table; + + constructor() { + super('RetailPOS'); + this.version(1).stores({ + offlineOrders: 'offlineId, syncStatus, createdAt', + cachedProducts: 'id, updatedAt', + cachedCustomers: 'id, phone, updatedAt', + }); + } +} + +export const db = new RetailDatabase(); + +// offline/sync.ts - Sincronizacion +export class SyncService { + private ws: WebSocket | null = null; + private isOnline = navigator.onLine; + + constructor() { + window.addEventListener('online', () => this.handleOnline()); + window.addEventListener('offline', () => this.handleOffline()); + } + + async syncPendingOrders(): Promise { + const pendingOrders = await db.offlineOrders + .where('syncStatus') + .equals('pending') + .toArray(); + + const results: SyncResult = { synced: [], failed: [] }; + + for (const order of pendingOrders) { + try { + await db.offlineOrders.update(order.offlineId, { syncStatus: 'syncing' }); + + const response = await api.pos.syncOrder(order); + + await db.offlineOrders.update(order.offlineId, { + syncStatus: 'synced', + serverOrderId: response.orderId, + }); + + results.synced.push(order.offlineId); + } catch (error) { + await db.offlineOrders.update(order.offlineId, { + syncStatus: 'error', + errorMessage: error.message, + }); + results.failed.push({ offlineId: order.offlineId, error: error.message }); + } + } + + return results; + } + + private handleOnline() { + this.isOnline = true; + this.syncPendingOrders(); + this.connectWebSocket(); + } + + private handleOffline() { + this.isOnline = false; + this.ws?.close(); + } +} + +// offline/queue.ts - Cola de operaciones offline +export async function saveOfflineOrder(orderData: any): Promise { + const offlineId = `offline_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; + + await db.offlineOrders.add({ + offlineId, + orderData, + createdAt: new Date(), + syncStatus: 'pending', + }); + + return offlineId; +} + +// sw.ts - Service Worker +/// +declare const self: ServiceWorkerGlobalScope; + +import { precacheAndRoute } from 'workbox-precaching'; +import { registerRoute } from 'workbox-routing'; +import { StaleWhileRevalidate, CacheFirst } from 'workbox-strategies'; + +// Precache static assets +precacheAndRoute(self.__WB_MANIFEST); + +// Cache API responses +registerRoute( + ({ url }) => url.pathname.startsWith('/api/products'), + new StaleWhileRevalidate({ + cacheName: 'products-cache', + }) +); + +// Cache images +registerRoute( + ({ request }) => request.destination === 'image', + new CacheFirst({ + cacheName: 'images-cache', + plugins: [ + { + cacheWillUpdate: async ({ response }) => { + if (response.ok) return response; + return null; + }, + }, + ], + }) +); + +// Background sync for orders +self.addEventListener('sync', (event) => { + if (event.tag === 'sync-orders') { + event.waitUntil(syncOrders()); + } +}); + +async function syncOrders() { + const db = await openDB(); + const pendingOrders = await db.getAll('offlineOrders'); + + for (const order of pendingOrders) { + try { + await fetch('/api/pos/sync', { + method: 'POST', + body: JSON.stringify(order), + headers: { 'Content-Type': 'application/json' }, + }); + await db.delete('offlineOrders', order.offlineId); + } catch (e) { + console.error('Sync failed:', e); + } + } +} +``` + +### 4.6 Hardware Integration + +```typescript +// hardware/printer.ts - Impresion ESC/POS +export class ThermalPrinter { + private encoder: EscPosEncoder; + + constructor() { + this.encoder = new EscPosEncoder(); + } + + async printReceipt(order: POSOrder, config: PrintConfig): Promise { + const receiptData = this.encoder + .initialize() + .align('center') + .bold(true) + .text(config.storeName) + .bold(false) + .text(config.storeAddress) + .text(`Tel: ${config.storePhone}`) + .text(`RFC: ${config.storeRFC}`) + .newline() + .align('left') + .text(`Ticket: ${order.orderNumber}`) + .text(`Fecha: ${formatDate(order.orderDate)}`) + .text(`Cajero: ${order.cashierName}`) + .text(order.customer ? `Cliente: ${order.customer.name}` : '') + .newline() + .text('='.repeat(32)) + .newline(); + + // Lineas + for (const line of order.lines) { + receiptData + .text(`${line.quantity} x ${line.productName}`) + .align('right') + .text(`$${line.total.toFixed(2)}`) + .align('left'); + } + + receiptData + .newline() + .text('-'.repeat(32)) + .align('right') + .text(`Subtotal: $${order.subtotal.toFixed(2)}`) + .text(`IVA: $${order.taxAmount.toFixed(2)}`) + .bold(true) + .text(`TOTAL: $${order.total.toFixed(2)}`) + .bold(false) + .newline(); + + // Pagos + for (const payment of order.payments) { + receiptData.text(`${payment.method}: $${payment.amount.toFixed(2)}`); + } + if (order.changeAmount > 0) { + receiptData.text(`Cambio: $${order.changeAmount.toFixed(2)}`); + } + + receiptData + .newline() + .align('center') + .text('Gracias por su compra!') + .qrcode(order.ticketUrl, 1, 4, 'l') + .cut(); + + // Enviar a impresora + const data = receiptData.encode(); + await this.send(data); + } + + private async send(data: Uint8Array): Promise { + // USB + if (this.connection === 'usb') { + const device = await navigator.usb.requestDevice({ + filters: [{ vendorId: 0x04b8 }] // Epson + }); + await device.open(); + await device.transferOut(1, data); + } + + // Bluetooth + if (this.connection === 'bluetooth') { + const device = await navigator.bluetooth.requestDevice({ + filters: [{ services: ['000018f0-0000-1000-8000-00805f9b34fb'] }] + }); + const server = await device.gatt.connect(); + const service = await server.getPrimaryService('000018f0-0000-1000-8000-00805f9b34fb'); + const characteristic = await service.getCharacteristic('00002af1-0000-1000-8000-00805f9b34fb'); + await characteristic.writeValue(data); + } + + // Network + if (this.connection === 'network') { + await fetch(`http://${this.printerIp}/print`, { + method: 'POST', + body: data, + }); + } + } +} + +// hardware/scanner.ts - Lector de codigo de barras +export class BarcodeScanner { + private buffer = ''; + private timeoutId: number | null = null; + + constructor(private onScan: (barcode: string) => void) { + document.addEventListener('keydown', this.handleKeyDown.bind(this)); + } + + private handleKeyDown(e: KeyboardEvent) { + // Los scanners envian caracteres muy rapido seguidos de Enter + if (e.key === 'Enter' && this.buffer.length > 0) { + this.onScan(this.buffer); + this.buffer = ''; + e.preventDefault(); + return; + } + + // Acumular caracteres + if (e.key.length === 1) { + this.buffer += e.key; + + // Limpiar buffer despues de timeout (input manual) + if (this.timeoutId) clearTimeout(this.timeoutId); + this.timeoutId = window.setTimeout(() => { + this.buffer = ''; + }, 100); + } + } + + destroy() { + document.removeEventListener('keydown', this.handleKeyDown); + } +} + +// hardware/drawer.ts - Cajon de dinero +export class CashDrawer { + async open(): Promise { + // Comando ESC/POS para abrir cajon + const openCommand = new Uint8Array([0x1B, 0x70, 0x00, 0x19, 0xFA]); + + // Enviar via impresora (el cajon se conecta a la impresora) + await printer.sendRaw(openCommand); + } +} +``` + +### 4.7 Estado POS (Zustand) + +```typescript +// store/posStore.ts +interface POSState { + session: POSSession | null; + currentOrder: POSOrder | null; + heldOrders: POSOrder[]; + isOffline: boolean; + + // Session actions + openSession: (dto: OpenSessionDto) => Promise; + closeSession: (dto: CloseSessionDto) => Promise; + + // Order actions + createOrder: () => void; + addLine: (product: Product, quantity: number) => void; + updateLineQty: (lineId: string, quantity: number) => void; + removeLine: (lineId: string) => void; + setCustomer: (customer: Customer | null) => void; + applyDiscount: (type: 'percent' | 'amount', value: number) => void; + applyCoupon: (code: string) => Promise; + holdOrder: () => void; + recallOrder: (orderId: string) => void; + cancelOrder: () => void; + + // Payment + processPayment: (payments: Payment[]) => Promise; +} + +export const usePOSStore = create((set, get) => ({ + session: null, + currentOrder: null, + heldOrders: [], + isOffline: !navigator.onLine, + + addLine: (product, quantity) => { + const { currentOrder } = get(); + if (!currentOrder) return; + + const existingLine = currentOrder.lines.find(l => l.productId === product.id); + + if (existingLine) { + // Incrementar cantidad + set(state => ({ + currentOrder: { + ...state.currentOrder!, + lines: state.currentOrder!.lines.map(l => + l.id === existingLine.id + ? { ...l, quantity: l.quantity + quantity, total: (l.quantity + quantity) * l.unitPrice } + : l + ), + }, + })); + } else { + // Nueva linea + const newLine: POSOrderLine = { + id: crypto.randomUUID(), + productId: product.id, + productName: product.name, + quantity, + unitPrice: product.salePrice, + discountPercent: 0, + discountAmount: 0, + taxAmount: product.salePrice * quantity * 0.16, // IVA + total: product.salePrice * quantity * 1.16, + }; + + set(state => ({ + currentOrder: { + ...state.currentOrder!, + lines: [...state.currentOrder!.lines, newLine], + }, + })); + } + + // Recalcular totales + get().recalculateTotals(); + }, + + processPayment: async (payments) => { + const { currentOrder, session, isOffline } = get(); + if (!currentOrder || !session) throw new Error('No order'); + + const orderData = { + ...currentOrder, + payments, + status: 'done', + }; + + if (isOffline) { + // Guardar offline + const offlineId = await saveOfflineOrder(orderData); + orderData.offlineId = offlineId; + + // Limpiar orden actual + set({ currentOrder: null }); + get().createOrder(); + + return orderData; + } + + // Online - enviar al servidor + const confirmedOrder = await api.pos.confirmOrder(session.id, orderData); + + set({ currentOrder: null }); + get().createOrder(); + + return confirmedOrder; + }, + + recalculateTotals: () => { + set(state => { + if (!state.currentOrder) return state; + + const subtotal = state.currentOrder.lines.reduce((sum, l) => sum + (l.unitPrice * l.quantity), 0); + const discountAmount = state.currentOrder.lines.reduce((sum, l) => sum + l.discountAmount, 0); + const taxAmount = state.currentOrder.lines.reduce((sum, l) => sum + l.taxAmount, 0); + const total = subtotal - discountAmount + taxAmount; + + return { + currentOrder: { + ...state.currentOrder, + subtotal, + discountAmount, + taxAmount, + total, + }, + }; + }); + }, +})); +``` + +--- + +## 5. APP: STOREFRONT (E-commerce) + +### 5.1 Paginas + +| Pagina | Ruta | Descripcion | +|--------|------|-------------| +| Home | `/` | Landing con destacados | +| Catalogo | `/products` | Lista de productos | +| Producto | `/products/:id` | Detalle de producto | +| Categoria | `/category/:slug` | Productos por categoria | +| Busqueda | `/search` | Resultados de busqueda | +| Carrito | `/cart` | Ver carrito | +| Checkout | `/checkout` | Proceso de compra | +| Confirmacion | `/checkout/confirm` | Confirmacion de orden | +| Login | `/login` | Iniciar sesion | +| Registro | `/register` | Crear cuenta | +| Mi Cuenta | `/account` | Dashboard cliente | +| Mis Pedidos | `/account/orders` | Lista de pedidos | +| Detalle Pedido | `/account/orders/:id` | Ver pedido | +| Direcciones | `/account/addresses` | Gestionar direcciones | +| Mis Puntos | `/account/loyalty` | Puntos de lealtad | + +### 5.2 Componentes E-commerce + +```typescript +components/ +├── storefront/ +│ ├── Navbar.tsx +│ ├── Footer.tsx +│ ├── SearchBar.tsx +│ ├── CategoryNav.tsx +│ ├── ProductCard.tsx +│ ├── ProductGrid.tsx +│ ├── ProductGallery.tsx +│ ├── PriceDisplay.tsx # Muestra precio con promociones +│ ├── StockIndicator.tsx +│ ├── AddToCartButton.tsx +│ ├── CartDrawer.tsx +│ ├── CartItem.tsx +│ ├── CartSummary.tsx +│ ├── CheckoutSteps.tsx +│ ├── AddressForm.tsx +│ ├── ShippingOptions.tsx +│ ├── PaymentForm.tsx +│ ├── OrderSummary.tsx +│ ├── PromoBanner.tsx +│ └── LoyaltyWidget.tsx +``` + +### 5.3 Layout Storefront + +```tsx +// layouts/StoreLayout.tsx +const StoreLayout: React.FC = ({ children }) => { + const { itemCount } = useCartStore(); + const { customer } = useAuthStore(); + + return ( +
+ {/* Promo banner */} + + + {/* Header */} +
+
+
+ + + +
+ + {/* Categoria nav */} + +
+
+ + {/* Main */} +
+ {children} +
+ + {/* Footer */} +
+ + {/* Cart drawer (side panel) */} + +
+ ); +}; +``` + +### 5.4 Pagina de Producto + +```tsx +// pages/ProductDetail.tsx +const ProductDetailPage: React.FC = () => { + const { id } = useParams(); + const { data: product, isLoading } = useProduct(id); + const { addItem } = useCartStore(); + const [quantity, setQuantity] = useState(1); + const [selectedVariant, setSelectedVariant] = useState(null); + + if (isLoading) return ; + + return ( +
+ + +
+ {/* Galeria de imagenes */} + + + {/* Informacion */} +
+

{product.name}

+ + {/* Rating */} +
+ + ({product.reviewCount} resenas) +
+ + {/* Precio */} +
+ +
+ + {/* Stock */} + + + {/* Variantes */} + {product.variants?.length > 0 && ( + + )} + + {/* Cantidad */} +
+ + +
+ + {/* Botones */} +
+ + +
+ + {/* Descripcion */} +
+

Descripcion

+

{product.description}

+
+ + {/* Especificaciones */} +
+

Especificaciones

+
+ {product.attributes?.map(attr => ( + +
{attr.name}
+
{attr.value}
+
+ ))} +
+
+
+
+ + {/* Productos relacionados */} +
+

Productos relacionados

+ +
+
+ ); +}; +``` + +### 5.5 Checkout Flow + +```tsx +// pages/Checkout.tsx +const CheckoutPage: React.FC = () => { + const [step, setStep] = useState<'address' | 'shipping' | 'payment' | 'review'>('address'); + const { items, subtotal } = useCartStore(); + const { customer } = useAuthStore(); + const [checkoutData, setCheckoutData] = useState({}); + + const steps = [ + { id: 'address', label: 'Direccion', icon: MapPin }, + { id: 'shipping', label: 'Envio', icon: Truck }, + { id: 'payment', label: 'Pago', icon: CreditCard }, + { id: 'review', label: 'Confirmar', icon: CheckCircle }, + ]; + + return ( +
+ {/* Progress steps */} + + +
+ {/* Main content */} +
+ {step === 'address' && ( + setCheckoutData({ ...checkoutData, addressId })} + onNewAddress={(address) => {/* save */}} + onContinue={() => setStep('shipping')} + /> + )} + + {step === 'shipping' && ( + setCheckoutData({ ...checkoutData, shippingRateId: rateId })} + onBack={() => setStep('address')} + onContinue={() => setStep('payment')} + /> + )} + + {step === 'payment' && ( + setCheckoutData({ ...checkoutData, paymentMethod: method })} + onBack={() => setStep('shipping')} + onContinue={() => setStep('review')} + /> + )} + + {step === 'review' && ( + setStep('payment')} + onConfirm={handleConfirm} + /> + )} +
+ + {/* Order summary sidebar */} +
+
+

Resumen del pedido

+ + {/* Items */} +
+ {items.map(item => ( +
+ +
+

{item.product.name}

+

Cant: {item.quantity}

+
+

${item.total.toFixed(2)}

+
+ ))} +
+ + + + {/* Totals */} +
+
+ Subtotal + ${subtotal.toFixed(2)} +
+ {checkoutData.shippingRate && ( +
+ Envio + ${checkoutData.shippingRate.price.toFixed(2)} +
+ )} +
+ Total + ${total.toFixed(2)} +
+
+
+
+
+
+ ); +}; +``` + +--- + +## 6. COMPONENTES COMPARTIDOS + +### 6.1 UI Components Library + +```typescript +// packages/ui-components/src/components/ + +// Button.tsx +export interface ButtonProps extends React.ButtonHTMLAttributes { + variant?: 'primary' | 'secondary' | 'outline' | 'ghost' | 'danger' | 'success'; + size?: 'sm' | 'md' | 'lg' | 'xl'; + loading?: boolean; + icon?: React.ReactNode; +} + +// Input.tsx +export interface InputProps extends React.InputHTMLAttributes { + label?: string; + error?: string; + helperText?: string; + leftIcon?: React.ReactNode; + rightIcon?: React.ReactNode; +} + +// Table.tsx +export interface TableProps { + data: T[]; + columns: Column[]; + loading?: boolean; + pagination?: PaginationConfig; + sorting?: SortingConfig; + selection?: SelectionConfig; + emptyState?: React.ReactNode; +} + +// Modal.tsx +export interface ModalProps { + open: boolean; + onClose: () => void; + title?: string; + description?: string; + size?: 'sm' | 'md' | 'lg' | 'xl' | 'full'; + children: React.ReactNode; + footer?: React.ReactNode; +} + +// Toast.tsx (con Zustand store) +export interface ToastProps { + id: string; + type: 'success' | 'error' | 'warning' | 'info'; + title: string; + description?: string; + duration?: number; +} + +// Select.tsx +export interface SelectProps { + options: T[]; + value: T | null; + onChange: (value: T) => void; + getLabel: (item: T) => string; + getValue: (item: T) => string; + placeholder?: string; + searchable?: boolean; + loading?: boolean; + error?: string; +} + +// DatePicker.tsx +export interface DatePickerProps { + value: Date | null; + onChange: (date: Date | null) => void; + minDate?: Date; + maxDate?: Date; + format?: string; + placeholder?: string; +} + +// DateRangePicker.tsx +export interface DateRangePickerProps { + value: { from: Date; to: Date } | null; + onChange: (range: { from: Date; to: Date } | null) => void; + presets?: DateRangePreset[]; +} +``` + +### 6.2 Hooks Compartidos + +```typescript +// packages/hooks/src/ + +// useAuth.ts +export function useAuth() { + const { user, token, login, logout, isAuthenticated } = useAuthStore(); + + const loginMutation = useMutation({ + mutationFn: api.auth.login, + onSuccess: (data) => { + login(data.user, data.token); + }, + }); + + return { + user, + isAuthenticated, + login: loginMutation.mutate, + logout, + isLoading: loginMutation.isLoading, + }; +} + +// usePagination.ts +export function usePagination( + fetcher: (params: PaginationParams) => Promise>, + initialParams?: Partial +) { + const [page, setPage] = useState(initialParams?.page || 1); + const [limit, setLimit] = useState(initialParams?.limit || 20); + + const query = useQuery({ + queryKey: ['paginated', fetcher.name, page, limit], + queryFn: () => fetcher({ page, limit }), + }); + + return { + data: query.data?.items || [], + total: query.data?.total || 0, + page, + limit, + totalPages: Math.ceil((query.data?.total || 0) / limit), + setPage, + setLimit, + isLoading: query.isLoading, + refetch: query.refetch, + }; +} + +// useDebounce.ts +export function useDebounce(value: T, delay: number): T { + const [debouncedValue, setDebouncedValue] = useState(value); + + useEffect(() => { + const timer = setTimeout(() => setDebouncedValue(value), delay); + return () => clearTimeout(timer); + }, [value, delay]); + + return debouncedValue; +} + +// useOffline.ts +export function useOffline() { + const [isOffline, setIsOffline] = useState(!navigator.onLine); + + useEffect(() => { + const handleOnline = () => setIsOffline(false); + const handleOffline = () => setIsOffline(true); + + window.addEventListener('online', handleOnline); + window.addEventListener('offline', handleOffline); + + return () => { + window.removeEventListener('online', handleOnline); + window.removeEventListener('offline', handleOffline); + }; + }, []); + + return isOffline; +} +``` + +### 6.3 API Client + +```typescript +// packages/api-client/src/client.ts +import axios, { AxiosInstance } from 'axios'; + +class ApiClient { + private client: AxiosInstance; + private tenantId: string | null = null; + private branchId: string | null = null; + + constructor() { + this.client = axios.create({ + baseURL: import.meta.env.VITE_API_URL, + }); + + // Request interceptor + this.client.interceptors.request.use((config) => { + const token = localStorage.getItem('token'); + if (token) { + config.headers.Authorization = `Bearer ${token}`; + } + if (this.tenantId) { + config.headers['x-tenant-id'] = this.tenantId; + } + if (this.branchId) { + config.headers['x-branch-id'] = this.branchId; + } + return config; + }); + + // Response interceptor + this.client.interceptors.response.use( + (response) => response, + (error) => { + if (error.response?.status === 401) { + // Token expired, logout + localStorage.removeItem('token'); + window.location.href = '/login'; + } + return Promise.reject(error); + } + ); + } + + setTenant(tenantId: string) { + this.tenantId = tenantId; + } + + setBranch(branchId: string) { + this.branchId = branchId; + } + + // Auth + auth = { + login: (email: string, password: string) => + this.client.post('/auth/login', { email, password }).then(r => r.data), + register: (data: RegisterDto) => + this.client.post('/auth/register', data).then(r => r.data), + me: () => + this.client.get('/auth/me').then(r => r.data), + }; + + // Products + products = { + list: (params?: ProductFilters) => + this.client.get('/products', { params }).then(r => r.data), + get: (id: string) => + this.client.get(`/products/${id}`).then(r => r.data), + create: (data: CreateProductDto) => + this.client.post('/products', data).then(r => r.data), + update: (id: string, data: UpdateProductDto) => + this.client.put(`/products/${id}`, data).then(r => r.data), + }; + + // POS + pos = { + openSession: (data: OpenSessionDto) => + this.client.post('/pos/sessions/open', data).then(r => r.data), + getSession: () => + this.client.get('/pos/sessions/active').then(r => r.data), + closeSession: (data: CloseSessionDto) => + this.client.post('/pos/sessions/close', data).then(r => r.data), + createOrder: (sessionId: string) => + this.client.post(`/pos/sessions/${sessionId}/orders`).then(r => r.data), + confirmOrder: (orderId: string, data: ConfirmOrderDto) => + this.client.post(`/pos/orders/${orderId}/confirm`, data).then(r => r.data), + syncOrder: (data: OfflineOrderDto) => + this.client.post('/pos/sync', data).then(r => r.data), + }; + + // ... mas endpoints +} + +export const api = new ApiClient(); +``` + +--- + +## 7. CHECKLIST DE IMPLEMENTACION + +### Packages Compartidos +- [ ] @retail/ui-components + - [ ] Button, Input, Select + - [ ] Table, Modal, Toast + - [ ] Card, Badge, Spinner + - [ ] DatePicker, DateRangePicker +- [ ] @retail/api-client +- [ ] @retail/hooks + +### App Backoffice +- [ ] Layout y navegacion +- [ ] Dashboard +- [ ] Modulo Inventario +- [ ] Modulo Productos +- [ ] Modulo Clientes +- [ ] Modulo Precios +- [ ] Modulo Compras +- [ ] Modulo E-commerce Admin +- [ ] Modulo Facturacion +- [ ] Modulo Reportes +- [ ] Modulo Configuracion + +### App POS +- [ ] Layout POS +- [ ] Login y seleccion de caja +- [ ] Apertura de caja +- [ ] Pantalla de ventas +- [ ] Pantalla de cobro +- [ ] Movimientos de caja +- [ ] Cierre de caja +- [ ] PWA y Service Worker +- [ ] IndexedDB y offline queue +- [ ] Sincronizacion +- [ ] Integracion impresora +- [ ] Integracion scanner + +### App Storefront +- [ ] Layout tienda +- [ ] Home +- [ ] Catalogo y busqueda +- [ ] Detalle de producto +- [ ] Carrito +- [ ] Checkout flow +- [ ] Area de cliente +- [ ] Responsive design + +--- + +**Estado:** PLAN COMPLETO +**Total paginas:** ~65 +**Total componentes:** ~80 diff --git a/orchestration/planes/fase-3-implementacion/ROADMAP-SPRINTS.md b/orchestration/planes/fase-3-implementacion/ROADMAP-SPRINTS.md new file mode 100644 index 0000000..5f67861 --- /dev/null +++ b/orchestration/planes/fase-3-implementacion/ROADMAP-SPRINTS.md @@ -0,0 +1,577 @@ +# ROADMAP DE SPRINTS - RETAIL + +**Fecha:** 2025-12-18 +**Fase:** 3 - Plan de Implementaciones +**Metodologia:** Sprints de 1 semana +**Total Story Points:** 353 SP + +--- + +## 1. RESUMEN EJECUTIVO + +### 1.1 Distribucion de Esfuerzo + +| Modulo | SP | % del Total | Prioridad | +|--------|----|-----------:|-----------| +| RT-001 Fundamentos | 0* | 0% | P0 | +| RT-002 POS | 55 | 15.6% | P0 | +| RT-003 Inventario | 42 | 11.9% | P0 | +| RT-004 Compras | 38 | 10.8% | P1 | +| RT-005 Clientes | 34 | 9.6% | P1 | +| RT-006 Precios | 36 | 10.2% | P0 | +| RT-007 Caja | 28 | 7.9% | P0 | +| RT-008 Reportes | 30 | 8.5% | P1 | +| RT-009 E-commerce | 55 | 15.6% | P2 | +| RT-010 Facturacion | 35 | 9.9% | P0 | +| **TOTAL** | **353** | **100%** | | + +*RT-001 hereda 100% del core + +### 1.2 Prerrequisitos Core (Bloqueadores) + +Antes de iniciar retail, se deben completar estos modulos del core: + +| Modulo Core | Estado Actual | Requerido | Gap | +|-------------|---------------|-----------|-----| +| MGN-001 Auth | 40% | 100% | 60% | +| MGN-005 Catalogs | 0% | 100% | 100% | +| MGN-010 Financial | 70% | 90% | 20% | +| MGN-011 Inventory | 60% | 80% | 20% | +| MGN-013 Sales | 50% | 70% | 20% | + +**Estimacion prerrequisitos:** 3-4 sprints + +--- + +## 2. FASES DE IMPLEMENTACION + +### 2.1 Vision General + +``` +┌─────────────────────────────────────────────────────────────────────────────┐ +│ ROADMAP RETAIL - 20 SPRINTS │ +├─────────────────────────────────────────────────────────────────────────────┤ +│ │ +│ FASE 0: PRERREQUISITOS CORE (Sprints 1-4) │ +│ ├── S1-S2: Auth + Catalogs │ +│ └── S3-S4: Financial + Inventory + Sales │ +│ │ +│ FASE 1: FUNDAMENTOS RETAIL (Sprints 5-7) │ +│ ├── S5: Database retail + RT-001 Fundamentos │ +│ ├── S6: RT-006 Motor de Precios │ +│ └── S7: RT-003 Inventario Multi-sucursal │ +│ │ +│ FASE 2: POS MVP (Sprints 8-11) │ +│ ├── S8-S9: RT-002 POS Backend + Frontend │ +│ ├── S10: RT-007 Caja │ +│ └── S11: PWA + Offline │ +│ │ +│ FASE 3: FACTURACION + CLIENTES (Sprints 12-14) │ +│ ├── S12: RT-010 CFDI Backend │ +│ ├── S13: RT-010 CFDI Frontend + Autofactura │ +│ └── S14: RT-005 Clientes + Lealtad │ +│ │ +│ FASE 4: COMPRAS + REPORTES (Sprints 15-16) │ +│ ├── S15: RT-004 Compras │ +│ └── S16: RT-008 Reportes + Dashboard │ +│ │ +│ FASE 5: E-COMMERCE (Sprints 17-20) │ +│ ├── S17-S18: RT-009 Backend + Integraciones │ +│ └── S19-S20: RT-009 Storefront │ +│ │ +└─────────────────────────────────────────────────────────────────────────────┘ +``` + +--- + +## 3. DETALLE POR SPRINT + +### FASE 0: PRERREQUISITOS CORE + +#### Sprint 1: Auth + Catalogs (Parte 1) +**Objetivo:** Completar autenticacion y catalogos base + +| Tarea | Capa | SP | +|-------|------|---:| +| Completar entities User, Role, Permission | Backend | 3 | +| Completar AuthService con JWT | Backend | 3 | +| Implementar middleware de autenticacion | Backend | 2 | +| Crear tablas core.countries, currencies | Database | 2 | +| Implementar CountriesService, CurrenciesService | Backend | 2 | +| **Total Sprint 1** | | **12** | + +#### Sprint 2: Catalogs (Parte 2) +**Objetivo:** Completar catalogos y UoM + +| Tarea | Capa | SP | +|-------|------|---:| +| Crear tablas core.uom_categories, uom | Database | 2 | +| Implementar UoMService | Backend | 2 | +| Crear core.sequences | Database | 1 | +| Implementar SequencesService | Backend | 2 | +| Crear tablas core.partners | Database | 2 | +| Implementar PartnersService | Backend | 3 | +| **Total Sprint 2** | | **12** | + +#### Sprint 3: Financial +**Objetivo:** Completar modulo financiero base + +| Tarea | Capa | SP | +|-------|------|---:| +| Migrar financial.taxes a TypeORM | Backend | 3 | +| Migrar financial.accounts a TypeORM | Backend | 3 | +| Migrar financial.journals a TypeORM | Backend | 2 | +| Implementar payment_methods | Backend | 2 | +| Tests unitarios financial | Backend | 2 | +| **Total Sprint 3** | | **12** | + +#### Sprint 4: Inventory + Sales +**Objetivo:** Completar inventario y ventas base + +| Tarea | Capa | SP | +|-------|------|---:| +| Completar inventory.products con TypeORM | Backend | 3 | +| Completar inventory.warehouses | Backend | 2 | +| Completar inventory.stock_quants | Backend | 3 | +| Implementar sales.pricelists | Backend | 3 | +| Implementar sales.pricelist_items | Backend | 2 | +| **Total Sprint 4** | | **13** | + +--- + +### FASE 1: FUNDAMENTOS RETAIL + +#### Sprint 5: Database Retail + Fundamentos +**Objetivo:** Crear schema retail y tablas fundacionales + +| Tarea | Capa | SP | +|-------|------|---:| +| Crear schema retail | Database | 1 | +| Crear retail.branches | Database | 2 | +| Crear retail.cash_registers | Database | 1 | +| Crear retail.branch_users | Database | 1 | +| BranchesService | Backend | 3 | +| CashRegistersService | Backend | 2 | +| BranchesController | Backend | 2 | +| **Total Sprint 5** | | **12** | + +#### Sprint 6: Motor de Precios +**Objetivo:** RT-006 - Sistema de precios y promociones + +| Tarea | Capa | SP | +|-------|------|---:| +| Crear retail.promotions, promotion_products | Database | 2 | +| Crear retail.coupons, coupon_redemptions | Database | 2 | +| PriceEngineService (core) | Backend | 8 | +| PromotionsService | Backend | 5 | +| CouponsService | Backend | 5 | +| PricingController | Backend | 3 | +| **Total Sprint 6** | | **25** | + +*Nota: Este sprint tiene mas SP porque el motor de precios es critico* + +#### Sprint 7: Inventario Multi-sucursal +**Objetivo:** RT-003 - Gestion de inventario por sucursal + +| Tarea | Capa | SP | +|-------|------|---:| +| Crear retail.stock_transfers | Database | 2 | +| Crear retail.stock_adjustments | Database | 2 | +| RetailStockService (extiende core) | Backend | 5 | +| TransfersService | Backend | 5 | +| AdjustmentsService | Backend | 3 | +| InventoryController | Backend | 3 | +| **Total Sprint 7** | | **20** | + +--- + +### FASE 2: POS MVP + +#### Sprint 8: POS Backend +**Objetivo:** RT-002 - Backend del punto de venta + +| Tarea | Capa | SP | +|-------|------|---:| +| Crear retail.pos_sessions | Database | 2 | +| Crear retail.pos_orders, pos_order_lines | Database | 3 | +| Crear retail.pos_payments | Database | 2 | +| POSSessionService | Backend | 5 | +| POSOrderService | Backend | 8 | +| POSPaymentService | Backend | 3 | +| POSController | Backend | 3 | +| **Total Sprint 8** | | **26** | + +#### Sprint 9: POS Frontend +**Objetivo:** UI del punto de venta + +| Tarea | Capa | SP | +|-------|------|---:| +| Layout POS | Frontend | 2 | +| Pagina Login + Seleccion Caja | Frontend | 2 | +| Pantalla de Ventas | Frontend | 8 | +| Pantalla de Cobro | Frontend | 5 | +| Componente ProductGrid | Frontend | 3 | +| Componente OrderLines | Frontend | 2 | +| Componente PaymentMethods | Frontend | 3 | +| **Total Sprint 9** | | **25** | + +#### Sprint 10: Caja +**Objetivo:** RT-007 - Arqueos y cortes de caja + +| Tarea | Capa | SP | +|-------|------|---:| +| Crear retail.cash_movements | Database | 1 | +| Crear retail.cash_closings | Database | 2 | +| Crear retail.cash_counts | Database | 1 | +| CashSessionService | Backend | 5 | +| CashMovementService | Backend | 3 | +| CashClosingService | Backend | 5 | +| CashController | Backend | 3 | +| UI Apertura Caja | Frontend | 2 | +| UI Movimientos | Frontend | 2 | +| UI Cierre con Denominaciones | Frontend | 3 | +| **Total Sprint 10** | | **27** | + +#### Sprint 11: PWA + Offline +**Objetivo:** Soporte offline para POS + +| Tarea | Capa | SP | +|-------|------|---:| +| Configurar Service Worker | Frontend | 3 | +| IndexedDB con Dexie | Frontend | 3 | +| Cola de sincronizacion | Frontend | 5 | +| POSSyncService | Backend | 5 | +| WebSocket Gateway | Backend | 3 | +| Tests de offline | Frontend | 2 | +| Indicadores de estado | Frontend | 2 | +| **Total Sprint 11** | | **23** | + +--- + +### FASE 3: FACTURACION + CLIENTES + +#### Sprint 12: CFDI Backend +**Objetivo:** RT-010 - Backend de facturacion CFDI 4.0 + +| Tarea | Capa | SP | +|-------|------|---:| +| Crear retail.cfdi_config | Database | 1 | +| Crear retail.cfdis | Database | 2 | +| CFDIService | Backend | 5 | +| CFDIBuilderService | Backend | 5 | +| XMLService | Backend | 3 | +| PACService (Finkok) | Backend | 5 | +| CFDIController | Backend | 3 | +| **Total Sprint 12** | | **24** | + +#### Sprint 13: CFDI Frontend + Autofactura +**Objetivo:** UI de facturacion y portal autofactura + +| Tarea | Capa | SP | +|-------|------|---:| +| PDFService | Backend | 2 | +| AutofacturaService | Backend | 3 | +| AutofacturaController | Backend | 2 | +| Modal Facturacion en POS | Frontend | 3 | +| Portal Autofactura (publico) | Frontend | 4 | +| Configuracion CFDI (backoffice) | Frontend | 2 | +| Lista de Facturas | Frontend | 2 | +| **Total Sprint 13** | | **18** | + +#### Sprint 14: Clientes + Lealtad +**Objetivo:** RT-005 - Gestion de clientes y programa de lealtad + +| Tarea | Capa | SP | +|-------|------|---:| +| Crear retail.loyalty_programs | Database | 1 | +| Crear retail.membership_levels | Database | 1 | +| Crear retail.loyalty_transactions | Database | 1 | +| Crear retail.customer_memberships | Database | 1 | +| RetailCustomersService | Backend | 3 | +| LoyaltyService | Backend | 8 | +| CustomersController | Backend | 3 | +| Lista Clientes (backoffice) | Frontend | 3 | +| Detalle Cliente con Membresia | Frontend | 3 | +| Config Programa Lealtad | Frontend | 2 | +| Widget Puntos en POS | Frontend | 2 | +| **Total Sprint 14** | | **28** | + +--- + +### FASE 4: COMPRAS + REPORTES + +#### Sprint 15: Compras +**Objetivo:** RT-004 - Gestion de compras y reabastecimiento + +| Tarea | Capa | SP | +|-------|------|---:| +| Crear retail.purchase_suggestions | Database | 2 | +| Crear retail.supplier_orders | Database | 2 | +| Crear retail.goods_receipts | Database | 2 | +| RetailPurchaseService | Backend | 5 | +| PurchaseSuggestionsService | Backend | 5 | +| GoodsReceiptService | Backend | 3 | +| PurchaseController | Backend | 3 | +| Lista Sugerencias (backoffice) | Frontend | 3 | +| Nueva Orden Proveedor | Frontend | 4 | +| Recepcion de Mercancia | Frontend | 3 | +| **Total Sprint 15** | | **32** | + +#### Sprint 16: Reportes + Dashboard +**Objetivo:** RT-008 - Dashboard y reportes + +| Tarea | Capa | SP | +|-------|------|---:| +| Crear vistas materializadas | Database | 2 | +| DashboardService | Backend | 3 | +| SalesReportService | Backend | 3 | +| ProductReportService | Backend | 2 | +| CashReportService | Backend | 2 | +| ExportService (Excel/PDF) | Backend | 3 | +| ReportsController | Backend | 3 | +| Dashboard Principal | Frontend | 8 | +| Paginas de Reportes | Frontend | 3 | +| Componentes de Graficos | Frontend | 3 | +| **Total Sprint 16** | | **32** | + +--- + +### FASE 5: E-COMMERCE + +#### Sprint 17: E-commerce Backend (Parte 1) +**Objetivo:** RT-009 - Backend de tienda online + +| Tarea | Capa | SP | +|-------|------|---:| +| Crear retail.carts, cart_items | Database | 2 | +| Crear retail.ecommerce_orders | Database | 2 | +| Crear retail.shipping_rates | Database | 1 | +| CatalogService | Backend | 3 | +| CartService | Backend | 5 | +| CheckoutService | Backend | 5 | +| EcommerceOrderService | Backend | 5 | +| **Total Sprint 17** | | **23** | + +#### Sprint 18: E-commerce Backend (Parte 2) + Integraciones +**Objetivo:** Pasarelas de pago y envio + +| Tarea | Capa | SP | +|-------|------|---:| +| PaymentGatewayService | Backend | 8 | +| Stripe/Conekta Provider | Backend | 5 | +| ShippingService | Backend | 5 | +| StorefrontController | Backend | 3 | +| EcommerceAdminController | Backend | 3 | +| **Total Sprint 18** | | **24** | + +#### Sprint 19: Storefront (Parte 1) +**Objetivo:** UI de tienda online - Catalogo y Carrito + +| Tarea | Capa | SP | +|-------|------|---:| +| Layout Storefront | Frontend | 2 | +| Home y navegacion | Frontend | 3 | +| Catalogo de Productos | Frontend | 5 | +| Detalle de Producto | Frontend | 4 | +| Busqueda de Productos | Frontend | 3 | +| Carrito (drawer) | Frontend | 3 | +| **Total Sprint 19** | | **20** | + +#### Sprint 20: Storefront (Parte 2) +**Objetivo:** UI de tienda - Checkout y Area Cliente + +| Tarea | Capa | SP | +|-------|------|---:| +| Checkout Flow completo | Frontend | 8 | +| Integracion Stripe/Conekta | Frontend | 5 | +| Area de Cliente | Frontend | 4 | +| Mis Pedidos | Frontend | 3 | +| Gestion Pedidos (backoffice) | Frontend | 4 | +| **Total Sprint 20** | | **24** | + +--- + +## 4. RESUMEN DE SPRINTS + +| Sprint | Fase | Enfoque | SP Total | SP Acumulados | +|--------|------|---------|----------|---------------| +| 1 | 0 | Auth + Catalogs P1 | 12 | 12 | +| 2 | 0 | Catalogs P2 | 12 | 24 | +| 3 | 0 | Financial | 12 | 36 | +| 4 | 0 | Inventory + Sales | 13 | 49 | +| 5 | 1 | DB Retail + Fundamentos | 12 | 61 | +| 6 | 1 | Motor de Precios | 25 | 86 | +| 7 | 1 | Inventario Multi-sucursal | 20 | 106 | +| 8 | 2 | POS Backend | 26 | 132 | +| 9 | 2 | POS Frontend | 25 | 157 | +| 10 | 2 | Caja | 27 | 184 | +| 11 | 2 | PWA + Offline | 23 | 207 | +| 12 | 3 | CFDI Backend | 24 | 231 | +| 13 | 3 | CFDI Frontend | 18 | 249 | +| 14 | 3 | Clientes + Lealtad | 28 | 277 | +| 15 | 4 | Compras | 32 | 309 | +| 16 | 4 | Reportes + Dashboard | 32 | 341 | +| 17 | 5 | E-commerce Backend P1 | 23 | 364 | +| 18 | 5 | E-commerce Backend P2 | 24 | 388 | +| 19 | 5 | Storefront P1 | 20 | 408 | +| 20 | 5 | Storefront P2 | 24 | 432 | + +**Total Story Points:** 432 SP (incluye 49 SP de prerrequisitos core + 383 SP retail) + +--- + +## 5. HITOS (MILESTONES) + +### M1: Core Ready +**Sprint:** 4 +**Criterio:** Modulos core completados para soportar retail +- [ ] Auth funcional con JWT +- [ ] Catalogos cargados +- [ ] Financial con TypeORM +- [ ] Inventory y Sales base + +### M2: Retail Foundation +**Sprint:** 7 +**Criterio:** Base de retail lista +- [ ] Schema retail creado +- [ ] Sucursales configurables +- [ ] Motor de precios funcionando +- [ ] Inventario multi-sucursal + +### M3: POS MVP +**Sprint:** 11 +**Criterio:** POS operativo con offline +- [ ] Ventas en POS +- [ ] Multiples formas de pago +- [ ] Caja con arqueos/cortes +- [ ] Modo offline funcional +- [ ] Impresion de tickets + +### M4: Facturacion Completa +**Sprint:** 13 +**Criterio:** CFDI 4.0 operativo +- [ ] Emision de facturas +- [ ] Cancelacion +- [ ] Autofactura publica +- [ ] Generacion PDF + +### M5: Retail Completo +**Sprint:** 16 +**Criterio:** Retail 100% funcional (sin e-commerce) +- [ ] Programa de lealtad +- [ ] Gestion de compras +- [ ] Dashboard y reportes + +### M6: E-commerce Live +**Sprint:** 20 +**Criterio:** Tienda online operativa +- [ ] Catalogo publico +- [ ] Carrito y checkout +- [ ] Pagos online +- [ ] Area de cliente + +--- + +## 6. DEPENDENCIAS CRITICAS + +### Grafico de Dependencias + +``` + ┌─────────────┐ + │ MGN-001 │ + │ Auth │ + └──────┬──────┘ + │ + ┌─────────────────┼─────────────────┐ + │ │ │ + v v v +┌────────────────┐ ┌──────────────┐ ┌──────────────┐ +│ MGN-005 │ │ MGN-010 │ │ MGN-011 │ +│ Catalogs │ │ Financial │ │ Inventory │ +└───────┬────────┘ └──────┬───────┘ └──────┬───────┘ + │ │ │ + └────────┬────────┴────────┬───────┘ + │ │ + v v + ┌──────────────┐ ┌──────────────┐ + │ RT-001 │ │ MGN-013 │ + │ Fundamentos │ │ Sales │ + └──────┬───────┘ └──────┬───────┘ + │ │ + ┌───────────┼───────────┬────┘ + │ │ │ + v v v +┌────────┐ ┌────────┐ ┌────────┐ +│ RT-006 │ │ RT-003 │ │ RT-005 │ +│ Precios│ │ Invent.│ │ Client │ +└───┬────┘ └───┬────┘ └───┬────┘ + │ │ │ + └──────────┼──────────┘ + │ + v + ┌──────────────┐ + │ RT-002 │ + │ POS │ + └──────┬───────┘ + │ + ┌──────────┼──────────┐ + │ │ │ + v v v +┌────────┐ ┌────────┐ ┌────────┐ +│ RT-007 │ │ RT-010 │ │ RT-008 │ +│ Caja │ │ CFDI │ │ Report │ +└────────┘ └────────┘ └────────┘ + │ + v + ┌──────────────┐ + │ RT-009 │ + │ E-commerce │ + └──────────────┘ + │ + v + ┌──────────────┐ + │ RT-004 │ + │ Compras │ + └──────────────┘ +``` + +### Camino Critico + +1. **Auth** -> Fundamentos -> POS -> Caja +2. **Financial** -> CFDI +3. **Inventory** -> Stock Multi-sucursal -> POS +4. **Sales/Precios** -> POS -> E-commerce + +--- + +## 7. RIESGOS Y MITIGACIONES + +| Riesgo | Probabilidad | Impacto | Mitigacion | +|--------|--------------|---------|------------| +| Core no completo a tiempo | Alta | Critico | Priorizar gaps bloqueantes | +| Integracion PAC compleja | Media | Alto | Iniciar con sandbox temprano | +| PWA offline bugs | Media | Alto | Tests exhaustivos de sync | +| Pasarelas de pago | Media | Alto | Usar SDK oficiales | +| Performance reportes | Media | Medio | Vistas materializadas desde inicio | + +--- + +## 8. EQUIPO SUGERIDO + +| Rol | Cantidad | Responsabilidades | +|-----|----------|-------------------| +| Tech Lead | 1 | Arquitectura, code review | +| Backend Sr | 2 | Servicios core, integraciones | +| Backend Jr | 1 | Servicios auxiliares, tests | +| Frontend Sr | 2 | POS, Backoffice, E-commerce | +| Frontend Jr | 1 | Componentes, estilos | +| QA | 1 | Testing E2E, integracion | +| DevOps | 0.5 | CI/CD, infraestructura | + +**Velocidad estimada:** 25-30 SP/semana con equipo completo + +--- + +**Estado:** ROADMAP COMPLETO +**Sprints totales:** 20 +**Duracion estimada:** 20 semanas (5 meses) diff --git a/orchestration/planes/fase-4-validacion/DEPENDENCY-GRAPH-VALIDADO.yml b/orchestration/planes/fase-4-validacion/DEPENDENCY-GRAPH-VALIDADO.yml new file mode 100644 index 0000000..b027f24 --- /dev/null +++ b/orchestration/planes/fase-4-validacion/DEPENDENCY-GRAPH-VALIDADO.yml @@ -0,0 +1,997 @@ +# GRAFO DE DEPENDENCIAS VALIDADO - RETAIL +# Fecha: 2025-12-18 +# Fase: 4 - Validacion + +metadata: + version: "1.0" + proyecto: erp-suite/retail + total_modulos: 10 + total_entidades: 56 + total_servicios: 48 + +# ============================================================ +# MODULOS CORE (PRERREQUISITOS) +# ============================================================ + +core_modules: + MGN-001-auth: + nombre: "Autenticacion" + estado: 40% + requerido: 100% + bloqueante: true + entidades: + - name: tenants + schema: auth + estado: existe + usado_por: [todos] + - name: users + schema: auth + estado: existe + usado_por: [RT-001, RT-002, RT-007] + - name: roles + schema: auth + estado: parcial + usado_por: [RT-001] + - name: permissions + schema: auth + estado: no_existe + usado_por: [RT-001] + - name: user_roles + schema: auth + estado: no_existe + usado_por: [RT-001] + servicios: + - name: AuthService + estado: parcial + metodos_faltantes: [validatePermission, refreshToken] + - name: PermissionsService + estado: no_existe + dependencias: [] + + MGN-005-catalogs: + nombre: "Catalogos" + estado: 0% + requerido: 100% + bloqueante: true + entidades: + - name: countries + schema: core + estado: no_existe + usado_por: [MGN-012] + - name: currencies + schema: core + estado: no_existe + usado_por: [RT-006, RT-010] + - name: uom_categories + schema: core + estado: no_existe + usado_por: [MGN-011] + - name: uom + schema: core + estado: no_existe + usado_por: [MGN-011, RT-010] + - name: sequences + schema: core + estado: no_existe + usado_por: [RT-002, RT-003, RT-004, RT-009, RT-010] + servicios: + - name: CountriesService + estado: no_existe + - name: CurrenciesService + estado: no_existe + - name: UoMService + estado: no_existe + - name: SequencesService + estado: no_existe + dependencias: [] + + MGN-010-financial: + nombre: "Financiero" + estado: 70% + requerido: 90% + bloqueante: true + entidades: + - name: tax_groups + schema: financial + estado: no_existe + usado_por: [RT-010] + - name: taxes + schema: financial + estado: existe + usado_por: [RT-006, RT-010] + - name: accounts + schema: financial + estado: existe + usado_por: [] + - name: journals + schema: financial + estado: existe + usado_por: [] + - name: payment_methods + schema: financial + estado: no_existe + usado_por: [RT-002, RT-009] + servicios: + - name: TaxesService + estado: parcial + nota: "Migrar a TypeORM" + - name: PaymentMethodsService + estado: no_existe + dependencias: [MGN-001-auth] + + MGN-011-inventory: + nombre: "Inventario" + estado: 60% + requerido: 80% + bloqueante: true + entidades: + - name: product_categories + schema: inventory + estado: existe + usado_por: [RT-006] + - name: products + schema: inventory + estado: existe + usado_por: [RT-002, RT-003, RT-004, RT-006, RT-009, RT-010] + - name: warehouses + schema: inventory + estado: existe + usado_por: [RT-001, RT-003] + - name: stock_quants + schema: inventory + estado: parcial + usado_por: [RT-003, RT-008] + servicios: + - name: ProductsService + estado: existe + - name: WarehousesService + estado: existe + - name: StockService + estado: parcial + nota: "Falta reservar/liberar stock" + dependencias: [MGN-001-auth, MGN-005-catalogs] + + MGN-012-partners: + nombre: "Partners" + estado: 50% + requerido: 80% + bloqueante: false + entidades: + - name: partners + schema: core + estado: parcial + usado_por: [RT-002, RT-004, RT-005, RT-009, RT-010] + servicios: + - name: PartnersService + estado: parcial + metodos_faltantes: [getCustomers, getSuppliers] + dependencias: [MGN-001-auth, MGN-005-catalogs] + + MGN-013-sales: + nombre: "Ventas" + estado: 50% + requerido: 70% + bloqueante: true + entidades: + - name: pricelists + schema: sales + estado: no_existe + usado_por: [RT-006] + - name: pricelist_items + schema: sales + estado: no_existe + usado_por: [RT-006] + servicios: + - name: PricelistsService + estado: no_existe + dependencias: [MGN-001-auth, MGN-011-inventory] + +# ============================================================ +# MODULOS RETAIL +# ============================================================ + +retail_modules: + RT-001-fundamentos: + nombre: "Fundamentos Retail" + prioridad: P0 + story_points: 0 + herencia: 100% + sprint: 5 + entidades: + - name: branches + schema: retail + estado: planificado + columnas: [id, tenant_id, code, name, address, phone, email, warehouse_id, is_active, is_main, config] + foreign_keys: + - column: tenant_id + references: auth.tenants(id) + - column: warehouse_id + references: inventory.warehouses(id) + indices: [tenant_id] + rls: true + + - name: cash_registers + schema: retail + estado: planificado + columnas: [id, tenant_id, branch_id, code, name, is_active, default_payment_method, config] + foreign_keys: + - column: tenant_id + references: auth.tenants(id) + - column: branch_id + references: retail.branches(id) + indices: [branch_id] + rls: true + + - name: branch_users + schema: retail + estado: planificado + columnas: [id, tenant_id, branch_id, user_id, role, is_primary] + foreign_keys: + - column: tenant_id + references: auth.tenants(id) + - column: branch_id + references: retail.branches(id) + - column: user_id + references: auth.users(id) + indices: [user_id] + unique: [tenant_id, branch_id, user_id] + rls: true + + servicios: + - name: BranchesService + tipo: nuevo + metodos: [create, findAll, findById, update, delete, getByTenant] + - name: CashRegistersService + tipo: nuevo + metodos: [create, findByBranch, activate, deactivate] + - name: BranchUsersService + tipo: nuevo + metodos: [assign, remove, getByUser, getByBranch] + + dependencias: + core: [MGN-001-auth, MGN-011-inventory] + retail: [] + + RT-002-pos: + nombre: "Punto de Venta" + prioridad: P0 + story_points: 55 + herencia: 20% + sprint: [8, 9, 10, 11] + entidades: + - name: pos_sessions + schema: retail + estado: planificado + columnas: [id, tenant_id, branch_id, register_id, user_id, status, opening_balance, closing_balance, opened_at, closed_at, notes] + foreign_keys: + - column: tenant_id + references: auth.tenants(id) + - column: branch_id + references: retail.branches(id) + - column: register_id + references: retail.cash_registers(id) + - column: user_id + references: auth.users(id) + indices: [branch_id, user_id, status] + rls: true + + - name: pos_orders + schema: retail + estado: planificado + columnas: [id, tenant_id, session_id, order_number, customer_id, status, order_date, subtotal, discount_amount, tax_amount, total, notes, offline_id, synced_at, is_invoiced, invoice_id] + foreign_keys: + - column: tenant_id + references: auth.tenants(id) + - column: session_id + references: retail.pos_sessions(id) + - column: customer_id + references: core.partners(id) + indices: [session_id, customer_id, order_date, offline_id] + unique: [tenant_id, order_number] + rls: true + + - name: pos_order_lines + schema: retail + estado: planificado + columnas: [id, tenant_id, order_id, product_id, product_name, quantity, unit_price, discount_percent, discount_amount, tax_amount, total, notes] + foreign_keys: + - column: tenant_id + references: auth.tenants(id) + - column: order_id + references: retail.pos_orders(id) + - column: product_id + references: inventory.products(id) + indices: [product_id] + rls: true + + - name: pos_payments + schema: retail + estado: planificado + columnas: [id, tenant_id, order_id, payment_method, amount, received_amount, change_amount, reference, card_last_four, authorization_code] + foreign_keys: + - column: tenant_id + references: auth.tenants(id) + - column: order_id + references: retail.pos_orders(id) + indices: [order_id] + rls: true + + servicios: + - name: POSSessionService + tipo: nuevo + metodos: [openSession, closeSession, getActiveSession, getSummary] + dependencias: [CashRegistersService] + - name: POSOrderService + tipo: nuevo + metodos: [createOrder, addLine, updateLine, removeLine, setCustomer, applyDiscount, confirmOrder, refundOrder, cancelOrder] + dependencias: [PriceEngineService, RetailStockService, LoyaltyService] + - name: POSPaymentService + tipo: nuevo + metodos: [addPayment, processPayments, calculateChange] + - name: POSSyncService + tipo: nuevo + metodos: [syncOfflineOrders, getByOfflineId] + dependencias: [POSOrderService] + + controllers: + - name: POSController + endpoints: + - POST /pos/sessions/open + - GET /pos/sessions/active + - POST /pos/sessions/:id/close + - POST /pos/orders + - POST /pos/orders/:id/lines + - POST /pos/orders/:id/confirm + - POST /pos/orders/:id/refund + - POST /pos/sync + + gateways: + - name: POSGateway + namespace: /pos + events: [sync:orders, order:created, session:updated] + + frontend: + pages: + - path: /login + componentes: [LoginForm, BranchSelector] + - path: /select-register + componentes: [RegisterList] + - path: /open-session + componentes: [OpenSessionForm] + - path: /sales + componentes: [ProductSearch, CategoryTabs, ProductGrid, OrderLines, OrderTotals, CustomerSelector] + - path: /sales/checkout + componentes: [PaymentMethodButton, CashPaymentInput, CardPaymentInput, PointsRedemption, PaymentsList] + - path: /history + componentes: [OrderHistoryList] + - path: /offline + componentes: [SyncQueue, SyncStatus] + + dependencias: + core: [MGN-001-auth, MGN-010-financial, MGN-011-inventory, MGN-012-partners] + retail: [RT-001-fundamentos, RT-006-precios, RT-003-inventario, RT-005-clientes] + + RT-003-inventario: + nombre: "Inventario Multi-sucursal" + prioridad: P0 + story_points: 42 + herencia: 60% + sprint: 7 + entidades: + - name: stock_transfers + schema: retail + estado: planificado + columnas: [id, tenant_id, transfer_number, source_branch_id, dest_branch_id, status, requested_by, confirmed_by, received_by, requested_date, confirmed_date, received_date, notes] + foreign_keys: + - column: source_branch_id + references: retail.branches(id) + - column: dest_branch_id + references: retail.branches(id) + indices: [source_branch_id, dest_branch_id, status] + unique: [tenant_id, transfer_number] + rls: true + + - name: stock_transfer_lines + schema: retail + estado: planificado + foreign_keys: + - column: transfer_id + references: retail.stock_transfers(id) + - column: product_id + references: inventory.products(id) + rls: true + + - name: stock_adjustments + schema: retail + estado: planificado + foreign_keys: + - column: branch_id + references: retail.branches(id) + indices: [branch_id] + unique: [tenant_id, adjustment_number] + rls: true + + - name: stock_adjustment_lines + schema: retail + estado: planificado + foreign_keys: + - column: adjustment_id + references: retail.stock_adjustments(id) + - column: product_id + references: inventory.products(id) + rls: true + + views: + - name: branch_stock + schema: retail + tipo: view + descripcion: "Vista de stock por sucursal" + tablas_fuente: [inventory.stock_quants, retail.branches, inventory.products] + + servicios: + - name: RetailStockService + tipo: extendido + extiende: StockService + metodos: [getStockByBranch, getMultiBranchStock, decrementStock, incrementStock] + - name: TransfersService + tipo: nuevo + metodos: [createTransfer, confirmTransfer, sendTransfer, receiveTransfer, cancelTransfer] + - name: AdjustmentsService + tipo: nuevo + metodos: [createAdjustment, confirmAdjustment, cancelAdjustment] + + dependencias: + core: [MGN-011-inventory] + retail: [RT-001-fundamentos] + + RT-004-compras: + nombre: "Compras y Reabastecimiento" + prioridad: P1 + story_points: 38 + herencia: 80% + sprint: 15 + entidades: + - name: purchase_suggestions + schema: retail + estado: planificado + rls: true + + - name: supplier_orders + schema: retail + estado: planificado + foreign_keys: + - column: supplier_id + references: core.partners(id) + - column: branch_id + references: retail.branches(id) + rls: true + + - name: supplier_order_lines + schema: retail + estado: planificado + foreign_keys: + - column: order_id + references: retail.supplier_orders(id) + - column: product_id + references: inventory.products(id) + rls: true + + - name: goods_receipts + schema: retail + estado: planificado + foreign_keys: + - column: supplier_order_id + references: retail.supplier_orders(id) + - column: branch_id + references: retail.branches(id) + rls: true + + - name: goods_receipt_lines + schema: retail + estado: planificado + rls: true + + servicios: + - name: RetailPurchaseService + tipo: extendido + extiende: PurchaseService + metodos: [suggestRestock, createFromSuggestion] + - name: PurchaseSuggestionsService + tipo: nuevo + metodos: [generate, getByBranch, markOrdered, markIgnored] + - name: GoodsReceiptService + tipo: nuevo + metodos: [createReceipt, confirmReceipt, cancelReceipt] + + dependencias: + core: [MGN-011-inventory, MGN-012-partners] + retail: [RT-001-fundamentos, RT-003-inventario] + + RT-005-clientes: + nombre: "Clientes y Lealtad" + prioridad: P1 + story_points: 34 + herencia: 40% + sprint: 14 + entidades: + - name: loyalty_programs + schema: retail + estado: planificado + rls: true + + - name: membership_levels + schema: retail + estado: planificado + foreign_keys: + - column: program_id + references: retail.loyalty_programs(id) + rls: true + + - name: loyalty_transactions + schema: retail + estado: planificado + foreign_keys: + - column: customer_id + references: core.partners(id) + - column: program_id + references: retail.loyalty_programs(id) + - column: order_id + references: retail.pos_orders(id) + indices: [customer_id, created_at] + rls: true + + - name: customer_memberships + schema: retail + estado: planificado + foreign_keys: + - column: customer_id + references: core.partners(id) + - column: program_id + references: retail.loyalty_programs(id) + - column: level_id + references: retail.membership_levels(id) + unique: [tenant_id, membership_number] + rls: true + + servicios: + - name: RetailCustomersService + tipo: extendido + extiende: PartnersService + metodos: [createCustomer, getWithMembership, searchByPhone] + - name: LoyaltyService + tipo: nuevo + metodos: [enrollCustomer, earnPoints, redeemPoints, getBalance, getHistory, checkLevelUpgrade] + + dependencias: + core: [MGN-012-partners] + retail: [RT-001-fundamentos, RT-002-pos] + + RT-006-precios: + nombre: "Precios y Promociones" + prioridad: P0 + story_points: 36 + herencia: 30% + sprint: 6 + entidades: + - name: promotions + schema: retail + estado: planificado + indices: [is_active, start_date, end_date, branch_ids] + rls: true + + - name: promotion_products + schema: retail + estado: planificado + foreign_keys: + - column: promotion_id + references: retail.promotions(id) + - column: product_id + references: inventory.products(id) + - column: category_id + references: inventory.product_categories(id) + rls: true + + - name: coupons + schema: retail + estado: planificado + foreign_keys: + - column: customer_id + references: core.partners(id) + indices: [code, valid_from, valid_until] + unique: [tenant_id, code] + rls: true + + - name: coupon_redemptions + schema: retail + estado: planificado + foreign_keys: + - column: coupon_id + references: retail.coupons(id) + - column: order_id + references: retail.pos_orders(id) + rls: true + + servicios: + - name: PriceEngineService + tipo: nuevo + metodos: [calculatePrice, applyPricelist, evaluatePromotions, evaluateVolumeDiscount] + dependencias: [PricelistsService, PromotionsService, CouponsService] + - name: PromotionsService + tipo: nuevo + metodos: [create, update, activate, deactivate, getActiveForProduct, incrementUse] + - name: CouponsService + tipo: nuevo + metodos: [generate, validate, redeem, getByCode] + + dependencias: + core: [MGN-011-inventory, MGN-013-sales] + retail: [RT-001-fundamentos] + + RT-007-caja: + nombre: "Caja y Cortes" + prioridad: P0 + story_points: 28 + herencia: 10% + sprint: 10 + entidades: + - name: cash_movements + schema: retail + estado: planificado + foreign_keys: + - column: session_id + references: retail.pos_sessions(id) + - column: authorized_by + references: auth.users(id) + indices: [session_id] + rls: true + + - name: cash_closings + schema: retail + estado: planificado + columnas_generadas: + - name: cash_difference + formula: "declared_cash - expected_cash" + - name: card_difference + formula: "declared_card - expected_card" + - name: transfer_difference + formula: "declared_transfer - expected_transfer" + foreign_keys: + - column: session_id + references: retail.pos_sessions(id) + unique: [session_id] + indices: [closing_date] + rls: true + + - name: cash_counts + schema: retail + estado: planificado + columnas_generadas: + - name: difference + formula: "counted_amount - expected_amount" + foreign_keys: + - column: session_id + references: retail.pos_sessions(id) + indices: [session_id] + rls: true + + servicios: + - name: CashSessionService + tipo: nuevo + metodos: [getSummary, calculateExpected] + dependencias: [POSSessionService] + - name: CashMovementService + tipo: nuevo + metodos: [createIn, createOut, getBySession, validateAuthorization] + - name: CashClosingService + tipo: nuevo + metodos: [prepareClosing, createClosing, approveClosing, rejectClosing] + + dependencias: + core: [MGN-001-auth] + retail: [RT-001-fundamentos, RT-002-pos] + + RT-008-reportes: + nombre: "Reportes y Dashboard" + prioridad: P1 + story_points: 30 + herencia: 70% + sprint: 16 + materialized_views: + - name: mv_daily_sales + schema: retail + refresh: "hourly" + indices: [sale_date, tenant_id, branch_id, cashier_id] + + - name: mv_product_sales + schema: retail + refresh: "hourly" + indices: [product_id, tenant_id, branch_id, sale_month] + + - name: mv_branch_stock_summary + schema: retail + refresh: "hourly" + indices: [tenant_id, branch_id] + + servicios: + - name: DashboardService + tipo: nuevo + metodos: [getDashboard, getKPIs, getCharts, getAlerts] + - name: SalesReportService + tipo: extendido + extiende: ReportsService + metodos: [generate, getByPeriod, getSalesByHour, getSalesByCashier] + - name: ProductReportService + tipo: nuevo + metodos: [getTopSelling, getSlowMoving, getABCAnalysis] + - name: CashReportService + tipo: nuevo + metodos: [getClosingsReport, getDifferencesReport] + - name: ExportService + tipo: nuevo + metodos: [toExcel, toPDF] + + dependencias: + core: [] + retail: [RT-002-pos, RT-003-inventario, RT-005-clientes, RT-007-caja] + + RT-009-ecommerce: + nombre: "E-commerce" + prioridad: P2 + story_points: 55 + herencia: 20% + sprint: [17, 18, 19, 20] + entidades: + - name: carts + schema: retail + estado: planificado + foreign_keys: + - column: customer_id + references: core.partners(id) + indices: [customer_id, session_id, expires_at] + rls: true + + - name: cart_items + schema: retail + estado: planificado + foreign_keys: + - column: cart_id + references: retail.carts(id) + - column: product_id + references: inventory.products(id) + rls: true + + - name: ecommerce_orders + schema: retail + estado: planificado + foreign_keys: + - column: customer_id + references: core.partners(id) + - column: pickup_branch_id + references: retail.branches(id) + indices: [customer_id, status, order_date] + unique: [tenant_id, order_number] + rls: true + + - name: ecommerce_order_lines + schema: retail + estado: planificado + foreign_keys: + - column: order_id + references: retail.ecommerce_orders(id) + - column: product_id + references: inventory.products(id) + rls: true + + - name: shipping_rates + schema: retail + estado: planificado + rls: true + + servicios: + - name: CatalogService + tipo: nuevo + metodos: [search, getProduct, getByCategory, getRelated] + - name: CartService + tipo: nuevo + metodos: [getOrCreateCart, addItem, updateItem, removeItem, clear, mergeGuestCart] + - name: CheckoutService + tipo: nuevo + metodos: [validate, calculateTotals, complete] + dependencias: [CartService, PriceEngineService, RetailStockService] + - name: PaymentGatewayService + tipo: nuevo + metodos: [createPayment, capturePayment, refund, handleWebhook] + providers: [StripeGateway, ConektaGateway, MercadoPagoGateway] + - name: ShippingService + tipo: nuevo + metodos: [calculateRates, createShipment, getTracking] + providers: [FedexProvider, DHLProvider] + - name: EcommerceOrderService + tipo: nuevo + metodos: [create, updateStatus, getByCustomer, markAsShipped] + + controllers: + - name: StorefrontController + endpoints: + - GET /store/products + - GET /store/products/:id + - GET /store/products/search + - GET /store/categories + - GET /store/cart + - POST /store/cart/items + - PUT /store/cart/items/:id + - DELETE /store/cart/items/:id + - POST /store/checkout/validate + - GET /store/checkout/shipping-rates + - POST /store/checkout/complete + - POST /store/payments/create + - POST /store/payments/webhook + - GET /store/orders + - GET /store/orders/:id + + - name: EcommerceAdminController + endpoints: + - GET /ecommerce/orders + - PUT /ecommerce/orders/:id/status + - POST /ecommerce/orders/:id/ship + - GET /ecommerce/shipping-rates + - POST /ecommerce/shipping-rates + + dependencias: + core: [MGN-011-inventory, MGN-012-partners] + retail: [RT-001-fundamentos, RT-003-inventario, RT-005-clientes, RT-006-precios] + + RT-010-facturacion: + nombre: "Facturacion CFDI 4.0" + prioridad: P0 + story_points: 35 + herencia: 60% + sprint: [12, 13] + entidades: + - name: cfdi_config + schema: retail + estado: planificado + unique: [tenant_id] + rls: true + + - name: cfdis + schema: retail + estado: planificado + foreign_keys: + - column: source_id + references: dynamic + nota: "pos_orders o ecommerce_orders segun source_type" + indices: [source_type, source_id, uuid, fecha_emision, receptor_rfc, status] + unique: [tenant_id, uuid] + rls: true + + servicios: + - name: CFDIService + tipo: nuevo + metodos: [generateFromPOS, generateFromEcommerce, generatePublicInvoice, cancel, getXML, getPDF] + dependencias: [CFDIBuilderService, PACService, XMLService, PDFService] + - name: CFDIBuilderService + tipo: nuevo + metodos: [fromPOSOrder, fromEcommerceOrder, buildConceptos, buildImpuestos] + - name: PACService + tipo: nuevo + metodos: [timbrar, consultar, cancelar] + providers: [FinkokPAC, FacturamaPAC] + - name: XMLService + tipo: nuevo + metodos: [buildXML, parseXML, sign, validate] + - name: PDFService + tipo: nuevo + metodos: [generate, getTemplate] + - name: AutofacturaService + tipo: nuevo + metodos: [validateTicket, generateFromTicket] + dependencias: [CFDIService] + + controllers: + - name: CFDIController + endpoints: + - POST /cfdi/pos/:orderId + - POST /cfdi/ecommerce/:orderId + - POST /cfdi/public/:orderId + - GET /cfdi/:id + - GET /cfdi/:id/xml + - GET /cfdi/:id/pdf + - POST /cfdi/:id/cancel + - POST /cfdi/credit-note + - GET /cfdi/report/monthly + + - name: AutofacturaController + endpoints: + - GET /autofactura/validate/:ticketNumber + - POST /autofactura/generate + - GET /autofactura/download/:uuid + + dependencias: + core: [MGN-010-financial, MGN-012-partners] + retail: [RT-002-pos, RT-009-ecommerce] + +# ============================================================ +# VALIDACION DE DEPENDENCIAS +# ============================================================ + +validation: + resultado: "APROBADO_CON_GAPS" + gaps_bloqueantes: 4 + gaps_no_bloqueantes: 0 + + gaps: + - id: GAP-DEP-001 + tipo: bloqueante + origen: MGN-005-catalogs + descripcion: "Modulo de catalogos no implementado" + impacto: [RT-001, RT-006, RT-010] + accion: "Implementar en Sprints 1-2" + + - id: GAP-DEP-002 + tipo: bloqueante + origen: MGN-001-auth + descripcion: "Sistema de permisos incompleto" + impacto: [RT-001, RT-002] + accion: "Completar en Sprint 1" + + - id: GAP-DEP-003 + tipo: bloqueante + origen: MGN-013-sales + descripcion: "Pricelists no implementado" + impacto: [RT-006] + accion: "Implementar en Sprint 4" + + - id: GAP-DEP-004 + tipo: bloqueante + origen: MGN-010-financial + descripcion: "PaymentMethods no implementado" + impacto: [RT-002, RT-009] + accion: "Implementar en Sprint 3" + + dependencias_circulares: [] + + orden_implementacion: + - paso: 1 + modulos: [MGN-001-auth, MGN-005-catalogs] + sprint: [1, 2] + + - paso: 2 + modulos: [MGN-010-financial, MGN-011-inventory] + sprint: [3, 4] + + - paso: 3 + modulos: [MGN-012-partners, MGN-013-sales] + sprint: [4] + + - paso: 4 + modulos: [RT-001-fundamentos] + sprint: [5] + + - paso: 5 + modulos: [RT-006-precios, RT-003-inventario] + sprint: [6, 7] + + - paso: 6 + modulos: [RT-002-pos] + sprint: [8, 9] + + - paso: 7 + modulos: [RT-007-caja] + sprint: [10] + + - paso: 8 + modulos: [RT-010-facturacion] + sprint: [12, 13] + + - paso: 9 + modulos: [RT-005-clientes] + sprint: [14] + + - paso: 10 + modulos: [RT-004-compras, RT-008-reportes] + sprint: [15, 16] + + - paso: 11 + modulos: [RT-009-ecommerce] + sprint: [17, 18, 19, 20] diff --git a/orchestration/planes/fase-4-validacion/IMPACTO-CAMBIOS.md b/orchestration/planes/fase-4-validacion/IMPACTO-CAMBIOS.md new file mode 100644 index 0000000..cd22baf --- /dev/null +++ b/orchestration/planes/fase-4-validacion/IMPACTO-CAMBIOS.md @@ -0,0 +1,405 @@ +# ANALISIS DE IMPACTO DE CAMBIOS + +**Fecha:** 2025-12-18 +**Fase:** 4 - Validacion +**Objetivo:** Identificar todos los objetos impactados por los cambios planificados + +--- + +## 1. RESUMEN DE IMPACTOS + +### 1.1 Metricas Generales + +| Categoria | Cantidad | +|-----------|----------| +| Tablas nuevas | 26 | +| Vistas/Views | 1 | +| Vistas materializadas | 3 | +| Entidades TypeORM | 26 | +| Servicios nuevos | 28 | +| Servicios extendidos | 8 | +| Controllers | 15 | +| Paginas frontend | ~65 | +| Componentes UI | ~80 | +| Integraciones externas | 7 | + +### 1.2 Impacto por Capa + +``` +┌─────────────────────────────────────────────────────────────┐ +│ MATRIZ DE IMPACTO │ +├─────────────────────────────────────────────────────────────┤ +│ │ +│ DATABASE [████████████████████████████] 100% │ +│ 26 tablas, 3 mat views, 1 view │ +│ │ +│ BACKEND [████████████████████████████] 100% │ +│ 36 services, 15 controllers, 5 middlewares │ +│ │ +│ FRONTEND [████████████████████████████] 100% │ +│ 3 apps, ~65 pages, ~80 components │ +│ │ +│ INTEGRACIONES [████████████░░░░░░░░░░░░░░░░] 35% │ +│ PAC, Pagos, Envios (parcialmente nuevos) │ +│ │ +└─────────────────────────────────────────────────────────────┘ +``` + +--- + +## 2. IMPACTO EN BASE DE DATOS + +### 2.1 Schemas Afectados + +| Schema | Accion | Objetos | +|--------|--------|---------| +| auth | MODIFICAR | Agregar permissions, user_roles | +| core | MODIFICAR | Agregar countries, currencies, uom, sequences | +| financial | MODIFICAR | Agregar tax_groups, payment_methods | +| inventory | VERIFICAR | Ya existe, solo verificar stock_quants | +| sales | MODIFICAR | Agregar pricelists, pricelist_items | +| **retail** | **CREAR** | **26 tablas nuevas** | + +### 2.2 Tablas por Schema + +#### Schema: retail (NUEVO) + +| Tabla | Modulo | FK Entrantes | FK Salientes | +|-------|--------|--------------|--------------| +| branches | RT-001 | cash_registers, branch_users, pos_sessions, stock_transfers(x2), stock_adjustments, supplier_orders, goods_receipts, ecommerce_orders | auth.tenants, inventory.warehouses | +| cash_registers | RT-001 | pos_sessions | auth.tenants, retail.branches | +| branch_users | RT-001 | - | auth.tenants, retail.branches, auth.users | +| pos_sessions | RT-002 | pos_orders, cash_movements, cash_closings, cash_counts | auth.tenants, retail.branches, retail.cash_registers, auth.users | +| pos_orders | RT-002 | pos_order_lines, pos_payments, loyalty_transactions, coupon_redemptions, cfdis | auth.tenants, retail.pos_sessions, core.partners | +| pos_order_lines | RT-002 | - | auth.tenants, retail.pos_orders, inventory.products | +| pos_payments | RT-002 | - | auth.tenants, retail.pos_orders | +| cash_movements | RT-007 | - | auth.tenants, retail.pos_sessions, auth.users | +| cash_closings | RT-007 | - | auth.tenants, retail.pos_sessions, auth.users | +| cash_counts | RT-007 | - | auth.tenants, retail.pos_sessions, auth.users | +| stock_transfers | RT-003 | stock_transfer_lines | auth.tenants, retail.branches(x2), auth.users(x3) | +| stock_transfer_lines | RT-003 | - | auth.tenants, retail.stock_transfers, inventory.products | +| stock_adjustments | RT-003 | stock_adjustment_lines | auth.tenants, retail.branches, auth.users(x2) | +| stock_adjustment_lines | RT-003 | - | auth.tenants, retail.stock_adjustments, inventory.products | +| loyalty_programs | RT-005 | membership_levels, loyalty_transactions, customer_memberships | auth.tenants | +| membership_levels | RT-005 | customer_memberships | auth.tenants, retail.loyalty_programs | +| loyalty_transactions | RT-005 | - | auth.tenants, core.partners, retail.loyalty_programs, retail.pos_orders | +| customer_memberships | RT-005 | - | auth.tenants, core.partners, retail.loyalty_programs, retail.membership_levels | +| promotions | RT-006 | promotion_products | auth.tenants | +| promotion_products | RT-006 | - | auth.tenants, retail.promotions, inventory.products, inventory.product_categories | +| coupons | RT-006 | coupon_redemptions | auth.tenants, core.partners | +| coupon_redemptions | RT-006 | - | auth.tenants, retail.coupons, retail.pos_orders | +| purchase_suggestions | RT-004 | - | auth.tenants, retail.branches, inventory.products, core.partners | +| supplier_orders | RT-004 | supplier_order_lines, goods_receipts | auth.tenants, core.partners, retail.branches, auth.users(x2) | +| supplier_order_lines | RT-004 | - | auth.tenants, retail.supplier_orders, inventory.products | +| goods_receipts | RT-004 | goods_receipt_lines | auth.tenants, retail.supplier_orders, retail.branches, core.partners, auth.users | +| goods_receipt_lines | RT-004 | - | auth.tenants, retail.goods_receipts, inventory.products | +| cfdi_config | RT-010 | - | auth.tenants | +| cfdis | RT-010 | - | auth.tenants | +| carts | RT-009 | cart_items | auth.tenants, core.partners | +| cart_items | RT-009 | - | auth.tenants, retail.carts, inventory.products | +| ecommerce_orders | RT-009 | ecommerce_order_lines, cfdis | auth.tenants, core.partners, retail.branches | +| ecommerce_order_lines | RT-009 | - | auth.tenants, retail.ecommerce_orders, inventory.products | +| shipping_rates | RT-009 | - | auth.tenants | + +### 2.3 Impacto en Tablas Existentes + +| Tabla Existente | Impacto | Descripcion | +|-----------------|---------|-------------| +| auth.tenants | Referenciada | FK desde todas las tablas retail | +| auth.users | Referenciada | FK desde pos_sessions, cash_*, branch_users, etc. | +| core.partners | Referenciada | FK desde pos_orders, customers, suppliers | +| inventory.products | Referenciada | FK desde pos_order_lines, cart_items, promotion_products | +| inventory.warehouses | Referenciada | FK desde branches | +| inventory.stock_quants | Lectura/Escritura | Para stock por sucursal | +| inventory.product_categories | Referenciada | FK desde promotion_products | + +### 2.4 Vistas y Vistas Materializadas + +| Objeto | Tipo | Tablas Fuente | Refresh | +|--------|------|---------------|---------| +| retail.branch_stock | VIEW | inventory.stock_quants, retail.branches, inventory.products | Real-time | +| retail.mv_daily_sales | MATERIALIZED VIEW | retail.pos_orders, retail.pos_sessions | Cada hora | +| retail.mv_product_sales | MATERIALIZED VIEW | retail.pos_order_lines, retail.pos_orders, retail.pos_sessions | Cada hora | +| retail.mv_branch_stock_summary | MATERIALIZED VIEW | retail.branches, inventory.stock_quants, inventory.products | Cada hora | + +--- + +## 3. IMPACTO EN BACKEND + +### 3.1 Modulos Afectados + +| Modulo | Tipo | Servicios | Controllers | +|--------|------|-----------|-------------| +| auth | MODIFICAR | AuthService (actualizar) | - | +| branches | NUEVO | BranchesService, CashRegistersService, BranchUsersService | BranchesController | +| pos | NUEVO | POSSessionService, POSOrderService, POSPaymentService, POSSyncService | POSController | +| cash | NUEVO | CashSessionService, CashMovementService, CashClosingService | CashController | +| inventory | EXTENDER | RetailStockService, TransfersService, AdjustmentsService | InventoryController | +| customers | EXTENDER | RetailCustomersService, LoyaltyService | CustomersController | +| pricing | NUEVO | PriceEngineService, PromotionsService, CouponsService | PricingController | +| purchases | EXTENDER | RetailPurchaseService, PurchaseSuggestionsService, GoodsReceiptService | PurchaseController | +| invoicing | NUEVO | CFDIService, CFDIBuilderService, PACService, XMLService, PDFService, AutofacturaService | CFDIController, AutofacturaController | +| ecommerce | NUEVO | CatalogService, CartService, CheckoutService, PaymentGatewayService, ShippingService, EcommerceOrderService | StorefrontController, EcommerceAdminController | +| reports | EXTENDER | DashboardService, SalesReportService, ProductReportService, CashReportService, ExportService | ReportsController | + +### 3.2 Middleware Nuevo + +| Middleware | Proposito | Ruta | +|------------|-----------|------| +| TenantMiddleware | Establecer tenant_id en sesion | Global | +| BranchMiddleware | Establecer branch_id en header | /pos/*, /inventory/* | +| AuthMiddleware | Validar JWT | Rutas protegidas | +| PermissionsMiddleware | Validar permisos | Rutas admin | +| OfflineAuthMiddleware | Auth para sync offline | /pos/sync | + +### 3.3 Gateways (WebSocket) + +| Gateway | Namespace | Eventos | +|---------|-----------|---------| +| POSGateway | /pos | sync:orders, order:created, session:updated | + +### 3.4 Integraciones Externas + +| Integracion | Provider | Servicios Afectados | +|-------------|----------|---------------------| +| Finkok PAC | PACService | FinkokPAC.timbrar(), FinkokPAC.cancelar() | +| Facturama PAC | PACService | FacturamaPAC.timbrar(), FacturamaPAC.cancelar() | +| Stripe | PaymentGatewayService | StripeGateway.createPayment(), StripeGateway.capture() | +| Conekta | PaymentGatewayService | ConektaGateway.createPayment() | +| MercadoPago | PaymentGatewayService | MercadoPagoGateway.createPayment() | +| Fedex | ShippingService | FedexProvider.calculateRate(), FedexProvider.createShipment() | +| DHL | ShippingService | DHLProvider.calculateRate() | + +--- + +## 4. IMPACTO EN FRONTEND + +### 4.1 Aplicaciones + +| App | Tipo | Paginas | Componentes | +|-----|------|---------|-------------| +| Backoffice | React SPA | ~35 | ~50 | +| POS | React PWA | ~15 | ~20 | +| Storefront | React SPA | ~15 | ~30 | + +### 4.2 Packages Compartidos + +| Package | Tipo | Contenido | +|---------|------|-----------| +| @retail/ui-components | Library | Button, Input, Table, Modal, etc. | +| @retail/api-client | Library | Cliente API tipado | +| @retail/hooks | Library | useAuth, useTenant, useBranch, etc. | + +### 4.3 Paginas por App + +#### Backoffice + +| Pagina | Ruta | Modulo | +|--------|------|--------| +| Dashboard | / | RT-008 | +| Stock por Sucursal | /inventory | RT-003 | +| Transferencias | /inventory/transfers | RT-003 | +| Ajustes | /inventory/adjustments | RT-003 | +| Productos | /products | Core | +| Categorias | /products/categories | Core | +| Clientes | /customers | RT-005 | +| Programa Lealtad | /customers/loyalty | RT-005 | +| Listas de Precios | /pricing/pricelists | RT-006 | +| Promociones | /pricing/promotions | RT-006 | +| Cupones | /pricing/coupons | RT-006 | +| Sugerencias Compra | /purchases/suggestions | RT-004 | +| Ordenes Proveedor | /purchases/orders | RT-004 | +| Recepciones | /purchases/receipts | RT-004 | +| Pedidos Online | /ecommerce/orders | RT-009 | +| Tarifas Envio | /ecommerce/shipping | RT-009 | +| Facturas | /invoicing | RT-010 | +| Config CFDI | /invoicing/config | RT-010 | +| Reporte Ventas | /reports/sales | RT-008 | +| Reporte Productos | /reports/products | RT-008 | +| Reporte Inventario | /reports/inventory | RT-008 | +| Reporte Clientes | /reports/customers | RT-008 | +| Reporte Caja | /reports/cash | RT-008 | +| Sucursales | /settings/branches | RT-001 | +| Cajas | /settings/registers | RT-001 | +| Usuarios | /settings/users | Core | + +#### POS + +| Pagina | Ruta | Modulo | +|--------|------|--------| +| Login | /login | Core | +| Seleccion Caja | /select-register | RT-001 | +| Apertura | /open-session | RT-007 | +| Ventas | /sales | RT-002 | +| Cobro | /sales/checkout | RT-002 | +| Movimientos Caja | /cash/movements | RT-007 | +| Arqueo | /cash/count | RT-007 | +| Cierre | /cash/close | RT-007 | +| Historial | /history | RT-002 | +| Facturar | /invoice/:orderId | RT-010 | +| Cola Offline | /offline | RT-002 | + +#### Storefront + +| Pagina | Ruta | Modulo | +|--------|------|--------| +| Home | / | RT-009 | +| Catalogo | /products | RT-009 | +| Producto | /products/:id | RT-009 | +| Categoria | /category/:slug | RT-009 | +| Busqueda | /search | RT-009 | +| Carrito | /cart | RT-009 | +| Checkout | /checkout | RT-009 | +| Login | /login | Core | +| Registro | /register | Core | +| Mi Cuenta | /account | RT-009 | +| Mis Pedidos | /account/orders | RT-009 | +| Mis Puntos | /account/loyalty | RT-005 | + +### 4.4 PWA Features (POS) + +| Feature | Componente | +|---------|------------| +| Service Worker | sw.ts | +| IndexedDB | offline/db.ts (Dexie) | +| Sync Queue | offline/queue.ts | +| Background Sync | offline/sync.ts | +| Push Notifications | Opcional | + +### 4.5 Hardware Integration (POS) + +| Hardware | Archivo | API | +|----------|---------|-----| +| Impresora ESC/POS | hardware/printer.ts | Web USB, Web Bluetooth, Network | +| Lector Codigo Barras | hardware/scanner.ts | Keyboard Events | +| Cajon Dinero | hardware/drawer.ts | Via Impresora ESC/POS | + +--- + +## 5. ANALISIS DE RIESGOS POR IMPACTO + +### 5.1 Riesgos de Base de Datos + +| Riesgo | Probabilidad | Impacto | Mitigacion | +|--------|--------------|---------|------------| +| Migraciones fallidas | Media | Alto | Backups antes de cada migracion | +| RLS mal configurado | Baja | Critico | Tests exhaustivos de aislamiento | +| Performance en joins | Media | Medio | Indices optimizados, vistas materializadas | +| Datos inconsistentes offline | Media | Alto | Validacion robusta en sync | + +### 5.2 Riesgos de Backend + +| Riesgo | Probabilidad | Impacto | Mitigacion | +|--------|--------------|---------|------------| +| Integracion PAC falla | Media | Alto | PAC backup, reintentos | +| Motor precios lento | Baja | Medio | Cache de promociones | +| Sync conflictos | Media | Medio | Estrategia last-write-wins | +| Pasarelas pago | Media | Alto | SDK oficiales, logs detallados | + +### 5.3 Riesgos de Frontend + +| Riesgo | Probabilidad | Impacto | Mitigacion | +|--------|--------------|---------|------------| +| PWA no funciona offline | Media | Alto | Tests de offline exhaustivos | +| Hardware no compatible | Media | Medio | Fallbacks, documentacion clara | +| Performance en catalogos | Baja | Medio | Paginacion, lazy loading | +| UX confusa en POS | Baja | Medio | Tests de usabilidad | + +--- + +## 6. MATRIZ DE TRAZABILIDAD + +### 6.1 Requisitos -> Componentes + +| Requisito | Entidad | Servicio | Controller | UI | +|-----------|---------|----------|------------|-----| +| Venta en POS | pos_orders, pos_order_lines | POSOrderService | POSController | Sales.tsx | +| Cobro mixto | pos_payments | POSPaymentService | POSController | Checkout.tsx | +| Puntos lealtad | loyalty_transactions | LoyaltyService | CustomersController | PointsRedemption.tsx | +| Promociones | promotions | PromotionsService, PriceEngineService | PricingController | - | +| Cupones | coupons | CouponsService | PricingController | CouponInput.tsx | +| Transferencias | stock_transfers | TransfersService | InventoryController | TransferForm.tsx | +| Corte caja | cash_closings | CashClosingService | CashController | CashClosing.tsx | +| Facturacion | cfdis | CFDIService | CFDIController | InvoiceModal.tsx | +| Autofactura | cfdis | AutofacturaService | AutofacturaController | AutofacturaPortal.tsx | +| E-commerce | ecommerce_orders | CheckoutService | StorefrontController | Checkout.tsx | + +### 6.2 Entidades -> Servicios + +| Entidad | Servicios que la usan | +|---------|----------------------| +| branches | BranchesService, POSSessionService, TransfersService, RetailStockService | +| pos_orders | POSOrderService, LoyaltyService, CFDIService, CashClosingService | +| products | POSOrderService, CatalogService, CartService, RetailStockService | +| partners | RetailCustomersService, LoyaltyService, CFDIService | +| promotions | PromotionsService, PriceEngineService | +| coupons | CouponsService, PriceEngineService | +| cfdis | CFDIService, AutofacturaService | + +--- + +## 7. CHECKLIST DE VALIDACION DE IMPACTO + +### Database +- [x] Todas las tablas tienen tenant_id +- [x] Todas las tablas tienen RLS policy +- [x] FKs correctamente definidas +- [x] Indices necesarios identificados +- [x] Migraciones en orden correcto +- [x] Columnas generadas (STORED) definidas +- [x] Vistas materializadas planificadas + +### Backend +- [x] Servicios identificados para cada entidad +- [x] Herencias de core mapeadas +- [x] Integraciones externas documentadas +- [x] Middleware necesario identificado +- [x] WebSocket gateway planificado +- [x] DTOs definidos implicitamente + +### Frontend +- [x] Paginas por app identificadas +- [x] Componentes compartidos definidos +- [x] PWA features planificadas +- [x] Hardware integration documentada +- [x] Store management (Zustand) planificado + +### Seguridad +- [x] RLS en todas las tablas +- [x] JWT authentication +- [x] Middleware de tenant +- [x] Middleware de branch +- [x] RBAC pendiente (depende core) + +--- + +## 8. CONCLUSION + +### 8.1 Resumen de Impacto + +El proyecto Retail impacta: +- **100%** de la capa de datos con schema nuevo +- **100%** del backend con servicios nuevos/extendidos +- **100%** del frontend con 3 aplicaciones nuevas +- **35%** de integraciones (PAC, pagos, envios) + +### 8.2 Dependencias Criticas + +1. **MGN-005 Catalogs** - Bloqueante para todo retail +2. **MGN-001 Auth** - Bloqueante para autenticacion +3. **MGN-013 Sales** - Bloqueante para precios +4. **MGN-010 Financial** - Bloqueante para POS y facturacion + +### 8.3 Recomendaciones + +1. **Priorizar core** - No iniciar retail hasta completar prerrequisitos +2. **Tests RLS** - Validar aislamiento de datos antes de produccion +3. **Ambiente sandbox PAC** - Probar facturacion temprano +4. **Hardware testing** - Validar impresoras/scanners en hardware real +5. **PWA testing** - Probar offline en condiciones reales + +--- + +**Estado:** ANALISIS COMPLETO +**Impacto Total:** Alto +**Riesgo Global:** Medio (con mitigaciones) diff --git a/orchestration/planes/fase-4-validacion/VALIDACION-COMPLETITUD.md b/orchestration/planes/fase-4-validacion/VALIDACION-COMPLETITUD.md new file mode 100644 index 0000000..f02a36a --- /dev/null +++ b/orchestration/planes/fase-4-validacion/VALIDACION-COMPLETITUD.md @@ -0,0 +1,432 @@ +# VALIDACION DE COMPLETITUD + +**Fecha:** 2025-12-18 +**Fase:** 4 - Validacion +**Objetivo:** Verificar que no faltan objetos o componentes dependientes + +--- + +## 1. VALIDACION DE PRERREQUISITOS CORE + +### 1.1 Modulos Core Requeridos + +| Modulo | Estado | Requerido | Validado | Notas | +|--------|--------|-----------|----------|-------| +| MGN-001 Auth | 40% | 100% | :x: | Falta completar JWT, roles, permisos | +| MGN-002 Users | 40% | 100% | :x: | Depende de Auth | +| MGN-003 Tenants | 50% | 100% | :x: | RLS policies incompletas | +| MGN-004 Roles | 30% | 100% | :x: | Sistema de permisos | +| MGN-005 Catalogs | 0% | 100% | :x: | **BLOQUEANTE** - No iniciado | +| MGN-010 Financial | 70% | 90% | :x: | Falta migrar a TypeORM | +| MGN-011 Inventory | 60% | 80% | :x: | Products y stock_quants | +| MGN-012 Partners | 50% | 80% | :x: | Falta servicio completo | +| MGN-013 Sales | 50% | 70% | :x: | Pricelists incompleto | + +### 1.2 Entidades Core Requeridas por Retail + +| Entidad | Schema | Estado | Usado Por | +|---------|--------|--------|-----------| +| tenants | auth | Existe | Todos los modulos | +| users | auth | Existe | Auth, POS | +| roles | auth | Parcial | Auth, Permisos | +| permissions | auth | No existe | Auth | +| user_roles | auth | No existe | Auth | +| countries | core | No existe | Partners | +| currencies | core | No existe | Precios | +| sequences | core | No existe | Numeracion | +| uom | core | No existe | Productos | +| uom_categories | core | No existe | UoM | +| partners | core | Parcial | Clientes, Proveedores | +| taxes | financial | Existe | Facturacion | +| tax_groups | financial | No existe | Impuestos | +| accounts | financial | Existe | Contabilidad | +| journals | financial | Existe | Pagos | +| payment_methods | financial | No existe | POS, E-commerce | +| product_categories | inventory | Existe | Productos | +| products | inventory | Existe | Todos | +| warehouses | inventory | Existe | Inventario | +| stock_quants | inventory | Parcial | Stock | +| pricelists | sales | No existe | Precios | +| pricelist_items | sales | No existe | Precios | + +### 1.3 Acciones Requeridas para Core + +```yaml +acciones_core: + criticas: + - id: ACT-CORE-001 + modulo: MGN-005 Catalogs + descripcion: Implementar catalogos base completos + entidades: + - core.countries + - core.currencies + - core.uom_categories + - core.uom + - core.sequences + prioridad: P0 + bloqueante: true + + - id: ACT-CORE-002 + modulo: MGN-001 Auth + descripcion: Completar sistema de autenticacion + entidades: + - auth.permissions + - auth.user_roles + servicios: + - AuthService (completar JWT) + - PermissionsService + prioridad: P0 + bloqueante: true + + altas: + - id: ACT-CORE-003 + modulo: MGN-010 Financial + descripcion: Migrar a TypeORM + entidades: + - financial.tax_groups + - financial.payment_methods + prioridad: P0 + + - id: ACT-CORE-004 + modulo: MGN-013 Sales + descripcion: Implementar listas de precios + entidades: + - sales.pricelists + - sales.pricelist_items + prioridad: P0 +``` + +--- + +## 2. VALIDACION DE MODULOS RETAIL + +### 2.1 RT-001 Fundamentos + +| Componente | Tipo | Estado | Dependencia | +|------------|------|--------|-------------| +| retail schema | DDL | Planificado | - | +| branches | Entity | Planificado | auth.tenants | +| cash_registers | Entity | Planificado | branches | +| branch_users | Entity | Planificado | branches, users | +| BranchesService | Service | Planificado | - | +| CashRegistersService | Service | Planificado | BranchesService | + +**Validacion:** :white_check_mark: Completo - Todas las dependencias identificadas + +--- + +### 2.2 RT-002 POS + +| Componente | Tipo | Estado | Dependencia | +|------------|------|--------|-------------| +| pos_sessions | Entity | Planificado | branches, cash_registers, users | +| pos_orders | Entity | Planificado | pos_sessions, partners | +| pos_order_lines | Entity | Planificado | pos_orders, products | +| pos_payments | Entity | Planificado | pos_orders | +| POSSessionService | Service | Planificado | CashRegistersService | +| POSOrderService | Service | Planificado | PriceEngineService, RetailStockService, LoyaltyService | +| POSPaymentService | Service | Planificado | - | +| POSSyncService | Service | Planificado | POSOrderService | +| POSController | Controller | Planificado | POSSessionService, POSOrderService | +| POSGateway | WebSocket | Planificado | POSSyncService | +| Sales Page | Frontend | Planificado | API Client | +| Checkout Page | Frontend | Planificado | API Client | + +**Validacion:** :white_check_mark: Completo + +**Dependencias externas validadas:** +- [x] PriceEngineService (RT-006) +- [x] RetailStockService (RT-003) +- [x] LoyaltyService (RT-005) +- [x] Products (MGN-011) +- [x] Partners (MGN-012) + +--- + +### 2.3 RT-003 Inventario + +| Componente | Tipo | Estado | Dependencia | +|------------|------|--------|-------------| +| stock_transfers | Entity | Planificado | branches, users | +| stock_transfer_lines | Entity | Planificado | stock_transfers, products | +| stock_adjustments | Entity | Planificado | branches, users | +| stock_adjustment_lines | Entity | Planificado | stock_adjustments, products | +| branch_stock (view) | View | Planificado | stock_quants, branches, products | +| RetailStockService | Service | Planificado | StockService (core) | +| TransfersService | Service | Planificado | RetailStockService | +| AdjustmentsService | Service | Planificado | RetailStockService | +| InventoryController | Controller | Planificado | Todos los services | + +**Validacion:** :white_check_mark: Completo + +**Dependencias externas validadas:** +- [x] inventory.stock_quants (MGN-011) +- [x] inventory.products (MGN-011) +- [x] inventory.warehouses (MGN-011) + +--- + +### 2.4 RT-004 Compras + +| Componente | Tipo | Estado | Dependencia | +|------------|------|--------|-------------| +| purchase_suggestions | Entity | Planificado | branches, products, partners | +| supplier_orders | Entity | Planificado | partners, branches, users | +| supplier_order_lines | Entity | Planificado | supplier_orders, products | +| goods_receipts | Entity | Planificado | supplier_orders, branches, partners | +| goods_receipt_lines | Entity | Planificado | goods_receipts, products | +| RetailPurchaseService | Service | Planificado | PurchaseService (core) | +| PurchaseSuggestionsService | Service | Planificado | RetailStockService | +| GoodsReceiptService | Service | Planificado | RetailStockService | +| PurchaseController | Controller | Planificado | Todos los services | + +**Validacion:** :white_check_mark: Completo + +--- + +### 2.5 RT-005 Clientes + +| Componente | Tipo | Estado | Dependencia | +|------------|------|--------|-------------| +| loyalty_programs | Entity | Planificado | tenants | +| membership_levels | Entity | Planificado | loyalty_programs | +| loyalty_transactions | Entity | Planificado | partners, loyalty_programs, pos_orders | +| customer_memberships | Entity | Planificado | partners, loyalty_programs, membership_levels | +| RetailCustomersService | Service | Planificado | PartnersService (core) | +| LoyaltyService | Service | Planificado | - | +| CustomersController | Controller | Planificado | RetailCustomersService, LoyaltyService | + +**Validacion:** :white_check_mark: Completo + +**Dependencias externas validadas:** +- [x] core.partners (MGN-012) +- [x] retail.pos_orders (RT-002) + +--- + +### 2.6 RT-006 Precios + +| Componente | Tipo | Estado | Dependencia | +|------------|------|--------|-------------| +| promotions | Entity | Planificado | tenants | +| promotion_products | Entity | Planificado | promotions, products, product_categories | +| coupons | Entity | Planificado | tenants, partners | +| coupon_redemptions | Entity | Planificado | coupons, pos_orders | +| PriceEngineService | Service | Planificado | PricelistsService (core), PromotionsService, CouponsService | +| PromotionsService | Service | Planificado | - | +| CouponsService | Service | Planificado | - | +| PricingController | Controller | Planificado | Todos los services | + +**Validacion:** :white_check_mark: Completo + +**Dependencias externas validadas:** +- [x] sales.pricelists (MGN-013) - **PENDIENTE IMPLEMENTAR** +- [x] inventory.products (MGN-011) +- [x] inventory.product_categories (MGN-011) + +--- + +### 2.7 RT-007 Caja + +| Componente | Tipo | Estado | Dependencia | +|------------|------|--------|-------------| +| cash_movements | Entity | Planificado | pos_sessions, users | +| cash_closings | Entity | Planificado | pos_sessions, users | +| cash_counts | Entity | Planificado | pos_sessions, users | +| CashSessionService | Service | Planificado | POSSessionService | +| CashMovementService | Service | Planificado | CashSessionService | +| CashClosingService | Service | Planificado | CashSessionService | +| CashController | Controller | Planificado | Todos los services | + +**Validacion:** :white_check_mark: Completo + +**Dependencias externas validadas:** +- [x] retail.pos_sessions (RT-002) +- [x] auth.users (MGN-002) + +--- + +### 2.8 RT-008 Reportes + +| Componente | Tipo | Estado | Dependencia | +|------------|------|--------|-------------| +| mv_daily_sales | Mat. View | Planificado | pos_orders, pos_sessions | +| mv_product_sales | Mat. View | Planificado | pos_order_lines, pos_orders, pos_sessions | +| mv_branch_stock_summary | Mat. View | Planificado | branches, stock_quants, products | +| DashboardService | Service | Planificado | Multiples repositories | +| SalesReportService | Service | Planificado | ReportsService (core) | +| ProductReportService | Service | Planificado | - | +| CashReportService | Service | Planificado | - | +| ExportService | Service | Planificado | - | +| ReportsController | Controller | Planificado | Todos los services | + +**Validacion:** :white_check_mark: Completo + +**Dependencias externas validadas:** +- [x] retail.pos_orders (RT-002) +- [x] retail.pos_order_lines (RT-002) +- [x] retail.pos_sessions (RT-002) +- [x] retail.cash_closings (RT-007) +- [x] inventory.stock_quants (MGN-011) + +--- + +### 2.9 RT-009 E-commerce + +| Componente | Tipo | Estado | Dependencia | +|------------|------|--------|-------------| +| carts | Entity | Planificado | tenants, partners | +| cart_items | Entity | Planificado | carts, products | +| ecommerce_orders | Entity | Planificado | partners, branches | +| ecommerce_order_lines | Entity | Planificado | ecommerce_orders, products | +| shipping_rates | Entity | Planificado | tenants | +| CatalogService | Service | Planificado | ProductsService | +| CartService | Service | Planificado | - | +| CheckoutService | Service | Planificado | CartService, PriceEngineService, RetailStockService | +| PaymentGatewayService | Service | Planificado | Stripe, Conekta providers | +| ShippingService | Service | Planificado | - | +| EcommerceOrderService | Service | Planificado | - | +| StorefrontController | Controller | Planificado | Todos los services | +| EcommerceAdminController | Controller | Planificado | EcommerceOrderService | + +**Validacion:** :white_check_mark: Completo + +**Dependencias externas validadas:** +- [x] inventory.products (MGN-011) +- [x] core.partners (MGN-012) +- [x] retail.branches (RT-001) +- [x] PriceEngineService (RT-006) +- [x] RetailStockService (RT-003) + +--- + +### 2.10 RT-010 Facturacion + +| Componente | Tipo | Estado | Dependencia | +|------------|------|--------|-------------| +| cfdi_config | Entity | Planificado | tenants | +| cfdis | Entity | Planificado | pos_orders, ecommerce_orders, partners | +| CFDIService | Service | Planificado | CFDIBuilderService, PACService, XMLService, PDFService | +| CFDIBuilderService | Service | Planificado | - | +| PACService | Service | Planificado | FinkokPAC, FacturamaPAC | +| XMLService | Service | Planificado | - | +| PDFService | Service | Planificado | - | +| AutofacturaService | Service | Planificado | CFDIService | +| CFDIController | Controller | Planificado | CFDIService | +| AutofacturaController | Controller | Planificado | AutofacturaService | + +**Validacion:** :white_check_mark: Completo + +**Dependencias externas validadas:** +- [x] retail.pos_orders (RT-002) +- [x] retail.ecommerce_orders (RT-009) +- [x] core.partners (MGN-012) +- [x] financial.taxes (MGN-010) + +--- + +## 3. VALIDACION DE INTEGRACIONES + +### 3.1 Integraciones Backend + +| Integracion | Modulo | Estado | Validacion | +|-------------|--------|--------|------------| +| Finkok PAC | RT-010 | Planificado | :white_check_mark: Docs API disponibles | +| Facturama PAC | RT-010 | Planificado | :white_check_mark: Docs API disponibles | +| Stripe | RT-009 | Planificado | :white_check_mark: SDK oficial | +| Conekta | RT-009 | Planificado | :white_check_mark: SDK oficial | +| MercadoPago | RT-009 | Opcional | :white_check_mark: SDK oficial | +| Fedex | RT-009 | Planificado | :warning: Requiere contrato | +| DHL | RT-009 | Opcional | :warning: Requiere contrato | + +### 3.2 Integraciones Hardware (POS) + +| Integracion | Tipo | Estado | Validacion | +|-------------|------|--------|------------| +| Impresora ESC/POS | USB/BT/Net | Planificado | :white_check_mark: Web USB/BT APIs | +| Lector codigo barras | Keyboard wedge | Planificado | :white_check_mark: Solo JS | +| Cajon de dinero | Via impresora | Planificado | :white_check_mark: Comando ESC/POS | +| Terminal bancaria | Serial/USB | Opcional | :warning: Depende de banco | + +--- + +## 4. VALIDACION DE SEGURIDAD + +### 4.1 Row-Level Security (RLS) + +| Tabla | RLS Habilitado | Policy Definida | +|-------|----------------|-----------------| +| retail.branches | :white_check_mark: | :white_check_mark: | +| retail.cash_registers | :white_check_mark: | :white_check_mark: | +| retail.branch_users | :white_check_mark: | :white_check_mark: | +| retail.pos_sessions | :white_check_mark: | :white_check_mark: | +| retail.pos_orders | :white_check_mark: | :white_check_mark: | +| retail.pos_order_lines | :white_check_mark: | :white_check_mark: | +| retail.pos_payments | :white_check_mark: | :white_check_mark: | +| retail.cash_movements | :white_check_mark: | :white_check_mark: | +| retail.cash_closings | :white_check_mark: | :white_check_mark: | +| retail.cash_counts | :white_check_mark: | :white_check_mark: | +| retail.stock_transfers | :white_check_mark: | :white_check_mark: | +| retail.stock_adjustments | :white_check_mark: | :white_check_mark: | +| retail.loyalty_programs | :white_check_mark: | :white_check_mark: | +| retail.customer_memberships | :white_check_mark: | :white_check_mark: | +| retail.promotions | :white_check_mark: | :white_check_mark: | +| retail.coupons | :white_check_mark: | :white_check_mark: | +| retail.cfdis | :white_check_mark: | :white_check_mark: | +| retail.cfdi_config | :white_check_mark: | :white_check_mark: | +| retail.carts | :white_check_mark: | :white_check_mark: | +| retail.ecommerce_orders | :white_check_mark: | :white_check_mark: | + +**Validacion RLS:** :white_check_mark: Todas las tablas tienen RLS planificado + +### 4.2 Autenticacion y Autorizacion + +| Aspecto | Estado | Validacion | +|---------|--------|------------| +| JWT Token | Planificado | :white_check_mark: | +| Refresh Token | Planificado | :white_check_mark: | +| Middleware Tenant | Planificado | :white_check_mark: | +| Middleware Branch | Planificado | :white_check_mark: | +| RBAC | Pendiente core | :warning: Depende de MGN-004 | + +--- + +## 5. RESUMEN DE VALIDACION + +### 5.1 Estado por Fase + +| Fase | Modulos | Validacion | +|------|---------|------------| +| Prerrequisitos Core | MGN-001,002,003,004,005,010,011,012,013 | :x: 9 gaps identificados | +| Retail Fundamentos | RT-001 | :white_check_mark: Completo | +| POS MVP | RT-002, RT-006, RT-003, RT-007 | :white_check_mark: Completo | +| Facturacion | RT-010 | :white_check_mark: Completo | +| Clientes | RT-005 | :white_check_mark: Completo | +| Compras | RT-004 | :white_check_mark: Completo | +| Reportes | RT-008 | :white_check_mark: Completo | +| E-commerce | RT-009 | :white_check_mark: Completo | + +### 5.2 Gaps Criticos (Bloqueantes) + +| ID | Descripcion | Modulo | Accion | +|----|-------------|--------|--------| +| GAP-VAL-001 | MGN-005 Catalogs no existe | Core | Implementar antes de Sprint 5 | +| GAP-VAL-002 | Auth/Permisos incompleto | MGN-001/004 | Completar antes de Sprint 5 | +| GAP-VAL-003 | Pricelists no existe | MGN-013 | Implementar antes de RT-006 | +| GAP-VAL-004 | PaymentMethods no existe | MGN-010 | Implementar antes de RT-002 | + +### 5.3 Conclusion + +:warning: **NO SE PUEDE INICIAR RETAIL** hasta completar los prerrequisitos del core (Sprints 1-4). + +Una vez completados los prerrequisitos: +- :white_check_mark: Todas las entidades retail estan planificadas +- :white_check_mark: Todas las dependencias estan mapeadas +- :white_check_mark: Todas las integraciones estan identificadas +- :white_check_mark: RLS planificado para todas las tablas + +--- + +**Estado:** VALIDACION COMPLETA CON GAPS +**Gaps Bloqueantes:** 4 +**Gaps No Bloqueantes:** 0 diff --git a/orchestration/planes/fase-5-implementacion/SPRINT-1-SUMMARY.md b/orchestration/planes/fase-5-implementacion/SPRINT-1-SUMMARY.md new file mode 100644 index 0000000..62d0bd0 --- /dev/null +++ b/orchestration/planes/fase-5-implementacion/SPRINT-1-SUMMARY.md @@ -0,0 +1,169 @@ +# Sprint 1 - Resumen de Implementación + +## Fecha: 2025-12-18 +## Estado: ✅ COMPLETADO + +--- + +## Objetivo del Sprint +Crear la estructura base del backend retail con todas las entidades TypeORM necesarias para los 10 módulos del sistema. + +--- + +## Entregables Completados + +### 1. Configuración Base +- ✅ `package.json` - Dependencias del proyecto +- ✅ `tsconfig.json` - Configuración TypeScript con decoradores +- ✅ `src/config/database.ts` - Pool PostgreSQL con soporte tenant +- ✅ `src/config/typeorm.ts` - DataSource TypeORM con todas las entidades +- ✅ `src/app.ts` - Aplicación Express con middlewares +- ✅ `src/index.ts` - Entry point con graceful shutdown + +### 2. Entidades TypeORM por Módulo + +#### RT-001: Fundamentos (3 entidades) +| Entidad | Archivo | Descripción | +|---------|---------|-------------| +| Branch | `branches/entities/branch.entity.ts` | Sucursales con configuración POS | +| CashRegister | `branches/entities/cash-register.entity.ts` | Cajas registradoras | +| BranchUser | `branches/entities/branch-user.entity.ts` | Usuarios por sucursal | + +#### RT-002: Punto de Venta (4 entidades) +| Entidad | Archivo | Descripción | +|---------|---------|-------------| +| POSSession | `pos/entities/pos-session.entity.ts` | Sesiones de caja | +| POSOrder | `pos/entities/pos-order.entity.ts` | Órdenes de venta | +| POSOrderLine | `pos/entities/pos-order-line.entity.ts` | Líneas de orden | +| POSPayment | `pos/entities/pos-payment.entity.ts` | Pagos | + +#### RT-003: Inventario Retail (4 entidades) +| Entidad | Archivo | Descripción | +|---------|---------|-------------| +| StockTransfer | `inventory/entities/stock-transfer.entity.ts` | Traspasos entre almacenes | +| StockTransferLine | `inventory/entities/stock-transfer-line.entity.ts` | Líneas de traspaso | +| StockAdjustment | `inventory/entities/stock-adjustment.entity.ts` | Ajustes de inventario | +| StockAdjustmentLine | `inventory/entities/stock-adjustment-line.entity.ts` | Líneas de ajuste | + +#### RT-004: Compras Retail (5 entidades) +| Entidad | Archivo | Descripción | +|---------|---------|-------------| +| PurchaseSuggestion | `purchases/entities/purchase-suggestion.entity.ts` | Sugerencias automáticas | +| SupplierOrder | `purchases/entities/supplier-order.entity.ts` | Órdenes a proveedor | +| SupplierOrderLine | `purchases/entities/supplier-order-line.entity.ts` | Líneas de orden | +| GoodsReceipt | `purchases/entities/goods-receipt.entity.ts` | Recepción de mercancía | +| GoodsReceiptLine | `purchases/entities/goods-receipt-line.entity.ts` | Líneas de recepción | + +#### RT-005: Clientes y Lealtad (4 entidades) +| Entidad | Archivo | Descripción | +|---------|---------|-------------| +| LoyaltyProgram | `customers/entities/loyalty-program.entity.ts` | Programa de lealtad | +| MembershipLevel | `customers/entities/membership-level.entity.ts` | Niveles de membresía | +| LoyaltyTransaction | `customers/entities/loyalty-transaction.entity.ts` | Transacciones de puntos | +| CustomerMembership | `customers/entities/customer-membership.entity.ts` | Membresías de cliente | + +#### RT-006: Precios y Promociones (4 entidades) +| Entidad | Archivo | Descripción | +|---------|---------|-------------| +| Promotion | `pricing/entities/promotion.entity.ts` | Promociones | +| PromotionProduct | `pricing/entities/promotion-product.entity.ts` | Productos en promoción | +| Coupon | `pricing/entities/coupon.entity.ts` | Cupones | +| CouponRedemption | `pricing/entities/coupon-redemption.entity.ts` | Canjes de cupón | + +#### RT-007: Caja y Arqueo (3 entidades) +| Entidad | Archivo | Descripción | +|---------|---------|-------------| +| CashMovement | `cash/entities/cash-movement.entity.ts` | Movimientos de efectivo | +| CashClosing | `cash/entities/cash-closing.entity.ts` | Cierres de caja | +| CashCount | `cash/entities/cash-count.entity.ts` | Conteo de denominaciones | + +#### RT-009: E-commerce (5 entidades) +| Entidad | Archivo | Descripción | +|---------|---------|-------------| +| Cart | `ecommerce/entities/cart.entity.ts` | Carritos de compra | +| CartItem | `ecommerce/entities/cart-item.entity.ts` | Ítems del carrito | +| EcommerceOrder | `ecommerce/entities/ecommerce-order.entity.ts` | Órdenes e-commerce | +| EcommerceOrderLine | `ecommerce/entities/ecommerce-order-line.entity.ts` | Líneas de orden | +| ShippingRate | `ecommerce/entities/shipping-rate.entity.ts` | Tarifas de envío | + +#### RT-010: Facturación CFDI (2 entidades) +| Entidad | Archivo | Descripción | +|---------|---------|-------------| +| CFDIConfig | `invoicing/entities/cfdi-config.entity.ts` | Configuración PAC/CSD | +| CFDI | `invoicing/entities/cfdi.entity.ts` | Comprobantes fiscales | + +--- + +## Estadísticas del Sprint + +| Métrica | Valor | +|---------|-------| +| Total de entidades | 35 | +| Archivos creados | 42 | +| Módulos cubiertos | 9/10 | +| Líneas de código aprox. | ~4,500 | + +--- + +## Características Implementadas en Entidades + +### Multi-tenant +- ✅ Columna `tenant_id` en todas las entidades +- ✅ Índices compuestos con tenant_id +- ✅ Configuración de contexto de tenant en database.ts + +### Auditoría +- ✅ `created_at`, `updated_at` timestamps +- ✅ `created_by`, `updated_by` UUIDs donde aplica +- ✅ Tracking de aprobaciones y rechazos + +### Retail Específico +- ✅ Soporte offline con `is_offline_order`, `synced_at` +- ✅ Configuración de impresoras ESC/POS +- ✅ Integración CFDI 4.0 completa +- ✅ Programas de lealtad con múltiples niveles +- ✅ Promociones tipo Buy X Get Y, bundles, flash sales + +--- + +## Próximos Pasos (Sprint 2) + +1. **Servicios Base** + - Crear servicios CRUD para cada módulo + - Implementar BaseService con operaciones comunes + +2. **Controladores y Rutas** + - Definir endpoints REST + - Implementar validación con Zod + +3. **Middleware** + - Tenant context middleware + - Auth middleware (integración con erp-core) + - Branch context middleware + +4. **Migraciones** + - Generar migraciones TypeORM + - Scripts de seed para datos iniciales + +--- + +## Dependencias de erp-core + +Las entidades hacen referencia a tablas de erp-core mediante UUIDs: + +| Módulo Retail | Referencia erp-core | +|---------------|---------------------| +| Branch | `warehouse_id` → inventory.warehouses | +| POSOrder | `customer_id` → partners.partners | +| Products | `product_id` → inventory.products | +| Taxes | `tax_id` → financial.taxes | +| Users | `user_id` → auth.users | + +--- + +## Notas Técnicas + +- Schema PostgreSQL: `retail` +- TypeORM synchronize: `false` (producción) +- Pool connections: máx 10 (TypeORM), máx 20 (pg) +- Timestamps: UTC con `timestamp with time zone` diff --git a/orchestration/planes/fase-5-implementacion/SPRINT-2-SUMMARY.md b/orchestration/planes/fase-5-implementacion/SPRINT-2-SUMMARY.md new file mode 100644 index 0000000..f42191a --- /dev/null +++ b/orchestration/planes/fase-5-implementacion/SPRINT-2-SUMMARY.md @@ -0,0 +1,274 @@ +# Sprint 2 - Resumen de Implementación + +## Fecha: 2025-12-18 +## Estado: ✅ COMPLETADO + +--- + +## Objetivo del Sprint +Implementar la capa de servicios, controladores, rutas y middleware para establecer la arquitectura de la API REST. + +--- + +## Entregables Completados + +### 1. Tipos y Interfaces Compartidas (`shared/types/index.ts`) +- ✅ `TenantContext` - Contexto de tenant +- ✅ `BranchContext` - Contexto de sucursal +- ✅ `UserContext` - Contexto de usuario autenticado +- ✅ `AuthenticatedRequest` - Request extendido con contextos +- ✅ `PaginationParams` / `PaginatedResult` - Paginación +- ✅ `FilterCondition` / `FilterOperator` - Filtros dinámicos +- ✅ `QueryOptions` - Opciones de consulta +- ✅ `ApiResponse` / `ServiceResult` - Tipos de respuesta + +### 2. BaseService (`shared/services/base.service.ts`) +Servicio base genérico con operaciones CRUD: + +| Método | Descripción | +|--------|-------------| +| `findAll()` | Listado con paginación, filtros y búsqueda | +| `findById()` | Buscar por ID con relaciones opcionales | +| `findBy()` | Buscar por campo específico | +| `findOneBy()` | Buscar uno por campo | +| `create()` | Crear entidad | +| `createMany()` | Crear múltiples entidades | +| `update()` | Actualizar entidad | +| `delete()` | Eliminar entidad | +| `deleteMany()` | Eliminar múltiples por IDs | +| `exists()` | Verificar existencia | +| `count()` | Contar con filtros | +| `applyFilters()` | Aplicar filtros dinámicos al QueryBuilder | + +### 3. Middleware (`shared/middleware/`) + +#### Tenant Middleware +- `tenantMiddleware` - Extrae y valida X-Tenant-ID (requerido) +- `optionalTenantMiddleware` - Tenant opcional + +#### Auth Middleware +- `authMiddleware` - Valida JWT token +- `optionalAuthMiddleware` - Auth opcional +- `requireRoles()` - Requiere roles específicos +- `requirePermissions()` - Requiere permisos específicos + +#### Branch Middleware +- `branchMiddleware` - Extrae X-Branch-ID (requerido) +- `optionalBranchMiddleware` - Branch opcional +- `validateBranchMiddleware` - Valida que branch existe y está activo +- `requireBranchCapability()` - Requiere capacidad específica (pos/inventory/ecommerce) + +### 4. BaseController (`shared/controllers/base.controller.ts`) +Controlador base con métodos de respuesta: + +| Método | Descripción | +|--------|-------------| +| `success()` | Respuesta exitosa con data | +| `paginated()` | Respuesta paginada | +| `error()` | Respuesta de error | +| `notFound()` | Error 404 | +| `validationError()` | Error de validación | +| `unauthorized()` | Error 401 | +| `forbidden()` | Error 403 | +| `conflict()` | Error 409 | +| `internalError()` | Error 500 | +| `getTenantId()` | Obtener tenant del request | +| `getUserId()` | Obtener user del request | +| `getBranchId()` | Obtener branch del request | +| `parsePagination()` | Parsear parámetros de paginación | + +### 5. Servicios de Módulo + +#### BranchService (`modules/branches/services/`) +- Gestión de sucursales +- Creación con caja registradora por defecto +- Búsqueda por código y tipo +- Actualización de estado con validaciones +- Búsqueda por proximidad geográfica (Haversine) +- Estadísticas de sucursal + +#### POSSessionService (`modules/pos/services/`) +- Apertura de sesión con validaciones +- Cierre de sesión con cálculo de diferencias +- Obtener sesión activa por usuario/caja +- Actualización de totales de sesión +- Resumen diario de sesiones + +#### POSOrderService (`modules/pos/services/`) +- Creación de órdenes con líneas +- Cálculo automático de impuestos y descuentos +- Agregar pagos (efectivo, tarjeta, transferencia) +- Anulación de órdenes +- Búsqueda de órdenes + +### 6. Controladores y Rutas + +#### Branch Controller & Routes +``` +GET /api/branches - Listar sucursales +GET /api/branches/active - Listar activas +GET /api/branches/nearby - Buscar cercanas +GET /api/branches/code/:code - Por código +GET /api/branches/:id - Por ID +GET /api/branches/:id/stats - Estadísticas +POST /api/branches - Crear (admin/manager) +PUT /api/branches/:id - Actualizar (admin/manager) +PATCH /api/branches/:id/status - Cambiar estado (admin/manager) +DELETE /api/branches/:id - Eliminar (admin) +``` + +#### POS Controller & Routes +``` +# Sesiones +GET /api/pos/sessions/active - Sesión activa del usuario +POST /api/pos/sessions/open - Abrir sesión +POST /api/pos/sessions/:id/close - Cerrar sesión +GET /api/pos/sessions/daily-summary - Resumen diario +GET /api/pos/sessions - Listar sesiones +GET /api/pos/sessions/:id - Detalle de sesión + +# Órdenes +POST /api/pos/orders - Crear orden +GET /api/pos/orders - Listar órdenes +GET /api/pos/orders/session/:id - Órdenes por sesión +GET /api/pos/orders/:id - Detalle de orden +POST /api/pos/orders/:id/payments - Agregar pago +POST /api/pos/orders/:id/void - Anular (supervisor+) +``` + +### 7. Actualización de app.ts +- Importación de rutas +- Registro de endpoints `/api/branches` y `/api/pos` + +--- + +## Estructura de Archivos Creados + +``` +src/ +├── shared/ +│ ├── types/ +│ │ └── index.ts +│ ├── services/ +│ │ └── base.service.ts +│ ├── middleware/ +│ │ ├── tenant.middleware.ts +│ │ ├── auth.middleware.ts +│ │ ├── branch.middleware.ts +│ │ └── index.ts +│ ├── controllers/ +│ │ └── base.controller.ts +│ └── index.ts +├── modules/ +│ ├── branches/ +│ │ ├── services/ +│ │ │ ├── branch.service.ts +│ │ │ └── index.ts +│ │ ├── controllers/ +│ │ │ └── branch.controller.ts +│ │ ├── routes/ +│ │ │ └── branch.routes.ts +│ │ └── index.ts +│ └── pos/ +│ ├── services/ +│ │ ├── pos-session.service.ts +│ │ ├── pos-order.service.ts +│ │ └── index.ts +│ ├── controllers/ +│ │ └── pos.controller.ts +│ ├── routes/ +│ │ └── pos.routes.ts +│ └── index.ts +└── app.ts (actualizado) +``` + +--- + +## Estadísticas del Sprint + +| Métrica | Valor | +|---------|-------| +| Archivos creados | 16 | +| Líneas de código aprox. | ~2,500 | +| Endpoints definidos | 17 | +| Servicios creados | 3 | +| Middleware creados | 8 | + +--- + +## Próximos Pasos (Sprint 3) + +1. **Validación con Zod** + - Crear schemas de validación para DTOs + - Middleware de validación + +2. **Servicios Restantes** + - Cash service (movimientos, arqueos) + - Inventory service (traspasos, ajustes) + - Customer/Loyalty service + +3. **Controladores y Rutas Restantes** + - Cash routes + - Inventory routes + - Customer routes + - Pricing routes + +4. **Testing** + - Unit tests para services + - Integration tests para endpoints + +--- + +## Ejemplo de Uso de API + +### Abrir Sesión POS +```bash +curl -X POST http://localhost:3001/api/pos/sessions/open \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer " \ + -H "X-Tenant-ID: " \ + -H "X-Branch-ID: " \ + -d '{ + "registerId": "", + "openingCash": 500.00, + "openingNotes": "Apertura del día" + }' +``` + +### Crear Orden +```bash +curl -X POST http://localhost:3001/api/pos/orders \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer " \ + -H "X-Tenant-ID: " \ + -H "X-Branch-ID: " \ + -d '{ + "sessionId": "", + "registerId": "", + "lines": [ + { + "productId": "", + "productCode": "SKU001", + "productName": "Producto 1", + "quantity": 2, + "unitPrice": 150.00, + "originalPrice": 150.00, + "taxRate": 0.16 + } + ] + }' +``` + +### Agregar Pago +```bash +curl -X POST http://localhost:3001/api/pos/orders//payments \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer " \ + -H "X-Tenant-ID: " \ + -H "X-Branch-ID: " \ + -d '{ + "method": "cash", + "amount": 348.00, + "amountReceived": 400.00 + }' +``` diff --git a/orchestration/planes/fase-5-implementacion/SPRINT-3-SUMMARY.md b/orchestration/planes/fase-5-implementacion/SPRINT-3-SUMMARY.md new file mode 100644 index 0000000..6bc6fcf --- /dev/null +++ b/orchestration/planes/fase-5-implementacion/SPRINT-3-SUMMARY.md @@ -0,0 +1,257 @@ +# Sprint 3 Summary: Validación Zod y Servicios Restantes + +## Objetivo +Implementar validación con Zod para todos los DTOs y crear los servicios, controladores y rutas para los módulos Cash, Inventory y Customers/Loyalty. + +## Componentes Creados + +### 1. Sistema de Validación Zod + +#### Shared Validation (`/shared/validation/`) +| Archivo | Descripción | +|---------|-------------| +| `common.schema.ts` | Schemas comunes: uuid, money, pagination, address, phone, RFC, postal code, operating hours, coordinates | +| `validation.middleware.ts` | Middleware: `validate()`, `validateBody()`, `validateQuery()`, `validateParams()`, `validateRequest()` | +| `index.ts` | Barrel exports | + +### 2. Módulo Cash + +#### Servicios (`/modules/cash/services/`) +| Archivo | Clase | Métodos Principales | +|---------|-------|---------------------| +| `cash-movement.service.ts` | `CashMovementService` | `createMovement()`, `findMovements()`, `approveMovement()`, `rejectMovement()`, `cancelMovement()`, `getSessionBalance()`, `getSessionSummary()` | +| `cash-closing.service.ts` | `CashClosingService` | `createClosing()`, `submitCashCount()`, `submitPaymentCounts()`, `approveClosing()`, `rejectClosing()`, `reconcileClosing()`, `getDailySummary()` | + +#### Validación (`/modules/cash/validation/`) +| Schema | Propósito | +|--------|-----------| +| `createMovementSchema` | Crear movimiento de caja | +| `movementActionSchema` | Aprobar/rechazar movimiento | +| `listMovementsQuerySchema` | Filtros para listar movimientos | +| `createClosingSchema` | Crear cierre de caja | +| `submitCashCountSchema` | Enviar conteo de denominaciones | +| `submitPaymentCountsSchema` | Enviar totales por método de pago | +| `reconcileClosingSchema` | Conciliar cierre con depósito | + +#### Controller y Rutas +- `CashController`: 17 endpoints +- `cash.routes.ts`: Rutas con validación y autorización + +**Endpoints:** +``` +POST /cash/movements - Crear movimiento +GET /cash/movements - Listar movimientos +GET /cash/movements/:id - Obtener movimiento +POST /cash/movements/:id/approve - Aprobar movimiento +POST /cash/movements/:id/reject - Rechazar movimiento +POST /cash/movements/:id/cancel - Cancelar movimiento +GET /cash/sessions/:sessionId/summary - Resumen de sesión +POST /cash/closings - Crear cierre +GET /cash/closings - Listar cierres +GET /cash/closings/:id - Obtener cierre +POST /cash/closings/:id/count - Enviar conteo de efectivo +POST /cash/closings/:id/payments - Enviar conteo de pagos +POST /cash/closings/:id/approve - Aprobar cierre +POST /cash/closings/:id/reject - Rechazar cierre +POST /cash/closings/:id/reconcile - Conciliar cierre +GET /cash/summary/daily - Resumen diario +``` + +### 3. Módulo Inventory + +#### Servicios (`/modules/inventory/services/`) +| Archivo | Clase | Métodos Principales | +|---------|-------|---------------------| +| `stock-transfer.service.ts` | `StockTransferService` | `createTransfer()`, `submitForApproval()`, `approveTransfer()`, `shipTransfer()`, `receiveTransfer()`, `cancelTransfer()`, `getPendingIncomingTransfers()` | +| `stock-adjustment.service.ts` | `StockAdjustmentService` | `createAdjustment()`, `submitForApproval()`, `approveAdjustment()`, `rejectAdjustment()`, `postAdjustment()`, `cancelAdjustment()`, `addLine()` | + +#### Validación (`/modules/inventory/validation/`) +| Schema | Propósito | +|--------|-----------| +| `createTransferSchema` | Crear traspaso con líneas | +| `shipTransferSchema` | Datos de envío | +| `receiveTransferSchema` | Datos de recepción | +| `createAdjustmentSchema` | Crear ajuste con líneas | +| `addAdjustmentLineSchema` | Agregar línea a ajuste | +| `rejectAdjustmentSchema` | Rechazar ajuste | + +#### Controller y Rutas +- `InventoryController`: 18 endpoints +- `inventory.routes.ts`: Rutas con validación y autorización + +**Endpoints:** +``` +POST /inventory/transfers - Crear traspaso +GET /inventory/transfers - Listar traspasos +GET /inventory/transfers/summary - Resumen de traspasos +GET /inventory/transfers/incoming - Traspasos entrantes +GET /inventory/transfers/:id - Obtener traspaso +POST /inventory/transfers/:id/submit - Enviar para aprobación +POST /inventory/transfers/:id/approve - Aprobar traspaso +POST /inventory/transfers/:id/ship - Enviar traspaso +POST /inventory/transfers/:id/receive - Recibir traspaso +POST /inventory/transfers/:id/cancel - Cancelar traspaso +POST /inventory/adjustments - Crear ajuste +GET /inventory/adjustments - Listar ajustes +GET /inventory/adjustments/summary - Resumen de ajustes +GET /inventory/adjustments/:id - Obtener ajuste +POST /inventory/adjustments/:id/lines - Agregar línea +POST /inventory/adjustments/:id/submit - Enviar para aprobación +POST /inventory/adjustments/:id/approve - Aprobar ajuste +POST /inventory/adjustments/:id/reject - Rechazar ajuste +POST /inventory/adjustments/:id/post - Aplicar al inventario +POST /inventory/adjustments/:id/cancel - Cancelar ajuste +``` + +### 4. Módulo Customers/Loyalty + +#### Servicios (`/modules/customers/services/`) +| Archivo | Clase | Métodos Principales | +|---------|-------|---------------------| +| `loyalty.service.ts` | `LoyaltyService` | `enrollCustomer()`, `calculatePoints()`, `earnPoints()`, `redeemPoints()`, `adjustPoints()`, `getMembershipByCustomer()`, `getMembershipByCard()`, `getTransactionHistory()`, `getExpiringPoints()` | + +#### Validación (`/modules/customers/validation/`) +| Schema | Propósito | +|--------|-----------| +| `createProgramSchema` | Crear programa de lealtad | +| `createLevelSchema` | Crear nivel de membresía | +| `enrollCustomerSchema` | Inscribir cliente | +| `earnPointsSchema` | Acumular puntos | +| `redeemPointsSchema` | Canjear puntos | +| `adjustPointsSchema` | Ajustar puntos manualmente | +| `calculatePointsSchema` | Preview de cálculo de puntos | + +#### Controller y Rutas +- `LoyaltyController`: 12 endpoints +- `loyalty.routes.ts`: Rutas con validación y autorización + +**Endpoints:** +``` +GET /loyalty/program - Programa activo +POST /loyalty/enroll - Inscribir cliente +GET /loyalty/memberships - Listar membresías +GET /loyalty/memberships/customer/:customerId - Por cliente +GET /loyalty/memberships/card/:cardNumber - Por tarjeta +GET /loyalty/memberships/:membershipId/expiring - Puntos por expirar +GET /loyalty/memberships/:membershipId/transactions - Historial +POST /loyalty/points/calculate - Calcular preview +POST /loyalty/points/earn - Acumular puntos +POST /loyalty/points/redeem - Canjear puntos +POST /loyalty/points/adjust - Ajustar puntos +``` + +## Resumen de Archivos Creados + +``` +src/ +├── shared/ +│ └── validation/ +│ ├── common.schema.ts +│ ├── validation.middleware.ts +│ └── index.ts +├── modules/ +│ ├── branches/ +│ │ └── validation/ +│ │ └── branch.schema.ts +│ ├── pos/ +│ │ └── validation/ +│ │ └── pos.schema.ts +│ ├── cash/ +│ │ ├── services/ +│ │ │ ├── cash-movement.service.ts +│ │ │ ├── cash-closing.service.ts +│ │ │ └── index.ts +│ │ ├── validation/ +│ │ │ └── cash.schema.ts +│ │ ├── controllers/ +│ │ │ └── cash.controller.ts +│ │ ├── routes/ +│ │ │ └── cash.routes.ts +│ │ └── index.ts +│ ├── inventory/ +│ │ ├── services/ +│ │ │ ├── stock-transfer.service.ts +│ │ │ ├── stock-adjustment.service.ts +│ │ │ └── index.ts +│ │ ├── validation/ +│ │ │ └── inventory.schema.ts +│ │ ├── controllers/ +│ │ │ └── inventory.controller.ts +│ │ ├── routes/ +│ │ │ └── inventory.routes.ts +│ │ └── index.ts +│ └── customers/ +│ ├── services/ +│ │ ├── loyalty.service.ts +│ │ └── index.ts +│ ├── validation/ +│ │ └── customers.schema.ts +│ ├── controllers/ +│ │ └── loyalty.controller.ts +│ ├── routes/ +│ │ └── loyalty.routes.ts +│ └── index.ts +``` + +## Total de Endpoints Implementados + +| Módulo | Endpoints | +|--------|-----------| +| Branches (Sprint 2) | 10 | +| POS (Sprint 2) | 12 | +| Cash (Sprint 3) | 16 | +| Inventory (Sprint 3) | 18 | +| Loyalty (Sprint 3) | 11 | +| **Total** | **67** | + +## Características Implementadas + +### Sistema de Validación +- ✅ Schemas Zod reutilizables para tipos comunes +- ✅ Middleware de validación para body, query y params +- ✅ Transformación automática de datos (coerce) +- ✅ Mensajes de error personalizados +- ✅ Validación de UUIDs, money, percentages, dates +- ✅ Schemas para direcciones mexicanas (RFC, código postal) + +### Módulo Cash +- ✅ Gestión completa de movimientos de caja +- ✅ Flujo de aprobación para montos altos +- ✅ Cierre de caja con conteo por denominaciones +- ✅ Conciliación con depósitos bancarios +- ✅ Resumen diario por sucursal + +### Módulo Inventory +- ✅ Traspasos entre sucursales/almacenes +- ✅ Flujo completo: solicitar → aprobar → enviar → recibir +- ✅ Manejo de recepciones parciales +- ✅ Ajustes de inventario con líneas detalladas +- ✅ Soporte para conteos físicos +- ✅ Aplicación a inventario (post) + +### Módulo Loyalty +- ✅ Inscripción de clientes +- ✅ Generación automática de tarjetas (Luhn check digit) +- ✅ Cálculo de puntos configurable +- ✅ Multiplicadores por nivel y día +- ✅ Sistema de referidos +- ✅ Canje de puntos +- ✅ Expiración de puntos (FIFO) +- ✅ Upgrade automático de niveles + +## Próximos Pasos (Sprint 4+) + +1. **Pricing Module**: Promociones, cupones, listas de precios +2. **Invoicing Module**: CFDI 4.0, timbrado SAT +3. **E-commerce Module**: Carrito, checkout, envíos +4. **Purchases Module**: Pedidos a proveedor, recepciones +5. **Reports Module**: Reportes consolidados +6. **Integración app.ts**: Registro de todas las rutas +7. **Migraciones TypeORM**: Scripts de creación de tablas +8. **Seed data**: Datos iniciales para pruebas + +--- + +**Fecha de completado**: Sprint 3 +**Archivos creados**: 23 archivos nuevos +**Endpoints totales**: 67 (acumulados Sprint 1-3) diff --git a/orchestration/prompts/PROMPT-RT-BACKEND-AGENT.md b/orchestration/prompts/PROMPT-RT-BACKEND-AGENT.md new file mode 100644 index 0000000..e55bf49 --- /dev/null +++ b/orchestration/prompts/PROMPT-RT-BACKEND-AGENT.md @@ -0,0 +1,150 @@ +# Prompt: Retail Backend Agent + +## Identidad + +Eres un agente especializado en desarrollo backend para ERP Retail/POS. Tu expertise está en Node.js, Express, TypeScript, TypeORM y PostgreSQL, con conocimiento específico de sistemas punto de venta y comercio minorista. + +## Contexto del Proyecto + +```yaml +proyecto: ERP Retail / Punto de Venta +codigo: RT +tipo: Vertical de ERP-Suite +nivel: 2B.2 +stack: + runtime: Node.js 20+ + framework: Express.js + lenguaje: TypeScript 5.3+ + orm: TypeORM 0.3.17 + database: PostgreSQL 15+ + auth: JWT + bcryptjs (heredado de core) + +paths: + vertical: /home/isem/workspace/projects/erp-suite/apps/verticales/retail/ + backend: /home/isem/workspace/projects/erp-suite/apps/verticales/retail/backend/ + docs: /home/isem/workspace/projects/erp-suite/apps/verticales/retail/docs/ + core: /home/isem/workspace/projects/erp-suite/apps/erp-core/ + directivas: orchestration/directivas/ + +puertos: + backend: 3400 + frontend: 5177 + database: 5436 +``` + +## Herencia del Core + +Este proyecto HEREDA del ERP-Core: +- Módulos: auth, users, roles, tenants, inventory, sales, cfdi +- SPECS: Ver `orchestration/00-guidelines/HERENCIA-SPECS-CORE.md` +- Base de datos: 97 tablas heredadas + +**REGLA:** Extender, NUNCA modificar el core. + +## Módulos de la Vertical + +| Módulo | Descripción | Prioridad | Crítico | +|--------|-------------|-----------|---------| +| RT-001 | Fundamentos (100% core) | P0 | - | +| RT-002 | POS (terminal venta) | P0 | OFFLINE-FIRST | +| RT-003 | Inventario multi-sucursal | P0 | - | +| RT-004 | Compras | P0 | - | +| RT-005 | Clientes/CRM | P0 | - | +| RT-006 | Precios/Promociones | P0 | - | +| RT-007 | Caja (arqueos/cortes) | P0 | - | +| RT-008 | Reportes | P1 | - | +| RT-009 | E-commerce | P2 | - | +| RT-010 | Facturación CFDI | P0 | - | + +## Directivas Obligatorias + +### 1. Multi-Tenant (Heredada) +``` +OBLIGATORIO: Toda operación debe filtrar por tenant_id. +Ver: core/orchestration/directivas/DIRECTIVA-MULTI-TENANT.md +``` + +### 2. Punto de Venta +``` +ESPECÍFICO: Operación offline-first, sincronización. +Ver: directivas/DIRECTIVA-PUNTO-VENTA.md +``` + +### 3. Inventario Multi-Sucursal +``` +ESPECÍFICO: Stock independiente por sucursal. +Ver: directivas/DIRECTIVA-INVENTARIO-SUCURSALES.md +``` + +## Estructura de Módulo + +``` +backend/src/modules/{nombre}/ +├── {nombre}.module.ts +├── {nombre}.controller.ts +├── {nombre}.service.ts +├── {nombre}.entity.ts +├── dto/ +│ ├── create-{nombre}.dto.ts +│ └── update-{nombre}.dto.ts +└── __tests__/ + └── {nombre}.service.spec.ts +``` + +## Schemas de Base de Datos + +```yaml +schemas_especificos: + - pos: Sesiones, órdenes, pagos, cierres + - loyalty: Tarjetas, puntos, recompensas + - pricing: Listas, promociones, cupones + - ecommerce: Pedidos online, carritos +``` + +## SPECS del Core Aplicables + +- SPEC-PRICING-RULES (promociones, combos, descuentos) +- SPEC-INVENTARIOS-CICLICOS (conteos por sucursal) +- SPEC-TRAZABILIDAD-LOTES-SERIES (productos perecederos) +- SPEC-FACTURACION-CFDI (CFDI 4.0 + complementos) +- SPEC-VALORACION-INVENTARIO (costo promedio) + +## Consideraciones Especiales + +### Modo Offline (RT-002) +```typescript +// El POS debe funcionar sin conexión +// Sincronización cuando recupere conexión +// LocalStorage/IndexedDB para datos temporales +``` + +### Integración Hardware +```yaml +hardware_soportado: + - impresoras_termicas: ESC/POS + - scanners: Código de barras USB/Bluetooth + - cash_drawer: Apertura automática +``` + +## Flujo de Trabajo + +``` +1. Leer especificación del módulo en docs/02-definicion-modulos/ +2. Verificar SPECS aplicables en HERENCIA-SPECS-CORE.md +3. Revisar DDL existente en database/ +4. Implementar siguiendo estructura de módulo +5. Actualizar TRAZA-TAREAS-BACKEND.md +6. Actualizar BACKEND_INVENTORY.yml +``` + +## Referencias + +- Inventario: `orchestration/inventarios/MASTER_INVENTORY.yml` +- Trazabilidad: `orchestration/inventarios/TRACEABILITY_MATRIX.yml` +- Herencia: `orchestration/00-guidelines/HERENCIA-SPECS-CORE.md` +- Core directivas: `/home/isem/workspace/core/orchestration/directivas/` + +--- + +**Versión:** 1.0.0 +**Sistema:** SIMCO v2.2.0 diff --git a/orchestration/referencias/DEPENDENCIAS-ERP-CORE.yml b/orchestration/referencias/DEPENDENCIAS-ERP-CORE.yml new file mode 100644 index 0000000..af972e6 --- /dev/null +++ b/orchestration/referencias/DEPENDENCIAS-ERP-CORE.yml @@ -0,0 +1,109 @@ +# Dependencias de ERP-Core para ERP Retail +# ========================================= + +version: "1.0.0" +fecha_actualizacion: "2025-12-27" +proyecto: "erp-retail" + +# Base de la que hereda +base: + proyecto: "erp-core" + version_minima: "1.2.0" + ruta: "projects/erp-core" + ruta_absoluta: "/home/isem/workspace-v1/projects/erp-core" + +# Schemas de base de datos heredados +database: + herencia: "completa" + + schemas_usados: + - nombre: "auth_management" + tablas_heredadas: 26 + tablas_extendidas: 1 + uso: "Autenticacion rapida para POS" + extensiones: + - "Login por PIN para cajeros" + + - nombre: "core_management" + tablas_heredadas: 12 + tablas_extendidas: 2 + uso: "Clientes, proveedores retail" + extensiones: + - "Tarjetas de lealtad" + - "Historial de compras" + + - nombre: "core_catalogs" + tablas_heredadas: 8 + tablas_extendidas: 4 + uso: "Catalogos de productos" + extensiones: + - "Categorias de productos" + - "Marcas" + - "Promociones" + - "Formas de pago" + + - nombre: "inventory_management" + tablas_heredadas: 20 + tablas_extendidas: 4 + uso: "Inventario multi-sucursal" + extensiones: + - "Stock por sucursal" + - "Transferencias entre sucursales" + - "Minimos/maximos por sucursal" + - "Reposicion automatica" + +# Schemas propios de retail (no heredados) +schemas_propios: + - nombre: "pos_management" + tablas: 15 + descripcion: "Punto de venta, sesiones, movimientos de caja" + + - nombre: "promotions_management" + tablas: 8 + descripcion: "Ofertas, descuentos, cupones" + + - nombre: "loyalty_management" + tablas: 6 + descripcion: "Tarjetas de lealtad, puntos, beneficios" + +# Variable RLS obligatoria +rls: + variable: "app.current_tenant_id" + tipo: "UUID" + nota: "TODAS las queries deben filtrar por esta variable" + +# Modulos backend importados +backend: + modulos_importados: + - nombre: "AuthModule" + desde: "@erp-core/auth" + version: "1.0.0" + + - nombre: "UsersModule" + desde: "@erp-core/users" + version: "1.0.0" + + - nombre: "RolesModule" + desde: "@erp-core/roles" + version: "1.0.0" + + - nombre: "TenantsModule" + desde: "@erp-core/tenants" + version: "1.0.0" + + - nombre: "InventoryModule" + desde: "@erp-core/inventory" + version: "1.0.0" + +# Consideraciones especiales +performance: + - "POS debe responder en menos de 100ms" + - "Cache de productos frecuentes" + - "Modo offline con sincronizacion" + +# Validaciones requeridas +validaciones: + - "Variable RLS correcta en todo DDL" + - "Trazabilidad de productos con caducidad" + - "Imports de erp-core funcionando" + - "Tests pasando" diff --git a/orchestration/referencias/DEPENDENCIAS-SHARED.yml b/orchestration/referencias/DEPENDENCIAS-SHARED.yml new file mode 100644 index 0000000..2827cce --- /dev/null +++ b/orchestration/referencias/DEPENDENCIAS-SHARED.yml @@ -0,0 +1,66 @@ +# Dependencias de Modulos Compartidos para ERP Retail +# ==================================================== + +version: "1.0.0" +fecha_actualizacion: "2025-12-27" +proyecto: "erp-retail" + +# Modulos del catalogo usados +modulos_catalogo: + - id: "auth" + ruta: "core/catalog/auth" + version_usada: "1.0.0" + fecha_implementacion: "pendiente" + adaptaciones: + - descripcion: "Login por PIN para cajeros" + archivo: "Por implementar" + tests_pasando: false + + - id: "multi-tenancy" + ruta: "core/catalog/multi-tenancy" + version_usada: "1.0.0" + fecha_implementacion: "pendiente" + adaptaciones: + - descripcion: "Empresa matriz con multi-sucursal" + archivo: "Por implementar" + tests_pasando: false + + - id: "notifications" + ruta: "core/catalog/notifications" + version_usada: "1.0.0" + fecha_implementacion: "pendiente" + adaptaciones: + - descripcion: "Alertas de stock bajo" + archivo: "Por implementar" + tests_pasando: false + + - id: "rate-limiting" + ruta: "core/catalog/rate-limiting" + version_usada: "1.0.0" + fecha_implementacion: "pendiente" + adaptaciones: null + tests_pasando: false + +# Modulos de core/modules usados +modulos_core: [] + +# Librerias de shared/libs usadas +librerias_shared: [] + +# Modulos pendientes de implementar +pendientes: + - id: "offline-sync" + prioridad: "alta" + justificacion: "POS debe funcionar sin conexion" + + - id: "inventory" + prioridad: "alta" + justificacion: "Control de inventario multi-sucursal" + + - id: "pricing-engine" + prioridad: "alta" + justificacion: "Motor de precios, promociones, descuentos" + + - id: "loyalty" + prioridad: "media" + justificacion: "Sistema de puntos y tarjetas de lealtad" diff --git a/orchestration/trazas/TRAZA-TAREAS-BACKEND.md b/orchestration/trazas/TRAZA-TAREAS-BACKEND.md new file mode 100644 index 0000000..649204c --- /dev/null +++ b/orchestration/trazas/TRAZA-TAREAS-BACKEND.md @@ -0,0 +1,38 @@ +# TRAZA DE TAREAS - BACKEND LAYER +# Proyecto: ERP RETAIL (Vertical) +# Sistema: NEXUS + SIMCO v2.2.0 +# Estado: TEMPLATE - Proyecto en planificación + +--- + +## Formato de Registro + +```yaml +[FECHA] - [ID_TAREA] - [OPERACION] +Descripcion: {descripcion} +Archivos: + - {archivo_1} +Estado: {COMPLETADO | EN_PROGRESO | BLOQUEADO} +Ejecutado_por: {AGENTE | USUARIO} +``` + +--- + +## Historial de Tareas + +*Sin tareas registradas - Proyecto en planificación* + +--- + +## Resumen + +| Métrica | Valor | +|---------|-------| +| Total tareas | 0 | +| Completadas | 0 | +| En progreso | 0 | +| Bloqueadas | 0 | +| Última actualización | 2025-12-08 | + +--- +*Traza de tareas - Sistema NEXUS* diff --git a/orchestration/trazas/TRAZA-TAREAS-DATABASE.md b/orchestration/trazas/TRAZA-TAREAS-DATABASE.md new file mode 100644 index 0000000..8116b0d --- /dev/null +++ b/orchestration/trazas/TRAZA-TAREAS-DATABASE.md @@ -0,0 +1,38 @@ +# TRAZA DE TAREAS - DATABASE LAYER +# Proyecto: ERP RETAIL (Vertical) +# Sistema: NEXUS + SIMCO v2.2.0 +# Estado: TEMPLATE - Proyecto en planificación + +--- + +## Formato de Registro + +```yaml +[FECHA] - [ID_TAREA] - [OPERACION] +Descripcion: {descripcion} +Archivos: + - {archivo_1} +Estado: {COMPLETADO | EN_PROGRESO | BLOQUEADO} +Ejecutado_por: {AGENTE | USUARIO} +``` + +--- + +## Historial de Tareas + +*Sin tareas registradas - Proyecto en planificación* + +--- + +## Resumen + +| Métrica | Valor | +|---------|-------| +| Total tareas | 0 | +| Completadas | 0 | +| En progreso | 0 | +| Bloqueadas | 0 | +| Última actualización | 2025-12-08 | + +--- +*Traza de tareas - Sistema NEXUS* diff --git a/orchestration/trazas/TRAZA-TAREAS-FRONTEND.md b/orchestration/trazas/TRAZA-TAREAS-FRONTEND.md new file mode 100644 index 0000000..0ff33f6 --- /dev/null +++ b/orchestration/trazas/TRAZA-TAREAS-FRONTEND.md @@ -0,0 +1,38 @@ +# TRAZA DE TAREAS - FRONTEND LAYER +# Proyecto: ERP RETAIL (Vertical) +# Sistema: NEXUS + SIMCO v2.2.0 +# Estado: TEMPLATE - Proyecto en planificación + +--- + +## Formato de Registro + +```yaml +[FECHA] - [ID_TAREA] - [OPERACION] +Descripcion: {descripcion} +Archivos: + - {archivo_1} +Estado: {COMPLETADO | EN_PROGRESO | BLOQUEADO} +Ejecutado_por: {AGENTE | USUARIO} +``` + +--- + +## Historial de Tareas + +*Sin tareas registradas - Proyecto en planificación* + +--- + +## Resumen + +| Métrica | Valor | +|---------|-------| +| Total tareas | 0 | +| Completadas | 0 | +| En progreso | 0 | +| Bloqueadas | 0 | +| Última actualización | 2025-12-08 | + +--- +*Traza de tareas - Sistema NEXUS*