POS: venta de artículos de inventario (productos/consumibles)

- Toggle Servicios|Artículos en el catálogo del POS
- Líneas de venta con item_id (service_id opcional), stock descargado
  con movimiento 'venta' al cobrar, advertencias de sobreventa
- Campo precio de venta en artículos de inventario (backend Odoo)
This commit is contained in:
2026-08-14 01:52:01 +00:00
parent 07e05a643e
commit 84f34c542f
6 changed files with 193 additions and 36 deletions

View File

@@ -22,6 +22,7 @@ class SkeenInventarioItem(models.Model):
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')

View File

@@ -15,6 +15,7 @@
<field name="qty_min"/>
<field name="qty_optimal"/>
<field name="cost"/>
<field name="price"/>
<field name="inventory_value" sum="Total"/>
<field name="stock_level" widget="badge"
decoration-danger="stock_level=='out'"
@@ -46,6 +47,7 @@
<field name="qty_min"/>
<field name="qty_optimal"/>
<field name="cost"/>
<field name="price"/>
<field name="inventory_value" readonly="1"/>
<field name="stock_level" readonly="1"/>
<field name="expiry_date"/>

View File

@@ -189,7 +189,8 @@ class SkeenVentaLine(models.Model):
_description = 'Línea de Venta SKEEN'
venta_id = fields.Many2one('skeen.venta', string='Venta', required=True, ondelete='cascade')
service_id = fields.Many2one('skeen.servicio', string='Servicio', required=True)
service_id = fields.Many2one('skeen.servicio', string='Servicio')
item_id = fields.Many2one('skeen.inventario.item', string='Artículo inventario')
description = fields.Char(string='Descripción')
quantity = fields.Float(string='Cantidad', default=1.0, required=True)
price_unit = fields.Float(string='Precio Unitario', required=True)
@@ -199,6 +200,17 @@ class SkeenVentaLine(models.Model):
help='Marcar si este artículo fue recetado por un médico (entra al cálculo de comisiones).')
prescribed_by_id = fields.Many2one('hr.employee', string='Recetado por')
_sql_constraints = [
('service_o_item', 'CHECK(service_id IS NOT NULL OR item_id IS NOT NULL)',
'La línea debe tener un servicio o un artículo de inventario.')
]
@api.constrains('service_id', 'item_id')
def _check_service_o_item(self):
for line in self:
if bool(line.service_id) == bool(line.item_id):
raise ValidationError(_('La línea debe tener un servicio O un artículo, no ambos.'))
@api.depends('quantity', 'price_unit')
def _compute_subtotal(self):
for line in self:
@@ -210,6 +222,12 @@ class SkeenVentaLine(models.Model):
self.price_unit = self.service_id.price
self.description = self.service_id.name
@api.onchange('item_id')
def _onchange_item(self):
if self.item_id:
self.price_unit = self.item_id.price
self.description = self.item_id.name
class SkeenCorteCaja(models.Model):
_name = 'skeen.corte.caja'

View File

@@ -1935,8 +1935,10 @@ class SkeenFrontendController(http.Controller):
'refunded_at': v.refunded_at.strftime('%Y-%m-%d %H:%M') if v.refunded_at else None,
'lines': [{
'id': line.id,
'service_id': line.service_id.id,
'service': line.service_id.name,
'service_id': line.service_id.id if line.service_id else None,
'service': line.service_id.name if line.service_id else '',
'item_id': line.item_id.id if line.item_id else None,
'item': line.item_id.name if line.item_id else '',
'description': line.description or '',
'quantity': line.quantity,
'price_unit': line.price_unit,
@@ -1972,24 +1974,46 @@ class SkeenFrontendController(http.Controller):
discount = float(data.get('discount', 0) or 0)
line_vals = []
item_lines = [] # líneas de artículo: para el move 'venta' tras confirmar
stock_warnings = []
subtotal = 0.0
for l in lines:
qty = float(l.get('quantity', 0) or 0)
price = float(l.get('price_unit', 0) or 0)
if qty <= 0:
return json_response({'status': 'error', 'message': 'Cantidad inválida en una línea'}, 400)
servicio = request.env['skeen.servicio'].sudo().browse(int(l.get('service_id', 0) or 0))
if not servicio.exists():
return json_response({'status': 'error', 'message': 'Servicio no encontrado'}, 404)
service_id = int(l.get('service_id', 0) or 0)
item_id = int(l.get('item_id', 0) or 0)
if bool(service_id) == bool(item_id):
return json_response({'status': 'error', 'message': 'Cada línea debe tener un servicio o un artículo'}, 400)
subtotal += qty * price
line_vals.append((0, 0, {
'service_id': servicio.id,
'description': l.get('description') or servicio.name,
'quantity': qty,
'price_unit': price,
'is_prescribed': bool(l.get('prescribed_by_id')),
'prescribed_by_id': l.get('prescribed_by_id') or False,
}))
if item_id:
item = request.env['skeen.inventario.item'].sudo().browse(item_id)
if not item.exists() or not item.active:
return json_response({'status': 'error', 'message': 'Artículo no encontrado o inactivo'}, 404)
line_vals.append((0, 0, {
'item_id': item.id,
'description': l.get('description') or item.name,
'quantity': qty,
'price_unit': price,
'is_prescribed': bool(l.get('prescribed_by_id')),
'prescribed_by_id': l.get('prescribed_by_id') or False,
}))
item_lines.append((item, qty))
if qty > (item.qty or 0):
stock_warnings.append(f'{item.name}: existencia {item.qty:g}, se vendieron {qty:g}')
else:
servicio = request.env['skeen.servicio'].sudo().browse(service_id)
if not servicio.exists():
return json_response({'status': 'error', 'message': 'Servicio no encontrado'}, 404)
line_vals.append((0, 0, {
'service_id': servicio.id,
'description': l.get('description') or servicio.name,
'quantity': qty,
'price_unit': price,
'is_prescribed': bool(l.get('prescribed_by_id')),
'prescribed_by_id': l.get('prescribed_by_id') or False,
}))
total_est = max(0.0, subtotal - discount)
# Validar saldo de puntos ANTES de crear nada
@@ -2011,6 +2035,18 @@ class SkeenFrontendController(http.Controller):
})
venta.action_confirm()
# Descargo de inventario por cada artículo (el create del move ajusta el stock)
if item_lines:
Move = request.env['skeen.inventario.move'].sudo()
for item, qty in item_lines:
Move.create({
'item_id': item.id,
'type': 'venta',
'qty': qty,
'reference': venta.name,
'notes': 'Venta POS',
})
# Canje de puntos como forma de pago (hasta cubrir el total)
puntos_usados = 0
if pay_with_points:
@@ -2053,6 +2089,7 @@ class SkeenFrontendController(http.Controller):
'puntos_usados': puntos_usados,
'puntos_ganados': int(venta.total / 10) if venta.state == 'paid' else 0,
'wallet_points': partner.wallet_points,
'stock_warnings': stock_warnings,
}, 201)
except Exception as e:
return json_response({'status': 'error', 'message': str(e)}, 500)
@@ -3223,6 +3260,7 @@ class SkeenFrontendController(http.Controller):
'qty_optimal': it.qty_optimal,
'qty_min': it.qty_min,
'cost': it.cost,
'price': it.price,
'inventory_value': it.inventory_value,
'expiry_date': it.expiry_date.strftime('%Y-%m-%d') if it.expiry_date else None,
'last_count_date': it.last_count_date.strftime('%Y-%m-%d') if it.last_count_date else None,