diff --git a/docs/producto/configuracion-cliente.md b/docs/producto/configuracion-cliente.md index 8708121..1ac6fb0 100644 --- a/docs/producto/configuracion-cliente.md +++ b/docs/producto/configuracion-cliente.md @@ -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//points/adjust` exige motivo y deja el movimiento en el +historial y en la auditoría. El nivel se puede recalcular +(`POST /patients//lealtad/recalcular`) o asignar a mano +(`PUT /patients//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 diff --git a/frontend/src/components/LealtadPanel.tsx b/frontend/src/components/LealtadPanel.tsx new file mode 100644 index 0000000..ff8e8ac --- /dev/null +++ b/frontend/src/components/LealtadPanel.tsx @@ -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 }) => ( + +); + +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 = ({ settings, onSaved }) => { + const [form, setForm] = useState(settings.lealtad || LEALTAD_DEFAULT); + const [saving, setSaving] = useState(false); + const [services, setServices] = useState([]); + const [items, setItems] = useState([]); + const [categorias, setCategorias] = useState([]); + const [sucursales, setSucursales] = useState([]); + const [reporte, setReporte] = useState(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: K, v: LealtadSettings[K]) => + setForm((prev) => ({ ...prev, [k]: v })); + + const setNivel = (i: number, patch: Partial) => + 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) => + 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 ( +
+
+
+

+ Lealtad y niveles VIP +

+

+ 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. +

+
+ +
+ +
+ set('activo', v)} + ayuda="Apagado, no se acumulan puntos por compra." /> + +
+
+ + +
+
+ + +
+
+ + {clasico ? ( +
+ 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. +
+ ) : ( + <> +
+ set('puntos_por_unidad', Number(e.target.value) || 0)} /> + set('valor_punto', Number(e.target.value) || 0)} /> + set('canje_max_pct', Number(e.target.value) || 0)} /> +
+
+ set('expira_meses', Math.max(0, Math.floor(Number(e.target.value) || 0)))} /> +
+ + +
+
+ + +
+
+
+

Aplica a

+
+ {APLICAR.map((a) => { + const on = form.aplicar_a.includes(a.value); + return ( + + ); + })} +
+
+ + )} +
+ + {/* Niveles VIP */} + + +
+

Niveles VIP

+ +
+
+ + {clasico ? ( +

Cambia a modo configurable para definir niveles.

+ ) : form.niveles.length === 0 ? ( +

Sin niveles. Un cliente sin nivel no tiene descuento ni factor extra.

+ ) : ( +
+ {form.niveles.map((n, i) => ( +
+ setNivel(i, { codigo: e.target.value })} /> + setNivel(i, { nombre: e.target.value })} /> + setNivel(i, { gasto_minimo: Number(e.target.value) || 0 })} /> + setNivel(i, { ventana_meses: Math.max(1, Number(e.target.value) || 12) })} /> + setNivel(i, { descuento_pct: Number(e.target.value) || 0 })} /> + setNivel(i, { factor_puntos: Number(e.target.value) || 1 })} /> +
+ setNivel(i, { vigencia_meses: Math.max(0, Number(e.target.value) || 0) })} /> + +
+
+ ))} +
+ )} +
+
+ + {/* Multiplicadores */} + + +
+

Multiplicadores de puntos

+ +
+
+ + {clasico ? ( +

Cambia a modo configurable para definir multiplicadores.

+ ) : form.multiplicadores.length === 0 ? ( +

Sin reglas. Gana la más específica: servicio > artículo > categoría > nivel > sucursal > rango de fechas > día.

+ ) : ( +
+ {form.multiplicadores.map((m, i) => ( +
+ setMul(i, { nombre: e.target.value })} /> +
+ + +
+
+ + {m.alcance === 'servicio' ? ( + + ) : m.alcance === 'articulo' ? ( + + ) : m.alcance === 'categoria' ? ( + + ) : m.alcance === 'nivel' ? ( + + ) : m.alcance === 'sucursal' ? ( + + ) : m.alcance === 'dia_semana' ? ( + setMul(i, { valor: e.target.value })} + placeholder={DIAS.map((d) => d.v).join(',')} /> + ) : ( + setMul(i, { valor: e.target.value })} placeholder="—" /> + )} +
+ setMul(i, { factor: Number(e.target.value) || 1 })} /> +
+ setMul(i, { fecha_desde: e.target.value })} /> + setMul(i, { fecha_hasta: e.target.value })} /> +
+
+ setMul(i, { activo: v })} /> + +
+
+ ))} +
+ )} +
+
+ + {/* Resumen del reporte de lealtad */} + + +
+

Resumen de puntos

+ +
+
+ + {reporte ? ( +
+

Otorgados

{reporte.otorgados}

+

Canjeados

{reporte.canjeados}

+

Expirados

{reporte.expirados}

+

Pasivo (pesos)

${reporte.pasivo_mxn.toFixed(2)}

+
+ ) : ( +

Sin datos.

+ )} +
+
+
+ ); +}; + +export default LealtadPanel; diff --git a/frontend/src/components/PatientDrawer.tsx b/frontend/src/components/PatientDrawer.tsx index 4c98bad..719d361 100644 --- a/frontend/src/components/PatientDrawer.tsx +++ b/frontend/src/components/PatientDrawer.tsx @@ -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

Monedero

{cuenta?.puntos_monedero ?? 0} pts

+ {wallet?.nivel?.nombre && ( + + {wallet.nivel.nombre} + {wallet.nivel.descuento_pct > 0 ? ` · ${wallet.nivel.descuento_pct}%` : ''} + + )}

Saldo pendiente

diff --git a/frontend/src/components/PropinasPanel.tsx b/frontend/src/components/PropinasPanel.tsx new file mode 100644 index 0000000..51bdfb4 --- /dev/null +++ b/frontend/src/components/PropinasPanel.tsx @@ -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 }) => ( + +); + +// 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 = ({ settings, onSaved }) => { + const [form, setForm] = useState(settings.propinas || PROPINAS_DEFAULT); + const [sugerencias, setSugerencias] = useState((settings.propinas || PROPINAS_DEFAULT).sugerencias_pct.join(', ')); + const [saving, setSaving] = useState(false); + const [reporte, setReporte] = useState(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: 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 ( +
+
+
+

+ Propinas en el punto de venta +

+

+ 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. +

+
+ +
+ +
+ set('activo', v)} + ayuda="Muestra el campo de propina al cobrar en el punto de venta." /> + set('permitir_personalizado', v)} + ayuda="Además de los porcentajes sugeridos, permite capturar un monto libre." /> + set('permitir_sin_propina', v)} + ayuda="Si se apaga, el cobro exige registrar propina." /> + 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." /> + +
+ setSugerencias(e.target.value)} placeholder="10, 15, 20" /> +
+ + +
+ set('cuenta_propinas', e.target.value)} placeholder="Opcional" /> +
+
+ + + +
+

Propinas del mes por empleado

+ +
+
+ + {!reporte ? ( +

Sin datos.

+ ) : reporte.por_empleado.length === 0 ? ( +

Sin propinas registradas en el periodo.

+ ) : ( +
+ + + + + + + + + + {reporte.por_empleado.map((r, i) => ( + + + + + + ))} + +
EmpleadoPropinasMonto
{r.empleado}{r.propinas}${r.monto.toFixed(2)}
+
+ )} +
+
+
+ ); +}; + +export default PropinasPanel; diff --git a/frontend/src/components/TicketDoc.tsx b/frontend/src/components/TicketDoc.tsx index 1f2e85f..60f72ac 100644 --- a/frontend/src/components/TicketDoc.tsx +++ b/frontend/src/components/TicketDoc.tsx @@ -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 = ({ data, config, preview }) => { {on('puntos_canjeados') && (data.puntos_usados ?? 0) > 0 && ( )} + {(data.nivel_discount ?? 0) > 0 && ( + + )} {on('impuesto') && (data.tax ?? 0) > 0 && ( )} + {(data.propina ?? 0) > 0 && ( + + )} {on('total') && } {on('total_usd') && data.rate_used ? ( { + const { start, end } = useGlobalReportRange(); + const [data, setData] = useState(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 ( + + +
+

+ Lealtad y puntos +

+
+

{start} a {end}

+ +
+
+ + {loading ? ( + + ) : !data ? ( + } /> + ) : ( + <> +
+ +

Otorgados

+

{data.otorgados}

+
+ +

Canjeados

+

{data.canjeados}

+
+ +

Expirados

+

{data.expirados}

+
+ +

Pasivo (pesos)

+

${data.pasivo_mxn.toFixed(2)}

+
+
+
+
+

Saldo activo

+

{data.puntos_activos} puntos en {data.cuentas} cuentas · valor del punto ${data.valor_punto.toFixed(2)}

+
+
+

Clientes por nivel

+ {data.niveles.length === 0 ? ( +

Sin niveles configurados.

+ ) : ( +
    + {data.niveles.map((n) => ( +
  • + {n.nombre || n.codigo} + {data.conteo_niveles[n.codigo] || 0} +
  • + ))} +
+ )} +
+
+ + )} +
+
+ ); +}; + +export default LealtadReport; diff --git a/frontend/src/components/reportes/PropinasReport.tsx b/frontend/src/components/reportes/PropinasReport.tsx new file mode 100644 index 0000000..33ffc93 --- /dev/null +++ b/frontend/src/components/reportes/PropinasReport.tsx @@ -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(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 ( + + +
+

+ Propinas por empleado +

+
+

{start} a {end}

+ +
+
+ + {loading ? ( + + ) : !data ? ( + } /> + ) : !data.activo ? ( + } /> + ) : ( + <> +
+ +

Propina total del periodo

+

${data.total.toFixed(2)}

+
+ +

Repartida

+

${data.total_repartido.toFixed(2)}

+
+ +

Ventas con propina

+

{data.total_ventas}

+
+
+ + {data.por_empleado.length === 0 ? ( + } /> + ) : ( +
+ + + + + + + + + + {data.por_empleado.map((r, i) => ( + + + + + + ))} + +
EmpleadoPropinasMonto
{r.empleado}{r.propinas}${r.monto.toFixed(2)}
+
+ )} + + {data.detalle.length > 0 && ( +
+

Detalle

+ + + + + + + + + + + + {data.detalle.map((r, i) => ( + + + + + + + + ))} + +
FechaFolioEmpleadoRolMonto
{r.date}{r.folio}{r.empleado || '—'}{r.rol}${r.monto.toFixed(2)}
+
+ )} + + )} +
+
+ ); +}; + +export default PropinasReport; diff --git a/frontend/src/lib/clinic-settings.ts b/frontend/src/lib/clinic-settings.ts index a7b08b9..0556471 100644 --- a/frontend/src/lib/clinic-settings.ts +++ b/frontend/src/lib/clinic-settings.ts @@ -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 | 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 || {}), diff --git a/frontend/src/lib/i18n.tsx b/frontend/src/lib/i18n.tsx index 82a3ecf..47ca788 100644 --- a/frontend/src/lib/i18n.tsx +++ b/frontend/src/lib/i18n.tsx @@ -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', diff --git a/frontend/src/lib/permissions.ts b/frontend/src/lib/permissions.ts index e5bebfe..dbba17e 100644 --- a/frontend/src/lib/permissions.ts +++ b/frontend/src/lib/permissions.ts @@ -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 = { @@ -89,6 +93,8 @@ const DEFAULT_PERM_ROLES: Record = { '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, diff --git a/frontend/src/pages/Configuracion.tsx b/frontend/src/pages/Configuracion.tsx index dd14ae3..fe698c7 100644 --- a/frontend/src/pages/Configuracion.tsx +++ b/frontend/src/pages/Configuracion.tsx @@ -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 = () => { + ) : tab === 'lealtad' ? ( + + + + + + ) : tab === 'propinas' ? ( + + + + + ) : tab === 'terminologia' ? ( diff --git a/frontend/src/pages/MiCuenta.tsx b/frontend/src/pages/MiCuenta.tsx index 1dab57f..cf41b2a 100644 --- a/frontend/src/pages/MiCuenta.tsx +++ b/frontend/src/pages/MiCuenta.tsx @@ -520,8 +520,17 @@ const MonederoTab: FC = () => { const { t } = useI18n(); const { data, loading } = useFetch(() => portalApi.monedero()); if (loading || !data) return ; + const nivel = data.nivel as { codigo: string; nombre: string; descuento_pct: number } | null | undefined; return (
+ {nivel?.nombre && ( +
+ + {nivel.nombre} + {nivel.descuento_pct > 0 && · {nivel.descuento_pct}%} + +
+ )}

{t('portal.monedero.points')}

diff --git a/frontend/src/pages/Pos.tsx b/frontend/src/pages/Pos.tsx index b5e5ebb..97f758a 100644 --- a/frontend/src/pages/Pos.tsx +++ b/frontend/src/pages/Pos.tsx @@ -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(''); + const [propinaMonto, setPropinaMonto] = useState(''); const [cobrando, setCobrando] = useState(false); const [tcRate, setTcRate] = useState(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 = () => { />
+ {propinaNum > 0 && ( +
+ {t('pos.tip')} + {fmtMoney(propinaNum)} +
+ )}
{t('common.total')} - {fmtMoney(total)} + {fmtMoney(totalCobro)} - {toUsd(total) !== null ? `≈ ${fmtUsd(toUsd(total)!)}` : '—'} + {toUsd(totalCobro) !== null ? `≈ ${fmtUsd(toUsd(totalCobro)!)}` : '—'}
@@ -997,7 +1026,7 @@ const Pos: FC = () => { title={can('ventas.vender') ? t('pos.chargeTitle') : t('pos.noSellPerm')} > - {t('pos.chargeAmount', { amount: fmtMoney(total) })} + {t('pos.chargeAmount', { amount: fmtMoney(totalCobro) })}

{ setPropinaMonto(e.target.value); setPropinaPct(''); }} + placeholder="0" /> + )} +
+ )} {metodo === 'cash' && !conPuntos && ( <>
@@ -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 && (

≈ {fmtMoney(recibidoMxn)} MXN

@@ -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, diff --git a/frontend/src/pages/Reportes.tsx b/frontend/src/pages/Reportes.tsx index bc31b60..b507a7a 100644 --- a/frontend/src/pages/Reportes.tsx +++ b/frontend/src/pages/Reportes.tsx @@ -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 = () => { ) : tab === 'kpis' ? ( + ) : tab === 'lealtad' ? ( + + ) : tab === 'propinas' ? ( + ) : ( <> diff --git a/frontend/src/services/odoo.ts b/frontend/src/services/odoo.ts index 779525d..ae78ae9 100644 --- a/frontend/src/services/odoo.ts +++ b/frontend/src/services/odoo.ts @@ -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; + puntos_activos: number; + pasivo_mxn: number; + cuentas: number; + niveles: NivelLealtad[]; + conteo_niveles: Record; + 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 }; - 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 }; inventario: { total: number; by_type: Record }; } @@ -3120,6 +3297,37 @@ export const odooApi = { return data; }, + // ---- Lealtad: puntos y niveles ---- + async getPatientLealtad(patientId: number): Promise { + 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 { + const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/reports/lealtad`, { params: { start, end } }); + return data; + }, + + async getReportPropinas(start?: string, end?: string): Promise { + 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`); diff --git a/odoo-addons/skeen_citas/clinic_settings.py b/odoo-addons/skeen_citas/clinic_settings.py index ed7092f..7d4ecbe 100644 --- a/odoo-addons/skeen_citas/clinic_settings.py +++ b/odoo-addons/skeen_citas/clinic_settings.py @@ -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: diff --git a/odoo-addons/skeen_comisiones/controllers/comisiones.py b/odoo-addons/skeen_comisiones/controllers/comisiones.py index 4adfd62..ac4477c 100644 --- a/odoo-addons/skeen_comisiones/controllers/comisiones.py +++ b/odoo-addons/skeen_comisiones/controllers/comisiones.py @@ -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(), }) diff --git a/odoo-addons/skeen_comisiones/models/liquidacion.py b/odoo-addons/skeen_comisiones/models/liquidacion.py index 188bf0b..d08cc7e 100644 --- a/odoo-addons/skeen_comisiones/models/liquidacion.py +++ b/odoo-addons/skeen_comisiones/models/liquidacion.py @@ -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): diff --git a/odoo-addons/skeen_monedero/__manifest__.py b/odoo-addons/skeen_monedero/__manifest__.py index 17d4206..1f08c40 100644 --- a/odoo-addons/skeen_monedero/__manifest__.py +++ b/odoo-addons/skeen_monedero/__manifest__.py @@ -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', ], diff --git a/odoo-addons/skeen_monedero/data/lealtad_cron.xml b/odoo-addons/skeen_monedero/data/lealtad_cron.xml new file mode 100644 index 0000000..855cbc4 --- /dev/null +++ b/odoo-addons/skeen_monedero/data/lealtad_cron.xml @@ -0,0 +1,32 @@ + + + + + + SKEEN Lealtad: vencer puntos caducados + + code + model.expirar_puntos() + 1 + days + -1 + True + + + + + SKEEN Lealtad: recalcular niveles VIP + + code + model._lealtad_recalcular_todos() + 1 + days + -1 + True + + + diff --git a/odoo-addons/skeen_monedero/lealtad.py b/odoo-addons/skeen_monedero/lealtad.py new file mode 100644 index 0000000..31f6ca0 --- /dev/null +++ b/odoo-addons/skeen_monedero/lealtad.py @@ -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, + } diff --git a/odoo-addons/skeen_monedero/models/monedero.py b/odoo-addons/skeen_monedero/models/monedero.py index bd65efe..7c724a3 100644 --- a/odoo-addons/skeen_monedero/models/monedero.py +++ b/odoo-addons/skeen_monedero/models/monedero.py @@ -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' @@ -28,6 +32,68 @@ class SkeenMonedero(models.Model): def _compute_equivalent(self): 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)""" @@ -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' diff --git a/odoo-addons/skeen_monedero/models/res_partner.py b/odoo-addons/skeen_monedero/models/res_partner.py index 19aee4a..8c66df9 100644 --- a/odoo-addons/skeen_monedero/models/res_partner.py +++ b/odoo-addons/skeen_monedero/models/res_partner.py @@ -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 diff --git a/odoo-addons/skeen_portal_cliente/controllers/public_portal.py b/odoo-addons/skeen_portal_cliente/controllers/public_portal.py index ead8a7a..59ade1b 100644 --- a/odoo-addons/skeen_portal_cliente/controllers/public_portal.py +++ b/odoo-addons/skeen_portal_cliente/controllers/public_portal.py @@ -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: diff --git a/odoo-addons/skeen_ventas/models/venta.py b/odoo-addons/skeen_ventas/models/venta.py index 02ccdac..113079e 100644 --- a/odoo-addons/skeen_ventas/models/venta.py +++ b/odoo-addons/skeen_ventas/models/venta.py @@ -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,8 +801,13 @@ 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) - points_to_reverse = int(refund_amount / 10) + # 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) if monedero and monedero.points > 0: @@ -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): diff --git a/odoo-addons/skeen_ventas/security/ir.model.access.csv b/odoo-addons/skeen_ventas/security/ir.model.access.csv index d756e4c..12ca537 100644 --- a/odoo-addons/skeen_ventas/security/ir.model.access.csv +++ b/odoo-addons/skeen_ventas/security/ir.model.access.csv @@ -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 diff --git a/odoo-addons/skeen_whatsapp/controllers/frontend.py b/odoo-addons/skeen_whatsapp/controllers/frontend.py index 3374097..f3e96bb 100644 --- a/odoo-addons/skeen_whatsapp/controllers/frontend.py +++ b/odoo-addons/skeen_whatsapp/controllers/frontend.py @@ -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//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//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//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//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//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) diff --git a/odoo-addons/skeen_whatsapp/permissions.py b/odoo-addons/skeen_whatsapp/permissions.py index 6090cc4..503c06b 100644 --- a/odoo-addons/skeen_whatsapp/permissions.py +++ b/odoo-addons/skeen_whatsapp/permissions.py @@ -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'),