Puntos 16 y 10: modulo de paquetes de sesiones (catalogo, saldo por paciente, descuento en agenda, venta en POS) + fix de auditoria silenciosa

This commit is contained in:
2026-09-24 17:17:29 +00:00
parent 09b4f25bb8
commit dcf2c367f1
18 changed files with 1892 additions and 17 deletions

View File

@@ -12,6 +12,7 @@ import Pos from './pages/Pos';
import Visitas from './pages/Visitas';
import Consultas from './pages/Consultas';
import Servicios from './pages/Servicios';
import Paquetes from './pages/Paquetes';
import Medicos from './pages/Medicos';
import Monedero from './pages/Monedero';
import Pagos from './pages/Pagos';
@@ -58,7 +59,7 @@ const LoginRoute: FC = () => {
// lugar de redirigir a "/" en bucle (pantalla en blanco).
const LANDING_CANDIDATOS = [
'/agenda', '/mis-ventas', '/pacientes', '/pos', '/visitas', '/consultas',
'/medicos', '/servicios', '/ventas', '/pagos', '/monedero',
'/medicos', '/servicios', '/paquetes', '/ventas', '/pagos', '/monedero',
'/inventario', '/cortes', '/reportes', '/cumpleanos', '/wacrm/messages',
'/wacrm/leads', '/configuracion',
];
@@ -99,6 +100,7 @@ const App: FC = () => {
<Route path="/ultimas-visitas" element={<Navigate to="/pacientes" replace />} />
<Route path="/servicios" element={<RequireAuth href="/servicios"><Servicios /></RequireAuth>} />
<Route path="/medicos" element={<RequireAuth href="/medicos"><Medicos /></RequireAuth>} />
<Route path="/paquetes" element={<RequireAuth href="/paquetes"><Paquetes /></RequireAuth>} />
<Route path="/productos" element={<Navigate to="/inventario" replace />} />
<Route path="/monedero" element={<RequireAuth href="/monedero"><Monedero /></RequireAuth>} />
<Route path="/pagos" element={<RequireAuth href="/pagos"><Pagos /></RequireAuth>} />

View File

@@ -13,6 +13,11 @@ const MODULOS = [
{ value: 'roles', label: 'Roles y permisos' },
{ value: 'usuarios', label: 'Usuarios' },
{ value: 'catalogos', label: 'Catálogos' },
{ value: 'pacientes', label: 'Expedientes de pacientes' },
{ value: 'agenda', label: 'Agenda' },
{ value: 'paquetes', label: 'Paquetes' },
{ value: 'monedero', label: 'Monedero' },
{ value: 'clinica', label: 'Clínica' },
];
const PAGE_SIZE = 25;

View File

@@ -70,6 +70,7 @@ const menuTree: MenuEntry[] = [
{ label: 'Expedientes', icon: FolderClock, href: '/expedientes' },
{ label: 'Médicos', icon: Stethoscope, href: '/medicos' },
{ label: 'Cosmetología', icon: FlaskConical, href: '/servicios' },
{ label: 'Paquetes', icon: Boxes, href: '/paquetes' },
{
label: 'Configuración', icon: Settings,
children: [

View File

@@ -34,6 +34,7 @@ import {
MessageCircle,
CircleDollarSign,
TicketCheck,
Layers,
} from 'lucide-react';
import { Button, Modal, Input } from './ui';
import QuickLinksDock, { QuickLinksModePicker } from './QuickLinksDock';
@@ -71,6 +72,7 @@ const menuSections = [
{ label: 'Mis ventas', icon: CircleDollarSign, href: '/mis-ventas' },
{ label: 'Médicos', icon: Stethoscope, href: '/medicos' },
{ label: 'Servicios', icon: Briefcase, href: '/servicios' },
{ label: 'Paquetes', icon: Layers, href: '/paquetes' },
],
},
{

View File

@@ -35,6 +35,7 @@ import {
PanelLeftClose,
PanelLeftOpen,
Ticket,
Layers,
} from 'lucide-react';
import { Button, Modal, Input } from './ui';
import QuickLinksDock, { QuickLinksModePicker } from './QuickLinksDock';
@@ -71,6 +72,7 @@ const menuGroups = [
{ label: 'Mis ventas', icon: CircleDollarSign, href: '/mis-ventas' },
{ label: 'Médicos', icon: Stethoscope, href: '/medicos' },
{ label: 'Servicios', icon: Briefcase, href: '/servicios' },
{ label: 'Paquetes', icon: Layers, href: '/paquetes' },
],
},
{

View File

@@ -33,6 +33,7 @@ const MENU_ROLES: Record<string, FrontendRole[]> = {
'/consultas': ['admin', 'recepcion', 'medico', 'gerencia'],
'/medicos': ['admin', 'recepcion', 'gerencia'],
'/servicios': ['admin', 'recepcion', 'medico', 'lectura', 'gerencia'],
'/paquetes': ['admin', 'recepcion', 'medico', 'lectura', 'gerencia', 'cosmetologia'],
'/productos': ['admin', 'recepcion', 'lectura', 'gerencia'], // deprecated: redirige a /inventario
'/ventas': ['admin', 'recepcion', 'gerencia'],
'/tickets': ['admin', 'gerencia'],

View File

@@ -0,0 +1,830 @@
import type { FC } from 'react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useSearchParams } from 'react-router-dom';
import {
Layers, Plus, Pencil, Search, XCircle, SlidersHorizontal, RefreshCw,
CheckCircle2, Clock, AlertTriangle, ChevronRight,
} from 'lucide-react';
import Layout from '../components/Layout';
import {
Card, Button, Input, Select, TextArea, Modal, PageHeader, EmptyState, Skeleton,
MobileCard, toast,
} from '../components/ui';
import { odooApi } from '../services/odoo';
import type { Paquete, PaqueteCompra, PaqueteConsumo, Service } from '../services/odoo';
import { useAuth } from '../lib/auth';
const fmtMoney = (n: number) =>
(n || 0).toLocaleString('es-MX', { style: 'currency', currency: 'MXN', maximumFractionDigits: 0 });
const STATE_META: Record<string, { label: string; cls: string }> = {
active: { label: 'Vigente', cls: 'bg-emerald-100 text-emerald-700 border-emerald-200' },
finished: { label: 'Completado', cls: 'bg-theme-bg text-theme-muted border-theme-border' },
expired: { label: 'Vencido', cls: 'bg-amber-100 text-amber-700 border-amber-200' },
cancelled: { label: 'Anulado', cls: 'bg-rose-100 text-rose-700 border-rose-200' },
};
const FILTROS_SALDO = [
{ key: '', label: 'Todos' },
{ key: 'active', label: 'Vigentes', icon: CheckCircle2 },
{ key: 'expired', label: 'Por vencer / vencidos', icon: AlertTriangle },
{ key: 'finished', label: 'Completados', icon: Clock },
{ key: 'cancelled', label: 'Anulados', icon: XCircle },
];
interface FormPaquete {
id?: number;
name: string;
code: string;
service_id: string;
sessions: string;
price: string;
validity_days: string;
duration_min: string;
notes: string;
genera_monedero: boolean;
}
const emptyForm: FormPaquete = {
name: '', code: '', service_id: '', sessions: '3', price: '',
validity_days: '90', duration_min: '0', notes: '', genera_monedero: true,
};
const Paquetes: FC = () => {
const [searchParams, setSearchParams] = useSearchParams();
const tab = searchParams.get('tab') === 'saldos' ? 'saldos' : 'catalogo';
const setTab = (key: string) =>
setSearchParams(key === 'catalogo' ? {} : { tab: key }, { replace: true });
const { hasRole } = useAuth();
const esAdmin = hasRole('admin');
// ---- Catálogo ----
const [paquetes, setPaquetes] = useState<Paquete[]>([]);
const [servicios, setServicios] = useState<Service[]>([]);
const [cargando, setCargando] = useState(true);
const [busqueda, setBusqueda] = useState('');
const [mostrarBaja, setMostrarBaja] = useState(false);
const [formOpen, setFormOpen] = useState(false);
const [form, setForm] = useState<FormPaquete>(emptyForm);
const [guardando, setGuardando] = useState(false);
// ---- Saldos ----
const [compras, setCompras] = useState<PaqueteCompra[]>([]);
const [totales, setTotales] = useState<Record<string, number>>({});
const [cargandoSaldos, setCargandoSaldos] = useState(false);
const [filtroEstado, setFiltroEstado] = useState('');
const [busquedaSaldo, setBusquedaSaldo] = useState('');
const [detalle, setDetalle] = useState<{ compra: PaqueteCompra; consumos: PaqueteConsumo[] } | null>(null);
const [cargandoDetalle, setCargandoDetalle] = useState(false);
const [ajuste, setAjuste] = useState<{ compra: PaqueteCompra; total: string; usadas: string } | null>(null);
const loadCatalogo = useCallback(async () => {
try {
setCargando(true);
const res = await odooApi.getPaqueteCatalogo({ includeInactive: mostrarBaja });
if (res.status === 'success') setPaquetes(res.paquetes);
} catch (err) {
console.error(err);
toast.error('Error al cargar los paquetes');
} finally {
setCargando(false);
}
}, [mostrarBaja]);
const loadSaldos = useCallback(async () => {
try {
setCargandoSaldos(true);
const res = await odooApi.getPaqueteCompras({
state: filtroEstado || undefined,
search: busquedaSaldo.trim() || undefined,
includeAll: filtroEstado === 'cancelled' || mostrarBaja,
});
if (res.status === 'success') {
setCompras(res.compras);
setTotales(res.totales || {});
}
} catch (err) {
console.error(err);
toast.error('Error al cargar los saldos');
} finally {
setCargandoSaldos(false);
}
}, [filtroEstado, busquedaSaldo, mostrarBaja]);
useEffect(() => {
odooApi.getServices({ page_size: 200 })
.then((res) => { if (res.status === 'success') setServicios(res.services); })
.catch(() => {});
}, []);
useEffect(() => { if (tab === 'catalogo') loadCatalogo(); }, [tab, loadCatalogo]);
useEffect(() => {
if (tab !== 'saldos') return;
const t = setTimeout(loadSaldos, 300);
return () => clearTimeout(t);
}, [tab, loadSaldos]);
const filtrados = useMemo(() => {
const q = busqueda.trim().toLowerCase();
if (!q) return paquetes;
return paquetes.filter((p) =>
p.name.toLowerCase().includes(q) || (p.service || '').toLowerCase().includes(q)
|| (p.code || '').toLowerCase().includes(q));
}, [paquetes, busqueda]);
const sinBaja = useMemo(() => filtrados.filter((p) => p.active), [filtrados]);
const dadosDeBaja = useMemo(() => filtrados.filter((p) => !p.active), [filtrados]);
const visibles = mostrarBaja ? filtrados : sinBaja;
const abrirNuevo = () => {
setForm(emptyForm);
setFormOpen(true);
};
const abrirEditar = (p: Paquete) => {
setForm({
id: p.id, name: p.name, code: p.code, service_id: String(p.service_id ?? ''),
sessions: String(p.sessions), price: String(p.price),
validity_days: String(p.validity_days || 0), duration_min: String(p.duration_min || 0),
notes: p.notes || '', genera_monedero: p.genera_monedero,
});
setFormOpen(true);
};
const guardar = async () => {
if (!form.name.trim()) { toast.error('El nombre es obligatorio'); return; }
if (!form.service_id) { toast.error('Selecciona el servicio'); return; }
const sesiones = parseInt(form.sessions, 10);
const precio = parseFloat(form.price);
if (!sesiones || sesiones < 1) { toast.error('Debe tener al menos 1 sesión'); return; }
if (Number.isNaN(precio) || precio <= 0) { toast.error('El precio debe ser mayor a 0'); return; }
const payload = {
name: form.name.trim(),
code: form.code.trim(),
service_id: parseInt(form.service_id, 10),
sessions: sesiones,
price: precio,
validity_days: parseInt(form.validity_days, 10) || 0,
duration_min: parseInt(form.duration_min, 10) || 0,
notes: form.notes.trim(),
genera_monedero: form.genera_monedero,
};
try {
setGuardando(true);
const res = form.id
? await odooApi.updatePaquete(form.id, payload)
: await odooApi.createPaquete(payload);
if (res.status !== 'success') {
toast.error(res.message || 'No se pudo guardar');
return;
}
toast.success(form.id ? 'Paquete actualizado' : 'Paquete creado');
setFormOpen(false);
loadCatalogo();
} catch (err: unknown) {
const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message;
toast.error(msg || 'Error al guardar el paquete');
} finally {
setGuardando(false);
}
};
const cambiarActivo = async (p: Paquete) => {
const confirmar = p.active
? `¿Dar de baja "${p.name}? Dejará de ofrecerse en el punto de venta. Los paquetes ya vendidos no se tocan.`
: `¿Reactivar "${p.name}"?`;
if (!window.confirm(confirmar)) return;
try {
const res = await odooApi.updatePaquete(p.id, { active: !p.active });
if (res.status !== 'success') { toast.error(res.message || 'No se pudo cambiar el estado'); return; }
toast.success(p.active ? 'Paquete dado de baja' : 'Paquete reactivado');
loadCatalogo();
} catch {
toast.error('Error al cambiar el estado');
}
};
const abrirDetalle = async (c: PaqueteCompra) => {
setCargandoDetalle(true);
setDetalle(null);
try {
const res = await odooApi.getPaqueteConsumos(c.id);
if (res.status === 'success') setDetalle({ compra: res.compra, consumos: res.consumos });
else toast.error(res.message || 'No se pudo abrir el paquete');
} catch {
toast.error('Error al abrir el paquete');
} finally {
setCargandoDetalle(false);
}
};
const guardarAjuste = async () => {
if (!ajuste) return;
const total = parseInt(ajuste.total, 10);
const usadas = parseInt(ajuste.usadas, 10);
if (Number.isNaN(total) || total < 0 || Number.isNaN(usadas) || usadas < 0) {
toast.error('Las sesiones deben ser números');
return;
}
if (usadas > total) { toast.error('Las usadas no pueden ser mayores al total'); return; }
try {
const res = await odooApi.ajustarPaqueteCompra(ajuste.compra.id, {
sessions_total: total, sessions_used: usadas,
});
if (res.status !== 'success') { toast.error(res.message || 'No se pudo ajustar'); return; }
toast.success('Saldo ajustado');
setAjuste(null);
loadSaldos();
} catch {
toast.error('Error al ajustar el saldo');
}
};
const anular = async (c: PaqueteCompra) => {
const motivo = window.prompt(
`¿Anular "${c.paquete}" de ${c.patient}? Se liberan las sesiones consumidas.\n\nMotivo (obligatorio):`
);
if (!motivo || !motivo.trim()) {
if (motivo !== null) toast.error('Indica el motivo de la anulación');
return;
}
try {
const res = await odooApi.anularPaqueteCompra(c.id, motivo.trim());
if (res.status !== 'success') { toast.error(res.message || 'No se pudo anular'); return; }
toast.success('Paquete anulado');
setDetalle(null);
loadSaldos();
} catch {
toast.error('Error al anular el paquete');
}
};
const progreso = (c: PaqueteCompra) =>
c.sessions_total > 0 ? Math.round((c.sessions_used / c.sessions_total) * 100) : 0;
const diasParaVencer = (c: PaqueteCompra) => {
if (!c.expiry_date) return null;
const diff = Math.ceil((new Date(c.expiry_date + 'T00:00:00').getTime() - Date.now()) / 86400000);
return diff;
};
const FilaSaldo: FC<{ c: PaqueteCompra }> = ({ c }) => {
const dias = diasParaVencer(c);
const meta = STATE_META[c.state] || STATE_META.active;
const cercaDeVencer = c.state === 'active' && dias !== null && dias <= 15;
return (
<tr className="hover:bg-theme-bg cursor-pointer" onClick={() => abrirDetalle(c)}>
<td className="px-4 py-3 text-theme-muted whitespace-nowrap">{c.date || '—'}</td>
<td className="px-4 py-3">
<p className="font-medium text-theme-heading">{c.patient}</p>
{c.phone && <p className="text-[11px] text-theme-muted">{c.phone}</p>}
</td>
<td className="px-4 py-3">
<p className="text-theme-heading">{c.paquete}</p>
<p className="text-[11px] text-theme-muted">{c.service}</p>
</td>
<td className="px-4 py-3 w-[170px]">
<div className="flex items-center gap-2">
<div className="flex-1 h-2 rounded-full bg-theme-bg overflow-hidden">
<div
className={`h-full rounded-full ${c.state === 'expired' ? 'bg-amber-500' : 'bg-emerald-500'}`}
style={{ width: `${progreso(c)}%` }}
/>
</div>
<span className="text-xs font-medium text-theme-heading whitespace-nowrap tabular-nums">
{c.sessions_used}/{c.sessions_total}
</span>
</div>
<p className="text-[11px] text-theme-muted mt-0.5">
{c.sessions_remaining} restantes
</p>
</td>
<td className="px-4 py-3 whitespace-nowrap">
{c.expiry_date ? (
<span className={cercaDeVencer ? 'text-amber-700 font-medium' : 'text-theme-muted'}>
{c.expiry_date}
{cercaDeVencer && (
<span className="block text-[10px] font-normal">
{dias >= 0 ? `en ${dias} días` : 'vencido'}
</span>
)}
</span>
) : (
<span className="text-theme-muted">Sin límite</span>
)}
</td>
<td className="px-4 py-3 text-right text-theme-heading tabular-nums">{fmtMoney(c.price)}</td>
<td className="px-4 py-3">
<span className={`inline-block text-[11px] font-medium px-2 py-0.5 rounded-full border ${meta.cls}`}>
{meta.label}
</span>
</td>
<td className="px-4 py-3 text-right">
<ChevronRight size={16} className="inline text-theme-muted" />
</td>
</tr>
);
};
return (
<Layout title="Paquetes" subtitle="Paquetes de sesiones">
<PageHeader
title="Paquetes de sesiones"
subtitle="Combos de sesiones a precio cerrado y el saldo que lleva cada paciente"
>
<Button variant="outline" onClick={() => (tab === 'catalogo' ? loadCatalogo() : loadSaldos())}>
<RefreshCw size={16} className="mr-2" /> Actualizar
</Button>
{tab === 'catalogo' && esAdmin && (
<Button onClick={abrirNuevo}>
<Plus size={16} className="mr-2" /> Nuevo paquete
</Button>
)}
</PageHeader>
{/* Tabs */}
<div className="flex flex-wrap gap-1.5 mb-4">
{[{ key: 'catalogo', label: 'Catálogo' }, { key: 'saldos', label: 'Paquetes vendidos' }].map((t) => (
<button
key={t.key}
type="button"
onClick={() => setTab(t.key)}
className={`px-4 py-2 text-sm font-medium rounded-full transition ${
tab === t.key ? 'bg-theme-accent text-theme-inverse' : 'bg-theme-accent-bg text-theme-heading hover:bg-theme-bg'
}`}
>
{t.label}
</button>
))}
</div>
{tab === 'catalogo' ? (
<Card>
<Card.Header>
<div className="relative w-full max-w-sm">
<Search size={15} className="absolute left-3 top-1/2 -translate-y-1/2 text-theme-muted" />
<Input
value={busqueda}
onChange={(e) => setBusqueda(e.target.value)}
placeholder="Buscar por nombre, servicio o código"
className="pl-9"
/>
</div>
<label className="flex items-center gap-2 text-sm text-theme-muted shrink-0">
<input
type="checkbox"
checked={mostrarBaja}
onChange={(e) => setMostrarBaja(e.target.checked)}
className="rounded border-theme-border-strong"
/>
Mostrar dados de baja{dadosDeBaja.length > 0 ? ` (${dadosDeBaja.length})` : ''}
</label>
</Card.Header>
<Card.Body noPadding>
{cargando ? (
<div className="p-6"><Skeleton count={6} /></div>
) : visibles.length === 0 ? (
<EmptyState
title="Sin paquetes"
subtitle="Crea el primero para poder venderlo en el punto de venta."
icon={<Layers size={28} />}
/>
) : (
<>
{/* Escritorio */}
<div className="overflow-x-auto hidden sm:block">
<table className="w-full text-sm">
<thead className="bg-theme-bg text-theme-muted text-[11px] uppercase">
<tr>
<th className="text-left px-4 py-3">Paquete</th>
<th className="text-left px-4 py-3">Servicio</th>
<th className="text-right px-4 py-3">Sesiones</th>
<th className="text-right px-4 py-3">Precio</th>
<th className="text-right px-4 py-3">Por sesión</th>
<th className="text-right px-4 py-3">Ahorro</th>
<th className="text-left px-4 py-3">Vigencia</th>
<th className="text-right px-4 py-3">Vendidos</th>
<th className="text-left px-4 py-3">Estado</th>
<th className="px-4 py-3" />
</tr>
</thead>
<tbody className="divide-y divide-theme-border">
{visibles.map((p) => {
const ahorro = p.session_price && p.price_per_session
? Math.round((1 - p.price_per_session / p.session_price) * 100) : 0;
return (
<tr key={p.id} className={!p.active ? 'opacity-60' : ''}>
<td className="px-4 py-3">
<p className="font-medium text-theme-heading">{p.name}</p>
{p.code && <p className="text-[11px] text-theme-muted">{p.code}</p>}
</td>
<td className="px-4 py-3 text-theme-muted">{p.service}</td>
<td className="px-4 py-3 text-right tabular-nums">{p.sessions}</td>
<td className="px-4 py-3 text-right font-medium text-theme-heading tabular-nums">{fmtMoney(p.price)}</td>
<td className="px-4 py-3 text-right text-theme-muted tabular-nums">{fmtMoney(p.price_per_session)}</td>
<td className="px-4 py-3 text-right">
{ahorro > 0 ? (
<span className="text-emerald-600 font-medium">−{ahorro}%</span>
) : (
<span className="text-theme-muted">—</span>
)}
</td>
<td className="px-4 py-3 text-theme-muted">
{p.validity_days > 0 ? `${p.validity_days} días` : 'Sin límite'}
</td>
<td className="px-4 py-3 text-right tabular-nums">{p.compras_count}</td>
<td className="px-4 py-3">
<button
type="button"
role="switch"
aria-checked={p.active}
onClick={() => cambiarActivo(p)}
disabled={!esAdmin}
title={p.active ? 'Dar de baja' : 'Reactivar'}
className={`inline-flex items-center gap-1.5 rounded-full border px-2 py-1 text-[11px] font-medium transition-colors disabled:opacity-50 ${
p.active
? 'border-emerald-300 bg-emerald-50 text-emerald-800'
: 'border-rose-300 bg-rose-50 text-rose-700'
}`}
>
<span className={`h-1.5 w-1.5 rounded-full ${p.active ? 'bg-emerald-600' : 'bg-rose-500'}`} />
{p.active ? 'Activo' : 'Baja'}
</button>
</td>
<td className="px-4 py-3 text-right">
<Button
variant="outline"
size="sm"
onClick={() => abrirEditar(p)}
disabled={!esAdmin}
title="Editar paquete"
>
<Pencil size={13} />
</Button>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
{/* Móvil */}
<div className="sm:hidden divide-y divide-theme-border">
{visibles.map((p) => (
<MobileCard
key={p.id}
title={p.name}
subtitle={p.service}
rows={[
{ label: 'Sesiones', value: p.sessions },
{ label: 'Precio', value: fmtMoney(p.price) },
{ label: 'Por sesión', value: fmtMoney(p.price_per_session) },
{ label: 'Estado', value: p.active ? 'Activo' : 'Baja' },
]}
actions={
<Button variant="outline" size="sm" onClick={() => abrirEditar(p)} disabled={!esAdmin}>
<Pencil size={13} className="mr-1" /> Editar
</Button>
}
/>
))}
</div>
</>
)}
</Card.Body>
</Card>
) : (
<Card>
<Card.Header>
<div className="flex flex-wrap items-center gap-1.5">
{FILTROS_SALDO.map((f) => (
<button
key={f.key}
type="button"
onClick={() => setFiltroEstado(f.key)}
className={`px-3 py-1.5 text-xs font-medium rounded-full transition flex items-center gap-1 ${
filtroEstado === f.key
? 'bg-theme-accent text-theme-inverse'
: 'bg-theme-accent-bg text-theme-heading hover:bg-theme-bg'
}`}
>
{f.icon && <f.icon size={12} />}
{f.label}
</button>
))}
</div>
<div className="relative w-full max-w-xs">
<Search size={15} className="absolute left-3 top-1/2 -translate-y-1/2 text-theme-muted" />
<Input
value={busquedaSaldo}
onChange={(e) => setBusquedaSaldo(e.target.value)}
placeholder="Buscar paciente o paquete"
className="pl-9"
/>
</div>
</Card.Header>
{tab === 'saldos' && (
<div className="px-4 sm:px-6 py-3 border-b border-theme-border flex flex-wrap gap-4 text-sm">
<div>
<span className="text-theme-muted">Vigentes: </span>
<span className="font-semibold text-theme-heading">{totales.vigentes ?? 0}</span>
</div>
<div>
<span className="text-theme-muted">Sesiones por usar: </span>
<span className="font-semibold text-theme-heading">{totales.sesiones_restantes ?? 0}</span>
</div>
<div>
<span className="text-theme-muted">Por vencer: </span>
<span className="font-semibold text-amber-700">{totales.vencidos ?? 0}</span>
</div>
<div>
<span className="text-theme-muted">Vendido: </span>
<span className="font-semibold text-theme-heading">{fmtMoney(totales.importe ?? 0)}</span>
</div>
</div>
)}
<Card.Body noPadding>
{cargandoSaldos ? (
<div className="p-6"><Skeleton count={6} /></div>
) : compras.length === 0 ? (
<EmptyState
title="Sin paquetes vendidos"
subtitle="Los paquetes aparecen aquí en cuanto se cobren en el punto de venta."
icon={<SlidersHorizontal size={28} />}
/>
) : (
<>
<div className="overflow-x-auto hidden sm:block">
<table className="w-full text-sm">
<thead className="bg-theme-bg text-theme-muted text-[11px] uppercase">
<tr>
<th className="text-left px-4 py-3">Compra</th>
<th className="text-left px-4 py-3">Paciente</th>
<th className="text-left px-4 py-3">Paquete</th>
<th className="text-left px-4 py-3">Sesiones</th>
<th className="text-left px-4 py-3">Vence</th>
<th className="text-right px-4 py-3">Pagado</th>
<th className="text-left px-4 py-3">Estado</th>
<th className="px-4 py-3" />
</tr>
</thead>
<tbody className="divide-y divide-theme-border">
{compras.map((c) => <FilaSaldo key={c.id} c={c} />)}
</tbody>
</table>
</div>
<div className="sm:hidden divide-y divide-theme-border">
{compras.map((c) => (
<MobileCard
key={c.id}
title={c.patient}
subtitle={`${c.paquete} · ${c.date || ''}`}
rows={[
{ label: 'Usadas', value: `${c.sessions_used} de ${c.sessions_total}` },
{ label: 'Restantes', value: c.sessions_remaining },
{ label: 'Vence', value: c.expiry_date || 'Sin límite' },
{ label: 'Estado', value: (STATE_META[c.state] || STATE_META.active).label },
]}
actions={
<Button variant="outline" size="sm" onClick={() => abrirDetalle(c)}>
Ver detalle
</Button>
}
onClick={() => abrirDetalle(c)}
/>
))}
</div>
</>
)}
</Card.Body>
</Card>
)}
{/* Alta / edición de paquete */}
<Modal
isOpen={formOpen}
onClose={() => setFormOpen(false)}
title={form.id ? 'Editar paquete' : 'Nuevo paquete de sesiones'}
>
<div className="space-y-3">
<div>
<label className="block text-sm font-medium text-theme-heading mb-1">Nombre *</label>
<Input
value={form.name}
onChange={(e) => setForm({ ...form, name: e.target.value })}
placeholder="Ej. Lumecca paquete 3 sesiones"
/>
</div>
<div>
<label className="block text-sm font-medium text-theme-heading mb-1">Servicio *</label>
<Select
options={[
{ value: '', label: 'Selecciona un servicio…' },
...servicios.map((s) => ({ value: String(s.id), label: `${s.name} · ${fmtMoney(s.price)} / sesión` })),
]}
value={form.service_id}
onChange={(e) => {
const sv = servicios.find((s) => String(s.id) === e.target.value);
setForm({
...form,
service_id: e.target.value,
duration_min: sv ? String(sv.duration_min || 0) : form.duration_min,
});
}}
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-sm font-medium text-theme-heading mb-1">Sesiones *</label>
<Input
type="number" min={1} value={form.sessions}
onChange={(e) => setForm({ ...form, sessions: e.target.value })}
/>
</div>
<div>
<label className="block text-sm font-medium text-theme-heading mb-1">Precio del paquete *</label>
<Input
type="number" min={0} step={50} value={form.price}
onChange={(e) => setForm({ ...form, price: e.target.value })}
/>
</div>
<div>
<label className="block text-sm font-medium text-theme-heading mb-1">Vigencia (días)</label>
<Input
type="number" min={0} value={form.validity_days}
onChange={(e) => setForm({ ...form, validity_days: e.target.value })}
placeholder="0 = sin límite"
/>
</div>
<div>
<label className="block text-sm font-medium text-theme-heading mb-1">Duración sesión (min)</label>
<Input
type="number" min={0} value={form.duration_min}
onChange={(e) => setForm({ ...form, duration_min: e.target.value })}
/>
</div>
</div>
{(parseInt(form.sessions, 10) > 0 && parseFloat(form.price) > 0) && (
<p className="text-xs text-theme-muted">
Sale a {fmtMoney(parseFloat(form.price) / parseInt(form.sessions, 10))} por sesión.
</p>
)}
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-sm font-medium text-theme-heading mb-1">Código</label>
<Input value={form.code} onChange={(e) => setForm({ ...form, code: e.target.value })} />
</div>
<label className="flex items-center gap-2 text-sm text-theme-heading mt-6">
<input
type="checkbox" checked={form.genera_monedero}
onChange={(e) => setForm({ ...form, genera_monedero: e.target.checked })}
className="rounded border-theme-border-strong"
/>
Genera monedero
</label>
</div>
<div>
<label className="block text-sm font-medium text-theme-heading mb-1">Notas</label>
<TextArea
rows={2} value={form.notes}
onChange={(e) => setForm({ ...form, notes: e.target.value })}
placeholder="Condiciones del paquete, restricciones, promociones…"
/>
</div>
<div className="flex justify-end gap-2 pt-2">
<Button variant="outline" onClick={() => setFormOpen(false)}>Cancelar</Button>
<Button onClick={guardar} disabled={guardando}>
{guardando ? 'Guardando…' : 'Guardar'}
</Button>
</div>
</div>
</Modal>
{/* Detalle de un paquete vendido */}
<Modal
isOpen={!!detalle || cargandoDetalle}
onClose={() => { setDetalle(null); setAjuste(null); }}
title={detalle ? `Paquete de ${detalle.compra.patient}` : 'Cargando…'}
>
{detalle && !ajuste && (
<div className="space-y-4">
<div className="grid grid-cols-2 gap-3 text-sm">
<div>
<p className="text-theme-muted">Paquete</p>
<p className="font-medium text-theme-heading">{detalle.compra.paquete}</p>
</div>
<div>
<p className="text-theme-muted">Servicio</p>
<p className="text-theme-heading">{detalle.compra.service || '—'}</p>
</div>
<div>
<p className="text-theme-muted">Comprado</p>
<p className="text-theme-heading">
{detalle.compra.date}{detalle.compra.venta ? ` · ${detalle.compra.venta}` : ''}
</p>
</div>
<div>
<p className="text-theme-muted">Vence</p>
<p className="text-theme-heading">{detalle.compra.expiry_date || 'Sin límite'}</p>
</div>
</div>
<div className="rounded-xl bg-theme-bg px-4 py-3">
<div className="flex items-center justify-between text-sm mb-2">
<span className="text-theme-muted">Sesiones consumidas</span>
<span className="font-semibold text-theme-heading tabular-nums">
{detalle.compra.sessions_used} de {detalle.compra.sessions_total}
</span>
</div>
<div className="h-2 rounded-full bg-theme-surface overflow-hidden">
<div
className={`h-full ${detalle.compra.state === 'expired' ? 'bg-amber-500' : 'bg-emerald-500'}`}
style={{ width: `${progreso(detalle.compra)}%` }}
/>
</div>
<p className="text-[11px] text-theme-muted mt-1.5">
Quedan {detalle.compra.sessions_remaining} sesiones por agendar.
</p>
</div>
<div>
<p className="text-sm font-medium text-theme-heading mb-2">Bitácora de sesiones</p>
{detalle.consumos.length === 0 ? (
<p className="text-sm text-theme-muted">Todavía no se ha consumido ninguna sesión.</p>
) : (
<ul className="divide-y divide-theme-border border border-theme-border rounded-xl overflow-hidden">
{detalle.consumos.map((m) => (
<li key={m.id} className="px-3 py-2 text-sm flex items-center justify-between gap-2">
<div className="min-w-0">
<p className="text-theme-heading truncate">
{m.cita_fecha || '—'} {m.cita_hora}
{m.doctor ? <span className="text-theme-muted"> · {m.doctor}</span> : null}
</p>
<p className="text-[11px] text-theme-muted">
{m.state === 'cancelled' ? 'Sesión liberada (cita cancelada)' : 'Sesión consumida'}
</p>
</div>
{m.cita_id && (
<a
href={`/agenda?cita=${m.cita_id}`}
className="text-xs text-theme-accent shrink-0"
onClick={() => setDetalle(null)}
>
Ver cita
</a>
)}
</li>
))}
</ul>
)}
</div>
<div className="flex flex-wrap justify-end gap-2 pt-1">
<Button variant="outline" size="sm" onClick={() => setAjuste({
compra: detalle.compra,
total: String(detalle.compra.sessions_total),
usadas: String(detalle.compra.sessions_used),
})}>
Ajustar saldo
</Button>
{hasRole('gerencia') && detalle.compra.state !== 'cancelled' && (
<Button variant="outline" size="sm" className="!text-rose-600 !border-rose-200" onClick={() => anular(detalle.compra)}>
Anular paquete
</Button>
)}
</div>
</div>
)}
{detalle && ajuste && (
<div className="space-y-3">
<p className="text-sm text-theme-muted">
Corrige el saldo si las sesiones se registraron mal. Se audita en Configuración.
</p>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-sm font-medium text-theme-heading mb-1">Sesiones contratadas</label>
<Input type="number" min={0} value={ajuste.total}
onChange={(e) => setAjuste({ ...ajuste, total: e.target.value })} />
</div>
<div>
<label className="block text-sm font-medium text-theme-heading mb-1">Sesiones usadas</label>
<Input type="number" min={0} value={ajuste.usadas}
onChange={(e) => setAjuste({ ...ajuste, usadas: e.target.value })} />
</div>
</div>
<div className="flex justify-end gap-2">
<Button variant="outline" onClick={() => setAjuste(null)}>Volver</Button>
<Button onClick={guardarAjuste}>Guardar ajuste</Button>
</div>
</div>
)}
</Modal>
</Layout>
);
};
export default Paquetes;

View File

@@ -3,7 +3,7 @@ import { useEffect, useState, useCallback, useMemo, useRef } from 'react';
import { Link, useSearchParams } from 'react-router-dom';
import {
Search, Plus, Minus, X, Store, AlertTriangle, ShoppingBag, Star, Wallet, Trash2, Package,
RotateCcw, Keyboard, Lock, TrendingUp, LayoutGrid, List,
RotateCcw, Keyboard, Lock, TrendingUp, LayoutGrid, List, Layers,
} from 'lucide-react';
import Layout from '../components/Layout';
import { usePatientDrawer } from '../components/PatientDrawer';
@@ -18,7 +18,7 @@ import {
Skeleton,
toast,
} from '../components/ui';
import { odooApi, type Service, type Patient, type InventoryItem, type PosCheckoutResult, type Doctor } from '../services/odoo';
import { odooApi, type Service, type Patient, type InventoryItem, type PosCheckoutResult, type Doctor, type Paquete } from '../services/odoo';
import type { PatientPackage } from '../services/odoo';
const fmtMoney = (n: number) =>
@@ -43,6 +43,8 @@ interface TicketLine {
price: number;
service_id?: number;
item_id?: number;
paquete_id?: number;
sesiones?: number;
stock?: number;
unit?: string;
}
@@ -51,7 +53,9 @@ const Pos: FC = () => {
const { openPatient } = usePatientDrawer();
const [searchParams, setSearchParams] = useSearchParams();
// Catálogo
const [catMode, setCatMode] = useState<'servicios' | 'articulos'>('servicios');
const [catMode, setCatMode] = useState<'servicios' | 'paquetes' | 'articulos'>('servicios');
// Catálogo de paquetes de sesiones (skeen.paquete)
const [paqueteCatalogo, setPaqueteCatalogo] = useState<Paquete[]>([]);
const [services, setServices] = useState<Service[]>([]);
const [catalogLoading, setCatalogLoading] = useState(true);
const [search, setSearch] = useState('');
@@ -162,14 +166,31 @@ const Pos: FC = () => {
}
}, [search]);
// Paquetes de sesiones: catálogo corto, se carga completo y se filtra en local
const loadPaquetes = useCallback(async () => {
try {
setCatalogLoading(true);
const res = await odooApi.getPaqueteCatalogo();
if (res.status === 'success') setPaqueteCatalogo(res.paquetes);
} catch (err) {
toast.error('Error al cargar paquetes');
console.error(err);
} finally {
setCatalogLoading(false);
}
}, []);
useEffect(() => {
const t = setTimeout(() => {
if (catMode === 'servicios') loadServices();
else loadItems();
else if (catMode === 'articulos') loadItems();
}, 300);
return () => clearTimeout(t);
}, [loadServices, loadItems, catMode]);
// El catálogo de paquetes es corto: se carga una vez y se filtra en local.
useEffect(() => { loadPaquetes(); }, [loadPaquetes]);
useEffect(() => {
odooApi.getCashClosings(new Date().toISOString().split('T')[0])
.then((res) => {
@@ -292,9 +313,43 @@ const Pos: FC = () => {
return arr;
}, [visiblesItems, ordenLista]);
// ---- Ticket ----
const addLine = (service: Service) => {
// Paquetes visibles: filtro local por nombre / servicio / código
const paquetesVisibles = useMemo(() => {
const q = (search || '').trim().toLowerCase();
const arr = !q
? [...paqueteCatalogo]
: paqueteCatalogo.filter((p) =>
p.name.toLowerCase().includes(q)
|| (p.service || '').toLowerCase().includes(q)
|| (p.code || '').toLowerCase().includes(q));
arr.sort((a, b) => (a.sequence || 0) - (b.sequence || 0) || a.name.localeCompare(b.name, 'es'));
return arr;
}, [paqueteCatalogo, search]);
// Un paquete solo puede venderse a un paciente: el saldo vive en él.
const addPaqueteLine = (p: Paquete) => {
if (!paciente) {
toast.error('Selecciona primero el paciente: el saldo del paquete es por paciente');
return;
}
setLines((prev) => {
const key = `p-${p.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: `${p.name} · Paquete (${p.sessions} sesiones)`,
qty: 1,
price: p.price,
service_id: p.service_id ?? undefined,
paquete_id: p.id,
sesiones: p.sessions,
}];
});
};
// ---- Ticket ----
const addLine = (service: Service) => { setLines((prev) => {
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));
@@ -382,6 +437,7 @@ const Pos: FC = () => {
lines: lines.map((l) => ({
...(l.service_id ? { service_id: l.service_id } : {}),
...(l.item_id ? { item_id: l.item_id } : {}),
...(l.paquete_id ? { paquete_id: l.paquete_id, description: l.name } : {}),
quantity: l.qty,
price_unit: l.price,
// Asignación a nivel venta: el doctor seleccionado aplica a todas las líneas
@@ -833,13 +889,13 @@ const Pos: FC = () => {
<div className="relative mb-3" ref={searchWrapRef}>
<Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-theme-muted" />
<Input
placeholder={catMode === 'servicios' ? 'Buscar servicio... (F2)' : 'Buscar artículo... (F2)'}
placeholder={catMode === 'servicios' ? 'Buscar servicio... (F2)' : catMode === 'paquetes' ? 'Buscar paquete... (F2)' : 'Buscar artículo... (F2)'}
value={search}
onChange={(e) => setSearch(e.target.value)}
className="pl-10"
/>
</div>
{/* Toggle Servicios / Artículos */}
{/* Toggle Servicios / Paquetes / Artículos */}
<div className="inline-flex rounded-full border border-theme-border-strong overflow-hidden mb-3">
<button
type="button"
@@ -850,6 +906,19 @@ const Pos: FC = () => {
>
<Store size={14} /> Servicios
</button>
<button
type="button"
onClick={() => setCatMode('paquetes')}
className={`px-4 py-1.5 text-sm font-medium transition flex items-center gap-1.5 ${
catMode === 'paquetes' ? 'bg-theme-accent text-theme-inverse' : 'bg-theme-surface text-theme-muted hover:bg-theme-bg'
}`}
title="Paquetes de sesiones"
>
<Layers size={14} /> Paquetes
{paqueteCatalogo.length > 0 && (
<span className="text-[10px] opacity-80">({paqueteCatalogo.length})</span>
)}
</button>
<button
type="button"
onClick={() => setCatMode('articulos')}
@@ -916,6 +985,7 @@ const Pos: FC = () => {
</div>
)}
{catMode !== 'paquetes' && (
<div className="inline-flex rounded-full border border-theme-border-strong overflow-hidden mb-3 ml-2">
<button
type="button"
@@ -938,9 +1008,57 @@ const Pos: FC = () => {
<List size={12} /> Lista
</button>
</div>
)}
{catalogLoading ? (
<Skeleton count={8} className="h-16 w-full" />
) : catMode === 'paquetes' ? (
paquetesVisibles.length === 0 ? (
<EmptyState
title="Sin paquetes"
subtitle={paqueteCatalogo.length === 0
? 'Aún no hay paquetes dados de alta. Créalos en el módulo Paquetes.'
: 'Ningún paquete coincide con la búsqueda.'}
icon={<Layers size={28} />}
/>
) : (
<div className="grid grid-cols-2 md:grid-cols-3 xl:grid-cols-4 gap-2">
{paquetesVisibles.map((p) => {
const ahorro = p.session_price && p.price_per_session
? Math.round((1 - p.price_per_session / p.session_price) * 100)
: 0;
return (
<button
key={p.id}
type="button"
onClick={() => addPaqueteLine(p)}
className="text-left p-2.5 bg-theme-bg rounded-xl border border-theme-border hover:shadow-card hover:border-theme-border-strong transition"
title={p.notes || p.name}
>
<p className="text-sm font-medium text-theme-heading leading-tight line-clamp-2 min-h-[2.4rem]">
{p.name}
</p>
<p className="text-[11px] text-theme-muted truncate">{p.service}</p>
<div className="flex items-center justify-between mt-1">
<span className="text-xs text-theme-muted">{p.sessions} sesiones</span>
<span className="text-sm font-bold text-theme-heading">{fmtMoney(p.price)}</span>
</div>
<div className="flex items-center justify-between mt-0.5">
<span className="text-[10px] text-theme-muted">
{fmtMoney(p.price_per_session)} / sesión
</span>
{ahorro > 0 && (
<span className="text-[10px] font-semibold text-emerald-600">-{ahorro}%</span>
)}
</div>
{p.validity_days > 0 && (
<p className="text-[10px] text-theme-muted mt-0.5">Vigencia {p.validity_days} días</p>
)}
</button>
);
})}
</div>
)
) : vista === 'lista' ? (
<div className="overflow-x-auto max-h-[60vh] overflow-y-auto border border-theme-border rounded-xl">
<table className="w-full text-sm">

View File

@@ -543,6 +543,8 @@ export interface LastSaleResponse {
}
export interface PatientPackage {
id?: number | null;
compra?: boolean;
service_id: number;
name: string;
package_sessions: number;
@@ -550,6 +552,74 @@ export interface PatientPackage {
used: number;
remaining: number;
next_date: string | null;
state?: string;
}
// ---------- Paquetes de sesiones ----------
export interface Paquete {
id: number;
name: string;
code: string;
service_id: number | null;
service: string;
sessions: number;
price: number;
price_per_session: number;
session_price: number;
duration_min: number;
validity_days: number;
notes: string;
color: string;
genera_monedero: boolean;
active: boolean;
sequence: number;
compras_count: number;
}
export interface PaqueteCompra {
id: number;
date: string | null;
patient_id: number;
patient: string;
phone: string;
paquete_id: number | null;
paquete: string;
service_id: number | null;
service: string;
sessions_total: number;
sessions_used: number;
sessions_remaining: number;
price: number;
expiry_date: string | null;
state: 'active' | 'finished' | 'expired' | 'cancelled';
venta_id: number | null;
venta: string;
user_login: string;
}
export interface PaqueteConsumo {
id: number;
date: string | null;
state: string;
cita_id: number | null;
cita_fecha: string | null;
cita_hora: string;
doctor: string;
estado_cita: string;
}
export interface PaquetePayload {
name?: string;
code?: string;
service_id?: number;
sessions?: number;
price?: number;
duration_min?: number;
validity_days?: number;
notes?: string;
color?: string;
genera_monedero?: boolean;
active?: boolean;
}
export interface MySaleItem {
@@ -1115,7 +1185,7 @@ export interface AppNotification {
export interface PosCheckoutPayload {
partner_id: number;
lines: { service_id?: number; item_id?: number; quantity: number; price_unit: number; description?: string; prescribed_by_id?: number }[];
lines: { service_id?: number; item_id?: number; paquete_id?: number; quantity: number; price_unit: number; description?: string; prescribed_by_id?: number }[];
discount?: number;
payment_method?: string;
amount_received?: number;
@@ -1975,6 +2045,60 @@ export const odooApi = {
return data;
},
// ---------- Paquetes de sesiones (catálogo y saldos) ----------
async getPaqueteCatalogo(params?: { search?: string; includeInactive?: boolean }): Promise<{ status: string; paquetes: Paquete[] }> {
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/paquetes`, {
params: {
search: params?.search || undefined,
include_inactive: params?.includeInactive ? '1' : undefined,
},
});
return data;
},
async createPaquete(payload: PaquetePayload): Promise<{ status: string; paquete: Paquete; message?: string }> {
const { data } = await axios.post(`${ODOO_BASE}/skeen/frontend/v1/paquetes`, payload);
return data;
},
async updatePaquete(id: number, payload: PaquetePayload): Promise<{ status: string; paquete: Paquete; message?: string }> {
const { data } = await axios.put(`${ODOO_BASE}/skeen/frontend/v1/paquetes/${id}`, payload);
return data;
},
async getPaqueteCompras(params?: {
patient_id?: number; state?: string; search?: string; doctor_id?: number;
start?: string; end?: string; includeAll?: boolean;
}): Promise<{ status: string; compras: PaqueteCompra[]; totales: Record<string, number> }> {
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/paquetes/compras`, {
params: {
patient_id: params?.patient_id || undefined,
state: params?.state || undefined,
search: params?.search || undefined,
doctor_id: params?.doctor_id || undefined,
start: params?.start || undefined,
end: params?.end || undefined,
include_all: params?.includeAll ? '1' : undefined,
},
});
return data;
},
async getPaqueteConsumos(id: number): Promise<{ status: string; compra: PaqueteCompra; consumos: PaqueteConsumo[]; message?: string }> {
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/paquetes/compras/${id}/consumos`);
return data;
},
async ajustarPaqueteCompra(id: number, payload: { sessions_total?: number; sessions_used?: number }): Promise<{ status: string; compra: PaqueteCompra; message?: string }> {
const { data } = await axios.put(`${ODOO_BASE}/skeen/frontend/v1/paquetes/compras/${id}`, payload);
return data;
},
async anularPaqueteCompra(id: number, reason: string): Promise<{ status: string; compra: PaqueteCompra; message?: string }> {
const { data } = await axios.post(`${ODOO_BASE}/skeen/frontend/v1/paquetes/compras/${id}/anular`, { reason });
return data;
},
async getCashReportRange(start: string, end: string): Promise<{ status: string; total: number; by_method: Record<string, number> }> {
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/reports/cash`, { params: { start, end } });
return data;

View File

@@ -16,6 +16,7 @@
'data/sequences.xml',
'views/venta_views.xml',
'views/corte_views.xml',
'views/paquete_views.xml',
'views/menu_views.xml',
],
'installable': True,

View File

@@ -1,3 +1,4 @@
from . import tipo_cambio
from . import pago
from . import venta
from . import paquete

View File

@@ -0,0 +1,279 @@
# -*- coding: utf-8 -*-
"""Paquetes de sesiones.
El catálogo de paquetes vive en `skeen.paquete` (un paquete = N sesiones de un
servicio a un precio cerrado). Cada vez que el POS vende uno se materializa una
`skeen.paquete.compra` a nombre del paciente, que es la que lleva el saldo:
sessions_total sesiones contratadas
sessions_used sesiones ya consumidas (citas atendidas)
-> restantes = sessions_total - sessions_used
El consumo lo hace `SkeenCitaPaquete` (cita_package.py) al pasar la cita por
'arrived'/'done', y lo revierte si la cita se cancela.
"""
from datetime import timedelta
from odoo import models, fields, api, _
from odoo.exceptions import ValidationError
# Estados de cita que ya gastaron una sesión del paquete
CONSUME_STATES = ('arrived', 'in_progress', 'done')
class SkeenPaquete(models.Model):
_name = 'skeen.paquete'
_description = 'Paquete de Sesiones'
_order = 'sequence, name'
name = fields.Char(string='Nombre', required=True)
code = fields.Char(string='Código')
service_id = fields.Many2one('skeen.servicio', string='Servicio', required=True,
help='Servicio cuyas sesiones incluye el paquete.')
duration_min = fields.Integer(string='Duración sesión (min)',
help='Solo informativo / para agendar. Si se deja '
'en 0 se usa la del servicio.')
sessions = fields.Integer(string='Sesiones', required=True, default=1)
price = fields.Float(string='Precio paquete', required=True, default=0.0)
validity_days = fields.Integer(string='Vigencia (días)', default=0,
help='Días desde la compra para usar las sesiones. '
'0 = sin caducidad.')
notes = fields.Char(string='Notas')
color = fields.Char(string='Color', default='#1abc9c')
genera_monedero = fields.Boolean(string='Genera monedero', default=True)
active = fields.Boolean(string='Activo', default=True)
sequence = fields.Integer(string='Secuencia', default=10)
compra_ids = fields.One2many('skeen.paquete.compra', 'paquete_id', string='Compras')
compras_count = fields.Integer(string='Compras', compute='_compute_compras_count')
@api.depends('compra_ids')
def _compute_compras_count(self):
for p in self:
p.compras_count = len(p.compra_ids)
@api.constrains('sessions', 'price')
def _check_sessions_price(self):
for p in self:
if p.sessions < 1:
raise ValidationError(_('Un paquete debe tener al menos 1 sesión.'))
if p.price < 0:
raise ValidationError(_('El precio del paquete no puede ser negativo.'))
@api.onchange('service_id')
def _onchange_service(self):
if self.service_id and not self.duration_min:
self.duration_min = self.service_id.duration_min
def name_get(self):
return [(p.id, '%s (%d sesiones)' % (p.name, p.sessions)) for p in self]
class SkeenPaqueteCompra(models.Model):
_name = 'skeen.paquete.compra'
_description = 'Paquete Comprado'
_order = 'date desc, id desc'
partner_id = fields.Many2one('res.partner', string='Paciente', required=True,
domain=[('is_patient', '=', True)])
paquete_id = fields.Many2one('skeen.paquete', string='Paquete', required=True,
ondelete='restrict')
service_id = fields.Many2one(related='paquete_id.service_id', string='Servicio',
store=True, readonly=True)
venta_id = fields.Many2one('skeen.venta', string='Venta', ondelete='set null')
sale_line_id = fields.Many2one('skeen.venta.line', string='Línea de venta',
ondelete='set null')
date = fields.Date(string='Fecha compra', required=True, default=fields.Date.today)
sessions_total = fields.Integer(string='Sesiones', required=True, default=1)
sessions_used = fields.Integer(string='Sesiones usadas', default=0)
price = fields.Float(string='Precio pagado', default=0.0)
user_login = fields.Char(string='Vendió', index=True)
sessions_remaining = fields.Integer(string='Restantes', compute='_compute_remaining',
store=True)
expiry_date = fields.Date(string='Vence', compute='_compute_expiry_date', store=True)
state = fields.Selection([
('active', 'Vigente'),
('finished', 'Completado'),
('expired', 'Vencido'),
('cancelled', 'Anulado'),
], string='Estado', default='active', compute='_compute_state', store=True)
line_ids = fields.One2many('skeen.paquete.consumo', 'compra_id', string='Consumos')
consumo_count = fields.Integer(string='Consumos', compute='_compute_consumo_count')
@api.depends('sessions_total', 'sessions_used')
def _compute_remaining(self):
for c in self:
c.sessions_remaining = max(0, c.sessions_total - c.sessions_used)
@api.depends('paquete_id.validity_days', 'date')
def _compute_expiry_date(self):
for c in self:
days = c.paquete_id.validity_days or 0
c.expiry_date = (c.date + timedelta(days=days)) \
if (c.date and days > 0) else False
@api.depends('sessions_remaining', 'expiry_date', 'date')
def _compute_state(self):
today = fields.Date.today()
for c in self:
if c.sessions_total <= 0:
c.state = 'cancelled'
elif c.sessions_remaining <= 0:
c.state = 'finished'
elif c.expiry_date and c.expiry_date < today:
c.state = 'expired'
else:
c.state = 'active'
@api.depends('line_ids')
def _compute_consumo_count(self):
for c in self:
c.consumo_count = len(c.line_ids)
@api.model_create_multi
def create(self, vals_list):
"""sessions_total por defecto = las del paquete (el POS no siempre lo manda)."""
for vals in vals_list:
if not vals.get('sessions_total') and vals.get('paquete_id'):
paquete = self.env['skeen.paquete'].browse(vals['paquete_id'])
vals['sessions_total'] = paquete.sessions or 1
return super().create(vals_list)
def action_cancel(self):
"""Anula el paquete: libera los consumos registrados."""
for c in self:
c.line_ids.write({'state': 'cancelled'})
c.write({'sessions_used': 0})
def action_adjust_sessions(self, sessions_total=None, sessions_used=None):
vals = {}
if sessions_total is not None:
vals['sessions_total'] = int(sessions_total)
if sessions_used is not None:
vals['sessions_used'] = int(sessions_used)
if vals:
self.write(vals)
return True
class SkeenPaqueteConsumo(models.Model):
_name = 'skeen.paquete.consumo'
_description = 'Consumo de Sesión de Paquete'
_order = 'date desc, id desc'
compra_id = fields.Many2one('skeen.paquete.compra', string='Paquete comprado',
required=True, ondelete='cascade')
cita_id = fields.Many2one('skeen.cita', string='Cita', ondelete='set null', index=True)
partner_id = fields.Many2one(related='compra_id.partner_id', string='Paciente',
store=True, readonly=True)
date = fields.Datetime(string='Fecha', default=fields.Datetime.now)
state = fields.Selection([
('consumed', 'Consumida'),
('cancelled', 'Liberada'),
], string='Estado', default='consumed')
_sql_constraints = [
('unique_cita_compra', 'unique(cita_id, compra_id)',
'Una misma cita solo puede consumir una sesión de un paquete.')
]
class SkeenVentaLinePaquete(models.Model):
_inherit = 'skeen.venta.line'
paquete_id = fields.Many2one('skeen.paquete', string='Paquete',
help='Si la línea vende un paquete de sesiones, '
'aquí se enlaza. Al confirmar la venta se crea '
'el saldo del paciente.')
is_package = fields.Boolean(string='Es paquete', compute='_compute_is_package',
store=True)
@api.depends('paquete_id')
def _compute_is_package(self):
for line in self:
line.is_package = bool(line.paquete_id)
@api.constrains('paquete_id', 'service_id')
def _check_paquete_service(self):
for line in self:
if line.paquete_id and line.service_id and \
line.service_id != line.paquete_id.service_id:
raise ValidationError(_(
'La línea mezcla un paquete con un servicio distinto al del paquete.'
))
class SkeenCitaPaquete(models.Model):
_inherit = 'skeen.cita'
paquete_compra_id = fields.Many2one('skeen.paquete.compra', string='Paquete origen',
ondelete='set null', index=True,
help='Compra de paquete que pagó esta cita.')
def _paquete_asignable(self):
"""Compra vigente del paciente para el servicio de esta cita."""
self.ensure_one()
if not self.partner_id or not self.servicio_id:
return None
Compra = self.env['skeen.paquete.compra'].sudo()
return Compra.search([
('partner_id', '=', self.partner_id.id),
('service_id', '=', self.servicio_id.id),
('sessions_remaining', '>', 0),
('state', '=', 'active'),
], order='expiry_date asc, date asc', limit=1)
def _registrar_consumo(self, compra):
for cita in self:
existe = self.env['skeen.paquete.consumo'].sudo().search([
('compra_id', '=', compra.id), ('cita_id', '=', cita.id)], limit=1)
if existe:
if existe.state != 'consumed':
existe.write({'state': 'consumed'})
compra.write({'sessions_used': compra.sessions_used + 1})
continue
self.env['skeen.paquete.consumo'].sudo().create({
'compra_id': compra.id,
'cita_id': cita.id,
})
compra.write({'sessions_used': compra.sessions_used + 1})
if not cita.paquete_compra_id:
cita.write({'paquete_compra_id': compra.id})
def _liberar_consumo(self):
Consumos = self.env['skeen.paquete.consumo'].sudo()
for cita in self:
registro = Consumos.search([('cita_id', '=', cita.id),
('state', '=', 'consumed')], limit=1)
if not registro:
continue
compra = registro.compra_id
registro.write({'state': 'cancelled'})
compra.write({'sessions_used': max(0, compra.sessions_used - 1)})
def write(self, vals):
cambio_estado = 'state' in vals
antes = {c.id: c.state for c in self} if cambio_estado else {}
res = super().write(vals)
if not cambio_estado:
return res
Consumir, Liberar = self.browse(), self.browse()
for c in self:
previo = antes.get(c.id)
if c.state in CONSUME_STATES and previo not in CONSUME_STATES:
if not c.paquete_compra_id:
compra = c._paquete_asignable()
if compra:
Consumir |= c
elif c.state in ('cancelled', 'no_show', 'waitlist') and \
previo in CONSUME_STATES:
Liberar |= c
for c in Consumir:
compra = c._paquete_asignable()
if compra:
c._registrar_consumo(compra)
Liberar._liberar_consumo()
return res

View File

@@ -3,3 +3,6 @@ access_skeen_venta_user,Acceso a Ventas SKEEN,model_skeen_venta,base.group_user,
access_skeen_venta_line_user,Acceso a Líneas de Venta SKEEN,model_skeen_venta_line,base.group_user,1,1,1,1
access_skeen_corte_caja_user,Acceso a Cortes de Caja SKEEN,model_skeen_corte_caja,base.group_user,1,1,1,1
access_skeen_tipo_cambio_user,Acceso a Tipo de Cambio SKEEN,model_skeen_tipo_cambio,base.group_user,1,1,1,1
access_skeen_paquete_user,Acceso a Paquetes SKEEN,model_skeen_paquete,base.group_user,1,1,1,1
access_skeen_paquete_compra_user,Acceso a Compras de Paquete SKEEN,model_skeen_paquete_compra,base.group_user,1,1,1,1
access_skeen_paquete_consumo_user,Acceso a Consumos de Paquete SKEEN,model_skeen_paquete_consumo,base.group_user,1,1,1,1
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
3 access_skeen_venta_line_user Acceso a Líneas de Venta SKEEN model_skeen_venta_line base.group_user 1 1 1 1
4 access_skeen_corte_caja_user Acceso a Cortes de Caja SKEEN model_skeen_corte_caja base.group_user 1 1 1 1
5 access_skeen_tipo_cambio_user Acceso a Tipo de Cambio SKEEN model_skeen_tipo_cambio base.group_user 1 1 1 1
6 access_skeen_paquete_user Acceso a Paquetes SKEEN model_skeen_paquete base.group_user 1 1 1 1
7 access_skeen_paquete_compra_user Acceso a Compras de Paquete SKEEN model_skeen_paquete_compra base.group_user 1 1 1 1
8 access_skeen_paquete_consumo_user Acceso a Consumos de Paquete SKEEN model_skeen_paquete_consumo base.group_user 1 1 1 1

View File

@@ -4,5 +4,7 @@
<menuitem id="menu_skeen_ventas_root" name="Ventas" parent="skeen_citas.menu_skeen_root" sequence="30" />
<menuitem id="menu_skeen_venta" name="Ventas" parent="menu_skeen_ventas_root" action="action_skeen_venta" sequence="10" />
<menuitem id="menu_skeen_corte_caja" name="Cortes de Caja" parent="menu_skeen_ventas_root" action="action_skeen_corte_caja" sequence="20" />
<menuitem id="menu_skeen_paquete" name="Paquetes" parent="menu_skeen_ventas_root" action="action_skeen_paquete" sequence="30" />
<menuitem id="menu_skeen_paquete_compra" name="Paquetes de Pacientes" parent="menu_skeen_ventas_root" action="action_skeen_paquete_compra" sequence="40" />
</data>
</odoo>

View File

@@ -0,0 +1,135 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data>
<!-- ============ Paquetes (catálogo) ============ -->
<record id="view_skeen_paquete_tree" model="ir.ui.view">
<field name="name">skeen.paquete.tree</field>
<field name="model">skeen.paquete</field>
<field name="arch" type="xml">
<tree>
<field name="sequence" widget="handle" />
<field name="name" />
<field name="service_id" />
<field name="sessions" />
<field name="price" />
<field name="validity_days" />
<field name="compras_count" />
<field name="active" />
</tree>
</field>
</record>
<record id="view_skeen_paquete_form" model="ir.ui.view">
<field name="name">skeen.paquete.form</field>
<field name="model">skeen.paquete</field>
<field name="arch" type="xml">
<form>
<sheet>
<div class="oe_title">
<h1><field name="name" placeholder="Ej. Lumecca paquete 3 sesiones" /></h1>
</div>
<group>
<group>
<field name="service_id" />
<field name="sessions" />
<field name="price" />
<field name="duration_min" />
</group>
<group>
<field name="code" />
<field name="validity_days" />
<field name="color" widget="color" />
<field name="genera_monedero" />
<field name="active" />
<field name="sequence" />
</group>
</group>
<field name="notes" placeholder="Notas del paquete" />
<separator string="Compras" />
<field name="compra_ids">
<tree>
<field name="date" />
<field name="partner_id" />
<field name="sessions_total" />
<field name="sessions_used" />
<field name="sessions_remaining" />
<field name="expiry_date" />
<field name="state" />
</tree>
</field>
</sheet>
</form>
</field>
</record>
<record id="action_skeen_paquete" model="ir.actions.act_window">
<field name="name">Paquetes</field>
<field name="res_model">skeen.paquete</field>
<field name="view_mode">tree,form</field>
</record>
<!-- ============ Paquetes comprados (saldos) ============ -->
<record id="view_skeen_paquete_compra_tree" model="ir.ui.view">
<field name="name">skeen.paquete.compra.tree</field>
<field name="model">skeen.paquete.compra</field>
<field name="arch" type="xml">
<tree decoration-success="state=='active'" decoration-muted="state=='cancelled'"
decoration-danger="state=='expired'" decoration-warning="state=='finished'">
<field name="date" />
<field name="partner_id" />
<field name="paquete_id" />
<field name="service_id" />
<field name="sessions_total" />
<field name="sessions_used" />
<field name="sessions_remaining" />
<field name="expiry_date" />
<field name="price" />
<field name="state" />
</tree>
</field>
</record>
<record id="view_skeen_paquete_compra_form" model="ir.ui.view">
<field name="name">skeen.paquete.compra.form</field>
<field name="model">skeen.paquete.compra</field>
<field name="arch" type="xml">
<form>
<sheet>
<group>
<group>
<field name="partner_id" />
<field name="paquete_id" />
<field name="service_id" />
<field name="date" />
</group>
<group>
<field name="sessions_total" />
<field name="sessions_used" />
<field name="sessions_remaining" />
<field name="expiry_date" />
<field name="state" />
<field name="venta_id" />
<field name="user_login" />
</group>
</group>
<separator string="Sesiones consumidas" />
<field name="line_ids">
<tree>
<field name="date" />
<field name="cita_id" />
<field name="state" />
</tree>
</field>
</sheet>
</form>
</field>
</record>
<record id="action_skeen_paquete_compra" model="ir.actions.act_window">
<field name="name">Paquetes de Pacientes</field>
<field name="res_model">skeen.paquete.compra</field>
<field name="view_mode">tree,form</field>
</record>
</data>
</odoo>

View File

@@ -2114,13 +2114,52 @@ class SkeenFrontendController(http.Controller):
def patient_packages(self, patient_id, **kw):
"""Paquetes del paciente con sesiones restantes.
El sistema no guarda un contador de sesiones: el paquete se materializa
como las citas creadas para un servicio con `package_sessions > 0`
(create_appointment genera una cita por sesión). Por eso:
Fuente principal: `skeen.paquete.compra`, el saldo que crea el POS al
vender un paquete y que la cita descuenta al atenderse.
De respaldo se mantiene la derivación antigua para pacientes migrados
del sistema legacy, donde el paquete nunca se registró como compra y
solo existe como las citas del servicio:
total = citas no canceladas del paciente con ese servicio
usadas = citas ya atendidas (llegó / en curso / completada)
restantes = total - usadas
"""
try:
Compras = request.env['skeen.paquete.compra'].sudo()
compras = Compras.search([
('partner_id', '=', patient_id),
('state', 'in', ('active', 'finished', 'expired')),
], order='date asc')
reales = []
servicios_con_compra = set()
for c in compras:
servicios_con_compra.add(c.service_id.id if c.service_id else None)
reales.append({
'id': c.id,
'compra': True,
'service_id': c.service_id.id if c.service_id else None,
'name': c.paquete_id.name if c.paquete_id else (c.service_id.name if c.service_id else ''),
'package_sessions': c.sessions_total,
'total': c.sessions_total,
'used': c.sessions_used,
'remaining': c.sessions_remaining,
'next_date': c.expiry_date.strftime('%Y-%m-%d') if c.expiry_date else None,
'state': c.state,
})
if reales:
heredados = self._paquetes_heredados(patient_id, servicios_con_compra)
return json_response({
'status': 'success',
'packages': sorted(reales + heredados,
key=lambda r: (-r['remaining'], r['name'])),
})
return json_response({'status': 'success',
'packages': self._paquetes_heredados(patient_id, set())})
except Exception as e:
return json_response({'status': 'error', 'message': str(e)}, 500)
def _paquetes_heredados(self, patient_id, excluir_servicios):
"""Saldos de paquete derivados de las citas (pacientes del legacy)."""
try:
Citas = request.env['skeen.cita'].sudo()
citas = Citas.search([
@@ -2130,6 +2169,8 @@ class SkeenFrontendController(http.Controller):
])
grupos = {}
for c in citas:
if excluir_servicios and c.servicio_id.id in excluir_servicios:
continue
g = grupos.setdefault(c.servicio_id.id, {
'service_id': c.servicio_id.id,
'name': c.servicio_id.name,
@@ -2146,12 +2187,288 @@ class SkeenFrontendController(http.Controller):
rows = []
for g in grupos.values():
g['remaining'] = max(0, g['total'] - g['used'])
g['compra'] = False
g['id'] = None
g['state'] = 'active' if g['remaining'] else 'finished'
rows.append(g)
rows.sort(key=lambda r: (-r['remaining'], r['name']))
return json_response({'status': 'success', 'packages': rows})
return rows
except Exception:
return []
# ============================================================
# Paquetes de sesiones
# ============================================================
def _paquete_to_dict(self, p):
servicio = p.service_id
return {
'id': p.id,
'name': p.name,
'code': p.code or '',
'service_id': servicio.id if servicio else None,
'service': servicio.name if servicio else '',
'sessions': p.sessions,
'price': p.price,
'price_per_session': round(p.price / p.sessions, 2) if p.sessions else 0.0,
'session_price': servicio.price if servicio else 0.0,
'duration_min': p.duration_min or (servicio.duration_min if servicio else 0),
'validity_days': p.validity_days or 0,
'notes': p.notes or '',
'color': p.color or '#1abc9c',
'genera_monedero': p.genera_monedero,
'active': p.active,
'sequence': p.sequence or 0,
'compras_count': p.compras_count,
}
def _paquete_compra_to_dict(self, c):
return {
'id': c.id,
'date': c.date.strftime('%Y-%m-%d') if c.date else None,
'patient_id': c.partner_id.id,
'patient': c.partner_id.name or '',
'phone': c.partner_id.phone or '',
'paquete_id': c.paquete_id.id if c.paquete_id else None,
'paquete': c.paquete_id.name if c.paquete_id else '',
'service_id': c.service_id.id if c.service_id else None,
'service': c.service_id.name if c.service_id else '',
'sessions_total': c.sessions_total,
'sessions_used': c.sessions_used,
'sessions_remaining': c.sessions_remaining,
'price': c.price,
'expiry_date': c.expiry_date.strftime('%Y-%m-%d') if c.expiry_date else None,
'state': c.state,
'venta_id': c.venta_id.id if c.venta_id else None,
'venta': c.venta_id.name if c.venta_id else '',
'user_login': c.user_login or '',
}
@http.route('/skeen/frontend/v1/paquetes', type='http', auth='none',
methods=['GET', 'OPTIONS'], csrf=False)
@require_role('lectura')
def list_paquetes(self, **kw):
"""Catálogo de paquetes. `include_inactive=1` para ver los dados de baja."""
try:
domain = []
if not (kw.get('include_inactive') or '').strip():
domain.append(('active', '=', True))
if kw.get('search'):
s = kw['search']
domain += ['|', ('name', 'ilike', s), ('code', 'ilike', s)]
if kw.get('service_id'):
domain.append(('service_id', '=', int(kw['service_id'])))
rows = request.env['skeen.paquete'].sudo().with_context(
active_test=False).search(domain, order='sequence asc, name asc')
return json_response({'status': 'success', 'paquetes': [
self._paquete_to_dict(p) for p in rows]})
except Exception as e:
return json_response({'status': 'error', 'message': str(e)}, 500)
@http.route('/skeen/frontend/v1/paquetes', type='http', auth='none',
methods=['POST'], csrf=False)
@require_role('admin')
def create_paquete(self, **kw):
try:
data = _parse_json_body()
name = (data.get('name') or '').strip()
if not name:
return json_response({'status': 'error',
'message': 'El nombre es obligatorio'}, 400)
if not data.get('service_id'):
return json_response({'status': 'error',
'message': 'El servicio es obligatorio'}, 400)
Paq = request.env['skeen.paquete'].sudo()
if Paq.search([('name', '=ilike', name)], limit=1):
return json_response({'status': 'error',
'message': f'Ya existe un paquete "{name}".'}, 409)
vals = {
'name': name,
'code': (data.get('code') or '').strip(),
'service_id': int(data['service_id']),
'sessions': int(data.get('sessions') or 1),
'price': float(data.get('price') or 0.0),
'duration_min': int(data.get('duration_min') or 0),
'validity_days': int(data.get('validity_days') or 0),
'notes': data.get('notes') or '',
'color': data.get('color') or '#1abc9c',
'genera_monedero': bool(data.get('genera_monedero', True)),
}
try:
p = Paq.create(vals)
except ValidationError as e:
return json_response({'status': 'error',
'message': e.args[0] if e.args else str(e)}, 400)
self._auditar('paquetes', 'skeen.paquete', 'create',
new_value=p.name, entity_label=p.name,
notes=f'{p.sessions} sesiones · {p.price}')
return json_response({'status': 'success', 'paquete': self._paquete_to_dict(p)}, 201)
except Exception as e:
return json_response({'status': 'error', 'message': str(e)}, 500)
@http.route('/skeen/frontend/v1/paquetes/<int:paquete_id>', type='http', auth='none',
methods=['PUT', 'PATCH'], csrf=False)
@require_role('admin')
def update_paquete(self, paquete_id, **kw):
try:
p = request.env['skeen.paquete'].sudo().with_context(active_test=False).browse(paquete_id)
if not p.exists():
return json_response({'status': 'error', 'message': 'Paquete no encontrado'}, 404)
data = _parse_json_body()
mapping = {
'name': 'name', 'code': 'code', 'notes': 'notes', 'color': 'color',
}
vals = {}
for key, field in mapping.items():
if key in data:
vals[field] = (data[key] or '').strip() if field != 'notes' else (data[key] or '')
if 'service_id' in data and data['service_id']:
vals['service_id'] = int(data['service_id'])
for key in ('sessions', 'duration_min', 'validity_days', 'sequence'):
if key in data:
vals[key] = int(data[key] or 0)
for key in ('price',):
if key in data:
vals[key] = float(data[key] or 0.0)
for key in ('genera_monedero', 'active'):
if key in data:
vals[key] = bool(data[key])
if 'active' in vals and vals['active'] != p.active:
self._auditar('paquetes', 'skeen.paquete', 'active',
old_value='activo' if p.active else 'inactivo',
new_value='activo' if vals['active'] else 'inactivo',
entity_label=p.name,
notes='Alta de baja' if not vals['active'] else 'Reactivación')
try:
p.write(vals)
except ValidationError as e:
return json_response({'status': 'error',
'message': e.args[0] if e.args else str(e)}, 400)
self._auditar('paquetes', 'skeen.paquete', 'edit',
new_value=', '.join(vals.keys()), entity_label=p.name)
return json_response({'status': 'success', 'paquete': self._paquete_to_dict(p)})
except Exception as e:
return json_response({'status': 'error', 'message': str(e)}, 500)
@http.route('/skeen/frontend/v1/paquetes/compras', type='http', auth='none',
methods=['GET', 'OPTIONS'], csrf=False)
@require_role('lectura')
def list_paquetes_compras(self, **kw):
"""Paquetes vendidos con su saldo.
Filtros: patient_id, state (active|finished|expired|cancelled),
doctor_id, search (paciente o paquete), start/end por fecha de compra.
"""
try:
domain = []
if kw.get('patient_id'):
domain.append(('partner_id', '=', int(kw['patient_id'])))
if kw.get('state'):
domain.append(('state', '=', kw['state']))
elif not (kw.get('include_all') or '').strip():
domain.append(('state', '!=', 'cancelled'))
if kw.get('search'):
s = f"%{kw['search']}%"
domain += ['|', ('partner_id.name', 'ilike', s),
('paquete_id.name', 'ilike', s)]
if kw.get('start'):
domain.append(('date', '>=', kw['start']))
if kw.get('end'):
domain.append(('date', '<=', kw['end']))
if kw.get('doctor_id'):
domain.append(('line_ids.cita_id.doctor_id', '=', int(kw['doctor_id'])))
limit = min(max(int(kw.get('limit', 300) or 300), 1), 1000)
rows = request.env['skeen.paquete.compra'].sudo().search(
domain, order='date desc, id desc', limit=limit)
totales = {
'vigentes': sum(1 for c in rows if c.state == 'active'),
'completados': sum(1 for c in rows if c.state == 'finished'),
'vencidos': sum(1 for c in rows if c.state == 'expired'),
'sesiones_restantes': sum(c.sessions_remaining for c in rows
if c.state == 'active'),
'importe': sum(c.price for c in rows),
}
return json_response({'status': 'success',
'compras': [self._paquete_compra_to_dict(c) for c in rows],
'totales': totales})
except Exception as e:
return json_response({'status': 'error', 'message': str(e)}, 500)
@http.route('/skeen/frontend/v1/paquetes/compras/<int:compra_id>/consumos',
type='http', auth='none', methods=['GET', 'OPTIONS'], csrf=False)
@require_role('lectura')
def paquete_compra_consumos(self, compra_id, **kw):
"""Bitácora de sesiones consumidas por un paquete (con su cita)."""
try:
c = request.env['skeen.paquete.compra'].sudo().browse(compra_id)
if not c.exists():
return json_response({'status': 'error',
'message': 'Paquete no encontrado'}, 404)
filas = []
for mov in c.line_ids:
cita = mov.cita_id
filas.append({
'id': mov.id,
'date': mov.date.strftime('%Y-%m-%d %H:%M') if mov.date else None,
'state': mov.state,
'cita_id': cita.id if cita else None,
'cita_fecha': cita.date.strftime('%Y-%m-%d') if (cita and cita.date) else None,
'cita_hora': cita.time_str if cita else '',
'doctor': cita.doctor_id.name if (cita and cita.doctor_id) else '',
'estado_cita': cita.state if cita else '',
})
filas.sort(key=lambda f: (f['cita_fecha'] or '', f['cita_hora'] or ''))
return json_response({'status': 'success', 'compra': self._paquete_compra_to_dict(c),
'consumos': filas})
except Exception as e:
return json_response({'status': 'error', 'message': str(e)}, 500)
@http.route('/skeen/frontend/v1/paquetes/compras/<int:compra_id>', type='http',
auth='none', methods=['PUT', 'PATCH'], csrf=False)
@require_role('recepcion')
def update_paquete_compra(self, compra_id, **kw):
"""Ajuste manual del saldo (sesiones compradas / usadas)."""
try:
c = request.env['skeen.paquete.compra'].sudo().browse(compra_id)
if not c.exists():
return json_response({'status': 'error', 'message': 'Paquete no encontrado'}, 404)
data = _parse_json_body()
before = f"{c.sessions_total}/{c.sessions_used}"
try:
c.action_adjust_sessions(data.get('sessions_total'), data.get('sessions_used'))
except (ValueError, TypeError):
return json_response({'status': 'error',
'message': 'Las sesiones deben ser números enteros'}, 400)
self._auditar('paquetes', 'skeen.paquete.compra', 'sessions',
old_value=before,
new_value=f"{c.sessions_total}/{c.sessions_used}",
entity_label=f'{c.partner_id.name} · {c.paquete_id.name}',
notes='Ajuste manual de saldo')
return json_response({'status': 'success', 'compra': self._paquete_compra_to_dict(c)})
except Exception as e:
return json_response({'status': 'error', 'message': str(e)}, 500)
@http.route('/skeen/frontend/v1/paquetes/compras/<int:compra_id>/anular',
type='http', auth='none', methods=['POST', 'OPTIONS'], csrf=False)
@require_role('gerencia')
def anular_paquete_compra(self, compra_id, **kw):
try:
c = request.env['skeen.paquete.compra'].sudo().browse(compra_id)
if not c.exists():
return json_response({'status': 'error', 'message': 'Paquete no encontrado'}, 404)
data = _parse_json_body()
motivo = (data.get('reason') or '').strip()
if not motivo:
return json_response({'status': 'error',
'message': 'Indica el motivo de la anulación'}, 400)
c.action_cancel()
c.write({'sessions_total': 0})
self._auditar('paquetes', 'skeen.paquete.compra', 'state',
old_value='activo', new_value='anulado',
entity_label=f'{c.partner_id.name} · {c.paquete_id.name}',
notes=motivo)
return json_response({'status': 'success', 'compra': self._paquete_compra_to_dict(c)})
except Exception as e:
return json_response({'status': 'error', 'message': str(e)}, 500)
# ============================================================
# Adeudos: cobro con recibo y plan de pagos
# ============================================================
@@ -4270,6 +4587,7 @@ 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
paquete_lines = [] # (paquete_id, qty, price): materializan el saldo del paciente
stock_warnings = []
subtotal = 0.0
for l in lines:
@@ -4279,6 +4597,12 @@ class SkeenFrontendController(http.Controller):
return json_response({'status': 'error', 'message': 'Cantidad inválida en una línea'}, 400)
service_id = int(l.get('service_id', 0) or 0)
item_id = int(l.get('item_id', 0) or 0)
paquete_id = int(l.get('paquete_id', 0) or 0)
if paquete_id and not service_id:
# Una línea de paquete también consume servicio (el del paquete)
# para que el reporte de comisiones no se rompa.
service_id = int(request.env['skeen.paquete'].sudo().browse(
paquete_id).service_id.id 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
@@ -4308,7 +4632,10 @@ class SkeenFrontendController(http.Controller):
'price_unit': price,
'is_prescribed': bool(l.get('prescribed_by_id')),
'prescribed_by_id': l.get('prescribed_by_id') or False,
'paquete_id': paquete_id or False,
}))
if paquete_id:
paquete_lines.append((paquete_id, qty, price))
total_est = max(0.0, subtotal - discount)
# Moneda y tipo de cambio: validar ANTES de crear nada
@@ -4343,6 +4670,25 @@ class SkeenFrontendController(http.Controller):
})
venta.action_confirm()
# Paquetes vendidos: materializar el saldo del paciente. Se enlazan
# las compras a las líneas reales de la venta para poder revertirlas.
compras_creadas = []
if paquete_lines:
Compra = request.env['skeen.paquete.compra'].sudo()
lineas_venta = venta.line_ids.filtered(lambda ln: ln.paquete_id)
for line in lineas_venta:
for _i in range(int(line.quantity or 1)):
compras_creadas.append(Compra.create({
'partner_id': partner.id,
'paquete_id': line.paquete_id.id,
'venta_id': venta.id,
'sale_line_id': line.id,
'date': venta.date or fields.Date.today(),
'sessions_total': line.paquete_id.sessions,
'price': line.price_unit,
'user_login': cobrador,
}))
# Descargo de inventario por cada artículo (el create del move ajusta el stock)
if item_lines:
Move = request.env['skeen.inventario.move'].sudo()
@@ -4425,6 +4771,14 @@ class SkeenFrontendController(http.Controller):
'puntos_ganados': int(venta.total / 10) if venta.state == 'paid' else 0,
'wallet_points': partner.wallet_points,
'stock_warnings': stock_warnings,
'packages': [{
'id': c.id,
'paquete': c.paquete_id.name,
'service_id': c.service_id.id if c.service_id else None,
'sessions_total': c.sessions_total,
'sessions_remaining': c.sessions_remaining,
'expiry_date': c.expiry_date.strftime('%Y-%m-%d') if c.expiry_date else None,
} for c in compras_creadas],
}, 201)
except Exception as e:
return json_response({'status': 'error', 'message': str(e)}, 500)

View File

@@ -29,6 +29,12 @@ class SkeenConfigAudit(models.Model):
('pacientes', 'Expedientes de pacientes'),
('catalogos', 'Catálogos'),
('clinica', 'Clínica'),
# 'agenda' (bloqueos y lista de espera) y 'paquetes' se escriben desde
# los endpoints del frontend; al no estar en la lista, Odoo rechazaba el
# create y el _auditar() lo silenciaba, perdiendo esos registros.
('agenda', 'Agenda'),
('paquetes', 'Paquetes'),
('monedero', 'Monedero'),
], string='Módulo', required=True, index=True)
entity = fields.Char(string='Entidad', index=True,

View File

@@ -58,6 +58,7 @@ DEFAULT_MENU_ROLES = {
'/consultas': ['admin', 'recepcion', 'medico', 'gerencia'],
'/medicos': ['admin', 'recepcion', 'gerencia'],
'/servicios': ['admin', 'recepcion', 'medico', 'lectura', 'gerencia'],
'/paquetes': ['admin', 'recepcion', 'medico', 'lectura', 'gerencia'],
'/productos': ['admin', 'recepcion', 'lectura', 'gerencia'],
'/ventas': ['admin', 'recepcion', 'gerencia'],
'/tickets': ['admin', 'gerencia'],
@@ -115,12 +116,20 @@ def default_menu_roles():
def get_menu_roles(env):
"""Matriz vigente: la guardada en ir.config_parameter o los defaults."""
"""Matriz vigente: la guardada en ir.config_parameter o los defaults.
La matriz guardada manda sobre los defaults, PERO se completan en memoria
las rutas que no existan en ella. Sin eso, una ruta nueva (p. ej.
'/paquetes') no aparecería en la matriz guardada y el frontend la trataría
como "visible para todos" por no matchear ninguna llave.
"""
raw = env['ir.config_parameter'].sudo().get_param(MENU_ROLES_KEY)
if raw:
try:
val = json.loads(raw)
if isinstance(val, dict):
for href, roles in default_menu_roles().items():
val.setdefault(href, roles)
return val
except Exception:
pass