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