# -*- coding: utf-8 -*- """API del frontend React para Compras y proveedores. Vive en su propio controlador (no en `skeen_whatsapp/controllers/frontend.py`) para no chocar con otros trabajos. Las rutas cuelgan de `/skeen/frontend/v1/{proveedores,compras,reports/compras,reports/margen}` y exigen token (`@require_role`) más el permiso por acción (`@require_perm`). Regla de oro: con `compras.activo=False` TODAS las rutas responden 403 y no tocan nada, así el despliegue en una instancia apagada no cambia ningún comportamiento. """ import functools import json from collections import defaultdict from datetime import date, datetime, timedelta from odoo import fields, http from odoo.exceptions import ValidationError, UserError from odoo.http import request, Response from odoo.addons.skeen_whatsapp.controllers.auth import require_perm, require_role from odoo.addons.skeen_citas.clinic_settings import get_compras BASE = '/skeen/frontend/v1' COMPRAS = BASE + '/compras' PROVEEDORES = BASE + '/proveedores' METODOS_PAGO = ('efectivo', 'transferencia', 'tarjeta', 'otro') METODOS_LABEL = {'efectivo': 'Efectivo', 'transferencia': 'Transferencia', 'tarjeta': 'Tarjeta', 'otro': 'Otro'} ESTADO_LABEL = { 'borrador': 'Borrador', 'enviada': 'Enviada', 'recibida_parcial': 'Recibida parcial', 'recibida': 'Recibida', 'cancelada': 'Cancelada', } def _json(data, status=200): return Response( json.dumps(data, default=str), status=status, mimetype='application/json', headers={ 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS', 'Access-Control-Allow-Headers': 'Content-Type, Authorization', }, ) def _body(): try: raw = request.httprequest.get_data(as_text=True) return json.loads(raw) if raw else {} except Exception: return {} def _cfg(): return get_compras(request.env) def _activo(): return bool(_cfg().get('activo')) def compras_activas(func): """Niega la operación si la instancia no tiene el módulo encendido.""" @functools.wraps(func) def wrapper(self, *args, **kw): if request.httprequest.method == 'OPTIONS': return _json({}, 200) if not _activo(): return _json({'status': 'error', 'message': 'Compras no está activado en esta clínica.'}, 403) return func(self, *args, **kw) return wrapper def _login(): return getattr(request.frontend_user, 'login', '') if hasattr(request, 'frontend_user') else '' def _num(valor, por_defecto=0.0): try: return float(valor) except (TypeError, ValueError): return por_defecto def _fecha(valor, por_defecto=None): if not valor: return por_defecto return fields.Date.to_date(valor) or por_defecto class SkeenComprasController(http.Controller): # ================================================================== # Serializadores # ================================================================== def _proveedor_to_dict(self, p): return { 'id': p.id, 'name': p.name or '', 'email': p.email or '', 'phone': p.phone or '', 'mobile': p.mobile or '', 'vat': p.vat or '', 'street': p.street or '', 'city': p.city or '', 'supplier_rank': getattr(p, 'supplier_rank', 0) or 0, 'es_proveedor': p.skeen_es_proveedor, 'activo': p.skeen_proveedor_activo, 'dias_credito': p.skeen_dias_credito or 0, 'contacto': p.skeen_contacto or '', 'banco': p.skeen_banco or '', 'cuenta_pago': p.skeen_cuenta_pago or '', 'notas': p.skeen_notas_proveedor or '', 'compras_count': p.skeen_compras_count, 'saldo': p.skeen_saldo, } def _linea_to_dict(self, l): return { 'id': l.id, 'item_id': l.item_id.id, 'item': l.item_id.name, 'sku': l.item_sku or '', 'unit': l.item_unit or 'pieza', 'kind': l.item_kind or 'producto', 'cantidad_pedida': l.cantidad_pedida, 'cantidad_recibida': l.cantidad_recibida, 'cantidad_pendiente': l.cantidad_pendiente, 'costo_unitario': l.costo_unitario, 'descuento': l.descuento, 'subtotal': l.subtotal, 'impuesto': l.impuesto, 'total': l.total, 'lote': l.lote or '', 'fecha_caducidad': l.fecha_caducidad.strftime('%Y-%m-%d') if l.fecha_caducidad else None, 'notas': l.notas or '', } def _recepcion_to_dict(self, r): return { 'id': r.id, 'fecha': r.fecha.strftime('%Y-%m-%d %H:%M') if r.fecha else None, 'usuario': r.usuario or '', 'notas': r.notas or '', 'total_unidades': r.total_unidades, 'total_costo': r.total_costo, 'lineas': [{ 'id': ln.id, 'item_id': ln.item_id.id, 'item': ln.item_id.name, 'cantidad': ln.cantidad, 'costo_unitario': ln.costo_unitario, 'lote': ln.lote or '', 'fecha_caducidad': ln.fecha_caducidad.strftime('%Y-%m-%d') if ln.fecha_caducidad else None, 'move_id': ln.move_id.id if ln.move_id else None, } for ln in r.line_ids], } def _pago_to_dict(self, p): return { 'id': p.id, 'fecha': p.fecha.strftime('%Y-%m-%d') if p.fecha else None, 'monto': p.monto, 'moneda': p.moneda, 'metodo': p.metodo, 'metodo_label': METODOS_LABEL.get(p.metodo, p.metodo or ''), 'referencia': p.referencia or '', 'notas': p.notas or '', 'usuario': p.usuario or '', 'monto_en_compra': p.monto_en_compra, } def _compra_to_dict(self, c, with_detail=False): d = { 'id': c.id, 'folio': c.name or '', 'partner_id': c.partner_id.id, 'proveedor': c.partner_id.name or '', 'fecha': c.fecha.strftime('%Y-%m-%d') if c.fecha else None, 'fecha_entrega_esperada': c.fecha_entrega_esperada.strftime('%Y-%m-%d') if c.fecha_entrega_esperada else None, 'estado': c.estado, 'estado_label': ESTADO_LABEL.get(c.estado, c.estado), 'moneda': c.moneda, 'subtotal': c.subtotal, 'descuento': c.descuento, 'impuestos': c.impuestos, 'total': c.total, 'pagado': c.pagado, 'saldo': c.saldo, 'sucursal_id': c.sucursal_id.id if c.sucursal_id else None, 'sucursal': c.sucursal_id.name if c.sucursal_id else '', 'referencia_factura': c.referencia_factura or '', 'notas': c.notas or '', 'creado_por_login': c.creado_por_login or '', 'recibida_at': c.recibida_at.strftime('%Y-%m-%d %H:%M') if c.recibida_at else None, 'cancel_reason': c.cancel_reason or '', 'lineas_count': len(c.linea_ids), 'recepciones_count': c.recepciones_count, 'tiene_recepciones': c.tiene_recepciones, 'total_pedido': c.total_pedido, 'total_recibido': c.total_recibido, 'puede_recibir': c.estado in ('borrador', 'enviada', 'recibida_parcial'), 'puede_cancelar': c.estado != 'cancelada' and not c.tiene_recepciones, } if with_detail: d['lineas'] = [self._linea_to_dict(l) for l in c.linea_ids] d['recepciones'] = [self._recepcion_to_dict(r) for r in c.recepcion_ids] d['pagos'] = [self._pago_to_dict(p) for p in c.pago_ids] return d def _compra(self, cid): c = request.env['skeen.compra'].sudo().browse(int(cid)) return c if c.exists() else None # ================================================================== # Proveedores # ================================================================== @http.route(PROVEEDORES, type='http', auth='none', methods=['GET', 'OPTIONS'], csrf=False) @require_role('lectura') @require_perm('proveedores.ver') @compras_activas def list_proveedores(self, **kw): try: domain = [('skeen_es_proveedor', '=', True)] if not (kw.get('include_inactive') or '').strip(): domain.append(('skeen_proveedor_activo', '=', True)) if kw.get('q'): q = kw['q'] domain += ['|', '|', '|', ('name', 'ilike', q), ('email', 'ilike', q), ('phone', 'ilike', q), ('vat', 'ilike', q)] Partner = request.env['res.partner'].sudo() total = Partner.search_count(domain) page = max(int(kw.get('page', 1) or 1), 1) page_size = min(max(int(kw.get('page_size', 100) or 100), 1), 500) rows = Partner.search(domain, order='name asc', limit=page_size, offset=(page - 1) * page_size) return _json({'status': 'success', 'proveedores': [self._proveedor_to_dict(p) for p in rows], 'total': total, 'page': page, 'page_size': page_size}) except Exception as e: request.env.cr.rollback() return _json({'status': 'error', 'message': str(e)}, 500) def _proveedor_vals(self, data, crear=False): vals = {} if crear or 'name' in data: nombre = (data.get('name') or '').strip() if not nombre: raise ValidationError('El nombre del proveedor es obligatorio') vals['name'] = nombre for key in ('email', 'phone', 'mobile', 'vat', 'street', 'city', 'skeen_contacto', 'skeen_banco', 'skeen_cuenta_pago', 'skeen_notas_proveedor'): if key in data: vals[key] = (data.get(key) or '').strip() if 'dias_credito' in data or 'skeen_dias_credito' in data: raw = data.get('dias_credito', data.get('skeen_dias_credito')) try: n = int(raw or 0) except (TypeError, ValueError): raise ValidationError('Los días de crédito deben ser un número entero') if n < 0: raise ValidationError('Los días de crédito no pueden ser negativos') vals['skeen_dias_credito'] = n if 'activo' in data: vals['skeen_proveedor_activo'] = bool(data['activo']) if crear: vals['skeen_es_proveedor'] = True if 'skeen_dias_credito' not in vals: vals['skeen_dias_credito'] = int(_cfg().get('dias_credito_default') or 0) vals['skeen_proveedor_activo'] = True return vals @http.route(PROVEEDORES, type='http', auth='none', methods=['POST', 'OPTIONS'], csrf=False) @require_role('gerencia') @require_perm('proveedores.editar') @compras_activas def create_proveedor(self, **kw): try: data = _body() try: vals = self._proveedor_vals(data, crear=True) partner = request.env['res.partner'].sudo().create(vals) partner.action_marcar_proveedor() except ValidationError as e: request.env.cr.rollback() return _json({'status': 'error', 'message': str(e.args[0] if e.args else e)}, 400) return _json({'status': 'success', 'proveedor': self._proveedor_to_dict(partner)}, 201) except Exception as e: request.env.cr.rollback() return _json({'status': 'error', 'message': str(e)}, 500) @http.route(PROVEEDORES + '/', type='http', auth='none', methods=['GET', 'OPTIONS'], csrf=False) @require_role('lectura') @require_perm('proveedores.ver') @compras_activas def get_proveedor(self, pid, **kw): try: p = request.env['res.partner'].sudo().browse(pid) if not p.exists() or not p.skeen_es_proveedor: return _json({'status': 'error', 'message': 'Proveedor no encontrado'}, 404) return _json({'status': 'success', 'proveedor': self._proveedor_to_dict(p)}) except Exception as e: request.env.cr.rollback() return _json({'status': 'error', 'message': str(e)}, 500) @http.route(PROVEEDORES + '/', type='http', auth='none', methods=['PUT', 'PATCH'], csrf=False) @require_role('gerencia') @require_perm('proveedores.editar') @compras_activas def update_proveedor(self, pid, **kw): try: p = request.env['res.partner'].sudo().browse(pid) if not p.exists() or not p.skeen_es_proveedor: return _json({'status': 'error', 'message': 'Proveedor no encontrado'}, 404) try: p.write(self._proveedor_vals(_body(), crear=False)) except ValidationError as e: request.env.cr.rollback() return _json({'status': 'error', 'message': str(e.args[0] if e.args else e)}, 400) return _json({'status': 'success', 'proveedor': self._proveedor_to_dict(p)}) except Exception as e: request.env.cr.rollback() return _json({'status': 'error', 'message': str(e)}, 500) @http.route(PROVEEDORES + '/', type='http', auth='none', methods=['DELETE'], csrf=False) @require_role('gerencia') @require_perm('proveedores.editar') @compras_activas def delete_proveedor(self, pid, **kw): try: p = request.env['res.partner'].sudo().browse(pid) if not p.exists() or not p.skeen_es_proveedor: return _json({'status': 'error', 'message': 'Proveedor no encontrado'}, 404) if p.skeen_compra_ids: return _json({ 'status': 'error', 'message': 'El proveedor tiene compras registradas: ' 'desactívalo en lugar de borrarlo.', 'desactivar': True, }, 409) p.unlink() return _json({'status': 'success', 'message': 'Proveedor eliminado'}) except Exception as e: request.env.cr.rollback() return _json({'status': 'error', 'message': str(e)}, 500) # ================================================================== # Compras # ================================================================== def _rango_kw(self, kw): inicio = _fecha(kw.get('start'), fields.Date.today() - timedelta(days=30)) fin = _fecha(kw.get('end'), fields.Date.today()) return inicio, fin @http.route(COMPRAS, type='http', auth='none', methods=['GET', 'OPTIONS'], csrf=False) @require_role('lectura') @require_perm('compras.ver') @compras_activas def list_compras(self, **kw): try: domain = [] if kw.get('estado'): estado = kw['estado'] if estado == 'por_recibir': domain.append(('estado', 'in', ('borrador', 'enviada', 'recibida_parcial'))) elif estado == 'por_pagar': domain.append(('estado', '!=', 'cancelada')) domain.append(('saldo', '>', 0)) elif estado in ESTADO_LABEL: domain.append(('estado', '=', estado)) else: return _json({'status': 'error', 'message': 'Estado inválido'}, 400) if kw.get('partner_id'): domain.append(('partner_id', '=', int(kw['partner_id']))) inicio, fin = self._rango_kw(kw) if kw.get('start'): domain.append(('fecha', '>=', inicio)) if kw.get('end'): domain.append(('fecha', '<=', fin)) if kw.get('q'): q = kw['q'] domain += ['|', '|', ('name', 'ilike', q), ('partner_id.name', 'ilike', q), ('referencia_factura', 'ilike', q)] Compra = request.env['skeen.compra'].sudo() total = Compra.search_count(domain) page = max(int(kw.get('page', 1) or 1), 1) page_size = min(max(int(kw.get('page_size', 50) or 50), 1), 200) rows = Compra.search(domain, order='fecha desc, id desc', limit=page_size, offset=(page - 1) * page_size) return _json({ 'status': 'success', 'compras': [self._compra_to_dict(c) for c in rows], 'total': total, 'page': page, 'page_size': page_size, 'total_pages': (total + page_size - 1) // page_size, 'resumen': self._resumen_compras(), }) except Exception as e: request.env.cr.rollback() return _json({'status': 'error', 'message': str(e)}, 500) def _resumen_compras(self): Compra = request.env['skeen.compra'].sudo() hoy = fields.Date.today() mes_ini = hoy.replace(day=1) abiertas = Compra.search([('estado', 'in', ('borrador', 'enviada', 'recibida_parcial'))]) por_pagar = Compra.search([('estado', '!=', 'cancelada'), ('saldo', '>', 0)]) del_mes = Compra.search([('estado', '!=', 'cancelada'), ('fecha', '>=', mes_ini), ('fecha', '<=', hoy)]) return { 'abiertas': len(abiertas), 'por_pagar': len(por_pagar), 'saldo_total': round(sum(c.saldo for c in por_pagar), 2), 'compras_mes': len(del_mes), 'gasto_mes': round(sum(c.total for c in del_mes), 2), 'borradores': Compra.search_count([('estado', '=', 'borrador')]), 'enviadas': Compra.search_count([('estado', '=', 'enviada')]), } def _compra_vals(self, data, crear=False): CompraLinea = request.env['skeen.compra.linea'].sudo() vals = {} if 'partner_id' in data: partner = request.env['res.partner'].sudo().browse(int(data['partner_id'] or 0)) if not partner.exists(): raise ValidationError('Proveedor no encontrado') vals['partner_id'] = partner.id if crear and not vals.get('partner_id'): raise ValidationError('El proveedor es obligatorio') if 'fecha' in data: vals['fecha'] = _fecha(data.get('fecha'), fields.Date.today()) if 'fecha_entrega_esperada' in data: vals['fecha_entrega_esperada'] = _fecha(data.get('fecha_entrega_esperada'), False) if 'moneda' in data: moneda = data['moneda'] or 'MXN' if moneda not in ('MXN', 'USD'): raise ValidationError('La moneda debe ser MXN o USD') vals['moneda'] = moneda if 'descuento' in data: vals['descuento'] = max(0.0, _num(data.get('descuento'))) for key in ('notas', 'referencia_factura'): if key in data: vals[key] = data.get(key) or '' if 'sucursal_id' in data: vals['sucursal_id'] = int(data['sucursal_id']) if data['sucursal_id'] else False if 'lineas' in data and data.get('lineas') is not None: lineas = [] for ld in (data.get('lineas') or []): item = request.env['skeen.inventario.item'].sudo().browse( int(ld.get('item_id') or 0)) if not item.exists(): raise ValidationError('Artículo de la línea no encontrado') cantidad = _num(ld.get('cantidad_pedida'), 1.0) if cantidad <= 0: raise ValidationError('La cantidad pedida debe ser mayor a 0') costo = ld.get('costo_unitario') if costo in (None, ''): costo = (item.ultimo_costo or item.costo_promedio or item.cost or 0.0) lineas.append((0, 0, { 'item_id': item.id, 'cantidad_pedida': cantidad, 'costo_unitario': max(0.0, _num(costo)), 'descuento': max(0.0, _num(ld.get('descuento'))), 'lote': (ld.get('lote') or '').strip(), 'fecha_caducidad': _fecha(ld.get('fecha_caducidad'), False), 'notas': (ld.get('notas') or '').strip(), })) vals['linea_ids'] = lineas return vals @http.route(COMPRAS, type='http', auth='none', methods=['POST', 'OPTIONS'], csrf=False) @require_role('recepcion') @require_perm('compras.crear') @compras_activas def create_compra(self, **kw): try: data = _body() try: vals = self._compra_vals(data, crear=True) if not vals.get('linea_ids'): raise ValidationError('Agrega al menos una línea') vals['creado_por_login'] = _login() compra = request.env['skeen.compra'].sudo().create(vals) except ValidationError as e: request.env.cr.rollback() return _json({'status': 'error', 'message': str(e.args[0] if e.args else e)}, 400) return _json({'status': 'success', 'compra': self._compra_to_dict(compra, with_detail=True)}, 201) except Exception as e: request.env.cr.rollback() return _json({'status': 'error', 'message': str(e)}, 500) @http.route(COMPRAS + '/', type='http', auth='none', methods=['GET', 'OPTIONS'], csrf=False) @require_role('lectura') @require_perm('compras.ver') @compras_activas def get_compra(self, cid, **kw): try: c = self._compra(cid) if not c: return _json({'status': 'error', 'message': 'Compra no encontrada'}, 404) return _json({'status': 'success', 'compra': self._compra_to_dict(c, with_detail=True)}) except Exception as e: request.env.cr.rollback() return _json({'status': 'error', 'message': str(e)}, 500) @http.route(COMPRAS + '/', type='http', auth='none', methods=['PUT', 'PATCH'], csrf=False) @require_role('recepcion') @require_perm('compras.editar') @compras_activas def update_compra(self, cid, **kw): try: c = self._compra(cid) if not c: return _json({'status': 'error', 'message': 'Compra no encontrada'}, 404) if c.estado in ('cancelada', 'recibida', 'recibida_parcial'): return _json({'status': 'error', 'message': 'La compra ya no se puede editar en su estado actual.'}, 409) data = _body() try: if data.get('lineas') is not None and c.estado != 'borrador': raise ValidationError('Solo se editan las líneas de una compra en borrador') vals = self._compra_vals(data, crear=False) if 'linea_ids' in vals: c.linea_ids.unlink() c.write(vals) except ValidationError as e: request.env.cr.rollback() return _json({'status': 'error', 'message': str(e.args[0] if e.args else e)}, 400) return _json({'status': 'success', 'compra': self._compra_to_dict(c, with_detail=True)}) except Exception as e: request.env.cr.rollback() return _json({'status': 'error', 'message': str(e)}, 500) @http.route(COMPRAS + '//enviar', type='http', auth='none', methods=['POST', 'OPTIONS'], csrf=False) @require_role('recepcion') @require_perm('compras.crear') @compras_activas def enviar_compra(self, cid, **kw): return self._accion_compra(cid, 'action_enviar') @http.route(COMPRAS + '//recibir', type='http', auth='none', methods=['POST', 'OPTIONS'], csrf=False) @require_role('recepcion') @require_perm('compras.recibir') @compras_activas def recibir_compra(self, cid, **kw): try: c = self._compra(cid) if not c: return _json({'status': 'error', 'message': 'Compra no encontrada'}, 404) data = _body() try: c.registrar_recepcion( lineas=data.get('lineas') or [], usuario=_login(), notas=data.get('notas') or '') except (UserError, ValidationError) as e: request.env.cr.rollback() return _json({'status': 'error', 'message': str(e.args[0] if e.args else e)}, 400) c.invalidate_recordset() return _json({'status': 'success', 'compra': self._compra_to_dict(c, with_detail=True)}, 201) except Exception as e: request.env.cr.rollback() return _json({'status': 'error', 'message': str(e)}, 500) @http.route(COMPRAS + '/directa', type='http', auth='none', methods=['POST', 'OPTIONS'], csrf=False) @require_role('recepcion') @require_perm('compras.recibir') @compras_activas def compra_directa(self, **kw): """Recepción sin orden de compra (si `permitir_compras_sin_orden`). Crea una compra ya recibida en un solo paso: es el documento que respalda la entrada de mercancía y el pago. Requiere el flag encendido. """ try: if not _cfg().get('permitir_compras_sin_orden'): return _json({'status': 'error', 'message': 'Esta clínica no permite compras sin orden.'}, 403) data = _body() try: vals = self._compra_vals(data, crear=True) if not vals.get('linea_ids'): raise ValidationError('Agrega al menos una línea') vals['creado_por_login'] = _login() compra = request.env['skeen.compra'].sudo().create(vals) lineas = [{ 'linea_id': l.id, 'cantidad': l.cantidad_pedida, 'costo_unitario': l.costo_unitario, 'lote': l.lote, 'fecha_caducidad': l.fecha_caducidad, } for l in compra.linea_ids] compra.registrar_recepcion(lineas=lineas, usuario=_login(), notas='Compra directa sin orden') except (UserError, ValidationError) as e: request.env.cr.rollback() return _json({'status': 'error', 'message': str(e.args[0] if e.args else e)}, 400) compra.invalidate_recordset() return _json({'status': 'success', 'compra': self._compra_to_dict(compra, with_detail=True)}, 201) except Exception as e: request.env.cr.rollback() return _json({'status': 'error', 'message': str(e)}, 500) @http.route(COMPRAS + '//cancelar', type='http', auth='none', methods=['POST', 'OPTIONS'], csrf=False) @require_role('gerencia') @require_perm('compras.cancelar') @compras_activas def cancelar_compra(self, cid, **kw): try: c = self._compra(cid) if not c: return _json({'status': 'error', 'message': 'Compra no encontrada'}, 404) data = _body() try: c.action_cancelar(data.get('motivo') or '') except UserError as e: request.env.cr.rollback() return _json({'status': 'error', 'message': str(e.args[0] if e.args else e)}, 409) return _json({'status': 'success', 'compra': self._compra_to_dict(c, with_detail=True)}) except Exception as e: request.env.cr.rollback() return _json({'status': 'error', 'message': str(e)}, 500) @http.route(COMPRAS + '//pagar', type='http', auth='none', methods=['POST', 'OPTIONS'], csrf=False) @require_role('recepcion') @require_perm('compras.pagar') @compras_activas def pagar_compra(self, cid, **kw): try: c = self._compra(cid) if not c: return _json({'status': 'error', 'message': 'Compra no encontrada'}, 404) data = _body() try: pago = c.registrar_pago( monto=data.get('monto'), moneda=data.get('moneda') or c.moneda or 'MXN', metodo=data.get('metodo') or 'transferencia', referencia=data.get('referencia') or '', notas=data.get('notas') or '', usuario=_login()) except (UserError, ValidationError) as e: request.env.cr.rollback() return _json({'status': 'error', 'message': str(e.args[0] if e.args else e)}, 400) c.invalidate_recordset() return _json({'status': 'success', 'pago': self._pago_to_dict(pago), 'compra': self._compra_to_dict(c, with_detail=True)}, 201) except Exception as e: request.env.cr.rollback() return _json({'status': 'error', 'message': str(e)}, 500) def _accion_compra(self, cid, metodo): try: c = self._compra(cid) if not c: return _json({'status': 'error', 'message': 'Compra no encontrada'}, 404) try: getattr(c, metodo)() except (UserError, ValidationError) as e: request.env.cr.rollback() return _json({'status': 'error', 'message': str(e.args[0] if e.args else e)}, 400) return _json({'status': 'success', 'compra': self._compra_to_dict(c, with_detail=True)}) except Exception as e: request.env.cr.rollback() return _json({'status': 'error', 'message': str(e)}, 500) # ================================================================== # Cuentas por pagar # ================================================================== def _buckets_antiguedad(self): return [ {'key': '0-30', 'label': '0 a 30 días'}, {'key': '31-60', 'label': '31 a 60 días'}, {'key': '61-90', 'label': '61 a 90 días'}, {'key': '+90', 'label': 'Más de 90 días'}, ] def _bucket_de(self, dias): if dias <= 30: return '0-30' if dias <= 60: return '31-60' if dias <= 90: return '61-90' return '+90' @http.route(COMPRAS + '/cuentas_por_pagar', type='http', auth='none', methods=['GET', 'OPTIONS'], csrf=False) @require_role('lectura') @require_perm('compras.ver') @compras_activas def cuentas_por_pagar(self, **kw): try: Compra = request.env['skeen.compra'].sudo() domain = [('estado', '!=', 'cancelada'), ('saldo', '>', 0)] if kw.get('partner_id'): domain.append(('partner_id', '=', int(kw['partner_id']))) compras = Compra.search(domain, order='fecha asc, id asc') hoy = fields.Date.today() por_proveedor = {} total = 0.0 buckets_tot = {b['key']: 0.0 for b in self._buckets_antiguedad()} for c in compras: dias = (hoy - c.fecha).days if c.fecha else 0 bucket = self._bucket_de(dias) total += c.saldo buckets_tot[bucket] += c.saldo key = c.partner_id.id if key not in por_proveedor: por_proveedor[key] = { 'partner_id': key, 'proveedor': c.partner_id.name or '', 'dias_credito': c.partner_id.skeen_dias_credito or 0, 'saldo': 0.0, 'compras': 0, 'buckets': {b['key']: 0.0 for b in self._buckets_antiguedad()}, 'detalle': [], } p = por_proveedor[key] p['saldo'] += c.saldo p['compras'] += 1 p['buckets'][bucket] += c.saldo p['detalle'].append({ 'id': c.id, 'folio': c.name, 'fecha': c.fecha.strftime('%Y-%m-%d') if c.fecha else None, 'fecha_vencimiento': (c.fecha + timedelta( days=c.partner_id.skeen_dias_credito or 0)).strftime('%Y-%m-%d') if c.fecha else None, 'dias': dias, 'bucket': bucket, 'total': c.total, 'pagado': c.pagado, 'saldo': c.saldo, 'moneda': c.moneda, }) proveedores = sorted(por_proveedor.values(), key=lambda x: x['saldo'], reverse=True) return _json({ 'status': 'success', 'proveedores': proveedores, 'buckets': self._buckets_antiguedad(), 'totales': {'saldo': round(total, 2), 'por_bucket': {k: round(v, 2) for k, v in buckets_tot.items()}, 'proveedores': len(proveedores), 'compras': len(compras)}, }) except Exception as e: request.env.cr.rollback() return _json({'status': 'error', 'message': str(e)}, 500) # ================================================================== # Reportes # ================================================================== @http.route(BASE + '/reports/compras', type='http', auth='none', methods=['GET', 'OPTIONS'], csrf=False) @require_role('lectura') @require_perm('compras.ver') @compras_activas def report_compras(self, **kw): try: inicio, fin = self._rango_kw(kw) Compra = request.env['skeen.compra'].sudo() domain = [('estado', '!=', 'cancelada'), ('fecha', '>=', inicio), ('fecha', '<=', fin)] compras = Compra.search(domain, order='fecha asc, id asc') total = sum(c.total for c in compras) subtotal = sum(c.subtotal for c in compras) impuestos = sum(c.impuestos for c in compras) pagado = sum(c.pagado for c in compras) por_proveedor = {} por_mes = defaultdict(float) lineas_por_item = {} for c in compras: key = c.partner_id.id pp = por_proveedor.setdefault(key, { 'partner_id': key, 'proveedor': c.partner_id.name or '', 'compras': 0, 'total': 0.0, 'pagado': 0.0, 'saldo': 0.0}) pp['compras'] += 1 pp['total'] += c.total pp['pagado'] += c.pagado pp['saldo'] += c.saldo por_mes[c.fecha.strftime('%Y-%m') if c.fecha else ''] += c.total for l in c.linea_ids: k = l.item_id.id li = lineas_por_item.setdefault(k, { 'item_id': k, 'item': l.item_id.name, 'sku': l.item_sku or '', 'category': l.item_id.category or '', 'unidad': l.item_unit or 'pieza', 'cantidad_pedida': 0.0, 'cantidad_recibida': 0.0, 'subtotal': 0.0}) li['cantidad_pedida'] += l.cantidad_pedida li['cantidad_recibida'] += l.cantidad_recibida li['subtotal'] += l.subtotal return _json({ 'status': 'success', 'start': inicio.strftime('%Y-%m-%d'), 'end': fin.strftime('%Y-%m-%d'), 'totales': { 'compras': len(compras), 'subtotal': round(subtotal, 2), 'impuestos': round(impuestos, 2), 'total': round(total, 2), 'pagado': round(pagado, 2), 'saldo': round(total - pagado, 2), }, 'por_proveedor': sorted(por_proveedor.values(), key=lambda x: x['total'], reverse=True), 'por_articulo': sorted(lineas_por_item.values(), key=lambda x: x['subtotal'], reverse=True), 'por_mes': [{'mes': k, 'total': round(v, 2)} for k, v in sorted(por_mes.items())], 'cuentas_por_pagar': self._json_cuentas(), }) except Exception as e: request.env.cr.rollback() return _json({'status': 'error', 'message': str(e)}, 500) def _json_cuentas(self): """Cuentas por pagar resumidas para incrustar en el reporte.""" Compra = request.env['skeen.compra'].sudo() compras = Compra.search([('estado', '!=', 'cancelada'), ('saldo', '>', 0)]) hoy = fields.Date.today() por_proveedor = {} total = 0.0 for c in compras: total += c.saldo key = c.partner_id.id pp = por_proveedor.setdefault(key, { 'partner_id': key, 'proveedor': c.partner_id.name or '', 'saldo': 0.0, 'compras': 0, 'dias_credito': c.partner_id.skeen_dias_credito or 0}) pp['saldo'] += c.saldo pp['compras'] += 1 return {'total': round(total, 2), 'proveedores': sorted(por_proveedor.values(), key=lambda x: x['saldo'], reverse=True)} @http.route(BASE + '/reports/margen', type='http', auth='none', methods=['GET', 'OPTIONS'], csrf=False) @require_role('lectura') @require_perm('compras.ver') @compras_activas def report_margen(self, **kw): try: inicio, fin = self._rango_kw(kw) Line = request.env['skeen.venta.line'].sudo() lineas = Line.search([ ('item_id', '!=', False), ('venta_id.state', 'in', ('confirmed', 'partial', 'paid')), ('venta_id.date', '>=', inicio), ('venta_id.date', '<=', fin), ]) ventas_por_item = defaultdict(lambda: {'unidades': 0.0, 'ingresos': 0.0}) for l in lineas: agg = ventas_por_item[l.item_id.id] agg['unidades'] += l.quantity or 0.0 agg['ingresos'] += l.subtotal or 0.0 Item = request.env['skeen.inventario.item'].sudo() items = Item.search([('active', '=', True)], order='name asc') margen_min = _num(_cfg().get('margen_minimo_alerta'), 0.0) filas = [] tot_ingresos = tot_costo = 0.0 for it in items: venta = ventas_por_item.get(it.id) costo_unit = it.costo_promedio or it.cost or 0.0 margen_unit = (it.margen or 0.0) # precio sin impuesto - costo if not venta and not it.price and not costo_unit: continue unidades = venta['unidades'] if venta else 0.0 ingresos = venta['ingresos'] if venta else 0.0 costo = unidades * costo_unit filas.append({ 'item_id': it.id, 'item': it.name, 'sku': it.sku or '', 'category': it.category or '', 'unidad': it.unit or 'pieza', 'precio': it.price or 0.0, 'costo_promedio': costo_unit, 'margen_unitario': margen_unit, 'margen_pct': it.margen_pct or 0.0, 'unidades_vendidas': unidades, 'ingresos': round(ingresos, 2), 'costo_ventas': round(costo, 2), 'margen_total': round(ingresos - costo, 2), 'alerta': bool(venta and (it.margen_pct or 0.0) < margen_min), }) tot_ingresos += ingresos tot_costo += costo filas.sort(key=lambda x: x['margen_total'], reverse=True) margen_agg = tot_ingresos - tot_costo return _json({ 'status': 'success', 'start': inicio.strftime('%Y-%m-%d'), 'end': fin.strftime('%Y-%m-%d'), 'margen_minimo_alerta': margen_min, 'articulos': filas, 'totales': { 'ingresos': round(tot_ingresos, 2), 'costo_ventas': round(tot_costo, 2), 'margen': round(margen_agg, 2), 'margen_pct': round((margen_agg / tot_ingresos * 100.0) if tot_ingresos else 0.0, 2), 'alertas': len([f for f in filas if f['alerta']]), }, }) except Exception as e: request.env.cr.rollback() return _json({'status': 'error', 'message': str(e)}, 500)