bloque 3: lealtad configurable (motor de puntos con multiplicadores por servicio/categoria/sucursal/dia/nivel, niveles VIP con descuento y factor, expiracion, ajuste manual con motivo y reportes) + propinas en punto de venta con reparto entre el personal, desglose en ticket y corte, y opcion en la liquidacion de comisiones; modo clasico y propinas apagadas preservan el comportamiento actual

This commit is contained in:
2026-10-12 01:37:07 +00:00
parent 1bd4cad6a0
commit 705b5a2842
28 changed files with 2748 additions and 59 deletions

View File

@@ -409,6 +409,90 @@ plano por empleado). Al encenderlo se usa el motor de reglas.
(sí), `permitir_ajustes_manuales` (sí), `dia_corte` (1), `redondeo` (2
decimales), `pago_minimo` (0).
## 13 bis. Lealtad (monedero, puntos y niveles VIP)
**Configuración → Lealtad**, la tarjeta del paciente (saldo, nivel e historial de
movimientos), la cuenta del cliente y **Reportes → Puntos**.
El programa de puntos vive en el bloque `lealtad` de los ajustes de la clínica.
Trae dos modos:
- **Clásico** (por defecto): reproduce exactamente el comportamiento de siempre
— 1 punto por cada $10 pagados, 1 punto = $1, canje máximo del 50 % y sin
niveles ni multiplicadores. Con este modo ninguna cifra cambia.
- **Configurable**: usa los valores del bloque.
**Opciones del bloque** (defaults entre paréntesis):
- `activo` (sí), `modo` (`clasico` | `configurable`).
- `puntos_por_unidad` (0.1 = 1 punto por cada $10), `valor_punto` (1.0 pesos por
punto al canjear), `moneda_base` (`MXN`).
- `canje_max_pct` (50): tope de canje sobre el total de la venta.
- `expira_meses` (0 = no expiran): con un valor mayor, el proceso diario vence
los puntos **no usados más antiguos** que ese plazo y lo registra en la
bitácora. En modo clásico nunca se expira nada.
- `acumular_sobre` (`pagado` | `vendido`), `aplicar_a`
(`servicios`, `productos`, `membresias`, `paquetes`), `redondear`
(`abajo` | `normal`).
**Multiplicadores** (`multiplicadores[]`): reglas con `alcance` (servicio,
artículo, categoría de servicio, nivel, sucursal, rango de fechas o día de la
semana), `valor`, `factor` (2 = doble de puntos; < 1 reduce) y vigencia
(`fecha_desde`/`fecha_hasta`). **Precedencia**: gana la más específica
(servicio > artículo > categoría > nivel > sucursal > rango de fechas > día de
la semana) y, a igual especificidad, la última de la lista.
**Niveles VIP** (`niveles[]`): cada nivel define `codigo`, `nombre`,
`gasto_minimo`, `ventana_meses` (por defecto 12), `descuento_pct` (se aplica
automáticamente en el punto de venta), `factor_puntos`, `vigencia_meses` y
`color`. El nivel se recalcula al cerrar cada venta y por el proceso diario.
Un cliente **no baja de inmediato**: el nivel conserva su vigencia y solo al
vencer se recalcula a la baja. La **asignación manual** (con fecha de revisión)
no se sobreescribe sola hasta esa fecha.
**Puntos a mano y auditoría** (`lealtad.ajustar`): el endpoint
`POST /patients/<id>/points/adjust` exige motivo y deja el movimiento en el
historial y en la auditoría. El nivel se puede recalcular
(`POST /patients/<id>/lealtad/recalcular`) o asignar a mano
(`PUT /patients/<id>/lealtad`).
**Reportes** (`Reportes → Puntos`): puntos otorgados, canjeados y expirados del
periodo, el pasivo en pesos (puntos activos × valor del punto) y los clientes
por nivel. En modo clásico coincide con las cifras de siempre.
**Permisos**: `lealtad.ver` (todos), `lealtad.ajustar`, `lealtad.nivel` y
`lealtad.configurar` (admin y gerencia).
## 13 ter. Propinas en el punto de venta
**Configuración → Propinas**, el cobro del punto de venta y
**Reportes → Propinas** (exportable a Excel).
Con el módulo **apagado** (por defecto) el punto de venta se ve y se comporta
exactamente igual que hoy: no aparece ningún campo de propina y el total no
cambia. Al encenderlo, al cobrar se puede agregar una propina por porcentaje
sugerido o por monto libre.
- La propina se guarda **aparte** del importe de los servicios (campos `propina`
y `propina_moneda` de la venta): el **total cobrado al cliente sí la incluye**
y se muestra desglosada en el ticket y en el corte de caja, pero las cifras de
servicios no cambian y los puntos se acumulan solo por los servicios.
- **Reparto** (`ninguno` | `equitativo` | `por_participacion`): al cobrar, la
propina se reparte entre el personal que atendió y quien cobró; el detalle
(quién, cuánto y de qué venta) queda en `skeen.propina.reparto` y en el
reporte por empleado.
- `incluir_en_liquidacion_comisiones` (no): al encenderlo, la propina del
empleado se suma a su liquidación de comisiones **sin alterar** el cálculo de
la comisión; apagado, no cambia ninguna liquidación.
**Opciones del bloque** (defaults): `activo` (no), `sugerencias_pct`
([10, 15, 20]), `permitir_personalizado` (sí), `permitir_sin_propina` (sí),
`reparto` (`ninguno`), `incluir_en_liquidacion_comisiones` (no) y
`cuenta_propinas` (etiqueta de la cuenta destino).
**Permisos**: `propinas.ver` (todos), `propinas.aplicar` (recepción y demás roles
de cobro) y `propinas.configurar` (admin y gerencia).
## 14. Formularios y consentimientos
**Configuración → Formularios** y, en el expediente del paciente, la pestaña de

View File

@@ -0,0 +1,378 @@
import type { FC } from 'react';
import { useEffect, useState } from 'react';
import { Save, Sparkles, Plus, Trash2, RefreshCw } from 'lucide-react';
import { Card, Button, Input, toast } from './ui';
import {
odooApi, LEALTAD_DEFAULT, type ClinicSettings, type LealtadSettings,
type NivelLealtad, type MultiplicadorLealtad, type LealtadAlcance,
type Service, type ServiceCategory, type Sucursal, type InventoryItem, type ReporteLealtad,
} from '../services/odoo';
interface Props {
settings: ClinicSettings;
onSaved: (settings: ClinicSettings) => void;
}
const Toggle: FC<{ label: string; checked: boolean; onChange: (v: boolean) => void; ayuda?: string }> = ({ label, checked, onChange, ayuda }) => (
<label className="flex items-start gap-3 cursor-pointer">
<button
type="button"
role="switch"
aria-checked={checked}
onClick={() => onChange(!checked)}
className={`mt-0.5 relative h-6 w-11 shrink-0 rounded-full transition-colors ${checked ? 'bg-theme-accent' : 'bg-theme-border-strong'}`}
>
<span className={`absolute top-0.5 h-5 w-5 rounded-full bg-white transition-transform ${checked ? 'translate-x-5' : 'translate-x-0.5'}`} />
</button>
<span>
<span className="block text-sm font-medium text-theme-heading">{label}</span>
{ayuda && <span className="block text-xs text-theme-muted">{ayuda}</span>}
</span>
</label>
);
const ALCANCES: { value: LealtadAlcance; label: string }[] = [
{ value: 'servicio', label: 'Servicio' },
{ value: 'articulo', label: 'Artículo' },
{ value: 'categoria', label: 'Categoría de servicio' },
{ value: 'nivel', label: 'Nivel del cliente' },
{ value: 'sucursal', label: 'Sucursal' },
{ value: 'rango_fechas', label: 'Rango de fechas' },
{ value: 'dia_semana', label: 'Día de la semana' },
];
const DIAS = [
{ v: '0', l: 'Lun' }, { v: '1', l: 'Mar' }, { v: '2', l: 'Mié' }, { v: '3', l: 'Jue' },
{ v: '4', l: 'Vie' }, { v: '5', l: 'Sáb' }, { v: '6', l: 'Dom' },
];
const APLICAR: { value: 'servicios' | 'productos' | 'membresias' | 'paquetes'; label: string }[] = [
{ value: 'servicios', label: 'Servicios' },
{ value: 'productos', label: 'Productos' },
{ value: 'membresias', label: 'Membresías' },
{ value: 'paquetes', label: 'Paquetes' },
];
const nuevoNivel = (): NivelLealtad => ({
codigo: '', nombre: '', gasto_minimo: 0, ventana_meses: 12,
descuento_pct: 0, factor_puntos: 1, vigencia_meses: 12, color: '',
});
const nuevoMultiplicador = (): MultiplicadorLealtad => ({
nombre: '', alcance: 'servicio', valor: '', factor: 2,
fecha_desde: '', fecha_hasta: '', activo: true,
});
// Configuración → Lealtad: bloque `lealtad`. Con `modo='clasico'` (default)
// nada cambia (1 punto por cada $10, 1 punto = $1, canje al 50%).
const LealtadPanel: FC<Props> = ({ settings, onSaved }) => {
const [form, setForm] = useState<LealtadSettings>(settings.lealtad || LEALTAD_DEFAULT);
const [saving, setSaving] = useState(false);
const [services, setServices] = useState<Service[]>([]);
const [items, setItems] = useState<InventoryItem[]>([]);
const [categorias, setCategorias] = useState<ServiceCategory[]>([]);
const [sucursales, setSucursales] = useState<Sucursal[]>([]);
const [reporte, setReporte] = useState<ReporteLealtad | null>(null);
useEffect(() => {
setForm(settings.lealtad || LEALTAD_DEFAULT);
}, [settings.lealtad]);
useEffect(() => {
odooApi.getServices({ page_size: 200 }).then((r) => setServices(r.services || [])).catch(() => {});
odooApi.getInventarioItems().then((r) => setItems(r.items || [])).catch(() => {});
odooApi.getServiceCategories().then((r) => setCategorias(r.categories || [])).catch(() => {});
odooApi.getSucursales().then((r) => setSucursales(r.sucursales || [])).catch(() => {});
}, []);
const cargarReporte = () => {
odooApi.getReportLealtad().then(setReporte).catch(() => {});
};
useEffect(() => { cargarReporte(); }, []);
const set = <K extends keyof LealtadSettings>(k: K, v: LealtadSettings[K]) =>
setForm((prev) => ({ ...prev, [k]: v }));
const setNivel = (i: number, patch: Partial<NivelLealtad>) =>
setForm((prev) => ({ ...prev, niveles: prev.niveles.map((n, j) => (j === i ? { ...n, ...patch } : n)) }));
const addNivel = () => setForm((prev) => ({ ...prev, niveles: [...prev.niveles, nuevoNivel()] }));
const delNivel = (i: number) => setForm((prev) => ({ ...prev, niveles: prev.niveles.filter((_, j) => j !== i) }));
const setMul = (i: number, patch: Partial<MultiplicadorLealtad>) =>
setForm((prev) => ({ ...prev, multiplicadores: prev.multiplicadores.map((m, j) => (j === i ? { ...m, ...patch } : m)) }));
const addMul = () => setForm((prev) => ({ ...prev, multiplicadores: [...prev.multiplicadores, nuevoMultiplicador()] }));
const delMul = (i: number) => setForm((prev) => ({ ...prev, multiplicadores: prev.multiplicadores.filter((_, j) => j !== i) }));
const guardar = async () => {
setSaving(true);
try {
const res = await odooApi.putClinicSettings({ lealtad: form, motivo: 'Configuración de lealtad' });
if (res.status === 'success') {
onSaved(res.settings);
toast.success('Programa de lealtad guardado');
cargarReporte();
}
} catch (err) {
const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message;
toast.error(msg || 'No se pudo guardar la lealtad');
} finally {
setSaving(false);
}
};
const clasico = form.modo !== 'configurable';
return (
<div className="space-y-6">
<div className="flex items-start justify-between gap-3">
<div>
<h3 className="font-heading text-lg text-theme-heading flex items-center gap-2">
<Sparkles size={18} /> Lealtad y niveles VIP
</h3>
<p className="text-sm text-theme-muted mt-1">
En modo clásico nada cambia: 1 punto por cada $10, 1 punto = $1 y canje máximo del 50%.
El modo configurable usa los valores de abajo.
</p>
</div>
<Button onClick={guardar} loading={saving}><Save size={16} className="mr-1.5" />Guardar</Button>
</div>
<div className="space-y-4">
<Toggle label="Programa de lealtad activo" checked={form.activo}
onChange={(v) => set('activo', v)}
ayuda="Apagado, no se acumulan puntos por compra." />
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 max-w-xl">
<div>
<label className="block text-sm font-medium text-theme-heading mb-1.5">Modo</label>
<select
value={form.modo}
onChange={(e) => set('modo', e.target.value as LealtadSettings['modo'])}
className="w-full rounded-lg border border-theme-border-strong bg-theme-surface px-3 py-2 text-sm text-theme-heading"
>
<option value="clasico">Clásico (comportamiento actual)</option>
<option value="configurable">Configurable</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-theme-heading mb-1.5">Redondeo de puntos</label>
<select
value={form.redondear}
onChange={(e) => set('redondear', e.target.value as LealtadSettings['redondear'])}
className="w-full rounded-lg border border-theme-border-strong bg-theme-surface px-3 py-2 text-sm text-theme-heading"
disabled={clasico}
>
<option value="abajo">Hacia abajo (truncar)</option>
<option value="normal">Normal (redondear)</option>
</select>
</div>
</div>
{clasico ? (
<div className="rounded-xl border border-theme-border bg-theme-bg p-3 text-sm text-theme-muted">
Modo clásico: 1 punto por cada $10 pagados, 1 punto = $1, canje máximo del 50% del total,
sin niveles ni multiplicadores. Es idéntico al comportamiento de hoy.
</div>
) : (
<>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
<Input label="Puntos por peso" type="number" step="0.01"
value={String(form.puntos_por_unidad)}
onChange={(e) => set('puntos_por_unidad', Number(e.target.value) || 0)} />
<Input label="Valor del punto ($)" type="number" step="0.01"
value={String(form.valor_punto)}
onChange={(e) => set('valor_punto', Number(e.target.value) || 0)} />
<Input label="Canje máximo (%)" type="number" step="1" min={0} max={100}
value={String(form.canje_max_pct)}
onChange={(e) => set('canje_max_pct', Number(e.target.value) || 0)} />
</div>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
<Input label="Expiración (meses, 0 = nunca)" type="number" min={0}
value={String(form.expira_meses)}
onChange={(e) => set('expira_meses', Math.max(0, Math.floor(Number(e.target.value) || 0)))} />
<div>
<label className="block text-sm font-medium text-theme-heading mb-1.5">Acumular sobre</label>
<select value={form.acumular_sobre}
onChange={(e) => set('acumular_sobre', e.target.value as LealtadSettings['acumular_sobre'])}
className="w-full rounded-lg border border-theme-border-strong bg-theme-surface px-3 py-2 text-sm text-theme-heading">
<option value="pagado">Lo pagado</option>
<option value="vendido">Lo vendido</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-theme-heading mb-1.5">Moneda base</label>
<select value={form.moneda_base}
onChange={(e) => set('moneda_base', e.target.value)}
className="w-full rounded-lg border border-theme-border-strong bg-theme-surface px-3 py-2 text-sm text-theme-heading">
<option value="MXN">MXN</option>
<option value="USD">USD</option>
</select>
</div>
</div>
<div>
<p className="text-sm font-medium text-theme-heading mb-1.5">Aplica a</p>
<div className="flex flex-wrap gap-2">
{APLICAR.map((a) => {
const on = form.aplicar_a.includes(a.value);
return (
<button key={a.value} type="button"
onClick={() => set('aplicar_a', on ? form.aplicar_a.filter((x) => x !== a.value) : [...form.aplicar_a, a.value])}
className={`px-3 py-1.5 text-sm rounded-full border ${on ? 'bg-theme-accent text-theme-inverse border-theme-accent' : 'bg-theme-accent-bg text-theme-heading border-theme-border'}`}>
{a.label}
</button>
);
})}
</div>
</div>
</>
)}
</div>
{/* Niveles VIP */}
<Card>
<Card.Header>
<div className="flex items-center justify-between">
<p className="font-medium text-theme-heading">Niveles VIP</p>
<Button size="sm" variant="outline" onClick={addNivel} disabled={clasico}><Plus size={14} className="mr-1" />Nivel</Button>
</div>
</Card.Header>
<Card.Body>
{clasico ? (
<p className="text-sm text-theme-muted">Cambia a modo configurable para definir niveles.</p>
) : form.niveles.length === 0 ? (
<p className="text-sm text-theme-muted">Sin niveles. Un cliente sin nivel no tiene descuento ni factor extra.</p>
) : (
<div className="space-y-3">
{form.niveles.map((n, i) => (
<div key={i} className="grid grid-cols-2 sm:grid-cols-7 gap-2 items-end border border-theme-border rounded-xl p-3">
<Input label="Código" value={n.codigo} onChange={(e) => setNivel(i, { codigo: e.target.value })} />
<Input label="Nombre" value={n.nombre} onChange={(e) => setNivel(i, { nombre: e.target.value })} />
<Input label="Gasto mínimo" type="number" value={String(n.gasto_minimo)}
onChange={(e) => setNivel(i, { gasto_minimo: Number(e.target.value) || 0 })} />
<Input label="Ventana (meses)" type="number" value={String(n.ventana_meses)}
onChange={(e) => setNivel(i, { ventana_meses: Math.max(1, Number(e.target.value) || 12) })} />
<Input label="Descuento %" type="number" value={String(n.descuento_pct)}
onChange={(e) => setNivel(i, { descuento_pct: Number(e.target.value) || 0 })} />
<Input label="Factor puntos" type="number" step="0.1" value={String(n.factor_puntos)}
onChange={(e) => setNivel(i, { factor_puntos: Number(e.target.value) || 1 })} />
<div className="flex items-end gap-2">
<Input label="Vigencia (meses)" type="number" value={String(n.vigencia_meses)}
onChange={(e) => setNivel(i, { vigencia_meses: Math.max(0, Number(e.target.value) || 0) })} />
<Button size="sm" variant="ghost" onClick={() => delNivel(i)} title="Quitar"><Trash2 size={14} /></Button>
</div>
</div>
))}
</div>
)}
</Card.Body>
</Card>
{/* Multiplicadores */}
<Card>
<Card.Header>
<div className="flex items-center justify-between">
<p className="font-medium text-theme-heading">Multiplicadores de puntos</p>
<Button size="sm" variant="outline" onClick={addMul} disabled={clasico}><Plus size={14} className="mr-1" />Regla</Button>
</div>
</Card.Header>
<Card.Body>
{clasico ? (
<p className="text-sm text-theme-muted">Cambia a modo configurable para definir multiplicadores.</p>
) : form.multiplicadores.length === 0 ? (
<p className="text-sm text-theme-muted">Sin reglas. Gana la más específica: servicio &gt; artículo &gt; categoría &gt; nivel &gt; sucursal &gt; rango de fechas &gt; día.</p>
) : (
<div className="space-y-3">
{form.multiplicadores.map((m, i) => (
<div key={i} className="grid grid-cols-2 sm:grid-cols-6 gap-2 items-end border border-theme-border rounded-xl p-3">
<Input label="Nombre" value={m.nombre} onChange={(e) => setMul(i, { nombre: e.target.value })} />
<div>
<label className="block text-xs font-medium text-theme-muted mb-1">Alcance</label>
<select value={m.alcance} onChange={(e) => setMul(i, { alcance: e.target.value as LealtadAlcance, valor: '' })}
className="w-full rounded-lg border border-theme-border-strong bg-theme-surface px-2 py-2 text-sm text-theme-heading">
{ALCANCES.map((a) => <option key={a.value} value={a.value}>{a.label}</option>)}
</select>
</div>
<div>
<label className="block text-xs font-medium text-theme-muted mb-1">Valor</label>
{m.alcance === 'servicio' ? (
<select value={m.valor} onChange={(e) => setMul(i, { valor: e.target.value })}
className="w-full rounded-lg border border-theme-border-strong bg-theme-surface px-2 py-2 text-sm text-theme-heading">
<option value="">—</option>
{services.map((s) => <option key={s.id} value={String(s.id)}>{s.name}</option>)}
</select>
) : m.alcance === 'articulo' ? (
<select value={m.valor} onChange={(e) => setMul(i, { valor: e.target.value })}
className="w-full rounded-lg border border-theme-border-strong bg-theme-surface px-2 py-2 text-sm text-theme-heading">
<option value="">—</option>
{items.map((s) => <option key={s.id} value={String(s.id)}>{s.name}</option>)}
</select>
) : m.alcance === 'categoria' ? (
<select value={m.valor} onChange={(e) => setMul(i, { valor: e.target.value })}
className="w-full rounded-lg border border-theme-border-strong bg-theme-surface px-2 py-2 text-sm text-theme-heading">
<option value="">—</option>
{categorias.map((c) => <option key={c.code} value={c.code}>{c.name}</option>)}
</select>
) : m.alcance === 'nivel' ? (
<select value={m.valor} onChange={(e) => setMul(i, { valor: e.target.value })}
className="w-full rounded-lg border border-theme-border-strong bg-theme-surface px-2 py-2 text-sm text-theme-heading">
<option value="">—</option>
{form.niveles.map((n) => <option key={n.codigo} value={n.codigo}>{n.nombre || n.codigo}</option>)}
</select>
) : m.alcance === 'sucursal' ? (
<select value={m.valor} onChange={(e) => setMul(i, { valor: e.target.value })}
className="w-full rounded-lg border border-theme-border-strong bg-theme-surface px-2 py-2 text-sm text-theme-heading">
<option value="">—</option>
{sucursales.map((s) => <option key={s.code} value={s.code}>{s.name}</option>)}
</select>
) : m.alcance === 'dia_semana' ? (
<Input value={m.valor} onChange={(e) => setMul(i, { valor: e.target.value })}
placeholder={DIAS.map((d) => d.v).join(',')} />
) : (
<Input value={m.valor} onChange={(e) => setMul(i, { valor: e.target.value })} placeholder="—" />
)}
</div>
<Input label="Factor" type="number" step="0.1" value={String(m.factor)}
onChange={(e) => setMul(i, { factor: Number(e.target.value) || 1 })} />
<div className="grid grid-cols-2 gap-1">
<Input label="Desde" type="date" value={m.fecha_desde}
onChange={(e) => setMul(i, { fecha_desde: e.target.value })} />
<Input label="Hasta" type="date" value={m.fecha_hasta}
onChange={(e) => setMul(i, { fecha_hasta: e.target.value })} />
</div>
<div className="flex items-end gap-2">
<Toggle label="Activa" checked={m.activo} onChange={(v) => setMul(i, { activo: v })} />
<Button size="sm" variant="ghost" onClick={() => delMul(i)} title="Quitar"><Trash2 size={14} /></Button>
</div>
</div>
))}
</div>
)}
</Card.Body>
</Card>
{/* Resumen del reporte de lealtad */}
<Card>
<Card.Header>
<div className="flex items-center justify-between">
<p className="font-medium text-theme-heading">Resumen de puntos</p>
<Button size="sm" variant="ghost" onClick={cargarReporte}><RefreshCw size={14} className="mr-1" />Actualizar</Button>
</div>
</Card.Header>
<Card.Body>
{reporte ? (
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3 text-sm">
<div><p className="text-theme-muted">Otorgados</p><p className="font-heading text-lg">{reporte.otorgados}</p></div>
<div><p className="text-theme-muted">Canjeados</p><p className="font-heading text-lg">{reporte.canjeados}</p></div>
<div><p className="text-theme-muted">Expirados</p><p className="font-heading text-lg">{reporte.expirados}</p></div>
<div><p className="text-theme-muted">Pasivo (pesos)</p><p className="font-heading text-lg">${reporte.pasivo_mxn.toFixed(2)}</p></div>
</div>
) : (
<p className="text-sm text-theme-muted">Sin datos.</p>
)}
</Card.Body>
</Card>
</div>
);
};
export default LealtadPanel;

View File

@@ -3,7 +3,7 @@ import { createContext, useCallback, useContext, useEffect, useMemo, useState }
import { useNavigate } from 'react-router-dom';
import {
X, Star, AlertTriangle, Phone, MessageCircle, Mail, Wallet, Stethoscope, ArrowRight,
Calendar, ClipboardList, ShoppingBag, CreditCard, History, Plus, Pencil,
Calendar, ClipboardList, ShoppingBag, CreditCard, History, Plus, Pencil, Sparkles,
} from 'lucide-react';
import { odooApi, type Patient, type Visita, type EstadoCuenta, type PatientWallet, type ReceiptData, type Appointment, type Sale, type Payment } from '../services/odoo';
import { Badge, Button, Skeleton } from './ui';
@@ -357,6 +357,12 @@ const PatientDrawerPanel: FC<{ patientId: number; onClose: () => void }> = ({ pa
<div className="p-3 bg-theme-bg rounded-xl">
<p className="text-xs text-theme-muted mb-1 flex items-center gap-1"><Wallet size={11} /> Monedero</p>
<p className="text-sm font-semibold text-theme-heading">{cuenta?.puntos_monedero ?? 0} pts</p>
{wallet?.nivel?.nombre && (
<span className="mt-1 inline-flex items-center gap-1 rounded-full bg-theme-accent-bg px-2 py-0.5 text-[11px] font-medium text-theme-heading">
<Sparkles size={10} /> {wallet.nivel.nombre}
{wallet.nivel.descuento_pct > 0 ? ` · ${wallet.nivel.descuento_pct}%` : ''}
</span>
)}
</div>
<div className="p-3 bg-theme-bg rounded-xl">
<p className="text-xs text-theme-muted mb-1">Saldo pendiente</p>

View File

@@ -0,0 +1,160 @@
import type { FC } from 'react';
import { useEffect, useState } from 'react';
import { Save, HandCoins, RefreshCw } from 'lucide-react';
import { Card, Button, Input, toast } from './ui';
import {
odooApi, PROPINAS_DEFAULT, type ClinicSettings, type PropinasSettings,
type ReportePropinas,
} from '../services/odoo';
interface Props {
settings: ClinicSettings;
onSaved: (settings: ClinicSettings) => void;
}
const Toggle: FC<{ label: string; checked: boolean; onChange: (v: boolean) => void; ayuda?: string }> = ({ label, checked, onChange, ayuda }) => (
<label className="flex items-start gap-3 cursor-pointer">
<button
type="button"
role="switch"
aria-checked={checked}
onClick={() => onChange(!checked)}
className={`mt-0.5 relative h-6 w-11 shrink-0 rounded-full transition-colors ${checked ? 'bg-theme-accent' : 'bg-theme-border-strong'}`}
>
<span className={`absolute top-0.5 h-5 w-5 rounded-full bg-white transition-transform ${checked ? 'translate-x-5' : 'translate-x-0.5'}`} />
</button>
<span>
<span className="block text-sm font-medium text-theme-heading">{label}</span>
{ayuda && <span className="block text-xs text-theme-muted">{ayuda}</span>}
</span>
</label>
);
// Configuración → Propinas: bloque `propinas`. Con `activo=false` (default) el
// punto de venta se ve y se comporta exactamente igual que hoy.
const PropinasPanel: FC<Props> = ({ settings, onSaved }) => {
const [form, setForm] = useState<PropinasSettings>(settings.propinas || PROPINAS_DEFAULT);
const [sugerencias, setSugerencias] = useState((settings.propinas || PROPINAS_DEFAULT).sugerencias_pct.join(', '));
const [saving, setSaving] = useState(false);
const [reporte, setReporte] = useState<ReportePropinas | null>(null);
useEffect(() => {
const p = settings.propinas || PROPINAS_DEFAULT;
setForm(p);
setSugerencias(p.sugerencias_pct.join(', '));
}, [settings.propinas]);
const cargarReporte = () => { odooApi.getReportPropinas().then(setReporte).catch(() => {}); };
useEffect(() => { cargarReporte(); }, []);
const set = <K extends keyof PropinasSettings>(k: K, v: PropinasSettings[K]) =>
setForm((prev) => ({ ...prev, [k]: v }));
const guardar = async () => {
setSaving(true);
try {
const pct = sugerencias.split(',').map((v) => parseFloat(v.trim())).filter((v) => !Number.isNaN(v) && v >= 0);
const res = await odooApi.putClinicSettings({
propinas: { ...form, sugerencias_pct: pct.length ? pct : [0] },
motivo: 'Configuración de propinas',
});
if (res.status === 'success') {
onSaved(res.settings);
toast.success('Configuración de propinas guardada');
cargarReporte();
}
} catch (err) {
const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message;
toast.error(msg || 'No se pudieron guardar las propinas');
} finally {
setSaving(false);
}
};
return (
<div className="space-y-6">
<div className="flex items-start justify-between gap-3">
<div>
<h3 className="font-heading text-lg text-theme-heading flex items-center gap-2">
<HandCoins size={18} /> Propinas en el punto de venta
</h3>
<p className="text-sm text-theme-muted mt-1">
Apagado (default) el cobro se ve exactamente igual que hoy: sin campos de propina y con
el mismo total. Encendido, la propina se registra aparte de los servicios.
</p>
</div>
<Button onClick={guardar} loading={saving}><Save size={16} className="mr-1.5" />Guardar</Button>
</div>
<div className="space-y-4">
<Toggle label="Activar propinas" checked={form.activo}
onChange={(v) => set('activo', v)}
ayuda="Muestra el campo de propina al cobrar en el punto de venta." />
<Toggle label="Permitir monto personalizado" checked={form.permitir_personalizado}
onChange={(v) => set('permitir_personalizado', v)}
ayuda="Además de los porcentajes sugeridos, permite capturar un monto libre." />
<Toggle label="Permitir cobrar sin propina" checked={form.permitir_sin_propina}
onChange={(v) => set('permitir_sin_propina', v)}
ayuda="Si se apaga, el cobro exige registrar propina." />
<Toggle label="Incluir la propina en la liquidación de comisiones" checked={form.incluir_en_liquidacion_comisiones}
onChange={(v) => set('incluir_en_liquidacion_comisiones', v)}
ayuda="La propina del empleado se suma a su liquidación. No altera el cálculo de la comisión." />
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
<Input label="Porcentajes sugeridos (%)" value={sugerencias}
onChange={(e) => setSugerencias(e.target.value)} placeholder="10, 15, 20" />
<div>
<label className="block text-sm font-medium text-theme-heading mb-1.5">Reparto entre el personal</label>
<select value={form.reparto} onChange={(e) => set('reparto', e.target.value as PropinasSettings['reparto'])}
className="w-full rounded-lg border border-theme-border-strong bg-theme-surface px-3 py-2 text-sm text-theme-heading">
<option value="ninguno">Sin reparto (queda en la venta)</option>
<option value="equitativo">Equitativo entre implicados</option>
<option value="por_participacion">Por participación en la venta</option>
</select>
</div>
<Input label="Cuenta destino de propinas" value={form.cuenta_propinas}
onChange={(e) => set('cuenta_propinas', e.target.value)} placeholder="Opcional" />
</div>
</div>
<Card>
<Card.Header>
<div className="flex items-center justify-between">
<p className="font-medium text-theme-heading">Propinas del mes por empleado</p>
<Button size="sm" variant="ghost" onClick={cargarReporte}><RefreshCw size={14} className="mr-1" />Actualizar</Button>
</div>
</Card.Header>
<Card.Body>
{!reporte ? (
<p className="text-sm text-theme-muted">Sin datos.</p>
) : reporte.por_empleado.length === 0 ? (
<p className="text-sm text-theme-muted">Sin propinas registradas en el periodo.</p>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="bg-theme-bg">
<tr>
<th className="text-left p-2 text-xs font-medium text-theme-muted uppercase">Empleado</th>
<th className="text-right p-2 text-xs font-medium text-theme-muted uppercase">Propinas</th>
<th className="text-right p-2 text-xs font-medium text-theme-muted uppercase">Monto</th>
</tr>
</thead>
<tbody className="divide-y">
{reporte.por_empleado.map((r, i) => (
<tr key={i}>
<td className="p-2 text-theme-heading">{r.empleado}</td>
<td className="p-2 text-right text-theme-muted">{r.propinas}</td>
<td className="p-2 text-right font-medium text-theme-heading">${r.monto.toFixed(2)}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</Card.Body>
</Card>
</div>
);
};
export default PropinasPanel;

View File

@@ -22,6 +22,10 @@ export interface TicketData {
/** Impuesto del ticket y su etiqueta (vacío cuando los impuestos están apagados) */
tax?: number;
tax_name?: string;
/** Descuento automático por nivel VIP (0 si no aplica). */
nivel_discount?: number;
/** Propina agregada al cobro (0 si el módulo está apagado). */
propina?: number;
total: number;
metodo?: string | null;
puntos_usados?: number;
@@ -176,9 +180,15 @@ export const TicketDoc: FC<TicketDocProps> = ({ data, config, preview }) => {
{on('puntos_canjeados') && (data.puntos_usados ?? 0) > 0 && (
<Fila izquierda="Puntos canjeados" derecha={`-${data.puntos_usados} pts`} />
)}
{(data.nivel_discount ?? 0) > 0 && (
<Fila izquierda="Descuento de nivel" derecha={`-${fmtMXN(data.nivel_discount!)}`} />
)}
{on('impuesto') && (data.tax ?? 0) > 0 && (
<Fila izquierda={data.tax_name || 'Impuesto'} derecha={fmtMXN(data.tax!)} />
)}
{(data.propina ?? 0) > 0 && (
<Fila izquierda="Propina" derecha={fmtMXN(data.propina!)} />
)}
{on('total') && <Fila izquierda="Total" derecha={fmtMXN(data.total)} fuerte />}
{on('total_usd') && data.rate_used ? (
<Fila

View File

@@ -0,0 +1,131 @@
import type { FC } from 'react';
import { useEffect, useState, useCallback } from 'react';
import { Sparkles } from 'lucide-react';
import { Card, EmptyState, Skeleton, toast } from '../ui';
import { ExportButton } from '../ui/ExportButton';
import { exportToExcel } from '../../lib/exporter';
import { odooApi, type ReporteLealtad } from '../../services/odoo';
import { useGlobalReportRange } from '../../lib/reportRange';
// Reporte de lealtad: puntos otorgados, canjeados y expirados en el periodo,
// más el pasivo en pesos (puntos activos × valor del punto) y el conteo por
// nivel. En modo clásico las cifras coinciden con las de siempre (valor = $1).
const LealtadReport: FC = () => {
const { start, end } = useGlobalReportRange();
const [data, setData] = useState<ReporteLealtad | null>(null);
const [loading, setLoading] = useState(true);
const load = useCallback(async () => {
try {
setLoading(true);
const res = await odooApi.getReportLealtad(start, end);
if (res.status === 'success') setData(res);
} catch (err) {
toast.error('Error al cargar el reporte de lealtad');
console.error(err);
} finally {
setLoading(false);
}
}, [start, end]);
useEffect(() => { load(); }, [load]);
const [exporting, setExporting] = useState(false);
const exportar = async () => {
if (!data) return;
try {
setExporting(true);
await exportToExcel({
filename: `lealtad-${start}_${end}`,
sheetName: 'Lealtad',
title: 'Reporte de lealtad',
subtitle: `${start} a ${end}`,
columns: [
{ header: 'Concepto', key: 'concepto' },
{ header: 'Valor', key: 'valor', format: 'number' },
],
rows: [
{ concepto: 'Puntos otorgados', valor: data.otorgados },
{ concepto: 'Puntos canjeados', valor: data.canjeados },
{ concepto: 'Puntos expirados', valor: data.expirados },
{ concepto: 'Ajustes manuales', valor: data.ajustes },
{ concepto: 'Depósitos (saldo a favor)', valor: data.depositos },
{ concepto: 'Puntos activos', valor: data.puntos_activos },
{ concepto: 'Pasivo en pesos', valor: data.pasivo_mxn },
],
});
toast.success('Excel descargado');
} catch (err) {
toast.error('Error al exportar');
console.error(err);
} finally {
setExporting(false);
}
};
return (
<Card>
<Card.Body>
<div className="flex items-center justify-between mb-4">
<h3 className="font-heading text-xl text-theme-heading flex items-center gap-2">
<Sparkles size={18} /> Lealtad y puntos
</h3>
<div className="flex items-center gap-3">
<p className="text-sm text-theme-muted">{start} a {end}</p>
<ExportButton onClick={exportar} loading={exporting} />
</div>
</div>
{loading ? (
<Skeleton count={4} className="h-20 w-full" />
) : !data ? (
<EmptyState title="Sin datos" subtitle="No se pudo cargar el reporte." icon={<Sparkles size={28} />} />
) : (
<>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4 mb-6">
<Card className="p-4 bg-theme-bg border-0">
<p className="text-xs text-theme-muted">Otorgados</p>
<p className="text-xl font-heading font-semibold text-theme-heading">{data.otorgados}</p>
</Card>
<Card className="p-4 bg-theme-bg border-0">
<p className="text-xs text-theme-muted">Canjeados</p>
<p className="text-xl font-heading font-semibold text-theme-heading">{data.canjeados}</p>
</Card>
<Card className="p-4 bg-theme-bg border-0">
<p className="text-xs text-theme-muted">Expirados</p>
<p className="text-xl font-heading font-semibold text-theme-heading">{data.expirados}</p>
</Card>
<Card className="p-4 bg-theme-bg border-0">
<p className="text-xs text-theme-muted">Pasivo (pesos)</p>
<p className="text-xl font-heading font-semibold text-theme-heading">${data.pasivo_mxn.toFixed(2)}</p>
</Card>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-6">
<div>
<p className="text-sm font-medium text-theme-heading mb-2">Saldo activo</p>
<p className="text-sm text-theme-muted">{data.puntos_activos} puntos en {data.cuentas} cuentas · valor del punto ${data.valor_punto.toFixed(2)}</p>
</div>
<div>
<p className="text-sm font-medium text-theme-heading mb-2">Clientes por nivel</p>
{data.niveles.length === 0 ? (
<p className="text-sm text-theme-muted">Sin niveles configurados.</p>
) : (
<ul className="text-sm text-theme-muted space-y-1">
{data.niveles.map((n) => (
<li key={n.codigo} className="flex justify-between">
<span>{n.nombre || n.codigo}</span>
<span className="font-medium text-theme-heading">{data.conteo_niveles[n.codigo] || 0}</span>
</li>
))}
</ul>
)}
</div>
</div>
</>
)}
</Card.Body>
</Card>
);
};
export default LealtadReport;

View File

@@ -0,0 +1,156 @@
import type { FC } from 'react';
import { useEffect, useState, useCallback } from 'react';
import { HandCoins } from 'lucide-react';
import { Card, EmptyState, Skeleton, toast } from '../ui';
import { ExportButton } from '../ui/ExportButton';
import { exportToExcel } from '../../lib/exporter';
import { odooApi, type ReportePropinas } from '../../services/odoo';
import { useGlobalReportRange } from '../../lib/reportRange';
// Reporte de propinas: total cobrado de propina, reparto por empleado y el
// detalle (quién, cuánto y de qué venta). Exportable a Excel.
const PropinasReport: FC = () => {
const { start, end } = useGlobalReportRange();
const [data, setData] = useState<ReportePropinas | null>(null);
const [loading, setLoading] = useState(true);
const load = useCallback(async () => {
try {
setLoading(true);
const res = await odooApi.getReportPropinas(start, end);
if (res.status === 'success') setData(res);
} catch (err) {
toast.error('Error al cargar el reporte de propinas');
console.error(err);
} finally {
setLoading(false);
}
}, [start, end]);
useEffect(() => { load(); }, [load]);
const [exporting, setExporting] = useState(false);
const exportar = async () => {
if (!data) return;
try {
setExporting(true);
await exportToExcel({
filename: `propinas-${start}_${end}`,
sheetName: 'Propinas',
title: 'Propinas por empleado',
subtitle: `${start} a ${end}`,
columns: [
{ header: 'Empleado', key: 'empleado' },
{ header: 'Propinas', key: 'propinas', format: 'number' },
{ header: 'Monto', key: 'monto', format: 'number' },
],
rows: data.por_empleado.map((r) => ({
empleado: r.empleado, propinas: r.propinas, monto: r.monto,
})),
totals: { empleado: 'Total', propinas: '', monto: data.total_repartido },
});
toast.success('Excel descargado');
} catch (err) {
toast.error('Error al exportar');
console.error(err);
} finally {
setExporting(false);
}
};
return (
<Card>
<Card.Body>
<div className="flex items-center justify-between mb-4">
<h3 className="font-heading text-xl text-theme-heading flex items-center gap-2">
<HandCoins size={18} /> Propinas por empleado
</h3>
<div className="flex items-center gap-3">
<p className="text-sm text-theme-muted">{start} a {end}</p>
<ExportButton onClick={exportar} loading={exporting} />
</div>
</div>
{loading ? (
<Skeleton count={5} className="h-12 w-full" />
) : !data ? (
<EmptyState title="Sin datos" subtitle="No se pudo cargar el reporte." icon={<HandCoins size={28} />} />
) : !data.activo ? (
<EmptyState title="Propinas apagadas" subtitle="Activa el módulo de propinas para comenzar a registrar." icon={<HandCoins size={28} />} />
) : (
<>
<div className="grid grid-cols-2 sm:grid-cols-3 gap-4 mb-6">
<Card className="p-4 bg-theme-bg border-0">
<p className="text-xs text-theme-muted">Propina total del periodo</p>
<p className="text-xl font-heading font-semibold text-theme-heading">${data.total.toFixed(2)}</p>
</Card>
<Card className="p-4 bg-theme-bg border-0">
<p className="text-xs text-theme-muted">Repartida</p>
<p className="text-xl font-heading font-semibold text-theme-heading">${data.total_repartido.toFixed(2)}</p>
</Card>
<Card className="p-4 bg-theme-bg border-0">
<p className="text-xs text-theme-muted">Ventas con propina</p>
<p className="text-xl font-heading font-semibold text-theme-heading">{data.total_ventas}</p>
</Card>
</div>
{data.por_empleado.length === 0 ? (
<EmptyState title="Sin reparto" subtitle="No hay propinas repartidas entre el personal en el periodo." icon={<HandCoins size={28} />} />
) : (
<div className="overflow-x-auto mb-6">
<table className="w-full text-sm">
<thead className="bg-theme-bg text-theme-muted text-xs uppercase">
<tr>
<th className="text-left px-4 py-3">Empleado</th>
<th className="text-right px-4 py-3">Propinas</th>
<th className="text-right px-4 py-3">Monto</th>
</tr>
</thead>
<tbody className="divide-y divide-theme-border">
{data.por_empleado.map((r, i) => (
<tr key={i} className="hover:bg-theme-bg">
<td className="px-4 py-3 font-medium text-theme-heading">{r.empleado}</td>
<td className="px-4 py-3 text-right text-theme-muted">{r.propinas}</td>
<td className="px-4 py-3 text-right font-medium text-theme-heading">${r.monto.toFixed(2)}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{data.detalle.length > 0 && (
<div className="overflow-x-auto">
<p className="text-sm font-medium text-theme-heading mb-2">Detalle</p>
<table className="w-full text-sm">
<thead className="bg-theme-bg text-theme-muted text-xs uppercase">
<tr>
<th className="text-left px-3 py-2">Fecha</th>
<th className="text-left px-3 py-2">Folio</th>
<th className="text-left px-3 py-2">Empleado</th>
<th className="text-left px-3 py-2">Rol</th>
<th className="text-right px-3 py-2">Monto</th>
</tr>
</thead>
<tbody className="divide-y divide-theme-border">
{data.detalle.map((r, i) => (
<tr key={i} className="hover:bg-theme-bg">
<td className="px-3 py-2 text-theme-muted">{r.date}</td>
<td className="px-3 py-2 text-theme-muted">{r.folio}</td>
<td className="px-3 py-2 text-theme-heading">{r.empleado || '—'}</td>
<td className="px-3 py-2 text-theme-muted">{r.rol}</td>
<td className="px-3 py-2 text-right text-theme-heading">${r.monto.toFixed(2)}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</>
)}
</Card.Body>
</Card>
);
};
export default PropinasReport;

View File

@@ -1,5 +1,5 @@
import { useEffect, useState } from 'react';
import { odooApi, type ClinicSettings, CLINIC_SETTINGS_DEFAULT, IMPUESTOS_DEFAULT, NOTIFICACIONES_DEFAULT, MEMBRESIAS_DEFAULT, COBRO_AUTOMATICO_DEFAULT, RESERVAS_ONLINE_DEFAULT, ANTICIPO_DEFAULT, RECURSOS_DEFAULT, COMPRAS_DEFAULT, COMISIONES_DEFAULT, LICENCIA_DEFAULT, LICENCIA_LIMITES_DEFAULT, PORTAL_CLIENTE_DEFAULT } from '../services/odoo';
import { odooApi, type ClinicSettings, CLINIC_SETTINGS_DEFAULT, IMPUESTOS_DEFAULT, NOTIFICACIONES_DEFAULT, MEMBRESIAS_DEFAULT, COBRO_AUTOMATICO_DEFAULT, RESERVAS_ONLINE_DEFAULT, ANTICIPO_DEFAULT, RECURSOS_DEFAULT, COMPRAS_DEFAULT, COMISIONES_DEFAULT, LEALTAD_DEFAULT, PROPINAS_DEFAULT, LICENCIA_DEFAULT, LICENCIA_LIMITES_DEFAULT, PORTAL_CLIENTE_DEFAULT } from '../services/odoo';
// Ajustes de la clínica compartidos por la sesión (nombre, moneda e impuestos).
// Se leen una sola vez del servidor y se avisa a quien los use cuando cambian.
@@ -23,6 +23,8 @@ const normalizar = (settings?: Partial<ClinicSettings> | null): ClinicSettings =
recursos: { ...RECURSOS_DEFAULT, ...(settings?.recursos || {}) },
compras: { ...COMPRAS_DEFAULT, ...(settings?.compras || {}) },
comisiones: { ...COMISIONES_DEFAULT, ...(settings?.comisiones || {}) },
lealtad: { ...LEALTAD_DEFAULT, ...(settings?.lealtad || {}) },
propinas: { ...PROPINAS_DEFAULT, ...(settings?.propinas || {}) },
licencia: {
...LICENCIA_DEFAULT,
...(settings?.licencia || {}),

View File

@@ -325,6 +325,8 @@ export const es: Dict = {
'config.tabs.membresias': 'Membresías',
'config.tabs.compras': 'Compras',
'config.tabs.comisiones': 'Comisiones',
'config.tabs.lealtad': 'Lealtad',
'config.tabs.propinas': 'Propinas',
'config.tabs.terminologia': 'Terminología',
'config.tabs.usuarios': 'Usuarios',
'config.tabs.roles': 'Roles',
@@ -422,6 +424,12 @@ export const es: Dict = {
'reports.tab.vendedores.desc': 'Ventas atribuidas por vendedor',
'reports.tab.kpis': 'KPIs',
'reports.tab.kpis.desc': 'No-show, ocupación y conversión',
'reports.family.lealtad': 'Lealtad',
'reports.family.lealtad.hint': 'Puntos y propinas',
'reports.tab.lealtad': 'Puntos',
'reports.tab.lealtad.desc': 'Puntos otorgados, canjeados, expirados y pasivo en pesos',
'reports.tab.propinas': 'Propinas',
'reports.tab.propinas.desc': 'Propinas por empleado y periodo',
'reports.tab.exportar': 'Exportar',
'reports.tab.exportar.desc': 'Exportar datos',
@@ -530,6 +538,9 @@ export const es: Dict = {
'pos.points': 'Puntos',
'pos.discount': 'Descuento',
'pos.taxIncluded': ' (incluido)',
'pos.tip': 'Propina',
'pos.noTip': 'Sin propina',
'pos.tipCustom': 'Propina (monto libre)',
'pos.chargeAmount': 'Cobrar {amount}',
'pos.chargeTitle': 'Cobrar (F9)',
'pos.noSellPerm': 'Sin permiso para vender',
@@ -1122,6 +1133,8 @@ export const en: Dict = {
'config.tabs.membresias': 'Memberships',
'config.tabs.compras': 'Purchases',
'config.tabs.comisiones': 'Commissions',
'config.tabs.lealtad': 'Loyalty',
'config.tabs.propinas': 'Tips',
'config.tabs.terminologia': 'Terminology',
'config.tabs.usuarios': 'Users',
'config.tabs.roles': 'Roles',
@@ -1219,6 +1232,12 @@ export const en: Dict = {
'reports.tab.vendedores.desc': 'Sales attributed per seller',
'reports.tab.kpis': 'KPIs',
'reports.tab.kpis.desc': 'No-show, occupancy and conversion',
'reports.family.lealtad': 'Loyalty',
'reports.family.lealtad.hint': 'Points and tips',
'reports.tab.lealtad': 'Points',
'reports.tab.lealtad.desc': 'Points earned, redeemed, expired and liability in pesos',
'reports.tab.propinas': 'Tips',
'reports.tab.propinas.desc': 'Tips per employee and period',
'reports.tab.exportar': 'Export',
'reports.tab.exportar.desc': 'Export data',
@@ -1324,6 +1343,9 @@ export const en: Dict = {
'pos.points': 'Points',
'pos.discount': 'Discount',
'pos.taxIncluded': ' (included)',
'pos.tip': 'Tip',
'pos.noTip': 'No tip',
'pos.tipCustom': 'Tip (custom amount)',
'pos.chargeAmount': 'Charge {amount}',
'pos.chargeTitle': 'Charge (F9)',
'pos.noSellPerm': 'No permission to sell',

View File

@@ -24,6 +24,8 @@ export const PERM_KEYS: string[] = [
'compras.ver', 'compras.crear', 'compras.recibir', 'compras.pagar',
'compras.editar', 'compras.cancelar', 'proveedores.ver', 'proveedores.editar',
'comisiones.ver', 'comisiones.editar', 'comisiones.liquidar',
'lealtad.ver', 'lealtad.ajustar', 'lealtad.nivel', 'lealtad.configurar',
'propinas.ver', 'propinas.aplicar', 'propinas.configurar',
'cobros.ver', 'cobros.administrar', 'cobros.forzar', 'cobros.cancelar',
'config.usuarios', 'config.roles', 'config.editar', 'config.auditoria',
'api.claves', 'webhooks.editar',
@@ -56,6 +58,8 @@ const READ_PERM_KEYS = new Set([
'proveedores.ver',
'comisiones.ver',
'cobros.ver',
'lealtad.ver',
'propinas.ver',
]);
const DEFAULT_PERM_ROLES: Record<string, string[]> = {
@@ -89,6 +93,8 @@ const DEFAULT_PERM_ROLES: Record<string, string[]> = {
'compras.editar': AG, 'compras.cancelar': AG,
'proveedores.ver': ALL, 'proveedores.editar': AG,
'comisiones.ver': ALL, 'comisiones.editar': AG, 'comisiones.liquidar': AG,
'lealtad.ver': ALL, 'lealtad.ajustar': AG, 'lealtad.nivel': AG, 'lealtad.configurar': AG,
'propinas.ver': ALL, 'propinas.aplicar': MONEY, 'propinas.configurar': AG,
'cobros.ver': ALL, 'cobros.administrar': AG,
'cobros.forzar': ['admin', 'recepcion', 'gerencia'], 'cobros.cancelar': AG,
'config.usuarios': AG, 'config.roles': AG, 'config.editar': AG, 'config.auditoria': AD,

View File

@@ -22,6 +22,8 @@ import FormulariosPanel from '../components/FormulariosPanel';
import MembresiasPanel from '../components/MembresiasPanel';
import ComprasPanel from '../components/ComprasPanel';
import ComisionesPanel from '../components/ComisionesPanel';
import LealtadPanel from '../components/LealtadPanel';
import PropinasPanel from '../components/PropinasPanel';
import LicenciaPanel from '../components/LicenciaPanel';
import ApiKeysPanel from '../components/ApiKeysPanel';
import WebhooksPanel from '../components/WebhooksPanel';
@@ -45,6 +47,8 @@ const TABS = [
{ key: 'membresias', label: 'Membresías' },
{ key: 'compras', label: 'Compras' },
{ key: 'comisiones', label: 'Comisiones' },
{ key: 'lealtad', label: 'Lealtad' },
{ key: 'propinas', label: 'Propinas' },
{ key: 'terminologia', label: 'Terminología' },
{ key: 'usuarios', label: 'Usuarios' },
{ key: 'roles', label: 'Roles' },
@@ -58,7 +62,7 @@ const TABS = [
] as const;
// Secciones que ve gerencia (admin del sistema ve todas).
const TABS_GERENCIA = ['reservas', 'portal', 'notificaciones', 'notif_enviadas', 'formularios', 'sucursales', 'categorias', 'recursos', 'membresias', 'compras', 'comisiones', 'terminologia', 'usuarios', 'roles', 'tickets', 'licencia', 'webhooks'];
const TABS_GERENCIA = ['reservas', 'portal', 'notificaciones', 'notif_enviadas', 'formularios', 'sucursales', 'categorias', 'recursos', 'membresias', 'compras', 'comisiones', 'lealtad', 'propinas', 'terminologia', 'usuarios', 'roles', 'tickets', 'licencia', 'webhooks'];
const SUBCATALOGOS = [
{ key: 'diagnosticos', label: 'Diagnósticos' },
@@ -88,6 +92,8 @@ const Configuracion: FC = () => {
membresias: 'membresias.editar',
compras: 'compras.editar',
comisiones: 'comisiones.editar',
lealtad: 'lealtad.configurar',
propinas: 'propinas.configurar',
recursos: 'recursos.editar',
licencia: 'config.editar',
api: 'api.claves',
@@ -440,6 +446,18 @@ const Configuracion: FC = () => {
<ComisionesPanel settings={settings} onSaved={setSettings} />
</Card.Body>
</Card>
) : tab === 'lealtad' ? (
<Card>
<Card.Body>
<LealtadPanel settings={settings} onSaved={setSettings} />
</Card.Body>
</Card>
) : tab === 'propinas' ? (
<Card>
<Card.Body>
<PropinasPanel settings={settings} onSaved={setSettings} />
</Card.Body>
</Card>
) : tab === 'terminologia' ? (
<Card>
<Card.Body>

View File

@@ -520,8 +520,17 @@ const MonederoTab: FC = () => {
const { t } = useI18n();
const { data, loading } = useFetch(() => portalApi.monedero());
if (loading || !data) return <Loading />;
const nivel = data.nivel as { codigo: string; nombre: string; descuento_pct: number } | null | undefined;
return (
<div className="space-y-3">
{nivel?.nombre && (
<div className="flex items-center gap-2">
<span className="inline-flex items-center gap-1.5 rounded-full bg-theme-accent-bg px-3 py-1 text-sm font-medium text-theme-heading">
<Sparkles size={14} /> {nivel.nombre}
{nivel.descuento_pct > 0 && <span className="text-theme-muted">· {nivel.descuento_pct}%</span>}
</span>
</div>
)}
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
<Card>
<p className="text-xs uppercase tracking-wide text-theme-muted">{t('portal.monedero.points')}</p>

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, Layers, Repeat,
RotateCcw, Keyboard, Lock, TrendingUp, LayoutGrid, List, Layers, Repeat, HandCoins,
} from 'lucide-react';
import Layout from '../components/Layout';
import { usePatientDrawer } from '../components/PatientDrawer';
@@ -60,6 +60,8 @@ const Pos: FC = () => {
const metodoLabelL = (m: string) => metodoOpts.find((o) => o.value === m)?.label || m;
const impuestos = useClinicSettings().impuestos;
const membresiasCfg = useClinicSettings().membresias;
// Propinas: apagadas por defecto (el POS se ve y se comporta como hoy).
const propinasCfg = useClinicSettings().propinas;
const puedeImpuestos = can('ventas.impuestos');
const [searchParams, setSearchParams] = useSearchParams();
// Catálogo
@@ -112,6 +114,8 @@ const Pos: FC = () => {
const [moneda, setMoneda] = useState<'MXN' | 'USD'>('MXN');
const [recibido, setRecibido] = useState('');
const [conPuntos, setConPuntos] = useState(false);
const [propinaPct, setPropinaPct] = useState<number | ''>('');
const [propinaMonto, setPropinaMonto] = useState('');
const [cobrando, setCobrando] = useState(false);
const [tcRate, setTcRate] = useState<number | null>(null);
@@ -415,6 +419,15 @@ const Pos: FC = () => {
const subtotal = impuestos.activo && impuestos.precios_incluyen_impuesto ? bruto - impuesto : bruto;
const discountNum = Math.min(parseFloat(discount) || 0, subtotal);
const total = Math.max(0, subtotal - discountNum + impuesto);
// Propina (solo con el módulo encendido). El total cobrado la incluye; las
// cifras de servicios (`total`) no cambian.
const propinasActivas = propinasCfg.activo;
const propinaNum = !propinasActivas
? 0
: propinaPct !== ''
? Math.round(total * (Number(propinaPct) || 0)) / 100
: (parseFloat(propinaMonto) || 0);
const totalCobro = Math.max(0, total + propinaNum);
const numItems = lines.reduce((a, l) => a + l.qty, 0);
const puntosPaciente = paciente?.wallet_points ?? 0;
const puedePuntos = puntosPaciente >= total && total > 0;
@@ -425,7 +438,7 @@ const Pos: FC = () => {
? (parseFloat(recibido) || 0) * tcRate
: parseFloat(recibido) || 0
: 0;
const cambio = metodo === 'cash' && recibido ? Math.max(0, recibidoMxn - total) : 0;
const cambio = metodo === 'cash' && recibido ? Math.max(0, recibidoMxn - totalCobro) : 0;
const cambioUsd = moneda === 'USD' && tcRate ? cambio / tcRate : 0;
const crearPacienteRapido = async () => {
@@ -460,6 +473,8 @@ const Pos: FC = () => {
setMoneda('MXN');
setRecibido('');
setConPuntos(false);
setPropinaPct('');
setPropinaMonto('');
setCobroOpen(true);
};
@@ -484,6 +499,14 @@ const Pos: FC = () => {
amount_received: metodo === 'cash' && recibido ? parseFloat(recibido) : undefined,
pay_with_points: conPuntos,
currency: moneda,
...(propinasActivas && propinaNum > 0
? (propinaPct !== ''
? { propina_pct: Number(propinaPct) || 0 }
: { propina: propinaNum })
: {}),
// El doctor que atendió también es el empleado al que se atribuye la
// propina cuando el cobro no trae una cita con su propio doctor.
...(doctorId ? { propina_employee_id: parseInt(doctorId, 10), cobrador_employee_id: parseInt(doctorId, 10) } : {}),
});
if (res.status === 'success') {
setResultado(res);
@@ -975,15 +998,21 @@ const Pos: FC = () => {
/>
</span>
</div>
{propinaNum > 0 && (
<div className="flex items-center justify-between text-sm">
<span className="text-theme-muted">{t('pos.tip')}</span>
<span className="text-theme-heading">{fmtMoney(propinaNum)}</span>
</div>
)}
<div className="flex items-center justify-between">
<span className="text-base font-semibold text-theme-heading">{t('common.total')}</span>
<span className="text-right">
<span className="text-xl font-heading font-bold text-theme-heading">{fmtMoney(total)}</span>
<span className="text-xl font-heading font-bold text-theme-heading">{fmtMoney(totalCobro)}</span>
<span
className="block text-xs text-theme-muted"
title={toUsd(total) === null ? t('pos.setRate') : undefined}
title={toUsd(totalCobro) === null ? t('pos.setRate') : undefined}
>
{toUsd(total) !== null ? `≈ ${fmtUsd(toUsd(total)!)}` : '—'}
{toUsd(totalCobro) !== null ? `≈ ${fmtUsd(toUsd(totalCobro)!)}` : '—'}
</span>
</span>
</div>
@@ -997,7 +1026,7 @@ const Pos: FC = () => {
title={can('ventas.vender') ? t('pos.chargeTitle') : t('pos.noSellPerm')}
>
<ShoppingBag size={18} className="mr-2" />
{t('pos.chargeAmount', { amount: fmtMoney(total) })}
{t('pos.chargeAmount', { amount: fmtMoney(totalCobro) })}
</Button>
<p className="flex items-center justify-center gap-1.5 text-[11px] text-theme-muted">
<Keyboard size={11} aria-hidden="true" />
@@ -1371,7 +1400,7 @@ const Pos: FC = () => {
onClick={() => setTicketMovil(true)}
className="lg:hidden fixed bottom-4 right-4 z-30 px-5 py-3 rounded-full bg-theme-accent text-theme-inverse shadow-card font-semibold text-sm"
>
Ver ticket ({numItems}) {fmtMoney(total)}
Ver ticket ({numItems}) {fmtMoney(totalCobro)}
</button>
)}
@@ -1445,7 +1474,7 @@ const Pos: FC = () => {
<Modal
isOpen={cobroOpen}
onClose={() => setCobroOpen(false)}
title={t('pos.chargeAmount', { amount: fmtMoney(total) })}
title={t('pos.chargeAmount', { amount: fmtMoney(totalCobro) })}
maxWidth="md"
footer={
<>
@@ -1462,6 +1491,37 @@ const Pos: FC = () => {
</p>
</div>
<Select label={t('pos.methodLabel')} options={metodoOpts} value={metodo} onChange={(e) => setMetodo(e.target.value)} />
{propinasActivas && (
<div className="border border-theme-border rounded-xl p-3 space-y-2">
<div className="flex items-center justify-between">
<p className="text-sm font-medium text-theme-heading flex items-center gap-1.5">
<HandCoins size={15} className="text-theme-muted" />{t('pos.tip')}
</p>
{propinaNum > 0 && <span className="text-sm font-semibold text-theme-heading">{fmtMoney(propinaNum)}</span>}
</div>
<div className="flex flex-wrap gap-2">
{propinasCfg.sugerencias_pct.map((p) => (
<button key={p} type="button"
onClick={() => { setPropinaPct(p); setPropinaMonto(''); }}
className={`px-3 py-1.5 text-sm rounded-full border ${propinaPct === p ? 'bg-theme-accent text-theme-inverse border-theme-accent' : 'bg-theme-accent-bg text-theme-heading border-theme-border'}`}>
{p}%
</button>
))}
{propinasCfg.permitir_sin_propina && (
<button type="button"
onClick={() => { setPropinaPct(''); setPropinaMonto(''); }}
className={`px-3 py-1.5 text-sm rounded-full border ${propinaPct === '' && !propinaMonto ? 'bg-theme-accent text-theme-inverse border-theme-accent' : 'bg-theme-accent-bg text-theme-heading border-theme-border'}`}>
{t('pos.noTip')}
</button>
)}
</div>
{propinasCfg.permitir_personalizado && (
<Input label={t('pos.tipCustom')} type="number" min={0} step={10} value={propinaMonto}
onChange={(e) => { setPropinaMonto(e.target.value); setPropinaPct(''); }}
placeholder="0" />
)}
</div>
)}
{metodo === 'cash' && !conPuntos && (
<>
<div>
@@ -1499,7 +1559,7 @@ const Pos: FC = () => {
step={10}
value={recibido}
onChange={(e) => setRecibido(e.target.value)}
placeholder={moneda === 'USD' && tcRate ? (total / tcRate).toFixed(2) : String(total)}
placeholder={moneda === 'USD' && tcRate ? (totalCobro / tcRate).toFixed(2) : String(totalCobro)}
/>
{moneda === 'USD' && recibido && tcRate && (
<p className="text-sm text-theme-muted">≈ {fmtMoney(recibidoMxn)} MXN</p>
@@ -1556,6 +1616,8 @@ const Pos: FC = () => {
})),
subtotal: resultado.sale.subtotal,
discount: resultado.sale.discount,
nivel_discount: resultado.sale.nivel_discount ?? resultado.nivel_discount ?? 0,
propina: resultado.sale.propina ?? resultado.propina ?? 0,
tax: resultado.sale.tax_total ?? resultado.impuesto ?? 0,
tax_name: impuestos.nombre,
total: resultado.sale.total,

View File

@@ -1,7 +1,7 @@
import type { FC } from 'react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useSearchParams } from 'react-router-dom';
import { Download, BarChart3, Star, Wallet, Users, Activity, Stethoscope, Share2 } from 'lucide-react';
import { Download, BarChart3, Star, Wallet, Users, Activity, Stethoscope, Share2, Sparkles } from 'lucide-react';
import {
BarChart,
Bar,
@@ -38,6 +38,8 @@ import RecursosReport from '../components/reportes/RecursosReport';
import ComprasReport from '../components/reportes/ComprasReport';
import MargenReport from '../components/reportes/MargenReport';
import CobrosReport from '../components/reportes/CobrosReport';
import LealtadReport from '../components/reportes/LealtadReport';
import PropinasReport from '../components/reportes/PropinasReport';
import { Card, Button, Input, PageHeader, Skeleton, EmptyState, toast } from '../components/ui';
import { odooApi } from '../services/odoo';
import type { CommissionRow } from '../services/odoo';
@@ -126,6 +128,16 @@ const FAMILIAS: ReportFamily[] = [
{ key: 'kpis', label: 'KPIs', desc: 'No-show, ocupación y conversión' },
],
},
{
key: 'lealtad',
label: 'Lealtad',
hint: 'Puntos y propinas',
icon: Sparkles,
tabs: [
{ key: 'lealtad', label: 'Puntos', desc: 'Puntos otorgados, canjeados, expirados y pasivo en pesos' },
{ key: 'propinas', label: 'Propinas', desc: 'Propinas por empleado y periodo' },
],
},
];
// 'exportar' no vive en ninguna familia: se abre desde el botón del encabezado
@@ -157,7 +169,7 @@ interface CashReport {
const ReportesContent: FC = () => {
const [searchParams, setSearchParams] = useSearchParams();
const { hasRole, canSee, can, brand } = useAuth();
const { membresias: membresiasCfg, recursos: recursosCfg, compras: comprasCfg } = useClinicSettings();
const { membresias: membresiasCfg, recursos: recursosCfg, compras: comprasCfg, propinas: propinasCfg } = useClinicSettings();
const { t } = useI18n();
const esAdmin = hasRole('admin');
// PuedeVer una pestana = canSee('/reportes?tab=x'): la matriz guarda claves
@@ -169,8 +181,9 @@ const ReportesContent: FC = () => {
if (k === 'membresias' && !membresiasCfg.activo) return false;
if (k === 'recursos' && !recursosCfg.activo) return false;
if ((k === 'compras' || k === 'margen') && !comprasCfg.activo) return false;
if (k === 'propinas' && !propinasCfg.activo) return false;
return canSee(`/reportes?tab=${k}`);
}, [canSee, membresiasCfg.activo, recursosCfg.activo, comprasCfg.activo]);
}, [canSee, membresiasCfg.activo, recursosCfg.activo, comprasCfg.activo, propinasCfg.activo]);
const familiasVisibles = useMemo(
() =>
FAMILIAS
@@ -510,6 +523,10 @@ const ReportesContent: FC = () => {
<RecomendacionesReport />
) : tab === 'kpis' ? (
<KpisReport />
) : tab === 'lealtad' ? (
<LealtadReport />
) : tab === 'propinas' ? (
<PropinasReport />
) : (
<>
<Card className="mb-6">

View File

@@ -622,6 +622,12 @@ export interface Sale {
/** Cobro: quién cobró y con qué método (módulo Tickets) */
payment_method?: string;
user_login?: string;
/** Propina cobrada (0 si el módulo está apagado). */
propina?: number;
propina_moneda?: string;
/** Descuento automático por nivel VIP. */
nivel_discount?: number;
nivel_codigo?: string;
/** Pagos ligados al ticket (los nuevos; los antiguos no tienen vínculo) */
pagos?: Payment[];
}
@@ -1087,6 +1093,8 @@ export interface ClinicSettings {
recursos: RecursosSettings;
compras: ComprasSettings;
comisiones: ComisionesSettings;
lealtad: LealtadSettings;
propinas: PropinasSettings;
licencia: LicenciaSettings;
portal_cliente: PortalClienteSettings;
}
@@ -1567,6 +1575,156 @@ export const COMISIONES_DEFAULT: ComisionesSettings = {
pago_minimo: 0,
};
// ---- Lealtad (puntos del monedero y niveles VIP) ----
export type LealtadModo = 'clasico' | 'configurable';
export type LealtadAcumular = 'pagado' | 'vendido';
export type LealtadRedondear = 'abajo' | 'normal';
export type LealtadAplicar = 'servicios' | 'productos' | 'membresias' | 'paquetes';
export type LealtadAlcance = 'servicio' | 'articulo' | 'categoria' | 'nivel' | 'sucursal' | 'rango_fechas' | 'dia_semana';
export interface NivelLealtad {
codigo: string;
nombre: string;
gasto_minimo: number;
ventana_meses: number;
descuento_pct: number;
factor_puntos: number;
vigencia_meses: number;
color: string;
}
export interface MultiplicadorLealtad {
nombre: string;
alcance: LealtadAlcance;
valor: string;
factor: number;
fecha_desde: string;
fecha_hasta: string;
activo: boolean;
}
export interface LealtadSettings {
activo: boolean;
modo: LealtadModo;
puntos_por_unidad: number;
valor_punto: number;
moneda_base: string;
canje_max_pct: number;
expira_meses: number;
acumular_sobre: LealtadAcumular;
aplicar_a: LealtadAplicar[];
redondear: LealtadRedondear;
niveles: NivelLealtad[];
multiplicadores: MultiplicadorLealtad[];
}
export const LEALTAD_DEFAULT: LealtadSettings = {
activo: true,
modo: 'clasico',
puntos_por_unidad: 0.1,
valor_punto: 1,
moneda_base: 'MXN',
canje_max_pct: 50,
expira_meses: 0,
acumular_sobre: 'pagado',
aplicar_a: ['servicios', 'productos', 'membresias', 'paquetes'],
redondear: 'abajo',
niveles: [],
multiplicadores: [],
};
export interface NivelPaciente {
codigo: string;
nombre: string;
descuento_pct: number;
factor_puntos: number;
color?: string;
desde?: string | null;
manual?: boolean;
revisar?: string | null;
}
export interface LealtadPaciente {
status: string;
patient_id: number;
patient: string;
activo: boolean;
modo: LealtadModo;
valor_punto: number;
puntos: number;
equivalente_mxn: number;
gasto_ventana: number;
nivel: NivelPaciente;
niveles: NivelLealtad[];
message?: string;
}
export interface ReporteLealtad {
status: string;
modo: LealtadModo;
valor_punto: number;
otorgados: number;
canjeados: number;
expirados: number;
ajustes: number;
depositos: number;
by_type: Record<string, number>;
puntos_activos: number;
pasivo_mxn: number;
cuentas: number;
niveles: NivelLealtad[];
conteo_niveles: Record<string, number>;
message?: string;
}
// ---- Propinas en el punto de venta ----
export type PropinasReparto = 'ninguno' | 'equitativo' | 'por_participacion';
export interface PropinasSettings {
activo: boolean;
sugerencias_pct: number[];
permitir_personalizado: boolean;
permitir_sin_propina: boolean;
reparto: PropinasReparto;
incluir_en_liquidacion_comisiones: boolean;
cuenta_propinas: string;
}
export const PROPINAS_DEFAULT: PropinasSettings = {
activo: false,
sugerencias_pct: [10, 15, 20],
permitir_personalizado: true,
permitir_sin_propina: true,
reparto: 'ninguno',
incluir_en_liquidacion_comisiones: false,
cuenta_propinas: '',
};
export interface PropinaRepartoFila {
employee_id: number | null;
empleado: string;
monto: number;
propinas: number;
}
export interface ReportePropinas {
status: string;
start: string;
end: string;
activo: boolean;
reparto: PropinasReparto;
por_empleado: PropinaRepartoFila[];
detalle: {
venta_id: number; folio: string; date: string | null; empleado: string;
employee_id: number | null; rol: string; monto: number; moneda: string; cuenta: string;
}[];
total: number;
total_repartido: number;
total_ventas: number;
cuenta_propinas: string;
message?: string;
}
export type AlcanceComision = 'global' | 'categoria' | 'servicio' | 'articulo' | 'membresia' | 'paquete';
export type TipoComision = 'porcentaje' | 'monto_fijo' | 'escalonado';
export type TipoBonoComision = 'fijo' | 'porcentaje';
@@ -2424,6 +2582,8 @@ export const CLINIC_SETTINGS_DEFAULT: ClinicSettings = {
recursos: RECURSOS_DEFAULT,
compras: COMPRAS_DEFAULT,
comisiones: COMISIONES_DEFAULT,
lealtad: LEALTAD_DEFAULT,
propinas: PROPINAS_DEFAULT,
licencia: LICENCIA_DEFAULT,
portal_cliente: PORTAL_CLIENTE_DEFAULT,
};
@@ -2595,10 +2755,13 @@ export interface PatientWallet {
patient: string;
wallet_id: number | null;
points: number;
valor_punto?: number;
equivalent_mxn: number;
equivalent_usd: number | null;
rate: number | null;
depositado: number;
/** Nivel VIP del paciente (vacío si no hay niveles configurados). */
nivel?: NivelPaciente | null;
movimientos: WalletTransaction[];
message?: string;
}
@@ -2850,6 +3013,13 @@ export interface PosCheckoutPayload {
amount_received?: number;
pay_with_points?: boolean;
currency?: 'MXN' | 'USD';
/** Cita de origen (para atribuir la propina al personal que atendió). */
cita_id?: number;
/** Propina: porcentaje sugerido sobre los servicios, o monto libre. */
propina_pct?: number;
propina?: number;
propina_employee_id?: number;
cobrador_employee_id?: number;
}
export interface ImpuestosDesglose {
@@ -2884,13 +3054,20 @@ export interface PosCheckoutResult {
puntos_usados: number;
puntos_ganados: number;
wallet_points: number;
/** Propina registrada (0 si el módulo está apagado). */
propina?: number;
propina_moneda?: string;
/** Descuento automático por nivel VIP. */
nivel_discount?: number;
nivel?: NivelPaciente | null;
reparto_propina?: { employee_id: number | null; empleado: string; rol: string; monto: number; moneda: string }[];
stock_warnings?: string[];
}
export interface DailyReport {
date: string;
citas: { total: number; by_state: Record<string, number> };
ventas: { count: number; total: number; total_paid: number; total_due: number };
ventas: { count: number; total: number; total_paid: number; total_due: number; total_propinas?: number; total_servicios?: number };
pagos: { total: number; count: number; by_method: Record<string, number> };
inventario: { total: number; by_type: Record<string, number> };
}
@@ -3120,6 +3297,37 @@ export const odooApi = {
return data;
},
// ---- Lealtad: puntos y niveles ----
async getPatientLealtad(patientId: number): Promise<LealtadPaciente> {
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/patients/${patientId}/lealtad`);
return data;
},
async recalcularLealtad(patientId: number): Promise<{ status: string; nivel: NivelPaciente; gasto_ventana: number; message?: string }> {
const { data } = await axios.post(`${ODOO_BASE}/skeen/frontend/v1/patients/${patientId}/lealtad/recalcular`, {});
return data;
},
async setNivelLealtad(patientId: number, codigo: string, revisar?: string): Promise<{ status: string; nivel: NivelPaciente; message?: string }> {
const { data } = await axios.put(`${ODOO_BASE}/skeen/frontend/v1/patients/${patientId}/lealtad`, { codigo, revisar });
return data;
},
async adjustPatientPoints(patientId: number, points: number, motivo: string): Promise<{ status: string; points: number; applied: number; equivalente_mxn: number; message?: string }> {
const { data } = await axios.post(`${ODOO_BASE}/skeen/frontend/v1/patients/${patientId}/points/adjust`, { points, motivo });
return data;
},
async getReportLealtad(start?: string, end?: string): Promise<ReporteLealtad> {
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/reports/lealtad`, { params: { start, end } });
return data;
},
async getReportPropinas(start?: string, end?: string): Promise<ReportePropinas> {
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/reports/propinas`, { params: { start, end } });
return data;
},
// ---- Portal del cliente (personal: invitar, accesos, sesiones) ----
async getPortalAccesos(): Promise<{ status: string; activo: boolean; accesos: PortalAcceso[]; message?: string }> {
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/portal/accesos`);

View File

@@ -1186,6 +1186,355 @@ def get_comisiones(env):
"""Bloque de comisiones vigente (copia). Fuente única para modelos y API."""
return normalizar_comisiones(get_settings(env).get('comisiones'))
# ---------------------------------------------------------------------------
# Programa de lealtad (puntos del monedero) y niveles VIP.
#
# Bloque anidado del mismo JSON de ajustes, igual que `impuestos`. La regla de
# oro: con los valores por defecto (`modo='clasico'`) NADA cambia — se acumula
# 1 punto por cada $10 pagados, 1 punto = $1, el canje se topa al 50% y no hay
# niveles ni multiplicadores. `modo='configurable'` usa los valores del bloque.
#
# activo: enciende el programa de puntos
# modo: clasico | configurable
# puntos_por_unidad: puntos por cada peso (0.1 = 1 pt por $10)
# valor_punto: pesos que vale un punto al canjear
# moneda_base: MXN | USD (solo informativo / etiqueta)
# canje_max_pct: % máximo del total pagable con puntos
# expira_meses: 0 = no expiran; >0 expira lo no usado más antiguo
# acumular_sobre: pagado (monto cobrado) | vendido (monto vendido)
# aplicar_a: servicios | productos | membresias | paquetes
# redondear: abajo (truncar) | normal (redondeo comercial)
# niveles: [{codigo, nombre, gasto_minimo, ventana_meses,
# descuento_pct, factor_puntos, vigencia_meses, color}]
# multiplicadores: [{nombre, alcance, valor, factor, fecha_desde,
# fecha_hasta, activo}]
#
# PRECEDENCIA de multiplicadores (gana la MÁS específica; a igual
# especificidad, la última de la lista): servicio > articulo > categoria >
# nivel > sucursal > rango_fechas > dia_semana.
#
# NIVELES: el nivel se decide por el gasto acumulado en su ventana (por defecto
# 12 meses). Un cliente sube de nivel al cerrar la venta que lo alcanza (o por
# el proceso diario). NO baja de inmediato: el nivel conserva su vigencia
# (`vigencia_meses`) y solo al vencer se recalcula a la baja. Una asignación
# manual (con fecha de revisión) no se sobreescribe sola hasta esa fecha.
# ---------------------------------------------------------------------------
LEALTAD_DEFAULT = {
'activo': True,
'modo': 'clasico',
'puntos_por_unidad': 0.1,
'valor_punto': 1.0,
'moneda_base': 'MXN',
'canje_max_pct': 50,
'expira_meses': 0,
'acumular_sobre': 'pagado',
'aplicar_a': ['servicios', 'productos', 'membresias', 'paquetes'],
'redondear': 'abajo',
'niveles': [],
'multiplicadores': [],
}
LEALTAD_MODOS = ('clasico', 'configurable')
LEALTAD_ACUMULAR = ('pagado', 'vendido')
LEALTAD_APLICAR = ('servicios', 'productos', 'membresias', 'paquetes')
LEALTAD_REDONDEAR = ('abajo', 'normal')
LEALTAD_ALCANCES = ('servicio', 'articulo', 'categoria', 'nivel', 'sucursal',
'rango_fechas', 'dia_semana')
LEALTAD_MONEDAS = ('MXN', 'USD')
LEALTAD_EXPIRA_MAX = 120 # meses
LEALTAD_VENTANA_MAX = 120 # meses (ventana de gasto de un nivel)
LEALTAD_NIVELES_MAX = 20
LEALTAD_MULT_MAX = 100
def _fecha_iso(valor):
"""'YYYY-MM-DD' válida o ''."""
texto = str(valor or '').strip()[:10]
if not texto:
return ''
try:
date.fromisoformat(texto)
except ValueError:
return ''
return texto
def normalizar_niveles(raw):
"""Lista de niveles VIP normalizada (ordenada por gasto_minimo desc)."""
out = []
if not isinstance(raw, (list, tuple)):
return out
for item in raw:
if not isinstance(item, dict):
continue
codigo = str(item.get('codigo') or '').strip()[:40]
nombre = str(item.get('nombre') or '').strip()[:80]
if not codigo and not nombre:
continue
if not codigo:
codigo = nombre.lower().replace(' ', '_')[:40]
out.append({
'codigo': codigo,
'nombre': nombre or codigo,
'gasto_minimo': max(0.0, _num(item.get('gasto_minimo'), 0.0)),
'ventana_meses': min(max(_int(item.get('ventana_meses'), 12), 1), LEALTAD_VENTANA_MAX),
'descuento_pct': min(max(_num(item.get('descuento_pct'), 0.0), 0.0), 100.0),
'factor_puntos': max(0.0, _num(item.get('factor_puntos'), 1.0)),
'vigencia_meses': min(max(_int(item.get('vigencia_meses'), 12), 0), LEALTAD_VENTANA_MAX),
'color': str(item.get('color') or '').strip()[:20],
})
out.sort(key=lambda n: -n['gasto_minimo'])
return out[:LEALTAD_NIVELES_MAX]
def normalizar_multiplicadores(raw):
"""Lista de reglas multiplicadoras normalizada (tolerante)."""
out = []
if not isinstance(raw, (list, tuple)):
return out
for item in raw:
if not isinstance(item, dict):
continue
alcance = str(item.get('alcance') or '').strip().lower()
if alcance not in LEALTAD_ALCANCES:
continue
out.append({
'nombre': str(item.get('nombre') or '').strip()[:80],
'alcance': alcance,
'valor': str(item.get('valor') or '').strip()[:120],
'factor': max(0.0, _num(item.get('factor'), 1.0)),
'fecha_desde': _fecha_iso(item.get('fecha_desde')),
'fecha_hasta': _fecha_iso(item.get('fecha_hasta')),
'activo': _bool(item.get('activo'), True),
})
return out[:LEALTAD_MULT_MAX]
def normalizar_lealtad(raw):
"""Bloque `lealtad` completo, con defaults. Tolerante: nunca lanza."""
out = dict(LEALTAD_DEFAULT)
out['aplicar_a'] = list(LEALTAD_DEFAULT['aplicar_a'])
if not isinstance(raw, dict):
return out
out['activo'] = _bool(raw.get('activo'), LEALTAD_DEFAULT['activo'])
modo = str(raw.get('modo') or '').strip().lower()
out['modo'] = modo if modo in LEALTAD_MODOS else LEALTAD_DEFAULT['modo']
acu = str(raw.get('acumular_sobre') or '').strip().lower()
out['acumular_sobre'] = acu if acu in LEALTAD_ACUMULAR else LEALTAD_DEFAULT['acumular_sobre']
red = str(raw.get('redondear') or '').strip().lower()
out['redondear'] = red if red in LEALTAD_REDONDEAR else LEALTAD_DEFAULT['redondear']
mon = str(raw.get('moneda_base') or '').strip().upper()
out['moneda_base'] = mon if mon in LEALTAD_MONEDAS else LEALTAD_DEFAULT['moneda_base']
out['puntos_por_unidad'] = max(0.0, _num(raw.get('puntos_por_unidad'),
LEALTAD_DEFAULT['puntos_por_unidad']))
out['valor_punto'] = max(0.0, _num(raw.get('valor_punto'), LEALTAD_DEFAULT['valor_punto']))
out['canje_max_pct'] = min(max(_num(raw.get('canje_max_pct'),
LEALTAD_DEFAULT['canje_max_pct']), 0.0), 100.0)
out['expira_meses'] = min(max(_int(raw.get('expira_meses'),
LEALTAD_DEFAULT['expira_meses']), 0), LEALTAD_EXPIRA_MAX)
aplicar = raw.get('aplicar_a')
if isinstance(aplicar, (list, tuple)):
limpio = [a for a in (str(x).strip().lower() for x in aplicar)
if a in LEALTAD_APLICAR]
out['aplicar_a'] = list(dict.fromkeys(limpio))
out['niveles'] = normalizar_niveles(raw.get('niveles'))
out['multiplicadores'] = normalizar_multiplicadores(raw.get('multiplicadores'))
if out['modo'] == 'clasico':
# El modo clásico reproduce EXACTAMENTE el comportamiento de siempre:
# se congelan los parámetros numéricos y se ignoran niveles y
# multiplicadores (que se conservan para el editor, pero no aplican).
out['puntos_por_unidad'] = LEALTAD_DEFAULT['puntos_por_unidad']
out['valor_punto'] = LEALTAD_DEFAULT['valor_punto']
out['canje_max_pct'] = LEALTAD_DEFAULT['canje_max_pct']
out['acumular_sobre'] = LEALTAD_DEFAULT['acumular_sobre']
out['aplicar_a'] = list(LEALTAD_DEFAULT['aplicar_a'])
out['redondear'] = LEALTAD_DEFAULT['redondear']
return out
def validar_lealtad(raw):
"""Valida el bloque `lealtad` de un PUT (devuelve SOLO lo presente)."""
if raw is None:
return None
if not isinstance(raw, dict):
raise ValueError('El bloque de lealtad debe ser un objeto')
out = {}
for k in ('activo',):
if k in raw:
if not isinstance(raw[k], bool):
raise ValueError('El campo %s debe ser verdadero o falso' % k)
out[k] = raw[k]
if 'modo' in raw:
modo = str(raw['modo'] or '').strip().lower()
if modo not in LEALTAD_MODOS:
raise ValueError("El modo debe ser 'clasico' o 'configurable'")
out['modo'] = modo
if 'acumular_sobre' in raw:
acu = str(raw['acumular_sobre'] or '').strip().lower()
if acu not in LEALTAD_ACUMULAR:
raise ValueError("acumular_sobre debe ser 'pagado' o 'vendido'")
out['acumular_sobre'] = acu
if 'redondear' in raw:
red = str(raw['redondear'] or '').strip().lower()
if red not in LEALTAD_REDONDEAR:
raise ValueError("El redondeo debe ser 'abajo' o 'normal'")
out['redondear'] = red
if 'moneda_base' in raw:
mon = str(raw['moneda_base'] or '').strip().upper()
if mon not in LEALTAD_MONEDAS:
raise ValueError('La moneda base debe ser MXN o USD')
out['moneda_base'] = mon
for k, lo, hi in (('puntos_por_unidad', 0.0, 10000.0),
('valor_punto', 0.0, 100000.0),
('canje_max_pct', 0.0, 100.0)):
if k in raw:
n = _num(raw[k], None)
if n is None or not (lo <= n <= hi):
raise ValueError('El campo %s debe ser un número entre %s y %s' % (k, lo, hi))
out[k] = n
if 'expira_meses' in raw:
if isinstance(raw['expira_meses'], bool) or not isinstance(raw['expira_meses'], int):
raise ValueError('Los meses de expiración deben ser un número entero')
if not (0 <= raw['expira_meses'] <= LEALTAD_EXPIRA_MAX):
raise ValueError('Los meses de expiración deben estar entre 0 y %d' % LEALTAD_EXPIRA_MAX)
out['expira_meses'] = raw['expira_meses']
if 'aplicar_a' in raw:
valor = raw['aplicar_a']
if not isinstance(valor, list):
raise ValueError('aplicar_a debe ser una lista')
limpio = []
for a in valor:
a = str(a or '').strip().lower()
if a not in LEALTAD_APLICAR:
raise ValueError('Valor desconocido en aplicar_a: %s' % a)
if a not in limpio:
limpio.append(a)
out['aplicar_a'] = limpio
if 'niveles' in raw:
if not isinstance(raw['niveles'], list):
raise ValueError('Los niveles deben ser una lista')
for n in raw['niveles']:
if not isinstance(n, dict):
raise ValueError('Cada nivel debe ser un objeto')
out['niveles'] = normalizar_niveles(raw['niveles'])
if 'multiplicadores' in raw:
if not isinstance(raw['multiplicadores'], list):
raise ValueError('Los multiplicadores deben ser una lista')
for m in raw['multiplicadores']:
if not isinstance(m, dict):
raise ValueError('Cada multiplicador debe ser un objeto')
if str(m.get('alcance') or '').strip().lower() not in LEALTAD_ALCANCES:
raise ValueError('Alcance desconocido en un multiplicador')
out['multiplicadores'] = normalizar_multiplicadores(raw['multiplicadores'])
return out
def get_lealtad(env):
"""Bloque de lealtad vigente (copia). Fuente única para modelos y API."""
return normalizar_lealtad(get_settings(env).get('lealtad'))
# ---------------------------------------------------------------------------
# Propinas en el punto de venta y reparto entre el personal.
#
# Bloque anidado del mismo JSON de ajustes. La regla de oro: con `activo=False`
# (default) el punto de venta se ve y se comporta EXACTAMENTE igual que hoy —
# no aparece ningún campo de propina, la venta no guarda propina y el total es
# el de siempre.
#
# activo: enciende la propina en el POS
# sugerencias_pct: porcentajes sugeridos [10, 15, 20]
# permitir_personalizado: permite capturar un monto libre
# permitir_sin_propina: permite cobrar sin propina
# reparto: ninguno | equitativo | por_participacion
# incluir_en_liquidacion_comisiones: suma la propina a la liquidación
# cuenta_propinas: etiqueta de la cuenta destino (informativa)
# ---------------------------------------------------------------------------
PROPINAS_DEFAULT = {
'activo': False,
'sugerencias_pct': [10, 15, 20],
'permitir_personalizado': True,
'permitir_sin_propina': True,
'reparto': 'ninguno',
'incluir_en_liquidacion_comisiones': False,
'cuenta_propinas': '',
}
PROPINAS_REPARTO = ('ninguno', 'equitativo', 'por_participacion')
PROPINAS_SUGERENCIA_MAX = 100
PROPINAS_CUENTA_MAX = 80
def normalizar_propinas(raw):
"""Bloque `propinas` completo, con defaults. Tolerante: nunca lanza."""
out = dict(PROPINAS_DEFAULT)
out['sugerencias_pct'] = list(PROPINAS_DEFAULT['sugerencias_pct'])
if not isinstance(raw, dict):
return out
for k in ('activo', 'permitir_personalizado', 'permitir_sin_propina',
'incluir_en_liquidacion_comisiones'):
out[k] = _bool(raw.get(k), PROPINAS_DEFAULT[k])
rep = str(raw.get('reparto') or '').strip().lower()
out['reparto'] = rep if rep in PROPINAS_REPARTO else PROPINAS_DEFAULT['reparto']
sugerencias = raw.get('sugerencias_pct')
if isinstance(sugerencias, (list, tuple)):
limpias = []
for s in sugerencias:
n = _num(s, None)
if n is None:
continue
n = min(max(n, 0.0), PROPINAS_SUGERENCIA_MAX)
if n not in limpias:
limpias.append(n)
out['sugerencias_pct'] = sorted(limpias)
out['cuenta_propinas'] = str(raw.get('cuenta_propinas') or '').strip()[:PROPINAS_CUENTA_MAX]
return out
def validar_propinas(raw):
"""Valida el bloque `propinas` de un PUT (devuelve SOLO lo presente)."""
if raw is None:
return None
if not isinstance(raw, dict):
raise ValueError('El bloque de propinas debe ser un objeto')
out = {}
for k in ('activo', 'permitir_personalizado', 'permitir_sin_propina',
'incluir_en_liquidacion_comisiones'):
if k in raw:
if not isinstance(raw[k], bool):
raise ValueError('El campo %s debe ser verdadero o falso' % k)
out[k] = raw[k]
if 'reparto' in raw:
rep = str(raw['reparto'] or '').strip().lower()
if rep not in PROPINAS_REPARTO:
raise ValueError("El reparto debe ser 'ninguno', 'equitativo' o 'por_participacion'")
out['reparto'] = rep
if 'sugerencias_pct' in raw:
valor = raw['sugerencias_pct']
if not isinstance(valor, list):
raise ValueError('Las sugerencias de propina deben ser una lista')
limpias = []
for s in valor:
n = _num(s, None)
if n is None:
raise ValueError('Cada sugerencia de propina debe ser un número')
if not (0 <= n <= PROPINAS_SUGERENCIA_MAX):
raise ValueError('Las sugerencias deben estar entre 0 y %d' % PROPINAS_SUGERENCIA_MAX)
if n not in limpias:
limpias.append(n)
out['sugerencias_pct'] = sorted(limpias)
if 'cuenta_propinas' in raw:
if not isinstance(raw['cuenta_propinas'], str):
raise ValueError('La cuenta de propinas debe ser texto')
if len(raw['cuenta_propinas'].strip()) > PROPINAS_CUENTA_MAX:
raise ValueError('El nombre de la cuenta es demasiado largo (máx. %d)' % PROPINAS_CUENTA_MAX)
out['cuenta_propinas'] = raw['cuenta_propinas'].strip()
return out
def get_propinas(env):
"""Bloque de propinas vigente (copia). Apagado por defecto."""
return normalizar_propinas(get_settings(env).get('propinas'))
# ---------------------------------------------------------------------------
# Licencia por plan (producto comercial).
#
@@ -1615,6 +1964,8 @@ def get_settings(env, aplicar_licencia=True):
'recursos': dict(RECURSOS_DEFAULT),
'compras': dict(COMPRAS_DEFAULT),
'comisiones': dict(COMISIONES_DEFAULT),
'lealtad': normalizar_lealtad(None),
'propinas': dict(PROPINAS_DEFAULT),
'licencia': dict(LICENCIA_DEFAULT, limites=dict(LICENCIA_DEFAULT['limites'])),
'portal_cliente': normalizar_portal_cliente(None),
}
@@ -1635,6 +1986,8 @@ def get_settings(env, aplicar_licencia=True):
out['recursos'] = normalizar_recursos(guardado.get('recursos'))
out['compras'] = normalizar_compras(guardado.get('compras'))
out['comisiones'] = normalizar_comisiones(guardado.get('comisiones'))
out['lealtad'] = normalizar_lealtad(guardado.get('lealtad'))
out['propinas'] = normalizar_propinas(guardado.get('propinas'))
out['licencia'] = normalizar_licencia(guardado.get('licencia'))
out['portal_cliente'] = normalizar_portal_cliente(
guardado.get('portal_cliente'))
@@ -1696,6 +2049,16 @@ def set_settings(env, vals):
if bloque is not None:
# Merge anidado: un PUT parcial (p. ej. solo activo) no borra el resto.
actual['portal_cliente'] = {**actual['portal_cliente'], **bloque}
if 'lealtad' in vals:
bloque = validar_lealtad(vals['lealtad'])
if bloque is not None:
# Merge anidado: un PUT parcial no borra el resto. Las listas de
# niveles y multiplicadores sí se reemplazan completas.
actual['lealtad'] = {**actual['lealtad'], **bloque}
if 'propinas' in vals:
bloque = validar_propinas(vals['propinas'])
if bloque is not None:
actual['propinas'] = {**actual['propinas'], **bloque}
if 'licencia' in vals:
bloque = validar_licencia(vals['licencia'])
if bloque is not None:

View File

@@ -174,6 +174,7 @@ class SkeenComisionesController(http.Controller):
'periodo_fin': l.periodo_fin.strftime('%Y-%m-%d') if l.periodo_fin else None,
'periodo': l.periodo or '',
'total_calculado': l.total_calculado,
'total_propinas': l.total_propinas,
'total_ajustes': l.total_ajustes,
'total': l.total,
'estado': l.estado,
@@ -596,9 +597,12 @@ class SkeenComisionesController(http.Controller):
emp_ids = [int(data['empleado_id'])]
calc = motor.calcular(start, end, empleado_ids=emp_ids)
from odoo.addons.skeen_comisiones.models.liquidacion import propinas_de_empleado
creadas, omitidas = [], []
for emp in calc['empleados']:
if emp['lineas_count'] == 0 and abs(emp['total']) < 1e-9:
propina = propinas_de_empleado(request.env, emp['empleado_id'], start, end)
if emp['lineas_count'] == 0 and abs(emp['total']) < 1e-9 and propina <= 0:
continue
existente = Liq.search([
('empleado_id', '=', emp['empleado_id']),
@@ -610,12 +614,15 @@ class SkeenComisionesController(http.Controller):
'folio': existente.name,
'motivo': 'ya existe liquidación del periodo'})
continue
detalle = dict(emp)
detalle['propinas'] = propina
liq = Liq.create({
'empleado_id': emp['empleado_id'],
'periodo_inicio': start,
'periodo_fin': end,
'total_calculado': emp['total'],
'detalle': json.dumps(emp, ensure_ascii=False),
'total_propinas': propina,
'detalle': json.dumps(detalle, ensure_ascii=False),
'creado_por_login': _login(),
'notas': (data.get('notas') or '').strip(),
})

View File

@@ -15,10 +15,29 @@ import json
from odoo import models, fields, api
from odoo.exceptions import UserError
from odoo.addons.skeen_citas.clinic_settings import get_propinas
ESTADOS = ('borrador', 'pagada', 'cancelada')
ESTADO_LABEL = {'borrador': 'Borrador', 'pagada': 'Pagada', 'cancelada': 'Cancelada'}
def propinas_de_empleado(env, empleado_id, start, end):
"""Propina que le toca a un empleado en el periodo (0 si no aplica).
Solo se suma a la liquidación cuando la configuración de propinas lo pide
(`incluir_en_liquidacion_comisiones`); apagado, devuelve 0 y no altera
ninguna liquidación.
"""
cfg = get_propinas(env)
if not cfg.get('activo') or not cfg.get('incluir_en_liquidacion_comisiones'):
return 0.0
repartos = env['skeen.propina.reparto'].sudo().search([
('employee_id', '=', empleado_id),
('date', '>=', start), ('date', '<=', end),
])
return sum(r.monto or 0.0 for r in repartos)
class SkeenComisionLiquidacion(models.Model):
_name = 'skeen.comision.liquidacion'
_description = 'Liquidación de comisiones SKEEN'
@@ -33,6 +52,10 @@ class SkeenComisionLiquidacion(models.Model):
store=True, index=True)
total_calculado = fields.Float(string='Comisión calculada', default=0.0)
# Propina del empleado en el periodo. Solo tiene valor con
# `propinas.incluir_en_liquidacion_comisiones = true`; en cualquier otro
# caso queda en 0 y no cambia la liquidación.
total_propinas = fields.Float(string='Propinas', default=0.0)
total_ajustes = fields.Float(string='Ajustes', compute='_compute_total',
store=True)
total = fields.Float(string='Total a pagar', compute='_compute_total', store=True)
@@ -62,11 +85,11 @@ class SkeenComisionLiquidacion(models.Model):
for l in self:
l.periodo = l.periodo_inicio.strftime('%Y-%m') if l.periodo_inicio else ''
@api.depends('ajuste_ids.monto', 'total_calculado')
@api.depends('ajuste_ids.monto', 'total_calculado', 'total_propinas')
def _compute_total(self):
for l in self:
l.total_ajustes = sum(a.monto for a in l.ajuste_ids)
l.total = (l.total_calculado or 0.0) + l.total_ajustes
l.total = (l.total_calculado or 0.0) + l.total_ajustes + (l.total_propinas or 0.0)
@api.model_create_multi
def create(self, vals_list):

View File

@@ -13,6 +13,7 @@
'depends': ['base', 'skeen_pacientes'],
'data': [
'security/ir.model.access.csv',
'data/lealtad_cron.xml',
'views/monedero_views.xml',
'views/menu_views.xml',
],

View File

@@ -0,0 +1,32 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data noupdate="1">
<!-- Vencimiento de puntos de lealtad. Con `expira_meses = 0` (default)
el método regresa sin tocar nada: cero filas nuevas. Con un plazo
configurado expira, una vez al día, los puntos no usados más
antiguos que el plazo, dejando la transacción en la bitácora. -->
<record id="cron_lealtad_expirar" model="ir.cron">
<field name="name">SKEEN Lealtad: vencer puntos caducados</field>
<field name="model_id" ref="model_skeen_monedero"/>
<field name="state">code</field>
<field name="code">model.expirar_puntos()</field>
<field name="interval_number">1</field>
<field name="interval_type">days</field>
<field name="numbercall">-1</field>
<field name="active">True</field>
</record>
<!-- Recálculo diario del nivel VIP. Sin niveles configurados (default)
el método regresa sin tocar nada. -->
<record id="cron_lealtad_niveles" model="ir.cron">
<field name="name">SKEEN Lealtad: recalcular niveles VIP</field>
<field name="model_id" ref="model_res_partner"/>
<field name="state">code</field>
<field name="code">model._lealtad_recalcular_todos()</field>
<field name="interval_number">1</field>
<field name="interval_type">days</field>
<field name="numbercall">-1</field>
<field name="active">True</field>
</record>
</data>
</odoo>

View File

@@ -0,0 +1,200 @@
# -*- coding: utf-8 -*-
"""Motor del programa de lealtad configurable (puntos y niveles VIP).
Vive aquí (y no en `skeen_citas/clinic_settings.py`) porque es LÓGICA, no
configuración: la configuración sigue en el bloque `lealtad` de los ajustes de
la clínica y se lee con `get_lealtad(env)`.
Reglas de compatibilidad (regla de oro): con `modo='clasico'` (el default) el
cálculo de puntos reproduce EXACTAMENTE el de siempre:
puntos = int(total * (subtotal_elegible / subtotal_total) / 10)
donde se considera elegible toda línea de servicio/artículo cuyo catálogo tenga
`genera_monedero`. Así, ninguna instancia cambia de cifras al desplegar.
PRECEDENCIA de multiplicadores (gana la más específica; a igual especificidad
gana la última de la lista):
servicio > articulo > categoria > nivel > sucursal > rango_fechas > dia_semana
"""
from odoo.addons.skeen_citas.clinic_settings import get_lealtad # noqa: F401 (reexport)
# Orden de especificidad (menor índice = más específico).
PRECEDENCIA = ('servicio', 'articulo', 'categoria', 'nivel', 'sucursal',
'rango_fechas', 'dia_semana')
# Tipos de línea que puede filtrar `aplicar_a`.
TIPOS_LINEA = ('servicios', 'productos', 'membresias', 'paquetes')
def lineas_elegibles(venta, aplicar_a):
"""Líneas de la venta que generan puntos según `aplicar_a`.
Además del tipo de línea, se respeta el interruptor `genera_monedero` del
catálogo (igual que el cálculo clásico).
"""
aplicar = set(aplicar_a or [])
out = venta.line_ids.filtered(
lambda l: l.tipo_monedero in aplicar and l.genera_monedero)
return out
def _aplica_alcance(regla, ctx):
"""True si la regla multiplicadora aplica al contexto dado."""
alcance = regla.get('alcance')
valor = str(regla.get('valor') or '').strip()
if not valor:
return False
if alcance == 'servicio':
return ctx.get('servicio_id') is not None and str(ctx['servicio_id']) == valor
if alcance == 'articulo':
return ctx.get('articulo_id') is not None and str(ctx['articulo_id']) == valor
if alcance == 'categoria':
return str(ctx.get('categoria') or '') == valor
if alcance == 'nivel':
return str(ctx.get('nivel_codigo') or '') == valor
if alcance == 'sucursal':
return str(ctx.get('sucursal') or '') == valor
if alcance == 'dia_semana':
dias = [d.strip() for d in valor.split(',') if d.strip()]
return str(ctx.get('dia_semana')) in dias
if alcance == 'rango_fechas':
fecha = ctx.get('fecha')
if not fecha:
return False
desde = str(regla.get('fecha_desde') or '')
hasta = str(regla.get('fecha_hasta') or '')
if desde and str(fecha) < desde:
return False
if hasta and str(fecha) > hasta:
return False
return bool(desde or hasta)
return False
def _vigente(regla, fecha):
"""True si la regla está dentro de su propia vigencia (fecha_desde/hasta)."""
if not fecha:
return True
desde = str(regla.get('fecha_desde') or '')
hasta = str(regla.get('fecha_hasta') or '')
if desde and str(fecha) < desde:
return False
if hasta and str(fecha) > hasta:
return False
return True
def regla_multiplicador(cfg, ctx):
"""Regla multiplicadora más específica que aplica (o None).
Devuelve el dict de la regla; `factor` es su factor de acumulación.
"""
fecha = ctx.get('fecha')
mejor = None
mejor_rango = None
for regla in (cfg or {}).get('multiplicadores') or []:
if not regla.get('activo', True):
continue
if not _vigente(regla, fecha):
continue
if not _aplica_alcance(regla, ctx):
continue
try:
rango = PRECEDENCIA.index(regla.get('alcance'))
except ValueError:
continue
# Menor rango gana; a igual rango, la última de la lista gana (<=).
if mejor is None or rango <= mejor_rango:
mejor = regla
mejor_rango = rango
return mejor
def factor_multiplicador(cfg, ctx):
"""Factor a aplicar (1.0 si ninguna regla aplica)."""
regla = regla_multiplicador(cfg, ctx)
return float(regla.get('factor', 1.0)) if regla else 1.0
def nivel_para_gasto(cfg, gasto):
"""Nivel que corresponde a un gasto acumulado (o None).
Los niveles vienen ordenados de mayor a menor `gasto_minimo`; el primero
cuyo umbral ya se alcanzó es el nivel vigente.
"""
for nivel in (cfg or {}).get('niveles') or []:
if float(gasto or 0.0) >= float(nivel.get('gasto_minimo') or 0.0):
return nivel
return None
def nivel_por_codigo(cfg, codigo):
"""Nivel por su código (o None)."""
for nivel in (cfg or {}).get('niveles') or []:
if nivel.get('codigo') == codigo:
return nivel
return None
def _redondear(valor, modo):
"""'abajo' trunca; 'normal' redondea comercialmente."""
if modo == 'normal':
return int(round(valor))
return int(valor)
def puntos_venta(env, venta, cfg=None):
"""Puntos que genera una venta. Devuelve (puntos, detalle).
Detalle: {modo, base, factor, elegible, subtotal, sin_propina}.
"""
cfg = cfg or get_lealtad(env)
vacio = {'modo': cfg.get('modo'), 'base': 0.0, 'factor': 1.0,
'elegible': 0.0, 'subtotal': 0.0, 'sin_propina': 0.0}
if not cfg.get('activo'):
return 0, vacio
subtotal_total = sum(l.subtotal for l in venta.line_ids)
sin_propina = max(0.0, (venta.total or 0.0) - (venta.propina or 0.0))
if cfg.get('modo') != 'configurable':
# CLÁSICO: fórmula exacta de siempre (1 pt por cada $10 del total).
# Se evalúa con el predicado original, sin mirar `aplicar_a`, para no
# alterar ninguna cifra histórica.
elegible_sub = sum(
l.subtotal for l in venta.line_ids
if (l.service_id and l.service_id.genera_monedero)
or (l.item_id and l.item_id.genera_monedero))
if not subtotal_total or elegible_sub <= 0:
return 0, dict(vacio, subtotal=subtotal_total, elegible=elegible_sub,
sin_propina=sin_propina)
puntos = int(sin_propina * (elegible_sub / subtotal_total) / 10)
return puntos, {'modo': 'clasico', 'base': sin_propina, 'factor': 1.0,
'elegible': elegible_sub, 'subtotal': subtotal_total,
'sin_propina': sin_propina}
# CONFIGURABLE
elegibles = lineas_elegibles(venta, cfg.get('aplicar_a'))
elegible_sub = sum(l.subtotal for l in elegibles)
por_unidad = float(cfg.get('puntos_por_unidad') or 0.0)
sobre = cfg.get('acumular_sobre')
base_vendida = sin_propina
base_pagada = max(0.0, min(venta.amount_paid or 0.0, sin_propina))
ratio_pago = (base_pagada / base_vendida) if base_vendida else 0.0
nivel = nivel_por_codigo(cfg, venta.partner_id.lealtad_nivel_codigo or '')
factor_nivel = float((nivel or {}).get('factor_puntos', 1.0))
acumulado = 0.0
if subtotal_total:
for line in elegibles:
base_linea = line.subtotal
if sobre == 'pagado':
base_linea *= ratio_pago
factor = factor_multiplicador(cfg, venta.contexto_lealtad(line))
acumulado += base_linea * por_unidad * factor
acumulado *= factor_nivel
return _redondear(acumulado, cfg.get('redondear')), {
'modo': 'configurable', 'base': base_vendida, 'factor': factor_nivel,
'elegible': elegible_sub, 'subtotal': subtotal_total,
'sin_propina': sin_propina,
}

View File

@@ -1,8 +1,12 @@
# -*- coding: utf-8 -*-
from dateutil.relativedelta import relativedelta
from odoo import models, fields, api, _
from odoo.exceptions import ValidationError
from ..lealtad import get_lealtad, nivel_por_codigo, puntos_venta
class SkeenMonedero(models.Model):
_name = 'skeen.monedero'
@@ -29,6 +33,68 @@ class SkeenMonedero(models.Model):
for monedero in self:
monedero.equivalent_mxn = monedero.points * 1.0 # 1 punto = $1 MXN
# ------------------------------------------------------------------
# Nivel de lealtad (comodidad para el frontend y las vistas)
# ------------------------------------------------------------------
nivel_codigo = fields.Char(related='partner_id.lealtad_nivel_codigo',
string='Nivel', readonly=True)
nivel_nombre = fields.Char(related='partner_id.lealtad_nivel_nombre',
string='Nivel (nombre)', readonly=True)
# ------------------------------------------------------------------
# Motor de puntos (lealtad configurable)
# ------------------------------------------------------------------
@api.model
def puntos_de_venta(self, venta, cfg=None):
"""Puntos que genera una venta según la configuración vigente."""
return puntos_venta(self.env, venta, cfg=cfg)[0]
@api.model
def expirar_puntos(self, cfg=None):
"""Expira los puntos no usados más antiguos que `expira_meses`.
FIFO sobre las transacciones `accrual`: cada canje/ajuste/vencimiento
consume primero los lotes más viejos. Solo se expiran los lotes que
siguen vivos y ya pasaron el plazo. Devuelve el total expirado.
"""
cfg = cfg or get_lealtad(self.env)
meses = int(cfg.get('expira_meses') or 0)
# En modo clásico no se expira nada (el comportamiento de siempre).
if not cfg.get('activo') or cfg.get('modo') != 'configurable' or meses <= 0:
return 0
cutoff = fields.Datetime.now() - relativedelta(months=meses)
Tx = self.env['skeen.monedero.transaction']
total = 0
for monedero in self.search([]):
txs = Tx.search([('monedero_id', '=', monedero.id)],
order='create_date asc, id asc')
lotes = []
for tx in txs:
pts = int(tx.points or 0)
if pts > 0 and tx.type == 'accrual':
lotes.append([tx.create_date, pts])
elif pts < 0:
consumo = -pts
for lote in lotes:
if consumo <= 0:
break
if lote[1] <= 0:
continue
toma = min(lote[1], consumo)
lote[1] -= toma
consumo -= toma
expira = sum(l[1] for l in lotes
if l[1] > 0 and l[0] and l[0] < cutoff)
expira = min(expira, monedero.points)
if expira > 0:
monedero.adjust_points(
-expira,
description='Vencimiento de puntos (%d meses)' % meses,
reference='EXPIRACION',
tx_type='expiration')
total += expira
return total
def get_balance(self, phone):
"""Obtener saldo por teléfono (para API)"""
partner = self.env['res.partner'].search([('phone', '=', phone)], limit=1)
@@ -107,10 +173,13 @@ class SkeenMonedero(models.Model):
})
return True
def adjust_points(self, delta, description='', reference='', operator=''):
def adjust_points(self, delta, description='', reference='', operator='',
tx_type='adjustment'):
"""Ajuste de puntos (puede ser negativo). Hace piso en 0 para no dejar saldo negativo.
Usado por devoluciones para revertir puntos acumulados por una venta.
`tx_type` permite registrar la bitácora como 'expiration' (vencimiento)
sin cambiar el comportamiento de los ajustes normales.
Devuelve el delta efectivamente aplicado (puede ser menor si se pisó en 0).
"""
self.ensure_one()
@@ -123,7 +192,7 @@ class SkeenMonedero(models.Model):
self.write({'points': new_points})
self.env['skeen.monedero.transaction'].create({
'monedero_id': self.id,
'type': 'adjustment',
'type': tx_type,
'points': effective_delta,
'description': description or 'Ajuste de puntos',
'reference': reference,
@@ -131,6 +200,13 @@ class SkeenMonedero(models.Model):
})
return effective_delta
def nivel_dict(self, cfg=None):
"""Beneficios del nivel vigente del paciente (o dict vacío)."""
self.ensure_one()
cfg = cfg or get_lealtad(self.env)
nivel = nivel_por_codigo(cfg, self.partner_id.lealtad_nivel_codigo)
return dict(nivel) if nivel else {}
class SkeenMonederoTransaction(models.Model):
_name = 'skeen.monedero.transaction'

View File

@@ -1,7 +1,11 @@
# -*- coding: utf-8 -*-
from dateutil.relativedelta import relativedelta
from odoo import models, fields, api
from ..lealtad import get_lealtad, nivel_por_codigo, nivel_para_gasto
class ResPartner(models.Model):
_inherit = 'res.partner'
@@ -10,8 +14,116 @@ class ResPartner(models.Model):
monedero_ids = fields.One2many('skeen.monedero', 'partner_id', string='Monederos')
wallet_points = fields.Integer(string='Puntos Monedero', compute='_compute_wallet_points', store=True)
# ------------------------------------------------------------------
# Nivel de lealtad (VIP). El nivel se decide por el gasto acumulado en
# una ventana y se guarda su código (los niveles viven en el bloque
# `lealtad` de los ajustes). `lealtad_manual` + `lealtad_nivel_revisar`
# permiten asignarlo a mano sin que se sobreescriba solo.
# ------------------------------------------------------------------
lealtad_nivel_codigo = fields.Char(string='Nivel de lealtad', copy=False)
lealtad_nivel_desde = fields.Date(string='Nivel desde', copy=False)
lealtad_nivel_revisar = fields.Date(string='Revisar nivel el', copy=False)
lealtad_manual = fields.Boolean(string='Nivel asignado a mano', copy=False,
default=False)
lealtad_nivel_nombre = fields.Char(
string='Nivel (nombre)', compute='_compute_lealtad_beneficios')
lealtad_descuento_pct = fields.Float(
string='Descuento de nivel (%)', compute='_compute_lealtad_beneficios')
lealtad_factor_puntos = fields.Float(
string='Factor de puntos', compute='_compute_lealtad_beneficios')
lealtad_gasto = fields.Float(
string='Gasto en la ventana', compute='_compute_lealtad_beneficios')
@api.depends('monedero_ids.points')
def _compute_wallet_points(self):
for partner in self:
monedero = partner.monedero_ids[:1]
partner.wallet_points = monedero.points if monedero else 0
@api.depends('lealtad_nivel_codigo')
def _compute_lealtad_beneficios(self):
cfg = get_lealtad(self.env)
for partner in self:
nivel = nivel_por_codigo(cfg, partner.lealtad_nivel_codigo)
partner.lealtad_nivel_nombre = (nivel or {}).get('nombre') or ''
partner.lealtad_descuento_pct = float((nivel or {}).get('descuento_pct') or 0.0)
partner.lealtad_factor_puntos = float((nivel or {}).get('factor_puntos') or 1.0)
ventana = 12
if cfg.get('niveles'):
ventana = max(int(n.get('ventana_meses') or 12) for n in cfg['niveles'])
partner.lealtad_gasto = partner._lealtad_gasto_ventana(ventana)
# ------------------------------------------------------------------
# Consultas y recálculo de nivel
# ------------------------------------------------------------------
def _lealtad_gasto_ventana(self, meses):
"""Gasto (suma de ventas no canceladas) del paciente en `meses` meses."""
self.ensure_one()
meses = max(int(meses or 12), 1)
desde = fields.Date.today() - relativedelta(months=meses)
ventas = self.env['skeen.venta'].sudo().search([
('partner_id', '=', self.id),
('state', '!=', 'cancelled'),
('date', '>=', desde),
])
return sum(v.total for v in ventas)
def _lealtad_recalcular(self, cfg=None):
"""Recalcula el nivel VIP de los pacientes, respetando vigencias.
- Un nivel no baja de inmediato: se conserva hasta que venza su
`vigencia_meses` contados desde `lealtad_nivel_desde`.
- Una asignación manual con fecha de revisión futura no se toca.
Solo aplica con `modo='configurable'`; en clásico no hay niveles.
"""
cfg = cfg or get_lealtad(self.env)
if not cfg.get('activo') or cfg.get('modo') != 'configurable' or not cfg.get('niveles'):
return 0
hoy = fields.Date.today()
cambios = 0
for partner in self:
if partner.lealtad_manual and partner.lealtad_nivel_revisar \
and partner.lealtad_nivel_revisar > hoy:
continue
vals = {}
if partner.lealtad_manual and partner.lealtad_nivel_revisar \
and partner.lealtad_nivel_revisar <= hoy:
partner.lealtad_manual = False
partner.lealtad_nivel_revisar = False
ventana = max(int(n.get('ventana_meses') or 12) for n in cfg['niveles'])
gasto = partner._lealtad_gasto_ventana(ventana)
nuevo = nivel_para_gasto(cfg, gasto)
actual = nivel_por_codigo(cfg, partner.lealtad_nivel_codigo)
# Bajar de nivel: solo cuando venció la vigencia del actual.
if actual and (nuevo is None or nuevo['gasto_minimo'] < actual['gasto_minimo']):
desde = partner.lealtad_nivel_desde
vence = (desde + relativedelta(
months=int(actual.get('vigencia_meses') or 0))) if desde else None
if vence and hoy < vence:
continue
if not nuevo:
if partner.lealtad_nivel_codigo:
vals = {'lealtad_nivel_codigo': False, 'lealtad_nivel_desde': False}
elif partner.lealtad_manual:
vals = {'lealtad_manual': False, 'lealtad_nivel_revisar': False}
elif nuevo['codigo'] != partner.lealtad_nivel_codigo:
vals = {
'lealtad_nivel_codigo': nuevo['codigo'],
'lealtad_nivel_desde': hoy,
'lealtad_manual': False,
'lealtad_nivel_revisar': False,
}
if vals:
partner.write(vals)
cambios += 1
return cambios
@api.model
def _lealtad_recalcular_todos(self):
"""Proceso diario: recalcula el nivel de todos los pacientes."""
cfg = get_lealtad(self.env)
if not cfg.get('activo') or cfg.get('modo') != 'configurable' or not cfg.get('niveles'):
return True
partners = self.sudo().search([('is_patient', '=', True)])
partners._lealtad_recalcular(cfg)
return True

View File

@@ -29,8 +29,10 @@ from odoo.http import request, Response
from odoo.addons.skeen_citas.clinic_settings import (
get_settings as get_clinic_settings,
get_reservas_online,
get_lealtad,
brand_dict,
)
from odoo.addons.skeen_monedero.lealtad import nivel_por_codigo as lealtad_nivel_por_codigo
from odoo.addons.skeen_citas.glosario import resolve as resolver_glosario
from odoo.addons.skeen_whatsapp.controllers import public_booking as _pb
@@ -797,7 +799,9 @@ class SkeenPortalPublicoController(http.Controller):
monedero = request.env['skeen.monedero'].sudo().search(
[('partner_id', '=', partner.id)], limit=1)
points = monedero.points if monedero else 0
mxn = float(points) * 1.0 # 1 punto = $1 MXN
cfg_lea = get_lealtad(request.env)
valor = float(cfg_lea.get('valor_punto') or 1.0) # clásico: 1 pt = $1
mxn = float(points) * valor
rate = 0.0
try:
rate = request.env['skeen.tipo.cambio'].sudo().get_current_rate()
@@ -815,12 +819,20 @@ class SkeenPortalPublicoController(http.Controller):
'descripcion': tx.description or '',
'referencia': tx.reference or '',
})
nivel = lealtad_nivel_por_codigo(cfg_lea, partner.lealtad_nivel_codigo)
return _json({
'status': 'success',
'points': points,
'equivalent_mxn': round(mxn, 2),
'equivalent_usd': usd,
'rate': rate or None,
'valor_punto': valor,
'nivel': ({
'codigo': nivel['codigo'],
'nombre': nivel['nombre'],
'descuento_pct': nivel.get('descuento_pct') or 0.0,
'color': nivel.get('color') or '',
} if nivel else None),
'movimientos': movimientos,
})
except Exception as exc:

View File

@@ -5,7 +5,9 @@ from odoo.exceptions import ValidationError
from markupsafe import Markup
from odoo.addons.skeen_citas.clinic_settings import get_impuestos
from odoo.addons.skeen_citas.clinic_settings import get_impuestos, get_lealtad, get_propinas
from odoo.addons.skeen_monedero.lealtad import (
get_lealtad as _get_lealtad, nivel_por_codigo, puntos_venta, regla_multiplicador)
def _fmt_mxn(v):
@@ -99,6 +101,21 @@ class SkeenVenta(models.Model):
points_redeemed = fields.Integer(string='Puntos Redimidos', default=0)
points_discount = fields.Float(string='Descuento por Puntos', default=0.0)
# Lealtad: descuento automático por el nivel VIP del cliente (0 si no hay
# niveles o la lealtad está en modo clásico).
nivel_codigo = fields.Char(string='Nivel del cliente', copy=False)
nivel_discount = fields.Float(string='Descuento por nivel', default=0.0)
# Propinas (opcionales; 0 = comportamiento de siempre). La propina NO se
# mezcla con el importe de los servicios: va en su propio campo y se suma
# al total cobrado al cliente. `propina_employee_id` es a quién corresponde
# cuando el reparto está en 'ninguno'.
propina = fields.Float(string='Propina', default=0.0)
propina_moneda = fields.Char(string='Moneda de la propina', default='MXN')
propina_employee_id = fields.Many2one('hr.employee', string='Propina para')
reparto_ids = fields.One2many('skeen.propina.reparto', 'venta_id',
string='Reparto de propina')
# Devolución
refunded = fields.Boolean(string='Devuelta', default=False)
refund_amount = fields.Float(string='Monto Devuelto', default=0.0)
@@ -140,15 +157,18 @@ class SkeenVenta(models.Model):
notes = fields.Text(string='Notas')
@api.depends('line_ids.subtotal', 'line_ids.tax_amount', 'discount', 'tax',
'amount_paid', 'points_discount')
'amount_paid', 'points_discount', 'nivel_discount', 'propina')
def _compute_totals(self):
for venta in self:
subtotal = sum(line.subtotal for line in venta.line_ids)
venta.subtotal = subtotal
# La fórmula de siempre: subtotal - descuento - puntos + impuesto.
# Con impuestos apagados `tax` conserva el valor manual, así que el
# total sale idéntico al de antes.
venta.total = max(0.0, subtotal - venta.discount - venta.points_discount + venta.tax)
# total sale idéntico al de antes. `nivel_discount` y `propina` son
# 0 por defecto: no alteran ninguna cifra existente.
venta.total = max(
0.0, subtotal - venta.discount - venta.nivel_discount
- venta.points_discount + venta.tax + (venta.propina or 0.0))
venta.amount_due = max(0.0, venta.total - venta.amount_paid)
@api.depends('line_ids.subtotal', 'line_ids.tax_amount')
@@ -191,18 +211,16 @@ class SkeenVenta(models.Model):
'payment_state': 'paid',
'amount_paid': venta.total,
})
# Acumular puntos (proporcional a líneas que generan monedero)
# Acumular puntos según la configuración de lealtad vigente
# (clásico: 1 pt por cada $10; configurable: bloque `lealtad`).
monedero = self.env['skeen.monedero'].search([('partner_id', '=', venta.partner_id.id)], limit=1)
if not monedero:
monedero = self.env['skeen.monedero'].create({'partner_id': venta.partner_id.id})
subtotal_total = sum(l.subtotal for l in venta.line_ids)
elegible = sum(
l.subtotal for l in venta.line_ids
if (l.service_id and l.service_id.genera_monedero) or (l.item_id and l.item_id.genera_monedero)
)
points = int(venta.total * (elegible / subtotal_total) / 10) if subtotal_total else 0
points = venta._wallet_points_expected()
if points > 0:
monedero.add_points(points, description=f'Venta {venta.name}', reference=venta.name)
# El nivel VIP del cliente se recalcula al cerrar la venta.
venta.partner_id._lealtad_recalcular()
def action_cancel(self):
self.write({'state': 'cancelled'})
@@ -326,17 +344,117 @@ class SkeenVenta(models.Model):
def _wallet_points_expected(self):
"""Puntos que DEBIÓ generar esta venta con sus líneas y total actuales.
Es la misma fórmula de action_mark_paid: el total se reparte
proporcionalmente entre las líneas que generan monedero ($10 = 1 pt).
En modo clásico es la misma fórmula de action_mark_paid (el total se
reparte proporcionalmente entre las líneas que generan monedero,
$10 = 1 pt). En modo configurable la calcula el motor de lealtad.
"""
self.ensure_one()
subtotal_total = sum(l.subtotal for l in self.line_ids)
elegible = sum(
l.subtotal for l in self.line_ids
if (l.service_id and l.service_id.genera_monedero)
or (l.item_id and l.item_id.genera_monedero)
)
return int(self.total * (elegible / subtotal_total) / 10) if subtotal_total else 0
return puntos_venta(self.env, self)[0]
def contexto_lealtad(self, line):
"""Contexto de una línea para resolver multiplicadores y nivel."""
self.ensure_one()
fecha = self.date
return {
'servicio_id': line.service_id.id or None,
'articulo_id': line.item_id.id or None,
'categoria': line.service_id.category if line.service_id else '',
'sucursal': self.cita_id.branch if self.cita_id else '',
'nivel_codigo': self.partner_id.lealtad_nivel_codigo or '',
'fecha': fecha,
'dia_semana': fecha.weekday() if fecha else None,
}
def _aplicar_descuento_nivel(self, partner=None):
"""Aplica el descuento (%) del nivel VIP del cliente. Solo con lealtad
configurable y niveles; en clásico no hay descuento (queda en 0)."""
self.ensure_one()
cfg = _get_lealtad(self.env)
partner = partner or self.partner_id
if not cfg.get('activo') or cfg.get('modo') != 'configurable':
return 0.0
nivel = nivel_por_codigo(cfg, partner.lealtad_nivel_codigo)
pct = float((nivel or {}).get('descuento_pct') or 0.0)
if pct <= 0:
self.write({'nivel_codigo': (nivel or {}).get('codigo') or False})
return 0.0
descuento = round((self.subtotal - self.discount) * pct / 100.0, 2)
descuento = max(0.0, descuento)
self.write({'nivel_discount': descuento,
'nivel_codigo': (nivel or {}).get('codigo') or False})
return descuento
def _generar_reparto_propina(self, cfg=None, cobrador_employee=None,
cobrador_login=''):
"""Reparte la propina de la venta entre el personal implicado.
Con `reparto='ninguno'` no se crea ningún registro: la propina queda en
la venta (y en `propina_employee_id` si llega). Devuelve los registros
creados (vacío si no aplica).
"""
self.ensure_one()
cfg = cfg or get_propinas(self.env)
Reparto = self.env['skeen.propina.reparto']
if not cfg.get('activo') or (self.propina or 0.0) <= 0:
return Reparto
if cfg.get('reparto') not in ('equitativo', 'por_participacion'):
return Reparto
self.reparto_ids.unlink()
atendio = self.cita_id.doctor_id if self.cita_id else self.env['hr.employee']
participantes = []
if atendio:
participantes.append((atendio, 'atendio'))
if cobrador_employee and cobrador_employee != atendio:
participantes.append((cobrador_employee, 'cobro'))
if not participantes:
participantes = [(None, 'cobro')]
montos = {}
if cfg.get('reparto') == 'por_participacion' and self.line_ids:
pesos = {}
for emp, rol in participantes:
if not emp:
continue
pesos[emp.id] = pesos.get(emp.id, 0.0)
total_lineas = 0.0
for line in self.line_ids:
total_lineas += line.subtotal
if line.prescribed_by_id and line.prescribed_by_id.id in pesos:
pesos[line.prescribed_by_id.id] += line.subtotal
elif atendio and atendio.id in pesos and not line.prescribed_by_id:
pesos[atendio.id] += line.subtotal
suma = sum(pesos.values())
if suma > 0:
for emp, rol in participantes:
if emp:
montos[emp.id] = round(self.propina * pesos[emp.id] / suma, 2)
if not montos:
# equitativo (o respaldo de por_participacion sin pesos)
cupo = round(self.propina / len(participantes), 2)
for emp, rol in participantes:
montos[emp.id if emp else 0] = cupo
creados = Reparto
repartido = 0.0
for emp, rol in participantes:
monto = montos.get(emp.id if emp else 0, 0.0)
if monto <= 0:
continue
creados |= Reparto.create({
'venta_id': self.id,
'partner_id': self.partner_id.id,
'employee_id': emp.id if emp else False,
'rol': rol,
'monto': monto,
'moneda': self.propina_moneda or 'MXN',
'cuenta': cfg.get('cuenta_propinas') or '',
'user_login': cobrador_login or self.user_login or '',
'date': self.date,
})
repartido += monto
# Ajuste de centavos: el primero absorbe la diferencia.
if creados and abs(repartido - self.propina) >= 0.005:
creados[0].monto = round(creados[0].monto + (self.propina - repartido), 2)
return creados
def _wallet_points_accrued(self):
"""Puntos realmente acumulados por esta venta (suma de abonos a su referencia)."""
@@ -683,7 +801,12 @@ class SkeenVenta(models.Model):
new_amount_paid = round(self.amount_paid - refund_amount, 2)
# Revertir puntos acumulados en proporción a lo devuelto ($10 MXN = 1 punto)
# Revertir puntos acumulados en proporción a lo devuelto. Clásico:
# $10 MXN = 1 punto. Configurable: según `puntos_por_unidad`.
cfg_lea = _get_lealtad(self.env)
if cfg_lea.get('modo') == 'configurable':
points_to_reverse = int(refund_amount * float(cfg_lea.get('puntos_por_unidad') or 0.0))
else:
points_to_reverse = int(refund_amount / 10)
if points_to_reverse > 0:
monedero = self.env['skeen.monedero'].search([('partner_id', '=', self.partner_id.id)], limit=1)
@@ -729,20 +852,26 @@ class SkeenVenta(models.Model):
}
def action_apply_wallet(self, points):
"""Redime puntos del monedero como descuento en la venta"""
"""Redime puntos del monedero como descuento en la venta.
El tope de canje (`canje_max_pct`) y el valor del punto (`valor_punto`)
salen de la configuración de lealtad; en modo clásico equivalen al tope
de 50% y 1 punto = $1 de siempre.
"""
self.ensure_one()
monedero = self.env['skeen.monedero'].search([('partner_id', '=', self.partner_id.id)], limit=1)
if not monedero or monedero.points < points:
raise ValidationError(_('Puntos insuficientes'))
# Máximo 50% del total
max_discount = self.total * 0.5
point_value = min(points, int(max_discount))
cfg = _get_lealtad(self.env)
valor = float(cfg.get('valor_punto') or 1.0) or 1.0
max_discount = self.total * float(cfg.get('canje_max_pct') or 0.0) / 100.0
point_value = min(points, int(max_discount / valor))
monedero.redeem_points(point_value, description=f'Redención venta {self.name}', reference=self.name)
self.write({
'points_redeemed': point_value,
'points_discount': point_value * 1.0,
'points_discount': point_value * valor,
})
return True
@@ -768,6 +897,37 @@ class SkeenVentaLine(models.Model):
help='Marcar si este artículo fue recetado por un médico (entra al cálculo de comisiones).')
prescribed_by_id = fields.Many2one('hr.employee', string='Recetado por')
# Clasificación para el programa de lealtad (`aplicar_a` y multiplicadores).
tipo_monedero = fields.Selection([
('servicios', 'Servicios'),
('productos', 'Productos'),
('membresias', 'Membresías'),
('paquetes', 'Paquetes'),
], string='Tipo (lealtad)', compute='_compute_lealtad_linea')
genera_monedero = fields.Boolean(
string='Genera monedero', compute='_compute_lealtad_linea',
help='Igual que el indicador del catálogo: si el servicio/artículo acumula puntos.')
@api.depends('service_id', 'item_id', 'paquete_id',
'service_id.genera_monedero', 'item_id.genera_monedero')
def _compute_lealtad_linea(self):
planes = None
if 'skeen.membresia.plan' in self.env:
planes = set(self.env['skeen.membresia.plan'].sudo().search(
[('servicio_id', '!=', False)]).mapped('servicio_id').ids)
for line in self:
if line.item_id:
line.tipo_monedero = 'productos'
elif line.paquete_id:
line.tipo_monedero = 'paquetes'
elif line.service_id and planes is not None and line.service_id.id in planes:
line.tipo_monedero = 'membresias'
else:
line.tipo_monedero = 'servicios'
line.genera_monedero = bool(
(line.service_id and line.service_id.genera_monedero)
or (line.item_id and line.item_id.genera_monedero))
_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.')
@@ -868,6 +1028,36 @@ class SkeenVentaLine(models.Model):
self.tax_rate = self._tasa_para_linea({'item_id': self.item_id.id})
class SkeenPropinaReparto(models.Model):
"""Detalle del reparto de una propina entre el personal.
Una fila por persona con lo que le toca de la propina de una venta. Se usa
en el reporte de propinas por empleado y, si la configuración lo pide, se
suma a su liquidación de comisiones. Con `activo=False` (default) no se
genera ninguna fila.
"""
_name = 'skeen.propina.reparto'
_description = 'Reparto de Propina'
_order = 'date desc, id desc'
venta_id = fields.Many2one('skeen.venta', string='Venta', required=True,
ondelete='cascade', index=True)
venta_name = fields.Char(related='venta_id.name', string='Folio', readonly=True)
partner_id = fields.Many2one('res.partner', string='Paciente', index=True)
employee_id = fields.Many2one('hr.employee', string='Empleado', index=True)
rol = fields.Selection([
('atendio', 'Atendió la cita'),
('cobro', 'Cobró'),
('otro', 'Otro'),
], string='Rol', default='otro')
monto = fields.Float(string='Monto', default=0.0)
moneda = fields.Char(string='Moneda', default='MXN')
cuenta = fields.Char(string='Cuenta destino')
user_login = fields.Char(string='Registrado por')
date = fields.Date(string='Fecha', default=fields.Date.today, index=True)
notes = fields.Char(string='Notas')
class SkeenCorteCaja(models.Model):
_name = 'skeen.corte.caja'
_description = 'Corte de Caja SKEEN'
@@ -890,6 +1080,11 @@ class SkeenCorteCaja(models.Model):
total_transfer = fields.Float(string='Transferencia', compute='_compute_totals', store=True)
total_other = fields.Float(string='Otros', compute='_compute_totals', store=True)
total_sales = fields.Float(string='Total Ventas', compute='_compute_totals', store=True)
# Desglose de propinas del día (0 cuando el módulo está apagado). No altera
# ninguna cifra existente: es un renglón aparte.
total_propinas = fields.Float(string='Propinas', compute='_compute_totals', store=True)
total_servicios = fields.Float(string='Servicios (sin propina)',
compute='_compute_totals', store=True)
# Cierre guiado: efectivo contado por el cajero y conteo informativo
# de los demás métodos.
@@ -929,12 +1124,18 @@ class SkeenCorteCaja(models.Model):
card = sum(p.amount for p in pagos if p.payment_method in ('card', 'stripe', 'mercadopago'))
transfer = sum(p.amount for p in pagos if p.payment_method == 'transfer')
other = sum(p.amount for p in pagos if p.payment_method not in ('cash', 'card', 'stripe', 'mercadopago', 'transfer'))
ventas = self.env['skeen.venta'].sudo().search([
('date', '=', self.date), ('state', '!=', 'cancelled'),
])
propinas = sum(v.propina or 0.0 for v in ventas)
return {
'total_cash': cash,
'total_card': card,
'total_transfer': transfer,
'total_other': other,
'total_sales': cash + card + transfer + other,
'total_propinas': propinas,
'total_servicios': (cash + card + transfer + other) - propinas,
}
def refresh_totals(self):

View File

@@ -6,3 +6,4 @@ access_skeen_tipo_cambio_user,Acceso a Tipo de Cambio SKEEN,model_skeen_tipo_cam
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
access_skeen_propina_reparto_user,Acceso a Reparto de Propinas SKEEN,model_skeen_propina_reparto,base.group_user,1,1,1,1
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
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
9 access_skeen_propina_reparto_user Acceso a Reparto de Propinas SKEEN model_skeen_propina_reparto base.group_user 1 1 1 1

View File

@@ -22,6 +22,11 @@ from odoo.addons.skeen_citas.clinic_settings import (
get_impuestos,
get_recursos,
get_compras,
get_lealtad,
get_propinas,
)
from odoo.addons.skeen_monedero.lealtad import (
nivel_por_codigo as lealtad_nivel_por_codigo,
)
from odoo.addons.skeen_citas.glosario import (
GLOSARIO_KEYS as GLOSARIO_KEY_SET,
@@ -2967,15 +2972,19 @@ class SkeenFrontendController(http.Controller):
if refs:
for v in request.env['skeen.venta'].sudo().search([('name', 'in', list(refs))]):
totales[v.name] = v.total
cfg_lea = get_lealtad(request.env)
valor = float(cfg_lea.get('valor_punto') or 1.0)
return json_response({
'status': 'success',
'patient_id': partner.id,
'patient': partner.name,
'wallet_id': monedero.id if monedero else None,
'points': saldo,
'equivalent_mxn': float(saldo),
'equivalent_usd': round(saldo / rate, 2) if rate else None,
'valor_punto': valor,
'equivalent_mxn': float(saldo) * valor,
'equivalent_usd': round(saldo * valor / rate, 2) if rate else None,
'rate': rate,
'nivel': self._lealtad_nivel_dict(cfg_lea, partner),
'depositado': float(sum(t.points for t in (monedero.transaction_ids if monedero else []) if t.type == 'deposito')),
'movimientos': [{
'id': t.id,
@@ -2993,6 +3002,255 @@ class SkeenFrontendController(http.Controller):
except Exception as e:
return json_response({'status': 'error', 'message': str(e)}, 500)
# ============================================================
# Lealtad: puntos, niveles y reportes
# ============================================================
def _lealtad_nivel_dict(self, cfg, partner):
nivel = lealtad_nivel_por_codigo(cfg, partner.lealtad_nivel_codigo)
return {
'codigo': partner.lealtad_nivel_codigo or '',
'nombre': (nivel or {}).get('nombre', '') or '',
'descuento_pct': float((nivel or {}).get('descuento_pct') or 0.0),
'factor_puntos': float((nivel or {}).get('factor_puntos') or 1.0),
'color': (nivel or {}).get('color', '') or '',
'desde': partner.lealtad_nivel_desde.strftime('%Y-%m-%d') if partner.lealtad_nivel_desde else None,
'manual': bool(partner.lealtad_manual),
'revisar': partner.lealtad_nivel_revisar.strftime('%Y-%m-%d') if partner.lealtad_nivel_revisar else None,
}
@http.route('/skeen/frontend/v1/patients/<int:patient_id>/lealtad',
type='http', auth='none', methods=['GET', 'OPTIONS'], csrf=False)
@require_role('lectura')
@require_perm('lealtad.ver')
def patient_lealtad(self, patient_id, **kw):
"""Nivel VIP, saldo de puntos, valor y gasto del paciente."""
try:
partner = request.env['res.partner'].sudo().browse(patient_id)
if not partner.exists() or not partner.is_patient:
return json_response({'status': 'error', 'message': 'Paciente no encontrado'}, 404)
cfg = get_lealtad(request.env)
monedero = self._monedero_de(partner, crear=False)
saldo = monedero.points if monedero else 0
valor = float(cfg.get('valor_punto') or 1.0)
return json_response({
'status': 'success',
'patient_id': partner.id,
'patient': partner.name,
'activo': bool(cfg.get('activo')),
'modo': cfg.get('modo'),
'valor_punto': valor,
'puntos': saldo,
'equivalente_mxn': round(saldo * valor, 2),
'gasto_ventana': partner.lealtad_gasto,
'nivel': self._lealtad_nivel_dict(cfg, partner),
'niveles': cfg.get('niveles') or [],
})
except Exception as e:
return json_response({'status': 'error', 'message': str(e)}, 500)
@http.route('/skeen/frontend/v1/patients/<int:patient_id>/lealtad/recalcular',
type='http', auth='none', methods=['POST', 'OPTIONS'], csrf=False)
@require_role('gerencia')
@require_perm('lealtad.ajustar')
def patient_lealtad_recalcular(self, patient_id, **kw):
"""Recalcula el nivel VIP (respeta la asignación manual vigente)."""
try:
partner = request.env['res.partner'].sudo().browse(patient_id)
if not partner.exists() or not partner.is_patient:
return json_response({'status': 'error', 'message': 'Paciente no encontrado'}, 404)
cfg = get_lealtad(request.env)
partner._lealtad_recalcular(cfg)
return json_response({'status': 'success',
'nivel': self._lealtad_nivel_dict(cfg, partner),
'gasto_ventana': partner.lealtad_gasto})
except Exception as e:
return json_response({'status': 'error', 'message': str(e)}, 500)
@http.route('/skeen/frontend/v1/patients/<int:patient_id>/lealtad',
type='http', auth='none', methods=['PUT'], csrf=False)
@require_role('gerencia')
@require_perm('lealtad.nivel')
def patient_lealtad_set(self, patient_id, **kw):
"""Asigna el nivel a mano con fecha de revisión (no se sobreescribe)."""
try:
partner = request.env['res.partner'].sudo().browse(patient_id)
if not partner.exists() or not partner.is_patient:
return json_response({'status': 'error', 'message': 'Paciente no encontrado'}, 404)
cfg = get_lealtad(request.env)
data = _parse_json_body()
codigo = (data.get('codigo') or '').strip()
if codigo:
nivel = lealtad_nivel_por_codigo(cfg, codigo)
if not nivel:
return json_response({'status': 'error', 'message': 'Nivel desconocido'}, 400)
revisar = (data.get('revisar') or '').strip()
if revisar:
try:
revisar_d = fields.Date.to_date(revisar)
except Exception:
return json_response({'status': 'error', 'message': 'Fecha de revisión inválida'}, 400)
else:
revisar_d = False
usuario = getattr(request.frontend_user, 'login', '') if hasattr(request, 'frontend_user') else ''
partner.write({
'lealtad_nivel_codigo': codigo or False,
'lealtad_nivel_desde': fields.Date.today() if codigo else False,
'lealtad_manual': True,
'lealtad_nivel_revisar': revisar_d,
})
self._auditar('lealtad', f'paciente:{partner.id}', 'nivel_manual',
False, codigo, entity_label=partner.name,
notes='Asignación manual de nivel (%s)' % usuario)
return json_response({'status': 'success',
'nivel': self._lealtad_nivel_dict(cfg, partner)})
except Exception as e:
return json_response({'status': 'error', 'message': str(e)}, 500)
@http.route('/skeen/frontend/v1/patients/<int:patient_id>/points/adjust',
type='http', auth='none', methods=['POST', 'OPTIONS'], csrf=False)
@require_role('gerencia')
@require_perm('lealtad.ajustar')
def patient_points_adjust(self, patient_id, **kw):
"""Ajuste manual del saldo de puntos (con motivo, para auditoría)."""
try:
partner = request.env['res.partner'].sudo().browse(patient_id)
if not partner.exists() or not partner.is_patient:
return json_response({'status': 'error', 'message': 'Paciente no encontrado'}, 404)
data = _parse_json_body()
try:
delta = int(data.get('points') or 0)
except (TypeError, ValueError):
return json_response({'status': 'error', 'message': 'Puntos inválidos'}, 400)
motivo = (data.get('motivo') or '').strip()
if not delta:
return json_response({'status': 'error', 'message': 'Indica los puntos a ajustar'}, 400)
if not motivo:
return json_response({'status': 'error', 'message': 'El motivo es obligatorio'}, 400)
usuario = getattr(request.frontend_user, 'login', '') if hasattr(request, 'frontend_user') else ''
monedero = self._monedero_de(partner, crear=True)
antes = monedero.points
aplicado = monedero.adjust_points(
delta, description=motivo,
reference=(data.get('reference') or 'AJUSTE MANUAL'),
operator=usuario)
self._auditar('monedero', f'paciente:{partner.id}', 'ajuste', antes,
monedero.points, entity_label=partner.name, notes=motivo)
return json_response({
'status': 'success',
'points': monedero.points,
'applied': aplicado,
'equivalente_mxn': round(monedero.points * float(
get_lealtad(request.env).get('valor_punto') or 1.0), 2),
})
except Exception as e:
return json_response({'status': 'error', 'message': str(e)}, 500)
@http.route('/skeen/frontend/v1/reports/lealtad', type='http', auth='none',
methods=['GET', 'OPTIONS'], csrf=False)
@require_role('lectura')
@require_perm('lealtad.ver')
def report_lealtad(self, **kw):
"""Puntos otorgados, canjeados, expirados y el pasivo en pesos."""
try:
cfg = get_lealtad(request.env)
valor = float(cfg.get('valor_punto') or 1.0)
Tx = request.env['skeen.monedero.transaction'].sudo()
domain = []
if kw.get('start'):
domain.append(('create_date', '>=', f"{kw['start']} 00:00:00"))
if kw.get('end'):
domain.append(('create_date', '<=', f"{kw['end']} 23:59:59"))
by_type = {}
for g in Tx.read_group(domain, ['type', 'points:sum'], ['type']):
by_type[g['type']] = g['points']
monederos = request.env['skeen.monedero'].sudo().search([])
activos = sum(m.points for m in monederos)
otorgados = by_type.get('accrual', 0) + by_type.get('deposito', 0)
return json_response({
'status': 'success',
'modo': cfg.get('modo'),
'valor_punto': valor,
'otorgados': by_type.get('accrual', 0),
'canjeados': abs(by_type.get('redemption', 0)),
'expirados': abs(by_type.get('expiration', 0)),
'ajustes': by_type.get('adjustment', 0),
'depositos': by_type.get('deposito', 0),
'by_type': by_type,
'puntos_activos': activos,
'pasivo_mxn': round(activos * valor, 2),
'cuentas': len(monederos),
'niveles': cfg.get('niveles') or [],
'conteo_niveles': self._conteo_niveles(cfg),
})
except Exception as e:
return json_response({'status': 'error', 'message': str(e)}, 500)
def _conteo_niveles(self, cfg):
"""Cuántos pacientes hay en cada nivel (para el reporte de lealtad)."""
conteo = {}
for codigo in [n['codigo'] for n in cfg.get('niveles') or []]:
conteo[codigo] = 0
partners = request.env['res.partner'].sudo().search([
('is_patient', '=', True), ('lealtad_nivel_codigo', '!=', False)])
for p in partners:
if p.lealtad_nivel_codigo in conteo:
conteo[p.lealtad_nivel_codigo] += 1
return conteo
@http.route('/skeen/frontend/v1/reports/propinas', type='http', auth='none',
methods=['GET', 'OPTIONS'], csrf=False)
@require_role('lectura')
@require_perm('propinas.ver')
def report_propinas(self, **kw):
"""Propinas por empleado y periodo (con desglose de reparto)."""
try:
cfg = get_propinas(request.env)
start = kw.get('start') or (date.today().replace(day=1)).strftime('%Y-%m-%d')
end = kw.get('end') or date.today().strftime('%Y-%m-%d')
Reparto = request.env['skeen.propina.reparto'].sudo()
domain = [('date', '>=', start), ('date', '<=', end)]
grupos = Reparto.read_group(domain, ['employee_id', 'monto:sum'], ['employee_id'])
rows = []
for g in grupos:
emp = g['employee_id']
rows.append({
'employee_id': emp[0] if emp else None,
'empleado': emp[1] if emp else 'Sin asignar',
'monto': g['monto'],
'propinas': g.get('employee_id_count') or g.get('__count') or 0,
})
rows.sort(key=lambda r: -r['monto'])
ventas = request.env['skeen.venta'].sudo().search([
('date', '>=', start), ('date', '<=', end),
('state', '!=', 'cancelled'), ('propina', '>', 0)])
total_ventas = sum(v.propina for v in ventas)
detalle = [{
'venta_id': r.venta_id.id,
'folio': r.venta_id.name,
'date': r.date.strftime('%Y-%m-%d') if r.date else None,
'empleado': r.employee_id.name if r.employee_id else '',
'employee_id': r.employee_id.id if r.employee_id else None,
'rol': r.rol,
'monto': r.monto,
'moneda': r.moneda or 'MXN',
'cuenta': r.cuenta or '',
} for r in Reparto.search(domain, order='date desc, id desc', limit=500)]
return json_response({
'status': 'success',
'start': start,
'end': end,
'activo': bool(cfg.get('activo')),
'reparto': cfg.get('reparto'),
'por_empleado': rows,
'detalle': detalle,
'total': total_ventas,
'total_repartido': sum(r['monto'] for r in rows),
'total_ventas': len(ventas),
'cuenta_propinas': cfg.get('cuenta_propinas') or '',
})
except Exception as e:
return json_response({'status': 'error', 'message': str(e)}, 500)
@http.route('/skeen/frontend/v1/patients/<int:patient_id>/deposit', type='http', auth='none', methods=['POST', 'OPTIONS'], csrf=False)
@require_role('recepcion')
@require_perm('ventas.cobrar')
@@ -5098,6 +5356,11 @@ class SkeenFrontendController(http.Controller):
'cancel_points_restored': v.cancel_points_restored,
'payment_method': v.payment_method or '',
'user_login': v.user_login or '',
'propina': v.propina or 0.0,
'propina_moneda': v.propina_moneda or 'MXN',
'nivel_discount': v.nivel_discount or 0.0,
'nivel_codigo': v.nivel_codigo or '',
'cita_id': v.cita_id.id if v.cita_id else None,
'lines': [{
'id': line.id,
'service_id': line.service_id.id if line.service_id else None,
@@ -5249,6 +5512,17 @@ class SkeenFrontendController(http.Controller):
tax_total += bruto * tasa / 100.0
total_est = max(0.0, base_total - discount + tax_total)
# Lealtad y propinas de la instancia. Con propinas apagadas y
# lealtad clásica, `propina=0` y `nivel_discount=0`: el POS sale
# idéntico al de siempre.
cfg_lea = get_lealtad(request.env)
cfg_prop = get_propinas(request.env)
cita_id = int(data.get('cita_id', 0) or 0)
propina_pct = data.get('propina_pct')
propina_monto = data.get('propina')
propina_employee_id = int(data.get('propina_employee_id', 0) or 0)
cobrador_employee_id = int(data.get('cobrador_employee_id', 0) or 0)
# Moneda y tipo de cambio: validar ANTES de crear nada
currency = data.get('currency', 'MXN')
rate_used = None
@@ -5278,9 +5552,47 @@ class SkeenFrontendController(http.Controller):
'discount': discount,
'line_ids': line_vals,
'user_login': cobrador,
'cita_id': cita_id or False,
})
venta.action_confirm()
# Descuento automático del nivel VIP (0 con lealtad clásica).
nivel_pct = venta._aplicar_descuento_nivel(partner) or 0.0
# Propina: solo con el módulo encendido. Se calcula sobre los
# servicios (sin impuesto no; base = subtotal - descuentos + impuesto)
# y se guarda APARTE del importe de los servicios.
propina = 0.0
propina_moneda = (currency if currency in ('MXN', 'USD') else 'MXN')
if cfg_prop.get('activo'):
servicios_total = max(
0.0, venta.subtotal - venta.discount - venta.nivel_discount + venta.tax)
if propina_pct not in (None, ''):
try:
pct = float(propina_pct)
except (TypeError, ValueError):
pct = 0.0
if pct < 0:
pct = 0.0
if not cfg_prop.get('permitir_personalizado') and pct <= 0:
pct = 0.0
propina = round(servicios_total * pct / 100.0, 2)
elif propina_monto not in (None, ''):
try:
propina = round(float(propina_monto or 0), 2)
except (TypeError, ValueError):
propina = 0.0
elif not cfg_prop.get('permitir_sin_propina'):
return json_response({'status': 'error', 'message': 'Esta clínica exige registrar propina'}, 400)
if propina < 0:
propina = 0.0
if propina > 0:
venta.write({
'propina': propina,
'propina_moneda': propina_moneda,
'propina_employee_id': propina_employee_id or False,
})
# Paquetes vendidos: materializar el saldo del paciente. Se enlazan
# las compras a las líneas reales de la venta para poder revertirlas.
compras_creadas = []
@@ -5312,14 +5624,24 @@ class SkeenFrontendController(http.Controller):
'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).
# Clásico: cubre el 100% del servicio (comportamiento de siempre).
# Configurable: se respeta `canje_max_pct`. La propina nunca se
# cubre con puntos: siempre se paga en dinero.
puntos_usados = 0
if pay_with_points:
total = venta.total
puntos = int(total) if float(total).is_integer() else int(total) + 1
import math
valor_punto = float(cfg_lea.get('valor_punto') or 1.0) or 1.0
base_canje = venta.total
if cfg_lea.get('modo') == 'configurable':
tope = venta.total * float(cfg_lea.get('canje_max_pct') or 0.0) / 100.0
base_canje = min(base_canje, tope)
base_canje = min(base_canje, max(0.0, venta.total - venta.propina))
puntos = int(math.ceil(base_canje / valor_punto - 1e-9))
puntos = min(puntos, saldo)
monedero.redeem_points(puntos, description=f'Canje puntos venta {venta.name}', reference=venta.name)
venta.write({'points_redeemed': puntos, 'points_discount': float(puntos)})
venta.write({'points_redeemed': puntos,
'points_discount': round(puntos * valor_punto, 2)})
puntos_usados = puntos
# Pago monetario del restante
@@ -5378,6 +5700,22 @@ class SkeenFrontendController(http.Controller):
else:
venta.action_mark_paid()
# Reparto de la propina entre el personal (solo con propinas
# activas y regla distinta de 'ninguno').
if cfg_prop.get('activo') and (venta.propina or 0) > 0:
Emp = request.env['hr.employee'].sudo()
cobrador_emp = Emp.browse()
for cand in (cobrador_employee_id, propina_employee_id):
if cand:
rec = Emp.browse(cand)
if rec.exists():
cobrador_emp = rec
break
venta._generar_reparto_propina(
cfg_prop,
cobrador_employee=cobrador_emp or None,
cobrador_login=cobrador)
partner.invalidate_recordset()
# Desglose de impuestos cobrados (con impuestos apagados: base =
# subtotal, impuesto = 0, total = el de siempre).
@@ -5409,8 +5747,23 @@ class SkeenFrontendController(http.Controller):
'rate_used': rate_used,
'recibido_usd': recibido_usd,
'puntos_usados': puntos_usados,
'puntos_ganados': int(venta.total / 10) if venta.state == 'paid' else 0,
'puntos_ganados': venta._wallet_points_expected() if venta.state == 'paid' else 0,
'wallet_points': partner.wallet_points,
'propina': venta.propina or 0.0,
'propina_moneda': venta.propina_moneda or 'MXN',
'nivel_discount': venta.nivel_discount or 0.0,
'nivel': ({
'codigo': partner.lealtad_nivel_codigo or '',
'nombre': partner.lealtad_nivel_nombre or '',
'descuento_pct': partner.lealtad_descuento_pct or 0.0,
} if cfg_lea.get('modo') == 'configurable' and partner.lealtad_nivel_codigo else None),
'reparto_propina': [{
'employee_id': r.employee_id.id if r.employee_id else None,
'empleado': r.employee_id.name if r.employee_id else '',
'rol': r.rol,
'monto': r.monto,
'moneda': r.moneda or 'MXN',
} for r in venta.reparto_ids],
'stock_warnings': stock_warnings,
'packages': [{
'id': c.id,
@@ -5691,6 +6044,8 @@ class SkeenFrontendController(http.Controller):
'total_sales': sum(v.total for v in ventas),
'total_paid': sum(v.amount_paid for v in ventas),
'total_due': sum(v.amount_due for v in ventas),
'total_propinas': sum(v.propina or 0.0 for v in ventas),
'total_servicios': sum(v.total - (v.propina or 0.0) for v in ventas),
'count': len(ventas),
'by_day': [{'date': k, 'total': by_day_map[k]} for k in sorted(by_day_map)],
})
@@ -5778,6 +6133,8 @@ class SkeenFrontendController(http.Controller):
'total': sum(v.total for v in ventas),
'total_paid': sum(v.amount_paid for v in ventas),
'total_due': sum(v.amount_due for v in ventas),
'total_propinas': sum(v.propina or 0.0 for v in ventas),
'total_servicios': sum(v.total - (v.propina or 0.0) for v in ventas),
},
'pagos': {'total': sum(p.amount for p in pagos), 'count': len(pagos), 'by_method': pagos_by_method},
'inventario': {'total': len(moves), 'by_type': moves_by_type},
@@ -6366,6 +6723,8 @@ class SkeenFrontendController(http.Controller):
'total': sum(v.total for v in ventas),
'total_paid': sum(v.amount_paid for v in ventas),
'total_due': sum(v.amount_due for v in ventas),
'total_propinas': sum(v.propina or 0.0 for v in ventas),
'total_servicios': sum(v.total - (v.propina or 0.0) for v in ventas),
},
'pagos': {'total': sum(p.amount for p in pagos), 'count': len(pagos), 'by_method': pagos_by_method},
'devoluciones': {'count': len(devoluciones), 'total': sum(v.refund_amount for v in devoluciones)},
@@ -6561,12 +6920,19 @@ class SkeenFrontendController(http.Controller):
for g in Tx.read_group(domain, ['type', 'points:sum'], ['type']):
by_type[g['type']] = g['points']
monederos = request.env['skeen.monedero'].sudo().search([])
activos = sum(m.points for m in monederos)
# El valor del punto sale de la lealtad; en modo clásico es 1.0, así
# que `equivalente_mxn` conserva la cifra de siempre.
valor = float(get_lealtad(request.env).get('valor_punto') or 1.0)
return json_response({
'status': 'success',
'by_type': by_type,
'puntos_activos': sum(m.points for m in monederos),
'puntos_activos': activos,
'cuentas': len(monederos),
'equivalente_mxn': sum(m.points for m in monederos) * 1.0,
'equivalente_mxn': activos * valor,
'valor_punto': valor,
'expirados': abs(by_type.get('expiration', 0)),
'pasivo_mxn': round(activos * valor, 2),
})
except Exception as e:
return json_response({'status': 'error', 'message': str(e)}, 500)

View File

@@ -123,6 +123,17 @@ PERMISSION_GROUPS = [
('comisiones.editar', 'Administrar reglas y metas de comisiones'),
('comisiones.liquidar', 'Generar, ajustar y pagar liquidaciones'),
]),
('lealtad', 'Lealtad y puntos', [
('lealtad.ver', 'Ver saldo, nivel y reportes de lealtad'),
('lealtad.ajustar', 'Ajustar puntos a mano y recalcular el nivel'),
('lealtad.nivel', 'Asignar el nivel VIP a mano'),
('lealtad.configurar', 'Configurar el programa de lealtad y sus niveles'),
]),
('propinas', 'Propinas', [
('propinas.ver', 'Ver el reporte de propinas'),
('propinas.aplicar', 'Aplicar propinas en el punto de venta'),
('propinas.configurar', 'Configurar propinas y su reparto'),
]),
('cobros', 'Cobros y enlaces de pago', [
('cobros.ver', 'Ver enlaces de cobro pendientes y pagados'),
('cobros.administrar', 'Administrar enlaces de cobro'),
@@ -167,6 +178,8 @@ READ_PERM_KEYS = (
'proveedores.ver',
'comisiones.ver',
'cobros.ver',
'lealtad.ver',
'propinas.ver',
)
# ---------------------------------------------------------------------------
@@ -289,6 +302,19 @@ DEFAULT_PERM_ROLES = {
# Cobros y enlaces de pago. Ver es de todos; administrar (crear/cancelar) es
# de admin/gerencia; forzar el cobro (registrar un pago sin pasarela) lo
# hace recepción/gerencia/admin; cancelar retenciones, admin/gerencia.
# Lealtad y puntos. Ver es de todos; ajustar puntos (equivalen a dinero) y
# asignar el nivel a mano es de admin/gerencia; configurar el programa
# (reglas, niveles) también.
'lealtad.ver': ALL,
'lealtad.ajustar': AG,
'lealtad.nivel': AG,
'lealtad.configurar': AG,
# Propinas. Ver el reporte es de todos; aplicar la propina al cobrar lo
# hace quien cobra (recepción); configurar el módulo y el reparto es de
# admin/gerencia.
'propinas.ver': ALL,
'propinas.aplicar': MONEY,
'propinas.configurar': AG,
'cobros.ver': ALL,
'cobros.administrar': AG,
'cobros.forzar': ('admin', 'recepcion', 'gerencia'),