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:
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
|
||||
Reference in New Issue
Block a user