diff --git a/frontend/src/pages/Pos.tsx b/frontend/src/pages/Pos.tsx index 88370de..358b39f 100644 --- a/frontend/src/pages/Pos.tsx +++ b/frontend/src/pages/Pos.tsx @@ -2,7 +2,7 @@ import type { FC } from 'react'; import { useEffect, useState, useCallback, useMemo } from 'react'; import { Link } from 'react-router-dom'; import { - Search, Plus, Minus, X, Store, AlertTriangle, ShoppingBag, Star, Printer, Wallet, Trash2, + Search, Plus, Minus, X, Store, AlertTriangle, ShoppingBag, Star, Printer, Wallet, Trash2, Package, } from 'lucide-react'; import Layout from '../components/Layout'; import { @@ -15,7 +15,7 @@ import { Skeleton, toast, } from '../components/ui'; -import { odooApi, type Service, type Patient, type PosCheckoutResult } from '../services/odoo'; +import { odooApi, type Service, type Patient, type InventoryItem, type PosCheckoutResult } from '../services/odoo'; const fmtMoney = (n: number) => (n || 0).toLocaleString('es-MX', { style: 'currency', currency: 'MXN', maximumFractionDigits: 2 }); @@ -30,18 +30,25 @@ const METODO_OPTIONS = [ const metodoLabel = (m: string) => METODO_OPTIONS.find((o) => o.value === m)?.label || m; interface TicketLine { - service: Service; + key: string; + name: string; qty: number; price: number; + service_id?: number; + item_id?: number; + stock?: number; + unit?: string; } const Pos: FC = () => { // Catálogo + const [catMode, setCatMode] = useState<'servicios' | 'articulos'>('servicios'); const [services, setServices] = useState([]); const [catalogLoading, setCatalogLoading] = useState(true); const [search, setSearch] = useState(''); const [categoria, setCategoria] = useState(''); const [soloFavoritos, setSoloFavoritos] = useState(false); + const [invItems, setInvItems] = useState([]); // Ticket const [lines, setLines] = useState([]); @@ -82,10 +89,26 @@ const Pos: FC = () => { } }, [search, categoria]); + const loadItems = useCallback(async () => { + try { + setCatalogLoading(true); + const res = await odooApi.getInventarioItems(search || undefined); + if (res.status === 'success') setInvItems(res.items); + } catch (err) { + toast.error('Error al cargar artículos'); + console.error(err); + } finally { + setCatalogLoading(false); + } + }, [search]); + useEffect(() => { - const t = setTimeout(loadServices, 300); + const t = setTimeout(() => { + if (catMode === 'servicios') loadServices(); + else loadItems(); + }, 300); return () => clearTimeout(t); - }, [loadServices]); + }, [loadServices, loadItems, catMode]); useEffect(() => { odooApi.getCashClosings(new Date().toISOString().split('T')[0]) @@ -121,20 +144,33 @@ const Pos: FC = () => { [services, soloFavoritos] ); + // Los artículos ya vienen filtrados del server-side (search) + const visiblesItems = invItems; + // ---- Ticket ---- const addLine = (service: Service) => { setLines((prev) => { - const found = prev.find((l) => l.service.id === service.id); - if (found) return prev.map((l) => (l.service.id === service.id ? { ...l, qty: l.qty + 1 } : l)); - return [...prev, { service, qty: 1, price: service.price }]; + const key = `s-${service.id}`; + const found = prev.find((l) => l.key === key); + if (found) return prev.map((l) => (l.key === key ? { ...l, qty: l.qty + 1 } : l)); + return [...prev, { key, name: service.name, qty: 1, price: service.price, service_id: service.id }]; }); }; - const updateLine = (id: number, patch: Partial) => { - setLines((prev) => prev.map((l) => (l.service.id === id ? { ...l, ...patch } : l))); + const addItemLine = (item: InventoryItem) => { + setLines((prev) => { + const key = `i-${item.id}`; + const found = prev.find((l) => l.key === key); + if (found) return prev.map((l) => (l.key === key ? { ...l, qty: l.qty + 1 } : l)); + return [...prev, { key, name: item.name, qty: 1, price: item.price || 0, item_id: item.id, stock: item.qty, unit: item.unit }]; + }); }; - const removeLine = (id: number) => setLines((prev) => prev.filter((l) => l.service.id !== id)); + const updateLine = (key: string, patch: Partial) => { + setLines((prev) => prev.map((l) => (l.key === key ? { ...l, ...patch } : l))); + }; + + const removeLine = (key: string) => setLines((prev) => prev.filter((l) => l.key !== key)); const limpiarTicket = () => { if (lines.length === 0) return; @@ -189,7 +225,12 @@ const Pos: FC = () => { setCobrando(true); const res = await odooApi.posCheckout({ partner_id: paciente.id, - lines: lines.map((l) => ({ service_id: l.service.id, quantity: l.qty, price_unit: l.price })), + lines: lines.map((l) => ({ + ...(l.service_id ? { service_id: l.service_id } : {}), + ...(l.item_id ? { item_id: l.item_id } : {}), + quantity: l.qty, + price_unit: l.price, + })), discount: discountNum, payment_method: metodo, amount_received: metodo === 'cash' && recibido ? parseFloat(recibido) : undefined, @@ -199,6 +240,9 @@ const Pos: FC = () => { setResultado(res); setCobroOpen(false); toast.success(`Venta ${res.sale.name} cobrada`); + if (res.stock_warnings && res.stock_warnings.length > 0) { + res.stock_warnings.forEach((w) => toast.error(`Stock: ${w}`)); + } } } catch (err) { const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message; @@ -288,29 +332,33 @@ const Pos: FC = () => { ) : (
{lines.map((l) => ( -
+
-

- {l.service.name} +

+ {l.item_id && } + {l.name} + {l.item_id && l.stock !== undefined && ( + {l.stock} {l.unit || 'pzas'} en existencia + )}

- + {l.qty} - +
updateLine(l.service.id, { price: parseFloat(e.target.value) || 0 })} + onChange={(e) => updateLine(l.key, { price: parseFloat(e.target.value) || 0 })} className="w-[70px] shrink-0 border border-theme-border-strong rounded-lg px-1.5 py-1 text-xs text-right" title="Precio unitario" /> @@ -372,12 +420,34 @@ const Pos: FC = () => {
setSearch(e.target.value)} className="pl-10" />
+ {/* Toggle Servicios / Artículos */} +
+ + +
+ {catMode === 'servicios' && (
))}
+ )} {catalogLoading ? ( + ) : catMode === 'articulos' ? ( + visiblesItems.length === 0 ? ( + } /> + ) : ( +
+ {visiblesItems.map((i) => ( + + ))} +
+ ) ) : visibles.length === 0 ? ( } /> ) : ( @@ -566,7 +660,7 @@ const Pos: FC = () => { {resultado.sale.lines.map((l) => ( - {l.quantity} × {l.service || l.description} + {l.quantity} × {l.service || l.item || l.description} {fmtMoney(l.subtotal)} ))} diff --git a/frontend/src/services/odoo.ts b/frontend/src/services/odoo.ts index a715f58..a58a417 100644 --- a/frontend/src/services/odoo.ts +++ b/frontend/src/services/odoo.ts @@ -285,6 +285,8 @@ export interface SaleLine { id?: number; service_id: number; service?: string; + item_id?: number | null; + item?: string; description: string; quantity: number; price_unit: number; @@ -376,6 +378,7 @@ export interface InventoryItem { qty_optimal: number; qty_min: number; cost: number; + price?: number; inventory_value: number; expiry_date: string | null; last_count_date: string | null; @@ -682,7 +685,7 @@ export interface AppNotification { export interface PosCheckoutPayload { partner_id: number; - lines: { service_id: number; quantity: number; price_unit: number; description?: string; prescribed_by_id?: number }[]; + lines: { service_id?: number; item_id?: number; quantity: number; price_unit: number; description?: string; prescribed_by_id?: number }[]; discount?: number; payment_method?: string; amount_received?: number; @@ -697,6 +700,7 @@ export interface PosCheckoutResult { puntos_usados: number; puntos_ganados: number; wallet_points: number; + stock_warnings?: string[]; } export interface DailyReport { diff --git a/odoo-addons/skeen_inventario/models/inventario.py b/odoo-addons/skeen_inventario/models/inventario.py index 7782994..55867c1 100644 --- a/odoo-addons/skeen_inventario/models/inventario.py +++ b/odoo-addons/skeen_inventario/models/inventario.py @@ -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') diff --git a/odoo-addons/skeen_inventario/views/inventario_views.xml b/odoo-addons/skeen_inventario/views/inventario_views.xml index 5484efd..e979aa2 100644 --- a/odoo-addons/skeen_inventario/views/inventario_views.xml +++ b/odoo-addons/skeen_inventario/views/inventario_views.xml @@ -15,6 +15,7 @@ + + diff --git a/odoo-addons/skeen_ventas/models/venta.py b/odoo-addons/skeen_ventas/models/venta.py index 580ab1a..5da1e5f 100644 --- a/odoo-addons/skeen_ventas/models/venta.py +++ b/odoo-addons/skeen_ventas/models/venta.py @@ -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' diff --git a/odoo-addons/skeen_whatsapp/controllers/frontend.py b/odoo-addons/skeen_whatsapp/controllers/frontend.py index efe5960..1b30c44 100644 --- a/odoo-addons/skeen_whatsapp/controllers/frontend.py +++ b/odoo-addons/skeen_whatsapp/controllers/frontend.py @@ -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,