Files
SKEEN-Proyecto/odoo-addons/skeen_compras/models/compra.py

425 lines
19 KiB
Python

# -*- 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