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:
@@ -2,7 +2,7 @@ import type { FC } from 'react';
|
|||||||
import { useEffect, useState, useCallback, useMemo } from 'react';
|
import { useEffect, useState, useCallback, useMemo } from 'react';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import {
|
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';
|
} from 'lucide-react';
|
||||||
import Layout from '../components/Layout';
|
import Layout from '../components/Layout';
|
||||||
import {
|
import {
|
||||||
@@ -15,7 +15,7 @@ import {
|
|||||||
Skeleton,
|
Skeleton,
|
||||||
toast,
|
toast,
|
||||||
} from '../components/ui';
|
} 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) =>
|
const fmtMoney = (n: number) =>
|
||||||
(n || 0).toLocaleString('es-MX', { style: 'currency', currency: 'MXN', maximumFractionDigits: 2 });
|
(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;
|
const metodoLabel = (m: string) => METODO_OPTIONS.find((o) => o.value === m)?.label || m;
|
||||||
|
|
||||||
interface TicketLine {
|
interface TicketLine {
|
||||||
service: Service;
|
key: string;
|
||||||
|
name: string;
|
||||||
qty: number;
|
qty: number;
|
||||||
price: number;
|
price: number;
|
||||||
|
service_id?: number;
|
||||||
|
item_id?: number;
|
||||||
|
stock?: number;
|
||||||
|
unit?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const Pos: FC = () => {
|
const Pos: FC = () => {
|
||||||
// Catálogo
|
// Catálogo
|
||||||
|
const [catMode, setCatMode] = useState<'servicios' | 'articulos'>('servicios');
|
||||||
const [services, setServices] = useState<Service[]>([]);
|
const [services, setServices] = useState<Service[]>([]);
|
||||||
const [catalogLoading, setCatalogLoading] = useState(true);
|
const [catalogLoading, setCatalogLoading] = useState(true);
|
||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
const [categoria, setCategoria] = useState('');
|
const [categoria, setCategoria] = useState('');
|
||||||
const [soloFavoritos, setSoloFavoritos] = useState(false);
|
const [soloFavoritos, setSoloFavoritos] = useState(false);
|
||||||
|
const [invItems, setInvItems] = useState<InventoryItem[]>([]);
|
||||||
|
|
||||||
// Ticket
|
// Ticket
|
||||||
const [lines, setLines] = useState<TicketLine[]>([]);
|
const [lines, setLines] = useState<TicketLine[]>([]);
|
||||||
@@ -82,10 +89,26 @@ const Pos: FC = () => {
|
|||||||
}
|
}
|
||||||
}, [search, categoria]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
const t = setTimeout(loadServices, 300);
|
const t = setTimeout(() => {
|
||||||
|
if (catMode === 'servicios') loadServices();
|
||||||
|
else loadItems();
|
||||||
|
}, 300);
|
||||||
return () => clearTimeout(t);
|
return () => clearTimeout(t);
|
||||||
}, [loadServices]);
|
}, [loadServices, loadItems, catMode]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
odooApi.getCashClosings(new Date().toISOString().split('T')[0])
|
odooApi.getCashClosings(new Date().toISOString().split('T')[0])
|
||||||
@@ -121,20 +144,33 @@ const Pos: FC = () => {
|
|||||||
[services, soloFavoritos]
|
[services, soloFavoritos]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Los artículos ya vienen filtrados del server-side (search)
|
||||||
|
const visiblesItems = invItems;
|
||||||
|
|
||||||
// ---- Ticket ----
|
// ---- Ticket ----
|
||||||
const addLine = (service: Service) => {
|
const addLine = (service: Service) => {
|
||||||
setLines((prev) => {
|
setLines((prev) => {
|
||||||
const found = prev.find((l) => l.service.id === service.id);
|
const key = `s-${service.id}`;
|
||||||
if (found) return prev.map((l) => (l.service.id === service.id ? { ...l, qty: l.qty + 1 } : l));
|
const found = prev.find((l) => l.key === key);
|
||||||
return [...prev, { service, qty: 1, price: service.price }];
|
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<TicketLine>) => {
|
const addItemLine = (item: InventoryItem) => {
|
||||||
setLines((prev) => prev.map((l) => (l.service.id === id ? { ...l, ...patch } : l)));
|
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<TicketLine>) => {
|
||||||
|
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 = () => {
|
const limpiarTicket = () => {
|
||||||
if (lines.length === 0) return;
|
if (lines.length === 0) return;
|
||||||
@@ -189,7 +225,12 @@ const Pos: FC = () => {
|
|||||||
setCobrando(true);
|
setCobrando(true);
|
||||||
const res = await odooApi.posCheckout({
|
const res = await odooApi.posCheckout({
|
||||||
partner_id: paciente.id,
|
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,
|
discount: discountNum,
|
||||||
payment_method: metodo,
|
payment_method: metodo,
|
||||||
amount_received: metodo === 'cash' && recibido ? parseFloat(recibido) : undefined,
|
amount_received: metodo === 'cash' && recibido ? parseFloat(recibido) : undefined,
|
||||||
@@ -199,6 +240,9 @@ const Pos: FC = () => {
|
|||||||
setResultado(res);
|
setResultado(res);
|
||||||
setCobroOpen(false);
|
setCobroOpen(false);
|
||||||
toast.success(`Venta ${res.sale.name} cobrada`);
|
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) {
|
} catch (err) {
|
||||||
const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message;
|
const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message;
|
||||||
@@ -288,29 +332,33 @@ const Pos: FC = () => {
|
|||||||
) : (
|
) : (
|
||||||
<div className="divide-y divide-theme-border">
|
<div className="divide-y divide-theme-border">
|
||||||
{lines.map((l) => (
|
{lines.map((l) => (
|
||||||
<div key={l.service.id} className="flex items-center gap-1.5 py-2">
|
<div key={l.key} className="flex items-center gap-1.5 py-2">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => removeLine(l.service.id)}
|
onClick={() => removeLine(l.key)}
|
||||||
className="text-theme-muted hover:text-rose-600 shrink-0"
|
className="text-theme-muted hover:text-rose-600 shrink-0"
|
||||||
title="Quitar"
|
title="Quitar"
|
||||||
>
|
>
|
||||||
<X size={14} />
|
<X size={14} />
|
||||||
</button>
|
</button>
|
||||||
<p className="flex-1 min-w-0 text-sm font-medium text-theme-heading truncate" title={l.service.name}>
|
<p className="flex-1 min-w-0 text-sm font-medium text-theme-heading truncate" title={l.name}>
|
||||||
{l.service.name}
|
{l.item_id && <Package size={12} className="inline mr-1 text-theme-muted" />}
|
||||||
|
{l.name}
|
||||||
|
{l.item_id && l.stock !== undefined && (
|
||||||
|
<span className="block text-[10px] font-normal text-theme-muted">{l.stock} {l.unit || 'pzas'} en existencia</span>
|
||||||
|
)}
|
||||||
</p>
|
</p>
|
||||||
<div className="flex items-center gap-0.5 shrink-0">
|
<div className="flex items-center gap-0.5 shrink-0">
|
||||||
<Button variant="outline" size="sm" className="!px-1.5" onClick={() => updateLine(l.service.id, { qty: Math.max(1, l.qty - 1) })}><Minus size={12} /></Button>
|
<Button variant="outline" size="sm" className="!px-1.5" onClick={() => updateLine(l.key, { qty: Math.max(1, l.qty - 1) })}><Minus size={12} /></Button>
|
||||||
<span className="w-6 text-center text-sm font-medium text-theme-heading">{l.qty}</span>
|
<span className="w-6 text-center text-sm font-medium text-theme-heading">{l.qty}</span>
|
||||||
<Button variant="outline" size="sm" className="!px-1.5" onClick={() => updateLine(l.service.id, { qty: l.qty + 1 })}><Plus size={12} /></Button>
|
<Button variant="outline" size="sm" className="!px-1.5" onClick={() => updateLine(l.key, { qty: l.qty + 1 })}><Plus size={12} /></Button>
|
||||||
</div>
|
</div>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
min={0}
|
min={0}
|
||||||
step={50}
|
step={50}
|
||||||
value={l.price}
|
value={l.price}
|
||||||
onChange={(e) => 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"
|
className="w-[70px] shrink-0 border border-theme-border-strong rounded-lg px-1.5 py-1 text-xs text-right"
|
||||||
title="Precio unitario"
|
title="Precio unitario"
|
||||||
/>
|
/>
|
||||||
@@ -372,12 +420,34 @@ const Pos: FC = () => {
|
|||||||
<div className="relative mb-3">
|
<div className="relative mb-3">
|
||||||
<Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-theme-muted" />
|
<Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-theme-muted" />
|
||||||
<Input
|
<Input
|
||||||
placeholder="Buscar servicio..."
|
placeholder={catMode === 'servicios' ? 'Buscar servicio...' : 'Buscar artículo...'}
|
||||||
value={search}
|
value={search}
|
||||||
onChange={(e) => setSearch(e.target.value)}
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
className="pl-10"
|
className="pl-10"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
{/* Toggle Servicios / Artículos */}
|
||||||
|
<div className="inline-flex rounded-full border border-theme-border-strong overflow-hidden mb-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setCatMode('servicios')}
|
||||||
|
className={`px-4 py-1.5 text-sm font-medium transition flex items-center gap-1.5 ${
|
||||||
|
catMode === 'servicios' ? 'bg-theme-accent text-theme-inverse' : 'bg-theme-surface text-theme-muted hover:bg-theme-bg'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Store size={14} /> Servicios
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setCatMode('articulos')}
|
||||||
|
className={`px-4 py-1.5 text-sm font-medium transition flex items-center gap-1.5 ${
|
||||||
|
catMode === 'articulos' ? 'bg-theme-accent text-theme-inverse' : 'bg-theme-surface text-theme-muted hover:bg-theme-bg'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Package size={14} /> Artículos
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{catMode === 'servicios' && (
|
||||||
<div className="flex flex-wrap gap-1.5 mb-3">
|
<div className="flex flex-wrap gap-1.5 mb-3">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -410,9 +480,33 @@ const Pos: FC = () => {
|
|||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{catalogLoading ? (
|
{catalogLoading ? (
|
||||||
<Skeleton count={8} className="h-16 w-full" />
|
<Skeleton count={8} className="h-16 w-full" />
|
||||||
|
) : catMode === 'articulos' ? (
|
||||||
|
visiblesItems.length === 0 ? (
|
||||||
|
<EmptyState title="Sin artículos" subtitle="No hay artículos de inventario con esta búsqueda." icon={<Package size={28} />} />
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-3 xl:grid-cols-4 gap-2">
|
||||||
|
{visiblesItems.map((i) => (
|
||||||
|
<button
|
||||||
|
key={i.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => addItemLine(i)}
|
||||||
|
className="text-left p-2.5 bg-theme-bg rounded-xl border border-theme-border hover:shadow-card hover:border-theme-border-strong transition"
|
||||||
|
>
|
||||||
|
<p className="text-sm font-medium text-theme-heading leading-tight line-clamp-2 min-h-[2.4rem]" title={i.name}>
|
||||||
|
{i.name}
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center justify-between mt-1">
|
||||||
|
<span className={`text-xs ${i.qty <= 0 ? 'text-rose-600' : 'text-theme-muted'}`}>{i.qty} {i.unit || 'pzas'}</span>
|
||||||
|
<span className="text-sm font-bold text-theme-heading">{(i.price ?? 0) > 0 ? fmtMoney(i.price!) : <span className="text-xs font-normal text-theme-muted">sin precio</span>}</span>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
) : visibles.length === 0 ? (
|
) : visibles.length === 0 ? (
|
||||||
<EmptyState title="Sin servicios" subtitle="No hay servicios con estos filtros." icon={<Store size={28} />} />
|
<EmptyState title="Sin servicios" subtitle="No hay servicios con estos filtros." icon={<Store size={28} />} />
|
||||||
) : (
|
) : (
|
||||||
@@ -566,7 +660,7 @@ const Pos: FC = () => {
|
|||||||
<tbody>
|
<tbody>
|
||||||
{resultado.sale.lines.map((l) => (
|
{resultado.sale.lines.map((l) => (
|
||||||
<tr key={l.id}>
|
<tr key={l.id}>
|
||||||
<td className="py-1 text-theme-heading">{l.quantity} × {l.service || l.description}</td>
|
<td className="py-1 text-theme-heading">{l.quantity} × {l.service || l.item || l.description}</td>
|
||||||
<td className="py-1 text-right text-theme-heading">{fmtMoney(l.subtotal)}</td>
|
<td className="py-1 text-right text-theme-heading">{fmtMoney(l.subtotal)}</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -285,6 +285,8 @@ export interface SaleLine {
|
|||||||
id?: number;
|
id?: number;
|
||||||
service_id: number;
|
service_id: number;
|
||||||
service?: string;
|
service?: string;
|
||||||
|
item_id?: number | null;
|
||||||
|
item?: string;
|
||||||
description: string;
|
description: string;
|
||||||
quantity: number;
|
quantity: number;
|
||||||
price_unit: number;
|
price_unit: number;
|
||||||
@@ -376,6 +378,7 @@ export interface InventoryItem {
|
|||||||
qty_optimal: number;
|
qty_optimal: number;
|
||||||
qty_min: number;
|
qty_min: number;
|
||||||
cost: number;
|
cost: number;
|
||||||
|
price?: number;
|
||||||
inventory_value: number;
|
inventory_value: number;
|
||||||
expiry_date: string | null;
|
expiry_date: string | null;
|
||||||
last_count_date: string | null;
|
last_count_date: string | null;
|
||||||
@@ -682,7 +685,7 @@ export interface AppNotification {
|
|||||||
|
|
||||||
export interface PosCheckoutPayload {
|
export interface PosCheckoutPayload {
|
||||||
partner_id: number;
|
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;
|
discount?: number;
|
||||||
payment_method?: string;
|
payment_method?: string;
|
||||||
amount_received?: number;
|
amount_received?: number;
|
||||||
@@ -697,6 +700,7 @@ export interface PosCheckoutResult {
|
|||||||
puntos_usados: number;
|
puntos_usados: number;
|
||||||
puntos_ganados: number;
|
puntos_ganados: number;
|
||||||
wallet_points: number;
|
wallet_points: number;
|
||||||
|
stock_warnings?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DailyReport {
|
export interface DailyReport {
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ class SkeenInventarioItem(models.Model):
|
|||||||
qty_min = fields.Float(string='Stock mínimo', default=0.0)
|
qty_min = fields.Float(string='Stock mínimo', default=0.0)
|
||||||
|
|
||||||
cost = fields.Float(string='Costo unitario', 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)
|
inventory_value = fields.Float(string='Valor inventario', compute='_compute_value', store=True)
|
||||||
|
|
||||||
expiry_date = fields.Date(string='Caducidad')
|
expiry_date = fields.Date(string='Caducidad')
|
||||||
|
|||||||
@@ -15,6 +15,7 @@
|
|||||||
<field name="qty_min"/>
|
<field name="qty_min"/>
|
||||||
<field name="qty_optimal"/>
|
<field name="qty_optimal"/>
|
||||||
<field name="cost"/>
|
<field name="cost"/>
|
||||||
|
<field name="price"/>
|
||||||
<field name="inventory_value" sum="Total"/>
|
<field name="inventory_value" sum="Total"/>
|
||||||
<field name="stock_level" widget="badge"
|
<field name="stock_level" widget="badge"
|
||||||
decoration-danger="stock_level=='out'"
|
decoration-danger="stock_level=='out'"
|
||||||
@@ -46,6 +47,7 @@
|
|||||||
<field name="qty_min"/>
|
<field name="qty_min"/>
|
||||||
<field name="qty_optimal"/>
|
<field name="qty_optimal"/>
|
||||||
<field name="cost"/>
|
<field name="cost"/>
|
||||||
|
<field name="price"/>
|
||||||
<field name="inventory_value" readonly="1"/>
|
<field name="inventory_value" readonly="1"/>
|
||||||
<field name="stock_level" readonly="1"/>
|
<field name="stock_level" readonly="1"/>
|
||||||
<field name="expiry_date"/>
|
<field name="expiry_date"/>
|
||||||
|
|||||||
@@ -189,7 +189,8 @@ class SkeenVentaLine(models.Model):
|
|||||||
_description = 'Línea de Venta SKEEN'
|
_description = 'Línea de Venta SKEEN'
|
||||||
|
|
||||||
venta_id = fields.Many2one('skeen.venta', string='Venta', required=True, ondelete='cascade')
|
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')
|
description = fields.Char(string='Descripción')
|
||||||
quantity = fields.Float(string='Cantidad', default=1.0, required=True)
|
quantity = fields.Float(string='Cantidad', default=1.0, required=True)
|
||||||
price_unit = fields.Float(string='Precio Unitario', 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).')
|
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')
|
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')
|
@api.depends('quantity', 'price_unit')
|
||||||
def _compute_subtotal(self):
|
def _compute_subtotal(self):
|
||||||
for line in self:
|
for line in self:
|
||||||
@@ -210,6 +222,12 @@ class SkeenVentaLine(models.Model):
|
|||||||
self.price_unit = self.service_id.price
|
self.price_unit = self.service_id.price
|
||||||
self.description = self.service_id.name
|
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):
|
class SkeenCorteCaja(models.Model):
|
||||||
_name = 'skeen.corte.caja'
|
_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,
|
'refunded_at': v.refunded_at.strftime('%Y-%m-%d %H:%M') if v.refunded_at else None,
|
||||||
'lines': [{
|
'lines': [{
|
||||||
'id': line.id,
|
'id': line.id,
|
||||||
'service_id': line.service_id.id,
|
'service_id': line.service_id.id if line.service_id else None,
|
||||||
'service': line.service_id.name,
|
'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 '',
|
'description': line.description or '',
|
||||||
'quantity': line.quantity,
|
'quantity': line.quantity,
|
||||||
'price_unit': line.price_unit,
|
'price_unit': line.price_unit,
|
||||||
@@ -1972,16 +1974,38 @@ class SkeenFrontendController(http.Controller):
|
|||||||
|
|
||||||
discount = float(data.get('discount', 0) or 0)
|
discount = float(data.get('discount', 0) or 0)
|
||||||
line_vals = []
|
line_vals = []
|
||||||
|
item_lines = [] # líneas de artículo: para el move 'venta' tras confirmar
|
||||||
|
stock_warnings = []
|
||||||
subtotal = 0.0
|
subtotal = 0.0
|
||||||
for l in lines:
|
for l in lines:
|
||||||
qty = float(l.get('quantity', 0) or 0)
|
qty = float(l.get('quantity', 0) or 0)
|
||||||
price = float(l.get('price_unit', 0) or 0)
|
price = float(l.get('price_unit', 0) or 0)
|
||||||
if qty <= 0:
|
if qty <= 0:
|
||||||
return json_response({'status': 'error', 'message': 'Cantidad inválida en una línea'}, 400)
|
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))
|
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
|
||||||
|
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():
|
if not servicio.exists():
|
||||||
return json_response({'status': 'error', 'message': 'Servicio no encontrado'}, 404)
|
return json_response({'status': 'error', 'message': 'Servicio no encontrado'}, 404)
|
||||||
subtotal += qty * price
|
|
||||||
line_vals.append((0, 0, {
|
line_vals.append((0, 0, {
|
||||||
'service_id': servicio.id,
|
'service_id': servicio.id,
|
||||||
'description': l.get('description') or servicio.name,
|
'description': l.get('description') or servicio.name,
|
||||||
@@ -2011,6 +2035,18 @@ class SkeenFrontendController(http.Controller):
|
|||||||
})
|
})
|
||||||
venta.action_confirm()
|
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)
|
# Canje de puntos como forma de pago (hasta cubrir el total)
|
||||||
puntos_usados = 0
|
puntos_usados = 0
|
||||||
if pay_with_points:
|
if pay_with_points:
|
||||||
@@ -2053,6 +2089,7 @@ class SkeenFrontendController(http.Controller):
|
|||||||
'puntos_usados': puntos_usados,
|
'puntos_usados': puntos_usados,
|
||||||
'puntos_ganados': int(venta.total / 10) if venta.state == 'paid' else 0,
|
'puntos_ganados': int(venta.total / 10) if venta.state == 'paid' else 0,
|
||||||
'wallet_points': partner.wallet_points,
|
'wallet_points': partner.wallet_points,
|
||||||
|
'stock_warnings': stock_warnings,
|
||||||
}, 201)
|
}, 201)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return json_response({'status': 'error', 'message': str(e)}, 500)
|
return json_response({'status': 'error', 'message': str(e)}, 500)
|
||||||
@@ -3223,6 +3260,7 @@ class SkeenFrontendController(http.Controller):
|
|||||||
'qty_optimal': it.qty_optimal,
|
'qty_optimal': it.qty_optimal,
|
||||||
'qty_min': it.qty_min,
|
'qty_min': it.qty_min,
|
||||||
'cost': it.cost,
|
'cost': it.cost,
|
||||||
|
'price': it.price,
|
||||||
'inventory_value': it.inventory_value,
|
'inventory_value': it.inventory_value,
|
||||||
'expiry_date': it.expiry_date.strftime('%Y-%m-%d') if it.expiry_date else None,
|
'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,
|
'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