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:
@@ -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
|
||||
|
||||
378
frontend/src/components/LealtadPanel.tsx
Normal file
378
frontend/src/components/LealtadPanel.tsx
Normal 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 > artículo > categoría > nivel > sucursal > rango de fechas > 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;
|
||||
@@ -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>
|
||||
|
||||
160
frontend/src/components/PropinasPanel.tsx
Normal file
160
frontend/src/components/PropinasPanel.tsx
Normal 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;
|
||||
@@ -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
|
||||
|
||||
131
frontend/src/components/reportes/LealtadReport.tsx
Normal file
131
frontend/src/components/reportes/LealtadReport.tsx
Normal 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;
|
||||
156
frontend/src/components/reportes/PropinasReport.tsx
Normal file
156
frontend/src/components/reportes/PropinasReport.tsx
Normal 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;
|
||||
@@ -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 || {}),
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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`);
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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(),
|
||||
})
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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',
|
||||
],
|
||||
|
||||
32
odoo-addons/skeen_monedero/data/lealtad_cron.xml
Normal file
32
odoo-addons/skeen_monedero/data/lealtad_cron.xml
Normal 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>
|
||||
200
odoo-addons/skeen_monedero/lealtad.py
Normal file
200
odoo-addons/skeen_monedero/lealtad.py
Normal 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,
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
|
@@ -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)
|
||||
|
||||
@@ -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'),
|
||||
|
||||
Reference in New Issue
Block a user