234 lines
10 KiB
Python
234 lines
10 KiB
Python
# -*- 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')
|
|
|
|
# Impuesto propio del artículo (%). Mismo criterio que el catálogo de
|
|
# servicios: con la bandera activa se toma la tasa por defecto de la
|
|
# configuración al momento de vender.
|
|
tax_rate = fields.Float(string='Impuesto (%)', default=0.0,
|
|
help='Tasa de impuesto del artículo. Se ignora si usa la tasa por defecto.')
|
|
tax_use_default = fields.Boolean(string='Usar tasa de impuesto por defecto', default=True,
|
|
help='Si está activo, se aplica la tasa por defecto de la configuración de la clínica.')
|
|
|
|
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)
|