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

@@ -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