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

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 

14 

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) 

25 

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") 

48 

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") 

52 

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}) 

101 

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) 

106 

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) 

120 

121 totalPagar = round(subtotalDespuesPromos - descuentoGeneralMonto + totalIVA, 2) 

122 

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

149 

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) 

161 

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) 

171 

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) 

181 

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) 

222 

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})