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

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

View File

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