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:
@@ -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)
|
||||
|
||||
4
odoo-addons/skeen_compras/__init__.py
Normal file
4
odoo-addons/skeen_compras/__init__.py
Normal file
@@ -0,0 +1,4 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from . import models
|
||||
from . import controllers
|
||||
from .hooks import post_init_hook
|
||||
36
odoo-addons/skeen_compras/__manifest__.py
Normal file
36
odoo-addons/skeen_compras/__manifest__.py
Normal 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',
|
||||
}
|
||||
2
odoo-addons/skeen_compras/controllers/__init__.py
Normal file
2
odoo-addons/skeen_compras/controllers/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from . import compras
|
||||
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)
|
||||
14
odoo-addons/skeen_compras/data/sequences.xml
Normal file
14
odoo-addons/skeen_compras/data/sequences.xml
Normal 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>
|
||||
19
odoo-addons/skeen_compras/hooks.py
Normal file
19
odoo-addons/skeen_compras/hooks.py
Normal 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
|
||||
""")
|
||||
4
odoo-addons/skeen_compras/models/__init__.py
Normal file
4
odoo-addons/skeen_compras/models/__init__.py
Normal file
@@ -0,0 +1,4 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from . import partner
|
||||
from . import inventario
|
||||
from . import compra
|
||||
424
odoo-addons/skeen_compras/models/compra.py
Normal file
424
odoo-addons/skeen_compras/models/compra.py
Normal 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
|
||||
92
odoo-addons/skeen_compras/models/inventario.py
Normal file
92
odoo-addons/skeen_compras/models/inventario.py
Normal 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')
|
||||
|
||||
69
odoo-addons/skeen_compras/models/partner.py
Normal file
69
odoo-addons/skeen_compras/models/partner.py
Normal 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
|
||||
6
odoo-addons/skeen_compras/security/ir.model.access.csv
Normal file
6
odoo-addons/skeen_compras/security/ir.model.access.csv
Normal 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
|
||||
|
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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'],
|
||||
|
||||
Reference in New Issue
Block a user