Coverage for backend \ app \ Venta \ services \ ventaService.py: 86.71%
173 statements
« prev ^ index » next coverage.py v7.13.0, created at 2025-12-29 16:13 -0500
« prev ^ index » next coverage.py v7.13.0, created at 2025-12-29 16:13 -0500
1from app.Venta.repositories.ventaRepository import VentaRepository
2from app.Inventario.repositories.inventarioRepository import InventarioRepository
3from app.Productos.repositories.productoRepository import ProductoRepository
4from app.Venta.repositories.promocionRepository import PromocionRepository
5from app.ParametrosSistema.repositories.parametroSistemaRepository import ParametroSistemaRepository
6from app.Clientes.repositories.clienteRepository import ClienteRepository
7from app.Caja.repositories.cajaRepository import CajaRepository
8from app.configuracionGeneral.schemasGenerales import respuestaApi
9from app.configuracionGeneral.seguridadJWT import identificarUsuarioString
10from app.Venta.schemas.ventaSchemas import VentaCrearSchema, VentaRespuestaSchema
11from app.Venta.schemas.detalleVentaSchemas import DetalleVentaCrearSchema, DetalleVentaRespuestaSchema
12from fastapi import HTTPException
13from datetime import datetime
15class VentaService:
16 def __init__(self, dbSession):
17 self.dbSession = dbSession
18 self.repo = VentaRepository(dbSession)
19 self.prodRepo = ProductoRepository(dbSession)
20 self.invRepo = InventarioRepository(dbSession)
21 self.promRepo = PromocionRepository(dbSession)
22 self.paramRepo = ParametroSistemaRepository(dbSession)
23 self.cliRepo = ClienteRepository(dbSession)
24 self.cajaRepo = CajaRepository(dbSession)
26 def crearVenta(self, ventaCrear: VentaCrearSchema, usuario: dict):
27 # validar cliente
28 idUsuario = usuario.get("idUsuario")
29 cliente = None
30 # validar cliente: debe existir (idCliente obligatorio)
31 cliente = self.cliRepo.obtenerPorId(ventaCrear.idCliente)
32 if not cliente:
33 raise HTTPException(status_code=400, detail="Cliente no encontrado")
34 # validar caja abierta del usuario (debe existir una caja ABIERTA hoy)
35 cajas_hoy = self.cajaRepo.listarCajasHoy(idUsuario, False)
36 if not cajas_hoy:
37 # No se abrió ninguna caja hoy para el usuario
38 actor = identificarUsuarioString(usuario)
39 raise HTTPException(status_code=400, detail=f"No se ha abierto una caja para hoy para el usuario {actor}")
40 caja_obj = None
41 for c in cajas_hoy:
42 if getattr(c, 'estadoCaja', '') == 'ABIERTA':
43 caja_obj = c
44 break
45 if not caja_obj:
46 # Existe caja(s) hoy pero todas están cerradas -> no se permiten ventas
47 raise HTTPException(status_code=400, detail="La caja ya está cerrada; no se pueden registrar ventas porque se realizó el arqueo")
49 # validar descuentos
50 if ventaCrear.descuentoGeneral < 0 or ventaCrear.descuentoGeneral > 100:
51 raise HTTPException(status_code=400, detail="descuentoGeneral debe estar entre 0 y 100")
53 # Validar productos y calcular subtotales (recolectar errores como en Pedido)
54 detalles_objs = []
55 subtotalVenta = 0.0
56 totalPromos = 0.0
57 taxable_base = 0.0
58 missing = []
59 for item in ventaCrear.detalles:
60 cantidad = item.cantidadComprada
61 # Usar el helper de ProductoRepository que unifica validaciones (existencia, activo, stock)
62 val = self.prodRepo.validarProductoParaVenta(item.idProducto, cantidad)
63 if isinstance(val, dict) and val.get("error"):
64 missing.append({"idProducto": item.idProducto, "error": val.get("error")})
65 continue
66 producto = val.get("producto")
67 precio = val.get("precio")
68 inventario = val.get("inventario")
69 subtotalProducto = round(precio * cantidad, 2)
70 promo = self.promRepo.obtenerPromocionActivaMayorDescuento(item.idProducto)
71 valorDescPromo = 0.0
72 promo_summary = None
73 idPromo = None
74 if promo:
75 valorDescPromo = round((promo.porcentajePromocion / 100.0) * subtotalProducto, 2)
76 idPromo = promo.idPromocion
77 # crear resumen de promocion para la respuesta
78 from app.Venta.schemas.promocionSchemas import PromocionResumenSchema
79 promo_summary = PromocionResumenSchema.from_orm(promo)
80 subtotalVenta += subtotalProducto
81 totalPromos += valorDescPromo
82 if val.get("tieneIva", True):
83 taxable_base += subtotalProducto - valorDescPromo
84 # construir resumen del producto para la respuesta
85 from app.Productos.schemas.productoSchemas import ProductoResumenSchema
86 producto_summary = ProductoResumenSchema.from_orm(producto)
87 detalle = DetalleVentaRespuestaSchema(
88 idDetalleVenta=0,
89 idVenta=0,
90 producto=producto_summary,
91 promocion=promo_summary,
92 precioUnitarioVendido=precio,
93 cantidadVendida=cantidad,
94 subtotalProducto=subtotalProducto,
95 valorDescuentoProducto=valorDescPromo
96 )
97 detalles_objs.append(detalle)
98 if missing:
99 # Mantener el mismo formato que Pedido: raise HTTPException con detail estructurado
100 raise HTTPException(status_code=400, detail={"error": missing})
102 subtotalVenta = round(subtotalVenta, 2)
103 subtotalDespuesPromos = round(subtotalVenta - totalPromos, 2)
104 descuentoGeneralMonto = round((ventaCrear.descuentoGeneral / 100.0) * subtotalDespuesPromos, 2)
105 totalDescuento = round(totalPromos + descuentoGeneralMonto, 2)
107 # calcular IVA
108 param_iva = self.paramRepo.validarClaveExistente("IVA")
109 if not param_iva:
110 baseIVA = 0.0
111 else:
112 baseIVA = float(param_iva.valorParametro) / 100.0
113 # aplicar parte proporcional del descuento general a la porcion taxable
114 taxable_after_general = 0.0
115 if subtotalDespuesPromos > 0:
116 # proporción del descuento general que afecta la porción taxable
117 descuento_general_aplicado_a_taxable = (taxable_base / subtotalDespuesPromos) * descuentoGeneralMonto if subtotalDespuesPromos > 0 else 0
118 taxable_after_general = taxable_base - descuento_general_aplicado_a_taxable
119 totalIVA = round(taxable_after_general * baseIVA, 2)
121 totalPagar = round(subtotalDespuesPromos - descuentoGeneralMonto + totalIVA, 2)
123 # crear objetos de ORM Venta y DetalleVenta
124 from app.Venta.models.ventaModel import Venta
125 from app.Venta.models.detalleVentaModel import DetalleVenta
126 venta_obj = Venta(
127 idCaja=caja_obj.idCaja,
128 idUsuarioVenta=idUsuario,
129 idCliente=cliente.idCliente,
130 subtotalVenta=subtotalVenta,
131 descuentoGeneral=ventaCrear.descuentoGeneral,
132 totalDescuento=totalDescuento,
133 baseIVA=baseIVA * 100.0,
134 totalIVA=totalIVA,
135 totalPagar=totalPagar,
136 metodoPago=ventaCrear.metodoPago,
137 estadoVenta="COMPLETADA"
138 )
139 detalle_orms = []
140 for d in detalles_objs:
141 detalle_orms.append(DetalleVenta(
142 idProducto=d.producto.idProducto,
143 idPromocion=(d.promocion.idPromocion if d.promocion else None),
144 precioUnitarioVendido=d.precioUnitarioVendido,
145 cantidadVendida=d.cantidadVendida,
146 subtotalProducto=d.subtotalProducto,
147 valorDescuentoProducto=d.valorDescuentoProducto
148 ))
150 creado = self.repo.crearVenta(venta_obj, detalle_orms)
151 # deducir inventario
152 for d in creado.detalles:
153 inventario = self.invRepo.obtenerPorProducto(d.idProducto)
154 if inventario:
155 inventario.cantidadDisponible = (inventario.cantidadDisponible or 0) - (d.cantidadVendida or 0)
156 self.dbSession.add(inventario)
157 self.dbSession.commit()
158 data = VentaRespuestaSchema.from_orm(creado)
159 actor = identificarUsuarioString(usuario)
160 return respuestaApi(success=True, message=f"Venta registrada por {actor}", data=data)
162 def listarVentasHoy(self, usuario: dict):
163 rol = usuario.get("rol")
164 idUsuario = usuario.get("idUsuario")
165 esAdmin = rol == "Administrador"
166 ventas = self.repo.listarVentasHoy(idUsuario if not esAdmin else None, esAdmin)
167 if not ventas:
168 return respuestaApi(success=True, message="No se encontraron ventas para hoy", data=[])
169 data = [VentaRespuestaSchema.from_orm(v) for v in ventas]
170 return respuestaApi(success=True, message="Ventas encontradas", data=data)
172 def listarHistorico(self, usuario: dict):
173 rol = usuario.get("rol")
174 if rol != "Administrador":
175 raise HTTPException(status_code=403, detail="Solo Administrador puede ver el histórico")
176 ventas = self.repo.listarTodas()
177 if not ventas:
178 return respuestaApi(success=True, message="No se encontraron ventas", data=[])
179 data = [VentaRespuestaSchema.from_orm(v) for v in ventas]
180 return respuestaApi(success=True, message="Ventas encontradas", data=data)
182 def anularVenta(self, idVenta: int, usuario: dict):
183 rol = usuario.get("rol")
184 idUsuario = usuario.get("idUsuario")
185 venta = self.repo.obtenerPorId(idVenta)
186 if not venta:
187 raise HTTPException(status_code=404, detail="Venta no encontrada")
188 # Solo se permiten anulaciones de ventas del día actual (zona de Quito)
189 from datetime import datetime, timezone, timedelta
190 quitoTZ = timezone(timedelta(hours=-5))
191 venta_fecha = venta.fechaVenta
192 try:
193 venta_fecha_local = venta_fecha.astimezone(quitoTZ).date()
194 except Exception:
195 venta_fecha_local = venta_fecha.date()
196 hoy = datetime.now(quitoTZ).date()
197 if venta_fecha_local != hoy:
198 raise HTTPException(status_code=400, detail="Solo se pueden anular ventas del día actual")
199 if rol != "Administrador" and venta.idUsuarioVenta != idUsuario:
200 raise HTTPException(status_code=403, detail="No tiene permisos para anular esta venta")
201 # Restricción adicional para Cajero: la caja asociada a la venta debe estar abierta hoy
202 if rol != "Administrador":
203 cajas_hoy = self.cajaRepo.listarCajasHoy(idUsuario, False)
204 if not cajas_hoy:
205 raise HTTPException(status_code=400, detail="No se ha abierto una caja para hoy; no se pueden anular ventas")
206 caja_abierta = None
207 for c in cajas_hoy:
208 if getattr(c, 'idCaja', None) == venta.idCaja and getattr(c, 'estadoCaja', '') == 'ABIERTA':
209 caja_abierta = c
210 break
211 if not caja_abierta:
212 raise HTTPException(status_code=400, detail="La caja asociada a esta venta no está abierta; no se pueden anular ventas")
213 res = self.repo.anularVenta(idVenta)
214 if res is None:
215 raise HTTPException(status_code=404, detail="Venta no encontrada")
216 if isinstance(res, dict) and res.get("error") == "venta_ya_anulada":
217 data = VentaRespuestaSchema.from_orm(res.get("venta"))
218 return respuestaApi(success=False, message="La venta ya está anulada", data=data)
219 data = VentaRespuestaSchema.from_orm(res)
220 actor = identificarUsuarioString(usuario)
221 return respuestaApi(success=True, message=f"Venta anulada por {actor}", data=data)
223 def generarComprobanteVenta(self, idVenta: int, usuario: dict):
224 """Genera un comprobante con parámetros del sistema y la venta indicada."""
225 venta = self.repo.obtenerPorId(idVenta)
226 if not venta:
227 raise HTTPException(status_code=404, detail="Venta no encontrada")
228 # preparar parámetros solicitados
229 keys = ["nombreNegocio", "direccionNegocio", "telefonoNegocio", "correoNegocio"]
230 parametros = []
231 for k in keys:
232 p = self.paramRepo.validarClaveExistente(k)
233 parametros.append({"claveParametro": k, "valorParametro": (p.valorParametro if p else None)})
234 # serializar venta con detalle
235 venta_data = VentaRespuestaSchema.from_orm(venta)
236 return respuestaApi(success=True, message="Comprobante generado", data={"parametros": parametros, "venta": venta_data})