Files

951 lines
41 KiB
Python

# -*- 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 + '/<int:pid>', 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 + '/<int:pid>', 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 + '/<int:pid>', 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 + '/<int:cid>', 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 + '/<int:cid>', 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 + '/<int:cid>/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 + '/<int:cid>/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 + '/<int:cid>/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 + '/<int:cid>/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)