425 lines
19 KiB
Python
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
|