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)