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:
@@ -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')
|
||||
|
||||
@@ -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"/>
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user