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:
2026-10-11 12:17:23 +00:00
parent ee8a844d96
commit a916753ebe
38 changed files with 4201 additions and 30 deletions

View File

@@ -666,6 +666,91 @@ def get_recursos(env):
"""Bloque de recursos vigente (copia). Fuente única para modelos y API."""
return normalizar_recursos(get_settings(env).get('recursos'))
# ---------------------------------------------------------------------------
# Compras y proveedores.
#
# Bloque anidado del mismo JSON de ajustes, igual que `impuestos`. La regla de
# oro: con `activo=False` (default) NADA cambia en la instancia — la sección
# Compras no aparece en el menú, el inventario y el POS se ven idénticos, no
# corre ningún cron y las rutas del módulo responden 403. Por eso el default es
# apagado.
#
# activo: enciende TODO el módulo de compras
# actualizar_costo_promedio: al recibir, recalcula el costo promedio ponderado
# permitir_recepcion_parcial: permite recibir menos de lo pedido y recibir después
# requiere_autorizacion: las órdenes nacen en borrador y exigen enviarse
# dias_credito_default: días de crédito por defecto de un proveedor nuevo
# margen_minimo_alerta: margen (%) bajo el cual el reporte avisa
# permitir_compras_sin_orden: permite registrar una recepción sin orden de compra
# ---------------------------------------------------------------------------
COMPRAS_DEFAULT = {
'activo': False,
'actualizar_costo_promedio': True,
'permitir_recepcion_parcial': True,
'requiere_autorizacion': False,
'dias_credito_default': 0,
'margen_minimo_alerta': 0,
'permitir_compras_sin_orden': False,
}
COMPRAS_DIAS_CREDITO_MAX = 365
COMPRAS_MARGEN_MAX = 1000 # % (permite margen negativo / muy alto sin tope real)
def normalizar_compras(raw):
"""Bloque `compras` completo, con defaults. Tolerante: nunca lanza."""
out = dict(COMPRAS_DEFAULT)
if not isinstance(raw, dict):
return out
for k in ('activo', 'actualizar_costo_promedio', 'permitir_recepcion_parcial',
'requiere_autorizacion', 'permitir_compras_sin_orden'):
out[k] = _bool(raw.get(k), COMPRAS_DEFAULT[k])
out['dias_credito_default'] = min(max(_int(
raw.get('dias_credito_default'), COMPRAS_DEFAULT['dias_credito_default']),
0), COMPRAS_DIAS_CREDITO_MAX)
out['margen_minimo_alerta'] = min(max(_num(
raw.get('margen_minimo_alerta'), COMPRAS_DEFAULT['margen_minimo_alerta']),
-COMPRAS_MARGEN_MAX), COMPRAS_MARGEN_MAX)
return out
def validar_compras(raw):
"""Valida el bloque `compras` de un PUT. Lanza ValueError si algo no cuadra
(se traduce a 400). Devuelve SOLO las claves presentes y normalizadas para
que el merge anidado no reinicie lo que el PUT no tocó."""
if raw is None:
return None
if not isinstance(raw, dict):
raise ValueError('El bloque de compras debe ser un objeto')
out = {}
for k in ('activo', 'actualizar_costo_promedio', 'permitir_recepcion_parcial',
'requiere_autorizacion', 'permitir_compras_sin_orden'):
if k in raw:
if not isinstance(raw[k], bool):
raise ValueError('El campo %s debe ser verdadero o falso' % k)
out[k] = raw[k]
if 'dias_credito_default' in raw:
if isinstance(raw['dias_credito_default'], bool) or \
not isinstance(raw['dias_credito_default'], int):
raise ValueError('Los días de crédito deben ser un número entero')
if not (0 <= raw['dias_credito_default'] <= COMPRAS_DIAS_CREDITO_MAX):
raise ValueError('Los días de crédito deben estar entre 0 y %d'
% COMPRAS_DIAS_CREDITO_MAX)
out['dias_credito_default'] = raw['dias_credito_default']
if 'margen_minimo_alerta' in raw:
n = _num(raw['margen_minimo_alerta'], None)
if n is None:
raise ValueError('El margen mínimo de alerta debe ser un número')
if not (-COMPRAS_MARGEN_MAX <= n <= COMPRAS_MARGEN_MAX):
raise ValueError('El margen mínimo de alerta está fuera de rango')
out['margen_minimo_alerta'] = n
return out
def get_compras(env):
"""Bloque de compras vigente (copia). Fuente única para modelos y API."""
return normalizar_compras(get_settings(env).get('compras'))
# Horas de agenda de respaldo: el comportamiento de siempre (8:00-19:00).
AGENDA_START_DEFAULT = 8.0
AGENDA_END_DEFAULT = 19.0
@@ -710,6 +795,7 @@ def get_settings(env):
'notificaciones': normalizar_notificaciones(None),
'membresias': normalizar_membresias(None),
'recursos': dict(RECURSOS_DEFAULT),
'compras': dict(COMPRAS_DEFAULT),
}
raw = env['ir.config_parameter'].sudo().get_param(SETTINGS_KEY)
if raw:
@@ -726,6 +812,7 @@ def get_settings(env):
out['notificaciones'] = normalizar_notificaciones(guardado.get('notificaciones'))
out['membresias'] = normalizar_membresias(guardado.get('membresias'))
out['recursos'] = normalizar_recursos(guardado.get('recursos'))
out['compras'] = normalizar_compras(guardado.get('compras'))
return out
@@ -758,6 +845,11 @@ def set_settings(env, vals):
bloque = validar_recursos(vals['recursos'])
if bloque is not None:
actual['recursos'] = {**actual['recursos'], **bloque}
if 'compras' in vals:
bloque = validar_compras(vals['compras'])
if bloque is not None:
# Merge anidado: un PUT parcial (p. ej. solo activo) no borra el resto.
actual['compras'] = {**actual['compras'], **bloque}
env['ir.config_parameter'].sudo().set_param(
SETTINGS_KEY, json.dumps(actual, ensure_ascii=False))
return get_settings(env)

View File

@@ -0,0 +1,4 @@
# -*- coding: utf-8 -*-
from . import models
from . import controllers
from .hooks import post_init_hook

View File

@@ -0,0 +1,36 @@
# -*- coding: utf-8 -*-
{
'name': 'SKEEN Compras y Proveedores',
'version': '1.0.0',
'category': 'Healthcare',
'summary': 'Órdenes de compra, recepciones, costo promedio y cuentas por pagar',
'description': """
Compras y proveedores con costo promedio ponderado para cualquier giro.
Da de alta proveedores (sobre `res.partner`), genera órdenes de compra,
recibe mercancía total o parcialmente actualizando el stock y el costo
promedio del artículo (con lote/caducidad para consumibles), controla las
cuentas por pagar y mide el margen por artículo y del periodo.
El módulo está APAGADO por defecto (`compras.activo=false`): no aparece
en el menú, el inventario y el POS se ven idénticos, no corre ningún cron
y las rutas del módulo responden 403.
Los pagos a proveedores viven en `skeen.compra.pago` (son EGRESOS) y NO
se mezclan con `skeen.pago`, que registra los cobros a pacientes. Esta
separación evita que un egreso aparezca en los cortes de caja o en el
estado de cuenta de un paciente.
""",
'author': 'Consultoria Alcaraz Salazar, S.A.S.',
'website': 'https://skeen.mx',
'depends': ['base', 'skeen_citas', 'skeen_inventario', 'skeen_ventas', 'skeen_whatsapp'],
'data': [
'security/ir.model.access.csv',
'data/sequences.xml',
],
'installable': True,
'application': False,
'auto_install': False,
'post_init_hook': 'post_init_hook',
'license': 'LGPL-3',
}

View File

@@ -0,0 +1,2 @@
# -*- coding: utf-8 -*-
from . import compras

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

View File

@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data noupdate="1">
<!-- Folio de orden de compra: OC-0001, OC-0002, ... -->
<record id="seq_skeen_compra" model="ir.sequence">
<field name="name">Orden de compra SKEEN</field>
<field name="code">skeen.compra</field>
<field name="prefix">OC-</field>
<field name="padding">4</field>
<field name="number_next">1</field>
<field name="number_increment">1</field>
</record>
</data>
</odoo>

View File

@@ -0,0 +1,19 @@
# -*- coding: utf-8 -*-
"""Hook de instalación de skeen_compras.
Inicializa `costo_promedio` y `ultimo_costo` con el costo unitario que ya tenía
cada artículo, para que el margen sea significativo desde el primer día. Solo
escribe las columnas NUEVAS: no toca `cost`, `qty` ni ningún valor que usen los
reportes existentes, así que una instancia con `compras.activo=false` no cambia
ninguna cifra.
"""
def post_init_hook(env):
env.cr.execute("""
UPDATE skeen_inventario_item
SET costo_promedio = COALESCE(cost, 0.0),
ultimo_costo = COALESCE(cost, 0.0)
WHERE COALESCE(costo_promedio, 0.0) = 0.0
AND COALESCE(cost, 0.0) <> 0.0
""")

View File

@@ -0,0 +1,4 @@
# -*- coding: utf-8 -*-
from . import partner
from . import inventario
from . import compra

View File

@@ -0,0 +1,424 @@
# -*- coding: utf-8 -*-
"""Órdenes de compra, recepciones (parciales), pagos y costo promedio.
Reglas de negocio de este módulo:
* Una compra nace en `borrador`, se `enviar` y se recibe (total o parcial).
Recibir genera movimientos `skeen.inventario.move` tipo `compra` (que suben el
stock) y recalcula el costo promedio del artículo si `actualizar_costo_promedio`
está encendido en `compras` (clinic-settings).
* Las recepciones son ACUMULATIVAS: `skeen.compra.linea.cantidad_recibida` suma
cada recepción y el estado pasa a `recibida_parcial` o `recibida`.
* Los pagos viven en `skeen.compra.pago` (EGRESOS) y NO se mezclan con
`skeen.pago` (cobros a pacientes). Un egreso no debe aparecer en un corte de
caja ni en el estado de cuenta de un paciente.
* Cancelar solo se permite si NO hubo recepciones. Se decide bloquear en vez de
revertir movimientos: un ajuste de reversa dejaría el costo promedio ya
recalculado y el kardex sin trazabilidad de la compra cancelada. Si la
mercancía ya entró, el camino correcto es una baja/ajuste de inventario.
"""
from odoo import models, fields, api
from odoo.exceptions import ValidationError, UserError
from odoo.addons.skeen_citas.clinic_settings import get_compras, get_impuestos
MONEDAS = ('MXN', 'USD')
METODOS_PAGO = ('efectivo', 'transferencia', 'tarjeta', 'otro')
ESTADOS = ('borrador', 'enviada', 'recibida_parcial', 'recibida', 'cancelada')
ESTADO_LABEL = {
'borrador': 'Borrador',
'enviada': 'Enviada',
'recibida_parcial': 'Recibida parcial',
'recibida': 'Recibida',
'cancelada': 'Cancelada',
}
def _num(valor, por_defecto=0.0):
try:
return float(valor)
except (TypeError, ValueError):
return por_defecto
class SkeenCompra(models.Model):
_name = 'skeen.compra'
_description = 'Orden de compra SKEEN'
_order = 'fecha desc, id desc'
name = fields.Char(string='Folio', readonly=True, copy=False, index=True)
partner_id = fields.Many2one('res.partner', string='Proveedor',
required=True, index=True, ondelete='restrict')
partner_contacto = fields.Char(related='partner_id.skeen_contacto',
string='Contacto', readonly=True)
fecha = fields.Date(string='Fecha', default=fields.Date.today, required=True)
fecha_entrega_esperada = fields.Date(string='Entrega esperada')
estado = fields.Selection(
[(e, ESTADO_LABEL[e]) for e in ESTADOS], string='Estado',
default='borrador', required=True, index=True)
linea_ids = fields.One2many('skeen.compra.linea', 'compra_id', string='Líneas')
moneda = fields.Selection(
[(m, m) for m in MONEDAS], string='Moneda', default='MXN', required=True)
subtotal = fields.Float(string='Subtotal', compute='_compute_totales',
store=True)
descuento = fields.Float(string='Descuento', default=0.0)
impuestos = fields.Float(string='Impuestos', compute='_compute_totales',
store=True)
total = fields.Float(string='Total', compute='_compute_totales', store=True)
pagado = fields.Float(string='Pagado', compute='_compute_pagado', store=True)
saldo = fields.Float(string='Saldo', compute='_compute_pagado', store=True)
sucursal_id = fields.Many2one('skeen.sucursal', string='Sucursal')
referencia_factura = fields.Char(string='Referencia de factura')
notas = fields.Text(string='Notas')
creado_por_login = fields.Char(string='Creado por')
recibida_at = fields.Datetime(string='Recibida el', readonly=True)
cancel_reason = fields.Char(string='Motivo de cancelación', readonly=True)
recepcion_ids = fields.One2many('skeen.compra.recepcion', 'compra_id',
string='Recepciones')
pago_ids = fields.One2many('skeen.compra.pago', 'compra_id', string='Pagos')
total_pedido = fields.Float(string='Unidades pedidas',
compute='_compute_totales', store=True)
total_recibido = fields.Float(string='Unidades recibidas',
compute='_compute_recepcion')
recepciones_count = fields.Integer(compute='_compute_recepcion')
tiene_recepciones = fields.Boolean(compute='_compute_recepcion')
@api.model_create_multi
def create(self, vals_list):
for vals in vals_list:
if not vals.get('name'):
# Se busca la secuencia por código y se usa next_by_id (no
# next_by_code): en peticiones auth='none' `env.company` puede
# no ser la compañía de la secuencia y next_by_code devolvería
# False (folio repetido).
seq = self.env['ir.sequence'].sudo().search(
[('code', '=', 'skeen.compra')], limit=1)
folio = seq.next_by_id() if seq else False
vals['name'] = folio or 'OC-0001'
return super().create(vals_list)
@api.depends('linea_ids.subtotal', 'linea_ids.impuesto', 'linea_ids.cantidad_pedida',
'descuento')
def _compute_totales(self):
for c in self:
c.subtotal = sum(l.subtotal for l in c.linea_ids)
c.impuestos = sum(l.impuesto for l in c.linea_ids)
c.total = max(0.0, c.subtotal - (c.descuento or 0.0) + c.impuestos)
c.total_pedido = sum(l.cantidad_pedida for l in c.linea_ids)
@api.depends('pago_ids.monto_en_compra', 'total')
def _compute_pagado(self):
for c in self:
c.pagado = sum(p.monto_en_compra for p in c.pago_ids)
c.saldo = c.total - c.pagado
@api.depends('linea_ids.cantidad_recibida', 'recepcion_ids')
def _compute_recepcion(self):
for c in self:
recibido = sum(l.cantidad_recibida for l in c.linea_ids)
c.total_recibido = recibido
c.recepciones_count = len(c.recepcion_ids)
c.tiene_recepciones = recibido > 0
# ------------------------------------------------------------------
# Acciones
# ------------------------------------------------------------------
def action_enviar(self):
for c in self:
if c.estado == 'cancelada':
raise UserError('Una compra cancelada no se puede enviar.')
if c.estado != 'borrador':
raise UserError('Solo una compra en borrador se puede enviar.')
if not c.linea_ids:
raise UserError('Agrega al menos una línea antes de enviar.')
c.estado = 'enviada'
return True
def action_cancelar(self, motivo=''):
for c in self:
if c.estado == 'cancelada':
raise UserError('La compra ya está cancelada.')
if c.tiene_recepciones:
raise UserError(
'No se puede cancelar: la compra ya tiene mercancía '
'recibida. Registra una baja o un ajuste de inventario.')
c.write({'estado': 'cancelada', 'cancel_reason': (motivo or '').strip()})
return True
def registrar_recepcion(self, lineas, usuario='', notas=''):
"""Recibe cantidades (posiblemente parciales) y actualiza stock y costo.
`lineas` es una lista de dicts: `{'linea_id'|'item_id', 'cantidad',
'lote', 'fecha_caducidad', 'costo_unitario'}`. Devuelve el registro
`skeen.compra.recepcion` creado.
"""
self.ensure_one()
if self.estado == 'cancelada':
raise UserError('No se puede recibir una compra cancelada.')
if self.estado == 'recibida':
raise UserError('La compra ya está totalmente recibida.')
cfg = get_compras(self.env)
if cfg.get('requiere_autorizacion') and self.estado == 'borrador':
raise UserError('Esta clínica exige autorizar la compra: envíala '
'antes de recibirla.')
Recepcion = self.env['skeen.compra.recepcion']
RecepcionLinea = self.env['skeen.compra.recepcion.linea']
Move = self.env['skeen.inventario.move']
recepcion = Recepcion.create({
'compra_id': self.id,
'fecha': fields.Datetime.now(),
'usuario': usuario or '',
'notas': notas or '',
})
recibidas_total = 0
for data in (lineas or []):
linea = None
if data.get('linea_id'):
linea = self.linea_ids.filtered(
lambda l: l.id == int(data['linea_id']))
if not linea and data.get('item_id'):
linea = self.linea_ids.filtered(
lambda l: l.item_id.id == int(data['item_id']))
if not linea:
raise UserError('Línea de compra no encontrada para la recepción.')
linea = linea[0]
cantidad = _num(data.get('cantidad'))
if cantidad <= 0:
continue
pendiente = linea.cantidad_pendiente
if cantidad - pendiente > 1e-6:
if not cfg.get('permitir_recepcion_parcial', True):
raise UserError('No se permite recibir más de lo pendiente.')
cantidad = pendiente
if not cfg.get('permitir_recepcion_parcial', True):
if abs(cantidad - pendiente) > 1e-6:
raise UserError('Esta clínica no permite recepciones '
'parciales: recibe la cantidad completa.')
costo = _num(data.get('costo_unitario'), linea.costo_unitario)
lote = (data.get('lote') or '').strip()
caducidad = data.get('fecha_caducidad') or False
item = linea.item_id
# El costo promedio usa la existencia ANTES del movimiento: se
# actualiza aquí y el movimiento (que sube el stock) va después.
item._actualizar_costo_recepcion(
cantidad, costo,
actualizar_promedio=cfg.get('actualizar_costo_promedio', True))
if caducidad:
item.expiry_date = caducidad
move = Move.create({
'item_id': item.id,
'type': 'compra',
'qty': cantidad,
'reference': self.name,
'notes': ('Recepción %s%s' % (self.name, (' · lote ' + lote) if lote else '')),
'lote': lote,
'fecha_caducidad': caducidad or False,
})
RecepcionLinea.create({
'recepcion_id': recepcion.id,
'compra_linea_id': linea.id,
'item_id': item.id,
'cantidad': cantidad,
'costo_unitario': costo,
'lote': lote,
'fecha_caducidad': caducidad or False,
'move_id': move.id,
})
linea.cantidad_recibida = (linea.cantidad_recibida or 0.0) + cantidad
recibidas_total += cantidad
if recibidas_total <= 0:
raise UserError('No se indicó ninguna cantidad a recibir.')
completa = all(
(l.cantidad_recibida or 0.0) + 1e-6 >= (l.cantidad_pedida or 0.0)
for l in self.linea_ids)
vals = {'estado': 'recibida' if completa else 'recibida_parcial'}
if completa:
vals['recibida_at'] = fields.Datetime.now()
self.write(vals)
return recepcion
def registrar_pago(self, monto, moneda='MXN', metodo='transferencia',
referencia='', notas='', usuario=''):
self.ensure_one()
if self.estado == 'cancelada':
raise UserError('No se puede pagar una compra cancelada.')
monto = _num(monto)
if monto <= 0:
raise UserError('El monto del pago debe ser mayor a 0.')
if moneda not in MONEDAS:
moneda = self.moneda or 'MXN'
if metodo not in METODOS_PAGO:
metodo = 'otro'
return self.env['skeen.compra.pago'].create({
'compra_id': self.id,
'fecha': fields.Date.today(),
'monto': monto,
'moneda': moneda,
'metodo': metodo,
'referencia': referencia or '',
'notas': notas or '',
'usuario': usuario or '',
})
class SkeenCompraLinea(models.Model):
_name = 'skeen.compra.linea'
_description = 'Línea de orden de compra SKEEN'
_order = 'id'
compra_id = fields.Many2one('skeen.compra', string='Compra', required=True,
ondelete='cascade', index=True)
item_id = fields.Many2one('skeen.inventario.item', string='Artículo',
required=True, ondelete='restrict')
item_sku = fields.Char(related='item_id.sku', string='SKU', readonly=True)
item_unit = fields.Char(related='item_id.unit', string='Unidad', readonly=True)
item_kind = fields.Selection(related='item_id.kind', string='Tipo', readonly=True)
cantidad_pedida = fields.Float(string='Cantidad pedida', default=1.0)
cantidad_recibida = fields.Float(string='Cantidad recibida', default=0.0)
cantidad_pendiente = fields.Float(string='Pendiente',
compute='_compute_pendiente')
costo_unitario = fields.Float(string='Costo unitario', default=0.0)
descuento = fields.Float(string='Descuento', default=0.0)
subtotal = fields.Float(string='Subtotal', compute='_compute_subtotal',
store=True)
impuesto = fields.Float(string='Impuesto', compute='_compute_impuesto',
store=True)
total = fields.Float(string='Total', compute='_compute_total')
lote = fields.Char(string='Lote')
fecha_caducidad = fields.Date(string='Caducidad')
notas = fields.Char(string='Notas')
@api.depends('cantidad_pedida', 'cantidad_recibida')
def _compute_pendiente(self):
for l in self:
l.cantidad_pendiente = max(
0.0, (l.cantidad_pedida or 0.0) - (l.cantidad_recibida or 0.0))
@api.depends('cantidad_pedida', 'costo_unitario', 'descuento')
def _compute_subtotal(self):
for l in self:
bruto = (l.cantidad_pedida or 0.0) * (l.costo_unitario or 0.0)
l.subtotal = max(0.0, bruto - (l.descuento or 0.0))
@api.depends('subtotal', 'item_id')
def _compute_impuesto(self):
imp = get_impuestos(self.env)
for l in self:
tasa = 0.0
if imp.get('activo') and l.item_id:
tasa = (imp.get('tasa_default') or 0.0) if l.item_id.tax_use_default \
else (l.item_id.tax_rate or 0.0)
if tasa <= 0:
l.impuesto = 0.0
elif imp.get('precios_incluyen_impuesto'):
l.impuesto = l.subtotal - l.subtotal / (1.0 + tasa / 100.0)
else:
l.impuesto = l.subtotal * tasa / 100.0
@api.depends('subtotal', 'impuesto')
def _compute_total(self):
for l in self:
l.total = l.subtotal + l.impuesto
# Sugerencia de costo: la UI la pide, pero deja el dato listo también aquí.
def _sugerir_costo(self):
self.ensure_one()
return (self.item_id.ultimo_costo or self.item_id.costo_promedio
or self.item_id.cost or 0.0)
class SkeenCompraRecepcion(models.Model):
_name = 'skeen.compra.recepcion'
_description = 'Recepción de mercancía de una compra'
_order = 'fecha desc, id desc'
compra_id = fields.Many2one('skeen.compra', string='Compra', required=True,
ondelete='cascade', index=True)
fecha = fields.Datetime(string='Fecha', default=fields.Datetime.now)
usuario = fields.Char(string='Usuario')
notas = fields.Text(string='Notas')
line_ids = fields.One2many('skeen.compra.recepcion.linea', 'recepcion_id',
string='Detalle')
total_unidades = fields.Float(string='Unidades', compute='_compute_total')
total_costo = fields.Float(string='Costo recibido', compute='_compute_total')
@api.depends('line_ids.cantidad', 'line_ids.costo_unitario')
def _compute_total(self):
for r in self:
r.total_unidades = sum(l.cantidad for l in r.line_ids)
r.total_costo = sum(l.cantidad * l.costo_unitario for l in r.line_ids)
class SkeenCompraRecepcionLinea(models.Model):
_name = 'skeen.compra.recepcion.linea'
_description = 'Línea de recepción de compra'
_order = 'id'
recepcion_id = fields.Many2one('skeen.compra.recepcion', string='Recepción',
required=True, ondelete='cascade', index=True)
compra_linea_id = fields.Many2one('skeen.compra.linea', string='Línea de compra',
required=True, ondelete='cascade')
item_id = fields.Many2one('skeen.inventario.item', string='Artículo',
required=True, ondelete='restrict')
cantidad = fields.Float(string='Cantidad', default=0.0)
costo_unitario = fields.Float(string='Costo unitario', default=0.0)
lote = fields.Char(string='Lote')
fecha_caducidad = fields.Date(string='Caducidad')
move_id = fields.Many2one('skeen.inventario.move', string='Movimiento',
readonly=True)
class SkeenCompraPago(models.Model):
"""Pago (EGRESO) a un proveedor. Separado de `skeen.pago` (cobros)."""
_name = 'skeen.compra.pago'
_description = 'Pago a proveedor SKEEN'
_order = 'fecha desc, id desc'
compra_id = fields.Many2one('skeen.compra', string='Compra', required=True,
ondelete='cascade', index=True)
partner_id = fields.Many2one(related='compra_id.partner_id', string='Proveedor',
store=True, readonly=True)
fecha = fields.Date(string='Fecha', default=fields.Date.today, required=True)
monto = fields.Float(string='Monto', default=0.0)
moneda = fields.Selection([(m, m) for m in MONEDAS], string='Moneda',
default='MXN', required=True)
metodo = fields.Selection([(m, m) for m in METODOS_PAGO], string='Método',
default='transferencia')
referencia = fields.Char(string='Referencia')
notas = fields.Text(string='Notas')
usuario = fields.Char(string='Usuario')
monto_en_compra = fields.Float(
string='Equivalente', compute='_compute_monto_en_compra', store=True,
help='Monto convertido a la moneda de la compra (tipo de cambio vigente).')
@api.depends('monto', 'moneda', 'compra_id.moneda')
def _compute_monto_en_compra(self):
rate = self._tipo_cambio()
for p in self:
objetivo = p.compra_id.moneda or 'MXN'
if p.moneda == objetivo:
p.monto_en_compra = p.monto or 0.0
elif p.moneda == 'USD' and objetivo == 'MXN':
p.monto_en_compra = (p.monto or 0.0) * rate
elif p.moneda == 'MXN' and objetivo == 'USD':
p.monto_en_compra = (p.monto or 0.0) / rate if rate else 0.0
else:
p.monto_en_compra = p.monto or 0.0
def _tipo_cambio(self):
"""Último tipo de cambio MXN por USD registrado en `skeen.tipo.cambio`."""
try:
tc = self.env['skeen.tipo.cambio'].sudo().search(
[], order='date desc, id desc', limit=1)
return (tc.rate or 1.0) if tc else 1.0
except Exception:
return 1.0

View File

@@ -0,0 +1,92 @@
# -*- coding: utf-8 -*-
"""Campos de costo/margen que Compras agrega al artículo de inventario.
`costo_promedio` y `ultimo_costo` son NUEVOS: `cost` (Costo unitario) se queda
como estaba porque lo usan el valor de inventario y los reportes existentes, de
modo que una instancia con `compras.activo=false` conserva sus cifras idénticas.
El margen se calcula contra `costo_promedio` (con respaldo en `cost`) y el
precio de venta ya sin impuesto.
"""
from odoo import models, fields, api
class SkeenInventarioItem(models.Model):
_inherit = 'skeen.inventario.item'
costo_promedio = fields.Float(
string='Costo promedio', default=0.0,
help='Costo promedio ponderado de las compras recibidas. Editable por '
'administración como ajuste de inventario.')
ultimo_costo = fields.Float(
string='Último costo', default=0.0,
help='Costo unitario de la última recepción de compra.')
margen = fields.Float(
string='Margen', compute='_compute_margen',
help='Precio de venta sin impuesto menos costo promedio.')
margen_pct = fields.Float(
string='Margen (%)', compute='_compute_margen')
@api.depends('price', 'cost', 'costo_promedio', 'tax_rate',
'tax_use_default')
def _compute_margen(self):
from odoo.addons.skeen_citas.clinic_settings import get_impuestos
try:
imp = get_impuestos(self.env)
except Exception:
imp = {'activo': False, 'tasa_default': 0.0,
'precios_incluyen_impuesto': False}
for it in self:
precio = it.price or 0.0
if imp.get('activo'):
tasa = (imp.get('tasa_default') or 0.0) if it.tax_use_default \
else (it.tax_rate or 0.0)
if imp.get('precios_incluyen_impuesto') and tasa:
precio = precio / (1.0 + tasa / 100.0)
costo = it.costo_promedio or it.cost or 0.0
it.margen = precio - costo
it.margen_pct = (it.margen / precio * 100.0) if precio else 0.0
def _actualizar_costo_recepcion(self, cantidad, costo_unitario,
actualizar_promedio=True):
"""Aplica el costo de una recepción sobre el artículo.
Fórmula ponderada: (stock_actual*costo_actual + cantidad*costo_recibido)
/ (stock_actual + cantidad). Si el stock actual es <= 0 (cero o negativo)
el promedio se reinicia al costo recibido, porque mezclar un costo viejo
con existencias negativas daría un número sin sentido.
Debe llamarse ANTES de crear el movimiento de entrada, para que
`stock_actual` sea la existencia previa a la recepción.
"""
self.ensure_one()
stock_actual = self.qty or 0.0
cantidad = float(cantidad or 0.0)
costo_unitario = float(costo_unitario or 0.0)
vals = {'ultimo_costo': costo_unitario}
if actualizar_promedio and cantidad > 0:
if stock_actual > 0:
# Sin historial de compras el promedio arranca del costo unitario
# ya cargado (`cost`); si no, mezclar contra 0 daría un promedio
# artificialmente bajo.
costo_actual = self.costo_promedio or self.cost or 0.0
nuevo = ((stock_actual * costo_actual)
+ (cantidad * costo_unitario)) / (stock_actual + cantidad)
else:
nuevo = costo_unitario
vals['costo_promedio'] = nuevo
self.write(vals)
return self.costo_promedio
class SkeenInventarioMove(models.Model):
"""Lote y caducidad de la mercancía recibida.
Columnas nuevas y opcionales: el kardex actual no cambia. Las recepciones de
compra las llenan; los demás movimientos las dejan vacías.
"""
_inherit = 'skeen.inventario.move'
lote = fields.Char(string='Lote')
fecha_caducidad = fields.Date(string='Caducidad')

View File

@@ -0,0 +1,69 @@
# -*- coding: utf-8 -*-
"""Proveedores sobre `res.partner`.
No se duplica el modelo de persona: un proveedor es un `res.partner` marcado con
`skeen_es_proveedor` más un puñado de campos propios de negocio (días de crédito,
contacto, datos de pago y notas).
Nota de compatibilidad: el `supplier_rank` estándar de Odoo lo define el módulo
`account`, que NO está instalado en todas las instancias (p. ej. la de pruebas).
Por eso la fuente de verdad es el flag propio `skeen_es_proveedor`; si la
instancia sí tiene `supplier_rank`, se sincroniza para no perder la integración
con el estándar. El flag `skeen_proveedor_activo` permite "desactivar" un
proveedor con historial sin borrar los datos ni romper las compras pasadas.
"""
from odoo import models, fields, api
from odoo.exceptions import ValidationError
class ResPartner(models.Model):
_inherit = 'res.partner'
skeen_es_proveedor = fields.Boolean(
string='Es proveedor', default=False, index=True,
help='Marca propia de proveedor. Se sincroniza con supplier_rank si la '
'instancia tiene el módulo account instalado.')
skeen_proveedor_activo = fields.Boolean(
string='Proveedor activo', default=True,
help='Se apaga al "eliminar" un proveedor con compras registradas.')
skeen_dias_credito = fields.Integer(
string='Días de crédito', default=0)
skeen_contacto = fields.Char(
string='Contacto de compras')
skeen_banco = fields.Char(string='Banco')
skeen_cuenta_pago = fields.Char(string='Cuenta / CLABE de pago')
skeen_notas_proveedor = fields.Text(string='Notas de proveedor')
skeen_compra_ids = fields.One2many(
'skeen.compra', 'partner_id', string='Compras')
skeen_compras_count = fields.Integer(
string='Número de compras', compute='_compute_skeen_compras_count')
skeen_saldo = fields.Float(
string='Saldo por pagar', compute='_compute_skeen_saldo',
help='Suma de los saldos de las compras no canceladas.')
@api.depends('skeen_compra_ids')
def _compute_skeen_compras_count(self):
for p in self:
p.skeen_compras_count = len(p.skeen_compra_ids)
@api.depends('skeen_compra_ids.saldo', 'skeen_compra_ids.estado')
def _compute_skeen_saldo(self):
for p in self:
p.skeen_saldo = sum(
c.saldo for c in p.skeen_compra_ids
if c.estado not in ('cancelada',))
@api.constrains('skeen_dias_credito')
def _check_dias_credito(self):
for p in self:
if p.skeen_dias_credito is not None and p.skeen_dias_credito < 0:
raise ValidationError('Los días de crédito no pueden ser negativos.')
def action_marcar_proveedor(self):
"""Marca al partner como proveedor (flag propio + supplier_rank si existe)."""
for p in self:
p.skeen_es_proveedor = True
if 'supplier_rank' in p._fields and (p.supplier_rank or 0) <= 0:
p.supplier_rank = 1
p.skeen_proveedor_activo = True

View File

@@ -0,0 +1,6 @@
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_skeen_compra_user,skeen.compra.user,model_skeen_compra,base.group_user,1,1,1,1
access_skeen_compra_linea_user,skeen.compra.linea.user,model_skeen_compra_linea,base.group_user,1,1,1,1
access_skeen_compra_recepcion_user,skeen.compra.recepcion.user,model_skeen_compra_recepcion,base.group_user,1,1,1,1
access_skeen_compra_recepcion_linea_user,skeen.compra.recepcion.linea.user,model_skeen_compra_recepcion_linea,base.group_user,1,1,1,1
access_skeen_compra_pago_user,skeen.compra.pago.user,model_skeen_compra_pago,base.group_user,1,1,1,1
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_skeen_compra_user skeen.compra.user model_skeen_compra base.group_user 1 1 1 1
3 access_skeen_compra_linea_user skeen.compra.linea.user model_skeen_compra_linea base.group_user 1 1 1 1
4 access_skeen_compra_recepcion_user skeen.compra.recepcion.user model_skeen_compra_recepcion base.group_user 1 1 1 1
5 access_skeen_compra_recepcion_linea_user skeen.compra.recepcion.linea.user model_skeen_compra_recepcion_linea base.group_user 1 1 1 1
6 access_skeen_compra_pago_user skeen.compra.pago.user model_skeen_compra_pago base.group_user 1 1 1 1

View File

@@ -21,6 +21,7 @@ from odoo.addons.skeen_citas.clinic_settings import (
set_settings as set_clinic_settings,
get_impuestos,
get_recursos,
get_compras,
)
from odoo.addons.skeen_citas.glosario import (
GLOSARIO_KEYS as GLOSARIO_KEY_SET,
@@ -660,9 +661,16 @@ class SkeenFrontendController(http.Controller):
# Permisos por acción (catálogo + por rol)
# ============================================================
def _perm_catalogo(self):
# Con `compras.activo=false` el grupo de permisos de Compras no se
# muestra en el editor de roles: apagar el módulo no debe cambiar
# ninguna pantalla existente.
try:
ocultar = set() if get_compras(request.env).get('activo') else {'compras'}
except Exception:
ocultar = set()
return [{'grupo': g, 'label': label,
'permisos': [{'key': k, 'label': l} for k, l in perms]}
for g, label, perms in PERMISSION_GROUPS]
for g, label, perms in PERMISSION_GROUPS if g not in ocultar]
@http.route('/skeen/frontend/v1/permisos', type='http', auth='none',
methods=['GET', 'OPTIONS'], csrf=False)
@@ -8014,6 +8022,10 @@ class SkeenFrontendController(http.Controller):
'qty_min': it.qty_min,
'cost': it.cost,
'price': it.price,
'costo_promedio': getattr(it, 'costo_promedio', 0.0),
'ultimo_costo': getattr(it, 'ultimo_costo', 0.0),
'margen': getattr(it, 'margen', 0.0),
'margen_pct': getattr(it, 'margen_pct', 0.0),
'inventory_value': it.inventory_value,
'expiry_date': it.expiry_date.strftime('%Y-%m-%d') if it.expiry_date else None,
'last_count_date': it.last_count_date.strftime('%Y-%m-%d') if it.last_count_date else None,
@@ -8036,6 +8048,9 @@ class SkeenFrontendController(http.Controller):
'date': m.date.strftime('%Y-%m-%d %H:%M') if m.date else None,
'reference': m.reference or '',
'notes': m.notes or '',
'lote': getattr(m, 'lote', '') or '',
'fecha_caducidad': m.fecha_caducidad.strftime('%Y-%m-%d')
if getattr(m, 'fecha_caducidad', None) else None,
}
@http.route('/skeen/frontend/v1/inventory/items', type='http', auth='none', methods=['GET', 'OPTIONS'], csrf=False)
@@ -8114,12 +8129,13 @@ class SkeenFrontendController(http.Controller):
data = _parse_json_body()
allowed = ['name', 'kind', 'sku', 'category', 'unit', 'qty_optimal', 'qty_min',
'cost', 'price', 'expiry_date', 'notes', 'active', 'genera_monedero',
'tax_rate', 'tax_use_default']
'tax_rate', 'tax_use_default', 'costo_promedio', 'ultimo_costo']
vals = {}
for k in allowed:
if k in data:
vals[k] = data[k]
for f in ('qty_optimal', 'qty_min', 'cost', 'price', 'tax_rate'):
for f in ('qty_optimal', 'qty_min', 'cost', 'price', 'tax_rate',
'costo_promedio', 'ultimo_costo'):
if f in vals:
vals[f] = float(vals[f] or 0)
if 'tax_use_default' in vals:

View File

@@ -106,6 +106,16 @@ PERMISSION_GROUPS = [
('membresias.editar', 'Editar planes y membresías'),
('membresias.cancelar', 'Congelar y cancelar membresías'),
]),
('compras', 'Compras y proveedores', [
('compras.ver', 'Ver compras, cuentas por pagar y margen'),
('compras.crear', 'Crear órdenes de compra'),
('compras.recibir', 'Recibir mercancía (afecta stock y costo)'),
('compras.pagar', 'Registrar pagos a proveedores'),
('compras.editar', 'Editar órdenes de compra'),
('compras.cancelar', 'Cancelar órdenes de compra'),
('proveedores.ver', 'Ver proveedores'),
('proveedores.editar', 'Administrar proveedores'),
]),
('config', 'Usuarios y configuración', [
('config.usuarios', 'Administrar usuarios'),
('config.roles', 'Administrar roles y menús'),
@@ -136,6 +146,8 @@ READ_PERM_KEYS = (
'notificaciones.ver',
'formularios.ver',
'membresias.ver',
'compras.ver',
'proveedores.ver',
)
# ---------------------------------------------------------------------------
@@ -234,6 +246,17 @@ DEFAULT_PERM_ROLES = {
'membresias.cobrar': ('admin', 'recepcion', 'gerencia'),
'membresias.editar': AG,
'membresias.cancelar': AG,
# Compras y proveedores. Ver es de todos; crear/recibir/pagar es de
# recepción/gerencia/admin (igual que inventario y membresías); editar,
# cancelar y administrar proveedores es de admin/gerencia (tocan dinero).
'compras.ver': ALL,
'compras.crear': ('admin', 'recepcion', 'gerencia'),
'compras.recibir': ('admin', 'recepcion', 'gerencia'),
'compras.pagar': ('admin', 'recepcion', 'gerencia'),
'compras.editar': AG,
'compras.cancelar': AG,
'proveedores.ver': ALL,
'proveedores.editar': AG,
# Usuarios y configuración
'config.usuarios': AG,
'config.roles': AG,

View File

@@ -169,6 +169,9 @@ DEFAULT_MENU_ROLES = {
'/pagos': ['admin', 'recepcion', 'gerencia'],
'/monedero': ['admin', 'recepcion', 'lectura', 'gerencia'],
'/inventario': ['admin', 'recepcion', 'lectura', 'gerencia'],
# Compras: apagado por defecto (compras.activo=false). La sección solo se
# pinta cuando la instancia la enciende; aquí queda su visibilidad por rol.
'/compras': ['admin', 'recepcion', 'lectura', 'gerencia'],
'/cortes': ['admin', 'recepcion', 'gerencia'],
'/reportes': ['admin', 'recepcion', 'lectura', 'gerencia'],
'/reportes?tab=adeudos': ['admin', 'recepcion', 'lectura', 'gerencia'],
@@ -179,6 +182,8 @@ DEFAULT_MENU_ROLES = {
'/reportes?tab=vendedores': ['admin', 'recepcion', 'lectura', 'gerencia'],
'/reportes?tab=concentrado': ['admin', 'recepcion', 'lectura', 'gerencia'],
'/reportes?tab=recomendaciones': ['admin', 'recepcion', 'lectura', 'gerencia'],
'/reportes?tab=compras': ['admin', 'recepcion', 'lectura', 'gerencia'],
'/reportes?tab=margen': ['admin', 'recepcion', 'lectura', 'gerencia'],
'/reportes?tab=exportar': ['admin', 'recepcion', 'lectura', 'gerencia'],
'/configuracion': ['admin', 'gerencia'],
'/cumpleanos': ['admin', 'recepcion', 'gerencia', 'marketing'],