# -*- coding: utf-8 -*- from odoo import models, fields, api class SkeenInventarioItem(models.Model): _name = 'skeen.inventario.item' _description = 'Item de Inventario / Consumible SKEEN' _order = 'name asc' name = fields.Char(string='Nombre', required=True, index=True) kind = fields.Selection([ ('producto', 'Producto'), ('consumible', 'Consumible'), ], string='Tipo', required=True, default='producto', index=True) sku = fields.Char(string='SKU / Código', index=True) category = fields.Char(string='Línea / Categoría') unit = fields.Char(string='Unidad', default='pieza') qty = fields.Float(string='Existencia', default=0.0) qty_optimal = fields.Float(string='Nivel óptimo', default=0.0) qty_min = fields.Float(string='Stock mínimo', default=0.0) cost = fields.Float(string='Costo unitario', default=0.0) price = fields.Float(string='Precio venta', default=0.0) inventory_value = fields.Float(string='Valor inventario', compute='_compute_value', store=True) expiry_date = fields.Date(string='Caducidad') last_count_date = fields.Date(string='Último conteo físico') active = fields.Boolean(string='Activo', default=True) genera_monedero = fields.Boolean(string='Genera monedero', default=True, help='Si está activo, la venta de este artículo acumula puntos monedero.') notes = fields.Text(string='Notas') move_ids = fields.One2many('skeen.inventario.move', 'item_id', string='Movimientos') stock_level = fields.Selection([ ('out', 'Sin existencias'), ('critical', 'Crítico'), ('low', 'Bajo'), ('optimal', 'Óptimo'), ], string='Nivel', compute='_compute_level', store=True) @api.depends('qty', 'cost') def _compute_value(self): for rec in self: rec.inventory_value = (rec.qty or 0.0) * (rec.cost or 0.0) @api.depends('qty', 'qty_min', 'qty_optimal') def _compute_level(self): for rec in self: q = rec.qty or 0.0 if q <= 0: rec.stock_level = 'out' elif rec.qty_min and q <= rec.qty_min: rec.stock_level = 'critical' elif rec.qty_optimal and q < rec.qty_optimal: rec.stock_level = 'low' else: rec.stock_level = 'optimal' @api.model def _cron_descontar_caducados(self): """Descuenta (baja a cero) los artículos caducados con existencias. Corre diario vía ir.cron; la baja queda como movimiento tipo 'baja'.""" hoy = fields.Date.today() caducados = self.search([ ('expiry_date', '!=', False), ('expiry_date', '<', hoy), ('qty', '>', 0), ('active', '=', True), ]) Move = self.env['skeen.inventario.move'].sudo() for item in caducados: Move.create({ 'item_id': item.id, 'type': 'baja', 'qty': item.qty, 'reference': 'CADUCIDAD', 'notes': f'Descuento automático por caducidad {item.expiry_date}', }) if caducados: import logging logging.getLogger(__name__).info( 'Inventario: %s artículos caducados descontados', len(caducados)) return len(caducados) class SkeenInventarioMove(models.Model): _name = 'skeen.inventario.move' _description = 'Movimiento de Inventario SKEEN' _order = 'date desc, id desc' item_id = fields.Many2one('skeen.inventario.item', string='Item', required=True, ondelete='cascade', index=True) type = fields.Selection([ ('compra', 'Compra (entrada)'), ('venta', 'Venta (salida)'), ('baja', 'Baja (merma)'), ('ajuste', 'Ajuste (conteo físico)'), ('cancelacion', 'Cancelación de venta (entrada)'), ], string='Tipo', required=True) qty = fields.Float(string='Cantidad', required=True, default=0.0) before_qty = fields.Float(string='Existencia anterior', readonly=True) after_qty = fields.Float(string='Existencia resultante', readonly=True) date = fields.Datetime(string='Fecha', default=fields.Datetime.now) reference = fields.Char(string='Referencia') notes = fields.Char(string='Notas') @api.model_create_multi def create(self, vals_list): # Las existencias anterior/resultante se calculan ANTES del INSERT: si # se escribieran después con write(), Odoo omite el UPDATE cuando el # valor coincide con el de la caché (p. ej. after_qty = 0) y la columna # quedaría NULL en la base. Item = self.env['skeen.inventario.item'].sudo() for vals in vals_list: item = Item.browse(vals.get('item_id')) if not item.exists(): continue before = item.qty or 0.0 q = vals.get('qty') or 0.0 mtype = vals.get('type') if mtype in ('compra', 'cancelacion'): after = before + q elif mtype in ('venta', 'baja'): after = before - q elif mtype == 'ajuste': after = q # ajuste fija la existencia al valor del conteo else: after = before vals['before_qty'] = before vals['after_qty'] = max(0.0, after) moves = super().create(vals_list) for m in moves: if not m.item_id: continue vals_item = {'qty': m.after_qty} if m.type == 'ajuste': vals_item['last_count_date'] = fields.Date.today() m.item_id.write(vals_item) return moves class SkeenInventarioConteo(models.Model): """Conteo físico cíclico de inventario. Guarda la foto del conteo (sistema vs contado por item) y los ajustes generados, para poder consultar el historial y reimprimir el resumen. """ _name = 'skeen.inventario.conteo' _description = 'Conteo físico de inventario SKEEN' _order = 'date desc, id desc' name = fields.Char(string='Referencia', readonly=True, copy=False) date = fields.Datetime(string='Fecha', default=fields.Datetime.now) user_name = fields.Char(string='Realizado por') notes = fields.Text(string='Notas') line_ids = fields.One2many('skeen.inventario.conteo.line', 'conteo_id', string='Líneas') total_items = fields.Integer(string='Artículos contados', compute='_compute_totals', store=True) total_diferencias = fields.Integer(string='Con diferencia', compute='_compute_totals', store=True) total_faltante = fields.Float(string='Faltante', compute='_compute_totals', store=True) total_sobrante = fields.Float(string='Sobrante', compute='_compute_totals', store=True) total_value_diff = fields.Float(string='Diferencia valorizada', compute='_compute_totals', store=True) @api.depends('line_ids.difference', 'line_ids.value_diff') def _compute_totals(self): for c in self: c.total_items = len(c.line_ids) c.total_diferencias = len(c.line_ids.filtered(lambda l: abs(l.difference) > 1e-9)) c.total_faltante = sum(l.value_diff for l in c.line_ids if l.difference < 0) c.total_sobrante = sum(l.value_diff for l in c.line_ids if l.difference > 0) c.total_value_diff = c.total_faltante + c.total_sobrante @api.model_create_multi def create(self, vals_list): for vals in vals_list: if not vals.get('name'): vals['name'] = self.env['ir.sequence'].next_by_code('skeen.inventario.conteo') or 'CONTEO-001' return super().create(vals_list) def apply_count(self): """Genera un movimiento type='ajuste' por cada línea con diferencia. El movimiento de ajuste fija la existencia del artículo al valor contado (ver SkeenInventarioMove.create) y sella la fecha de conteo. Devuelve la cantidad de ajustes creados. """ Move = self.env['skeen.inventario.move'] ajustes = 0 for line in self.line_ids: if abs(line.difference) <= 1e-9: continue move = Move.create({ 'item_id': line.item_id.id, 'type': 'ajuste', 'qty': line.qty_contado, 'reference': self.name, 'notes': f'Conteo físico {self.name}', }) line.write({'move_id': move.id}) ajustes += 1 return ajustes class SkeenInventarioConteoLine(models.Model): _name = 'skeen.inventario.conteo.line' _description = 'Línea de conteo físico' _order = 'id' conteo_id = fields.Many2one('skeen.inventario.conteo', string='Conteo', required=True, ondelete='cascade') item_id = fields.Many2one('skeen.inventario.item', string='Artículo', required=True, ondelete='cascade') unit = fields.Char(related='item_id.unit', string='Unidad', readonly=True) qty_sistema = fields.Float(string='Sistema') qty_contado = fields.Float(string='Contado') difference = fields.Float(string='Diferencia', compute='_compute_difference', store=True) cost = fields.Float(string='Costo unitario') value_diff = fields.Float(string='Diferencia valorizada', compute='_compute_difference', store=True) move_id = fields.Many2one('skeen.inventario.move', string='Ajuste generado', readonly=True) @api.depends('qty_sistema', 'qty_contado', 'cost') def _compute_difference(self): for line in self: line.difference = (line.qty_contado or 0.0) - (line.qty_sistema or 0.0) line.value_diff = line.difference * (line.cost or 0.0)