fase 3.2: compras y proveedores (modulo skeen_compras: proveedores, ordenes de compra con recepcion total/parcial, costo promedio ponderado con lote y caducidad, pagos a proveedor, cuentas por pagar con antiguedad, reportes de compras y margen por articulo); apagado por defecto
This commit is contained in:
950
odoo-addons/skeen_compras/controllers/compras.py
Normal file
950
odoo-addons/skeen_compras/controllers/compras.py
Normal file
@@ -0,0 +1,950 @@
|
||||
# -*- 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)
|
||||
Reference in New Issue
Block a user