951 lines
41 KiB
Python
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)
|