fase 3.3: comisiones por reglas y liquidacion (modulo skeen_comisiones: motor con reglas por servicio/categoria/articulo/membresia/paquete, monto fijo y escalonado por metas, base vendido/cobrado con y sin impuestos, neto de devoluciones, metas con bonos, ajustes y liquidacion por periodo); reporte legacy intacto con el motor apagado
This commit is contained in:
@@ -32,6 +32,7 @@ import Configuracion from './pages/Configuracion';
|
|||||||
import Cumpleanos from './pages/Cumpleanos';
|
import Cumpleanos from './pages/Cumpleanos';
|
||||||
import Inventario from './pages/Inventario';
|
import Inventario from './pages/Inventario';
|
||||||
import Compras from './pages/Compras';
|
import Compras from './pages/Compras';
|
||||||
|
import Liquidaciones from './pages/Liquidaciones';
|
||||||
import WacrmMessages from './pages/WacrmMessages';
|
import WacrmMessages from './pages/WacrmMessages';
|
||||||
import WacrmLeads from './pages/WacrmLeads';
|
import WacrmLeads from './pages/WacrmLeads';
|
||||||
import Chat from './pages/Chat';
|
import Chat from './pages/Chat';
|
||||||
@@ -131,6 +132,15 @@ const ComprasRoute: FC = () => {
|
|||||||
return <Compras />;
|
return <Compras />;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Liquidaciones vive detrás de `comisiones.activo`. Apagado, la ruta rebota a
|
||||||
|
// "/" aunque el rol tenga permiso de menú (así apagarlo vuelve todo al estado
|
||||||
|
// anterior, incluso entrando por URL).
|
||||||
|
const LiquidacionesRoute: FC = () => {
|
||||||
|
const { comisiones } = useClinicSettings();
|
||||||
|
if (!comisiones.activo) return <Navigate to="/" replace />;
|
||||||
|
return <Liquidaciones />;
|
||||||
|
};
|
||||||
|
|
||||||
const HomeRoute: FC = () => {
|
const HomeRoute: FC = () => {
|
||||||
const { user, canSee } = useAuth();
|
const { user, canSee } = useAuth();
|
||||||
if (user?.role === 'medico') return <Navigate to="/agenda" replace />;
|
if (user?.role === 'medico') return <Navigate to="/agenda" replace />;
|
||||||
@@ -193,6 +203,8 @@ const App: FC = () => {
|
|||||||
<Route path="/inventario" element={<RequireAuth href="/inventario"><Inventario /></RequireAuth>} />
|
<Route path="/inventario" element={<RequireAuth href="/inventario"><Inventario /></RequireAuth>} />
|
||||||
{/* Compras solo cuando la clínica la activa (compras.activo). */}
|
{/* Compras solo cuando la clínica la activa (compras.activo). */}
|
||||||
<Route path="/compras" element={<RequireAuth href="/compras"><ComprasRoute /></RequireAuth>} />
|
<Route path="/compras" element={<RequireAuth href="/compras"><ComprasRoute /></RequireAuth>} />
|
||||||
|
{/* Liquidaciones de comisiones solo con comisiones.activo. */}
|
||||||
|
<Route path="/comisiones/liquidaciones" element={<RequireAuth href="/comisiones/liquidaciones"><LiquidacionesRoute /></RequireAuth>} />
|
||||||
<Route path="/chat" element={<RequireAuth href="/chat"><Chat /></RequireAuth>} />
|
<Route path="/chat" element={<RequireAuth href="/chat"><Chat /></RequireAuth>} />
|
||||||
<Route path="/wacrm/messages" element={<RequireAuth href="/wacrm/messages"><WacrmMessages /></RequireAuth>} />
|
<Route path="/wacrm/messages" element={<RequireAuth href="/wacrm/messages"><WacrmMessages /></RequireAuth>} />
|
||||||
<Route path="/wacrm/leads" element={<RequireAuth href="/wacrm/leads"><WacrmLeads /></RequireAuth>} />
|
<Route path="/wacrm/leads" element={<RequireAuth href="/wacrm/leads"><WacrmLeads /></RequireAuth>} />
|
||||||
|
|||||||
@@ -146,11 +146,11 @@ export const ClasicoLayout: FC<LayoutProps> = ({ children, title: _title, subtit
|
|||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { user, canSee, hasRole, logout, brand } = useAuth();
|
const { user, canSee, hasRole, logout, brand } = useAuth();
|
||||||
const { membresias: membresiasCfg, compras: comprasCfg } = useClinicSettings();
|
const { membresias: membresiasCfg, compras: comprasCfg, comisiones: comisionesCfg } = useClinicSettings();
|
||||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||||
const chatUnread = useChatUnread();
|
const chatUnread = useChatUnread();
|
||||||
|
|
||||||
// Membresías y Compras solo cuando la clínica enciende su bandera.
|
// Membresías, Compras y Liquidaciones solo cuando la clínica enciende su bandera.
|
||||||
const menuTreeT = useMemo<MenuEntry[]>(() => {
|
const menuTreeT = useMemo<MenuEntry[]>(() => {
|
||||||
let tree = menuTree;
|
let tree = menuTree;
|
||||||
if (comprasCfg.activo) {
|
if (comprasCfg.activo) {
|
||||||
@@ -161,8 +161,11 @@ export const ClasicoLayout: FC<LayoutProps> = ({ children, title: _title, subtit
|
|||||||
if (membresiasCfg.activo) {
|
if (membresiasCfg.activo) {
|
||||||
tree = [...tree, { label: 'Membresías', icon: Repeat, href: '/membresias' }];
|
tree = [...tree, { label: 'Membresías', icon: Repeat, href: '/membresias' }];
|
||||||
}
|
}
|
||||||
|
if (comisionesCfg.activo) {
|
||||||
|
tree = [...tree, { label: 'Liquidaciones', icon: Receipt, href: '/comisiones/liquidaciones' }];
|
||||||
|
}
|
||||||
return tree;
|
return tree;
|
||||||
}, [membresiasCfg.activo, comprasCfg.activo]);
|
}, [membresiasCfg.activo, comprasCfg.activo, comisionesCfg.activo]);
|
||||||
const menuLeavesT = useMemo(
|
const menuLeavesT = useMemo(
|
||||||
() => menuTreeT.flatMap((e) =>
|
() => menuTreeT.flatMap((e) =>
|
||||||
e.children
|
e.children
|
||||||
|
|||||||
492
frontend/src/components/ComisionesPanel.tsx
Normal file
492
frontend/src/components/ComisionesPanel.tsx
Normal file
@@ -0,0 +1,492 @@
|
|||||||
|
import type { FC } from 'react';
|
||||||
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
|
import { Coins, Plus, Pencil, Save, Trash2, Target, Wand2 } from 'lucide-react';
|
||||||
|
import { Button, Input, TextArea, Select, Modal, toast } from './ui';
|
||||||
|
import {
|
||||||
|
odooApi, COMISIONES_DEFAULT,
|
||||||
|
type ClinicSettings, type ComisionesSettings, type ComisionRegla, type ComisionMeta,
|
||||||
|
type ComisionEmpleado, type Service, type ServiceCategory, type InventoryItem, type MembresiaPlan,
|
||||||
|
} from '../services/odoo';
|
||||||
|
|
||||||
|
// Configuración → Comisiones: ajustes del bloque + reglas + metas.
|
||||||
|
// Este panel es la llave para encender el motor (`comisiones.activo`). Las rutas
|
||||||
|
// de configuración funcionan aunque el motor esté apagado para poder dejar todo
|
||||||
|
// listo antes de activarlo.
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
settings: ClinicSettings;
|
||||||
|
onSaved: (settings: ClinicSettings) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Toggle: FC<{ label: string; checked: boolean; onChange: (v: boolean) => void; ayuda?: string }> = ({ label, checked, onChange, ayuda }) => (
|
||||||
|
<label className="flex items-start gap-3 cursor-pointer">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="switch"
|
||||||
|
aria-checked={checked}
|
||||||
|
onClick={() => onChange(!checked)}
|
||||||
|
className={`mt-0.5 relative h-6 w-11 shrink-0 rounded-full transition-colors ${checked ? 'bg-theme-accent' : 'bg-theme-border-strong'}`}
|
||||||
|
>
|
||||||
|
<span className={`absolute top-0.5 h-5 w-5 rounded-full bg-white transition-transform ${checked ? 'translate-x-5' : 'translate-x-0.5'}`} />
|
||||||
|
</button>
|
||||||
|
<span>
|
||||||
|
<span className="block text-sm font-medium text-theme-heading">{label}</span>
|
||||||
|
{ayuda && <span className="block text-xs text-theme-muted">{ayuda}</span>}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
|
||||||
|
const ALCANCES = [
|
||||||
|
{ value: 'global', label: 'Global (todo)' },
|
||||||
|
{ value: 'categoria', label: 'Categoría de servicio' },
|
||||||
|
{ value: 'servicio', label: 'Servicio' },
|
||||||
|
{ value: 'articulo', label: 'Artículo de inventario' },
|
||||||
|
{ value: 'membresia', label: 'Membresía / plan' },
|
||||||
|
{ value: 'paquete', label: 'Paquete' },
|
||||||
|
];
|
||||||
|
const TIPOS = [
|
||||||
|
{ value: 'porcentaje', label: 'Porcentaje' },
|
||||||
|
{ value: 'monto_fijo', label: 'Monto fijo' },
|
||||||
|
{ value: 'escalonado', label: 'Escalonado por metas' },
|
||||||
|
];
|
||||||
|
|
||||||
|
interface FormRegla {
|
||||||
|
id?: number;
|
||||||
|
name: string;
|
||||||
|
empleado_id: string;
|
||||||
|
staff_type: string;
|
||||||
|
alcance: string;
|
||||||
|
categoria_code: string;
|
||||||
|
servicio_id: string;
|
||||||
|
item_id: string;
|
||||||
|
plan_id: string;
|
||||||
|
tipo: string;
|
||||||
|
valor: string;
|
||||||
|
escalones: string;
|
||||||
|
base_override: string;
|
||||||
|
prioridad: string;
|
||||||
|
fecha_desde: string;
|
||||||
|
fecha_hasta: string;
|
||||||
|
activo: boolean;
|
||||||
|
notas: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const emptyRegla: FormRegla = {
|
||||||
|
name: '', empleado_id: '', staff_type: '', alcance: 'global', categoria_code: '',
|
||||||
|
servicio_id: '', item_id: '', plan_id: '', tipo: 'porcentaje', valor: '', escalones: '',
|
||||||
|
base_override: '', prioridad: '10', fecha_desde: '', fecha_hasta: '', activo: true, notas: '',
|
||||||
|
};
|
||||||
|
|
||||||
|
interface FormMeta {
|
||||||
|
id?: number;
|
||||||
|
empleado_id: string;
|
||||||
|
periodo: string;
|
||||||
|
monto_objetivo: string;
|
||||||
|
bono: string;
|
||||||
|
tipo_bono: string;
|
||||||
|
activo: boolean;
|
||||||
|
notas: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const emptyMeta: FormMeta = {
|
||||||
|
empleado_id: '', periodo: 'mensual', monto_objetivo: '', bono: '', tipo_bono: 'fijo',
|
||||||
|
activo: true, notas: '',
|
||||||
|
};
|
||||||
|
|
||||||
|
const ComisionesPanel: FC<Props> = ({ settings, onSaved }) => {
|
||||||
|
const [form, setForm] = useState<ComisionesSettings>(settings.comisiones || COMISIONES_DEFAULT);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
|
const [empleados, setEmpleados] = useState<ComisionEmpleado[]>([]);
|
||||||
|
const [reglas, setReglas] = useState<ComisionRegla[]>([]);
|
||||||
|
const [metas, setMetas] = useState<ComisionMeta[]>([]);
|
||||||
|
const [servicios, setServicios] = useState<Service[]>([]);
|
||||||
|
const [categorias, setCategorias] = useState<ServiceCategory[]>([]);
|
||||||
|
const [items, setItems] = useState<InventoryItem[]>([]);
|
||||||
|
const [planes, setPlanes] = useState<MembresiaPlan[]>([]);
|
||||||
|
const [cargando, setCargando] = useState(false);
|
||||||
|
|
||||||
|
const [reglaOpen, setReglaOpen] = useState(false);
|
||||||
|
const [reglaForm, setReglaForm] = useState<FormRegla>(emptyRegla);
|
||||||
|
const [metaOpen, setMetaOpen] = useState(false);
|
||||||
|
const [metaForm, setMetaForm] = useState<FormMeta>(emptyMeta);
|
||||||
|
const [guardando, setGuardando] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => { setForm(settings.comisiones || COMISIONES_DEFAULT); }, [settings.comisiones]);
|
||||||
|
|
||||||
|
const cargar = useCallback(async () => {
|
||||||
|
setCargando(true);
|
||||||
|
try {
|
||||||
|
const [emp, reg, met] = await Promise.all([
|
||||||
|
odooApi.getComisionEmpleados(),
|
||||||
|
odooApi.getComisionesReglas(),
|
||||||
|
odooApi.getComisionesMetas(),
|
||||||
|
]);
|
||||||
|
setEmpleados(emp.empleados || []);
|
||||||
|
setReglas(reg.reglas || []);
|
||||||
|
setMetas(met.metas || []);
|
||||||
|
} catch (err) {
|
||||||
|
const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message;
|
||||||
|
toast.error(msg || 'No se pudo cargar la configuración de comisiones');
|
||||||
|
} finally {
|
||||||
|
setCargando(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => { cargar(); }, [cargar]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
odooApi.getServices({ page_size: 500 }).then((r) => { if (r.status === 'success') setServicios(r.services); }).catch(() => {});
|
||||||
|
odooApi.getServiceCategories().then((r) => { if (r.status === 'success') setCategorias(r.categories); }).catch(() => {});
|
||||||
|
odooApi.getInventarioItems().then((r) => { if (r.status === 'success') setItems(r.items); }).catch(() => {});
|
||||||
|
odooApi.getMembresiaPlanes().then((r) => { if (r.status === 'success') setPlanes(r.planes); }).catch(() => {});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const set = <K extends keyof ComisionesSettings>(k: K, v: ComisionesSettings[K]) =>
|
||||||
|
setForm((prev) => ({ ...prev, [k]: v }));
|
||||||
|
|
||||||
|
const guardarConfig = async () => {
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
const res = await odooApi.putClinicSettings({ comisiones: form, motivo: 'Configuración de comisiones' });
|
||||||
|
if (res.status === 'success') { onSaved(res.settings); toast.success('Configuración de comisiones guardada'); }
|
||||||
|
} catch (err) {
|
||||||
|
const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message;
|
||||||
|
toast.error(msg || 'No se pudo guardar la configuración');
|
||||||
|
} finally { setSaving(false); }
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---- Reglas ----
|
||||||
|
const abrirNueva = (prefill?: Partial<FormRegla>) => { setReglaForm({ ...emptyRegla, ...prefill }); setReglaOpen(true); };
|
||||||
|
|
||||||
|
const abrirEditarRegla = (r: ComisionRegla) => {
|
||||||
|
setReglaForm({
|
||||||
|
id: r.id, name: r.name, empleado_id: r.empleado_id ? String(r.empleado_id) : '',
|
||||||
|
staff_type: r.staff_type || '', alcance: r.alcance, categoria_code: r.categoria_code || '',
|
||||||
|
servicio_id: r.servicio_id ? String(r.servicio_id) : '', item_id: r.item_id ? String(r.item_id) : '',
|
||||||
|
plan_id: r.plan_id ? String(r.plan_id) : '', tipo: r.tipo, valor: String(r.valor ?? ''),
|
||||||
|
escalones: r.escalones && r.escalones.length ? JSON.stringify(r.escalones) : '',
|
||||||
|
base_override: r.base_override || '', prioridad: String(r.prioridad ?? 10),
|
||||||
|
fecha_desde: r.fecha_desde || '', fecha_hasta: r.fecha_hasta || '', activo: r.activo, notas: r.notas || '',
|
||||||
|
});
|
||||||
|
setReglaOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const guardarRegla = async () => {
|
||||||
|
if (!reglaForm.name.trim()) { toast.error('El nombre de la regla es obligatorio'); return; }
|
||||||
|
let escalones: unknown = undefined;
|
||||||
|
if (reglaForm.tipo === 'escalonado') {
|
||||||
|
try { escalones = reglaForm.escalones ? JSON.parse(reglaForm.escalones) : []; }
|
||||||
|
catch { toast.error('Los escalones deben ser JSON válido'); return; }
|
||||||
|
}
|
||||||
|
const payload: Record<string, unknown> = {
|
||||||
|
name: reglaForm.name.trim(),
|
||||||
|
empleado_id: reglaForm.empleado_id ? Number(reglaForm.empleado_id) : null,
|
||||||
|
staff_type: reglaForm.staff_type || null,
|
||||||
|
alcance: reglaForm.alcance,
|
||||||
|
categoria_code: reglaForm.categoria_code || '',
|
||||||
|
servicio_id: reglaForm.servicio_id ? Number(reglaForm.servicio_id) : null,
|
||||||
|
item_id: reglaForm.item_id ? Number(reglaForm.item_id) : null,
|
||||||
|
plan_id: reglaForm.plan_id ? Number(reglaForm.plan_id) : null,
|
||||||
|
tipo: reglaForm.tipo,
|
||||||
|
valor: reglaForm.tipo === 'escalonado' ? 0 : (parseFloat(reglaForm.valor) || 0),
|
||||||
|
base_override: reglaForm.base_override || null,
|
||||||
|
prioridad: parseInt(reglaForm.prioridad, 10) || 0,
|
||||||
|
fecha_desde: reglaForm.fecha_desde || null,
|
||||||
|
fecha_hasta: reglaForm.fecha_hasta || null,
|
||||||
|
activo: reglaForm.activo,
|
||||||
|
notas: reglaForm.notas,
|
||||||
|
...(escalones !== undefined ? { escalones } : {}),
|
||||||
|
};
|
||||||
|
setGuardando(true);
|
||||||
|
try {
|
||||||
|
if (reglaForm.id) await odooApi.updateComisionRegla(reglaForm.id, payload as Partial<ComisionRegla>);
|
||||||
|
else await odooApi.createComisionRegla(payload as Partial<ComisionRegla>);
|
||||||
|
toast.success('Regla guardada');
|
||||||
|
setReglaOpen(false);
|
||||||
|
await cargar();
|
||||||
|
} catch (err) {
|
||||||
|
const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message;
|
||||||
|
toast.error(msg || 'No se pudo guardar la regla');
|
||||||
|
} finally { setGuardando(false); }
|
||||||
|
};
|
||||||
|
|
||||||
|
const desactivarRegla = async (r: ComisionRegla) => {
|
||||||
|
try { await odooApi.deleteComisionRegla(r.id); toast.success('Regla desactivada'); await cargar(); }
|
||||||
|
catch { toast.error('No se pudo desactivar la regla'); }
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---- Metas ----
|
||||||
|
const abrirNuevaMeta = () => { setMetaForm(emptyMeta); setMetaOpen(true); };
|
||||||
|
const abrirEditarMeta = (m: ComisionMeta) => {
|
||||||
|
setMetaForm({
|
||||||
|
id: m.id, empleado_id: String(m.empleado_id), periodo: m.periodo,
|
||||||
|
monto_objetivo: String(m.monto_objetivo ?? ''), bono: String(m.bono ?? ''),
|
||||||
|
tipo_bono: m.tipo_bono, activo: m.activo, notas: m.notas || '',
|
||||||
|
});
|
||||||
|
setMetaOpen(true);
|
||||||
|
};
|
||||||
|
const guardarMeta = async () => {
|
||||||
|
if (!metaForm.empleado_id || !metaForm.periodo.trim()) { toast.error('Empleado y periodo son obligatorios'); return; }
|
||||||
|
const payload: Partial<ComisionMeta> = {
|
||||||
|
empleado_id: Number(metaForm.empleado_id),
|
||||||
|
periodo: metaForm.periodo.trim(),
|
||||||
|
monto_objetivo: parseFloat(metaForm.monto_objetivo) || 0,
|
||||||
|
bono: parseFloat(metaForm.bono) || 0,
|
||||||
|
tipo_bono: metaForm.tipo_bono as ComisionMeta['tipo_bono'],
|
||||||
|
activo: metaForm.activo,
|
||||||
|
notas: metaForm.notas,
|
||||||
|
};
|
||||||
|
setGuardando(true);
|
||||||
|
try {
|
||||||
|
if (metaForm.id) await odooApi.updateComisionMeta(metaForm.id, payload);
|
||||||
|
else await odooApi.createComisionMeta(payload);
|
||||||
|
toast.success('Meta guardada');
|
||||||
|
setMetaOpen(false);
|
||||||
|
await cargar();
|
||||||
|
} catch (err) {
|
||||||
|
const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message;
|
||||||
|
toast.error(msg || 'No se pudo guardar la meta');
|
||||||
|
} finally { setGuardando(false); }
|
||||||
|
};
|
||||||
|
const desactivarMeta = async (m: ComisionMeta) => {
|
||||||
|
try { await odooApi.deleteComisionMeta(m.id); toast.success('Meta desactivada'); await cargar(); }
|
||||||
|
catch { toast.error('No se pudo desactivar la meta'); }
|
||||||
|
};
|
||||||
|
|
||||||
|
const empleadoOptions = useMemo(
|
||||||
|
() => [{ value: '', label: 'Todos los empleados' }, ...empleados.map((e) => ({ value: String(e.id), label: e.name }))],
|
||||||
|
[empleados],
|
||||||
|
);
|
||||||
|
const nombreEmpleado = (id: number) => empleados.find((e) => e.id === id)?.name || `#${id}`;
|
||||||
|
|
||||||
|
const reglasPorEmpleado = useMemo(() => {
|
||||||
|
const grupos: { key: string; label: string; reglas: ComisionRegla[] }[] = [];
|
||||||
|
for (const e of empleados) {
|
||||||
|
const rs = reglas.filter((r) => r.empleado_id === e.id);
|
||||||
|
if (rs.length) grupos.push({ key: `e${e.id}`, label: e.name, reglas: rs });
|
||||||
|
}
|
||||||
|
const generales = reglas.filter((r) => !r.empleado_id);
|
||||||
|
if (generales.length) grupos.push({ key: 'general', label: 'Generales (todos)', reglas: generales });
|
||||||
|
return grupos;
|
||||||
|
}, [reglas, empleados]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Ajustes del bloque */}
|
||||||
|
<div>
|
||||||
|
<h3 className="font-heading text-xl text-theme-heading mb-1 flex items-center"><Coins size={20} className="mr-2" />Comisiones por reglas</h3>
|
||||||
|
<p className="text-sm text-theme-muted mb-4">
|
||||||
|
Motor de reglas configurable y liquidación por periodo. Con el módulo apagado, el reporte de comisiones de
|
||||||
|
siempre (porcentaje plano por empleado) sigue intacto.
|
||||||
|
</p>
|
||||||
|
<div className="mb-4 p-4 bg-theme-bg rounded-xl">
|
||||||
|
<Toggle label="Activar comisiones por reglas" checked={form.activo} onChange={(v) => set('activo', v)}
|
||||||
|
ayuda="Enciende el reporte nuevo, las liquidaciones y el acceso a las pantallas. Apagado, nada cambia." />
|
||||||
|
</div>
|
||||||
|
<div className="grid sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
|
<Select label="Base" value={form.base} onChange={(e) => set('base', e.target.value as ComisionesSettings['base'])}
|
||||||
|
options={[{ value: 'cobrado', label: 'Cobrado (lo pagado)' }, { value: 'vendido', label: 'Vendido (subtotal)' }]} />
|
||||||
|
<Input label="Día de corte (1-28)" type="number" min={1} max={28} value={String(form.dia_corte)} onChange={(e) => set('dia_corte', parseInt(e.target.value, 10) || 1)} />
|
||||||
|
<Input label="Redondeo (decimales)" type="number" min={0} max={6} value={String(form.redondeo)} onChange={(e) => set('redondeo', parseInt(e.target.value, 10) || 0)} />
|
||||||
|
<Input label="Pago mínimo" type="number" min={0} step="0.01" value={String(form.pago_minimo)} onChange={(e) => set('pago_minimo', parseFloat(e.target.value) || 0)} />
|
||||||
|
</div>
|
||||||
|
<div className="grid sm:grid-cols-2 lg:grid-cols-3 gap-3 mt-4">
|
||||||
|
<Toggle label="Incluir impuestos" checked={form.incluir_impuestos} onChange={(v) => set('incluir_impuestos', v)} />
|
||||||
|
<Toggle label="Neto de devoluciones" checked={form.neto_de_devoluciones} onChange={(v) => set('neto_de_devoluciones', v)} />
|
||||||
|
<Toggle label="Permitir ajustes manuales" checked={form.permitir_ajustes_manuales} onChange={(v) => set('permitir_ajustes_manuales', v)} />
|
||||||
|
<Toggle label="Comisionar membresías" checked={form.comisionar_membresias} onChange={(v) => set('comisionar_membresias', v)} />
|
||||||
|
<Toggle label="Comisionar paquetes" checked={form.comisionar_paquetes} onChange={(v) => set('comisionar_paquetes', v)} />
|
||||||
|
<Toggle label="Comisionar productos" checked={form.comisionar_productos} onChange={(v) => set('comisionar_productos', v)} />
|
||||||
|
</div>
|
||||||
|
<div className="mt-4">
|
||||||
|
<Button onClick={guardarConfig} loading={saving}><Save size={16} className="mr-2" />Guardar configuración</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Empleados: respaldo commission_pct */}
|
||||||
|
<div>
|
||||||
|
<h3 className="font-heading text-lg text-theme-heading mb-2">% de respaldo por empleado</h3>
|
||||||
|
<p className="text-xs text-theme-muted mb-2">
|
||||||
|
Sin ninguna regla aplicable, se usa este porcentaje (el comportamiento de siempre).
|
||||||
|
</p>
|
||||||
|
<div className="overflow-x-auto rounded-xl border border-theme-border">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead className="bg-theme-bg text-theme-muted text-[11px] uppercase">
|
||||||
|
<tr><th className="text-left px-4 py-2">Empleado</th><th className="text-left px-4 py-2">Tipo</th><th className="text-right px-4 py-2">% respaldo</th><th className="text-right px-4 py-2">Reglas</th><th className="text-right px-4 py-2">Metas</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-theme-border">
|
||||||
|
{empleados.map((e) => (
|
||||||
|
<tr key={e.id}>
|
||||||
|
<td className="px-4 py-2 text-theme-heading">{e.name}</td>
|
||||||
|
<td className="px-4 py-2 text-theme-muted">{e.staff_type === 'cosmetologa' ? 'Cosmetóloga' : 'Doctor'}</td>
|
||||||
|
<td className="px-4 py-2 text-right tabular-nums">{e.commission_pct}%</td>
|
||||||
|
<td className="px-4 py-2 text-right tabular-nums">{e.reglas_count}</td>
|
||||||
|
<td className="px-4 py-2 text-right tabular-nums">{e.metas_count}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
{empleados.length === 0 && <tr><td colSpan={5} className="px-4 py-4 text-center text-theme-muted">Sin empleados.</td></tr>}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Reglas */}
|
||||||
|
<div>
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-2 mb-2">
|
||||||
|
<h3 className="font-heading text-lg text-theme-heading">Reglas</h3>
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
<Button size="sm" variant="outline" onClick={() => abrirNueva({ name: '10% en todos los servicios', alcance: 'global', tipo: 'porcentaje', valor: '10' })}><Wand2 size={14} className="mr-1" />10% servicios</Button>
|
||||||
|
<Button size="sm" variant="outline" onClick={() => abrirNueva({ name: '5% en productos', alcance: 'articulo', tipo: 'porcentaje', valor: '5' })}><Wand2 size={14} className="mr-1" />5% productos</Button>
|
||||||
|
<Button size="sm" variant="outline" onClick={() => abrirNueva({ name: 'Escalonado por venta mensual', alcance: 'global', tipo: 'escalonado', escalones: JSON.stringify([{ desde: 0, hasta: 50000, porcentaje: 5 }, { desde: 50000, hasta: null, porcentaje: 8 }]) })}><Wand2 size={14} className="mr-1" />Escalonado</Button>
|
||||||
|
<Button size="sm" onClick={() => abrirNueva()}><Plus size={14} className="mr-1" />Nueva regla</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{cargando ? (
|
||||||
|
<p className="text-sm text-theme-muted">Cargando…</p>
|
||||||
|
) : reglas.length === 0 ? (
|
||||||
|
<div className="rounded-xl border border-theme-border p-4 text-sm text-theme-muted">
|
||||||
|
Sin reglas. Sin reglas, cada empleado comisiona con su % de respaldo.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{reglasPorEmpleado.map((g) => (
|
||||||
|
<div key={g.key}>
|
||||||
|
<p className="text-xs font-semibold text-theme-muted uppercase mb-1">{g.label}</p>
|
||||||
|
<div className="overflow-x-auto rounded-xl border border-theme-border">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead className="bg-theme-bg text-theme-muted text-[11px] uppercase">
|
||||||
|
<tr><th className="text-left px-3 py-2">Regla</th><th className="text-left px-3 py-2">Alcance</th><th className="text-left px-3 py-2">Tipo</th><th className="text-left px-3 py-2 hidden md:table-cell">Prioridad</th><th className="text-left px-3 py-2">Vigencia</th><th className="text-right px-3 py-2">Acciones</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-theme-border">
|
||||||
|
{g.reglas.map((r) => (
|
||||||
|
<tr key={r.id} className={r.activo ? '' : 'opacity-50'}>
|
||||||
|
<td className="px-3 py-2 text-theme-heading">{r.name}{!r.activo && <span className="ml-2 text-xs text-theme-muted">(inactiva)</span>}</td>
|
||||||
|
<td className="px-3 py-2 text-theme-muted">{r.resumen}</td>
|
||||||
|
<td className="px-3 py-2 text-theme-muted">{r.tipo_label}{r.tipo === 'porcentaje' ? ` ${r.valor}%` : r.tipo === 'monto_fijo' ? ` $${r.valor}` : ''}</td>
|
||||||
|
<td className="px-3 py-2 text-theme-muted hidden md:table-cell">{r.prioridad}{r.base_override ? ` · base ${r.base_override}` : ''}</td>
|
||||||
|
<td className="px-3 py-2 text-theme-muted text-xs">{(r.fecha_desde || '—')} a {(r.fecha_hasta || '—')}</td>
|
||||||
|
<td className="px-3 py-2">
|
||||||
|
<div className="flex items-center justify-end gap-1">
|
||||||
|
<button type="button" title="Editar" onClick={() => abrirEditarRegla(r)} className="p-1.5 rounded-lg hover:bg-theme-bg text-theme-muted"><Pencil size={15} /></button>
|
||||||
|
{r.activo && <button type="button" title="Desactivar" onClick={() => desactivarRegla(r)} className="p-1.5 rounded-lg hover:bg-rose-50 text-rose-600"><Trash2 size={15} /></button>}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Metas */}
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<h3 className="font-heading text-lg text-theme-heading flex items-center"><Target size={18} className="mr-2" />Metas y bonos</h3>
|
||||||
|
<Button size="sm" onClick={abrirNuevaMeta}><Plus size={14} className="mr-1" />Nueva meta</Button>
|
||||||
|
</div>
|
||||||
|
{metas.length === 0 ? (
|
||||||
|
<div className="rounded-xl border border-theme-border p-4 text-sm text-theme-muted">Sin metas. Una meta paga un bono al alcanzar su objetivo de venta.</div>
|
||||||
|
) : (
|
||||||
|
<div className="overflow-x-auto rounded-xl border border-theme-border">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead className="bg-theme-bg text-theme-muted text-[11px] uppercase">
|
||||||
|
<tr><th className="text-left px-3 py-2">Empleado</th><th className="text-left px-3 py-2">Periodo</th><th className="text-right px-3 py-2">Objetivo</th><th className="text-left px-3 py-2">Bono</th><th className="text-right px-3 py-2">Acciones</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-theme-border">
|
||||||
|
{metas.map((m) => (
|
||||||
|
<tr key={m.id} className={m.activo ? '' : 'opacity-50'}>
|
||||||
|
<td className="px-3 py-2 text-theme-heading">{nombreEmpleado(m.empleado_id)}</td>
|
||||||
|
<td className="px-3 py-2 text-theme-muted">{m.periodo}</td>
|
||||||
|
<td className="px-3 py-2 text-right tabular-nums">{m.monto_objetivo.toLocaleString('es-MX')}</td>
|
||||||
|
<td className="px-3 py-2 text-theme-muted">{m.tipo_bono === 'fijo' ? `$${m.bono}` : `${m.bono}%`}</td>
|
||||||
|
<td className="px-3 py-2">
|
||||||
|
<div className="flex items-center justify-end gap-1">
|
||||||
|
<button type="button" title="Editar" onClick={() => abrirEditarMeta(m)} className="p-1.5 rounded-lg hover:bg-theme-bg text-theme-muted"><Pencil size={15} /></button>
|
||||||
|
{m.activo && <button type="button" title="Desactivar" onClick={() => desactivarMeta(m)} className="p-1.5 rounded-lg hover:bg-rose-50 text-rose-600"><Trash2 size={15} /></button>}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Editor de regla */}
|
||||||
|
<Modal
|
||||||
|
isOpen={reglaOpen}
|
||||||
|
onClose={() => setReglaOpen(false)}
|
||||||
|
title={reglaForm.id ? 'Editar regla de comisión' : 'Nueva regla de comisión'}
|
||||||
|
footer={<div className="flex justify-end gap-2"><Button variant="outline" onClick={() => setReglaOpen(false)}>Cancelar</Button><Button onClick={guardarRegla} loading={guardando}>Guardar</Button></div>}
|
||||||
|
>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Input label="Nombre" value={reglaForm.name} onChange={(e) => setReglaForm((f) => ({ ...f, name: e.target.value }))} />
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<Select label="Empleado" value={reglaForm.empleado_id} onChange={(e) => setReglaForm((f) => ({ ...f, empleado_id: e.target.value }))} options={empleadoOptions} />
|
||||||
|
<Select label="Tipo de staff" value={reglaForm.staff_type} onChange={(e) => setReglaForm((f) => ({ ...f, staff_type: e.target.value }))} options={[{ value: '', label: 'Cualquiera' }, { value: 'doctor', label: 'Doctor' }, { value: 'cosmetologa', label: 'Cosmetóloga' }]} />
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<Select label="Alcance" value={reglaForm.alcance} onChange={(e) => setReglaForm((f) => ({ ...f, alcance: e.target.value }))} options={ALCANCES} />
|
||||||
|
<Select label="Tipo" value={reglaForm.tipo} onChange={(e) => setReglaForm((f) => ({ ...f, tipo: e.target.value }))} options={TIPOS} />
|
||||||
|
</div>
|
||||||
|
{reglaForm.alcance === 'categoria' && (
|
||||||
|
<Select label="Categoría" value={reglaForm.categoria_code} onChange={(e) => setReglaForm((f) => ({ ...f, categoria_code: e.target.value }))} options={[{ value: '', label: 'Selecciona…' }, ...categorias.map((c) => ({ value: c.code, label: c.name }))]} />
|
||||||
|
)}
|
||||||
|
{reglaForm.alcance === 'servicio' && (
|
||||||
|
<Select label="Servicio" value={reglaForm.servicio_id} onChange={(e) => setReglaForm((f) => ({ ...f, servicio_id: e.target.value }))} options={[{ value: '', label: 'Selecciona…' }, ...servicios.map((s) => ({ value: String(s.id), label: s.name }))]} />
|
||||||
|
)}
|
||||||
|
{reglaForm.alcance === 'articulo' && (
|
||||||
|
<Select label="Artículo" value={reglaForm.item_id} onChange={(e) => setReglaForm((f) => ({ ...f, item_id: e.target.value }))} options={[{ value: '', label: 'Selecciona…' }, ...items.map((i) => ({ value: String(i.id), label: i.name }))]} />
|
||||||
|
)}
|
||||||
|
{reglaForm.alcance === 'membresia' && (
|
||||||
|
<Select label="Plan" value={reglaForm.plan_id} onChange={(e) => setReglaForm((f) => ({ ...f, plan_id: e.target.value }))} options={[{ value: '', label: 'Selecciona…' }, ...planes.map((p) => ({ value: String(p.id), label: p.name }))]} />
|
||||||
|
)}
|
||||||
|
{reglaForm.tipo === 'escalonado' ? (
|
||||||
|
<TextArea label="Escalones (JSON)" value={reglaForm.escalones} onChange={(e) => setReglaForm((f) => ({ ...f, escalones: e.target.value }))}
|
||||||
|
placeholder='[{"desde":0,"hasta":50000,"porcentaje":5},{"desde":50000,"hasta":null,"porcentaje":8}]' />
|
||||||
|
) : (
|
||||||
|
<Input label={reglaForm.tipo === 'porcentaje' ? 'Porcentaje (%)' : 'Monto fijo (m.n.)'} type="number" step="0.5" value={reglaForm.valor} onChange={(e) => setReglaForm((f) => ({ ...f, valor: e.target.value }))} />
|
||||||
|
)}
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<Select label="Forzar base" value={reglaForm.base_override} onChange={(e) => setReglaForm((f) => ({ ...f, base_override: e.target.value }))} options={[{ value: '', label: 'Usar la del bloque' }, { value: 'vendido', label: 'Vendido' }, { value: 'cobrado', label: 'Cobrado' }]} />
|
||||||
|
<Input label="Prioridad" type="number" value={reglaForm.prioridad} onChange={(e) => setReglaForm((f) => ({ ...f, prioridad: e.target.value }))} />
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<Input label="Vigente desde" type="date" value={reglaForm.fecha_desde} onChange={(e) => setReglaForm((f) => ({ ...f, fecha_desde: e.target.value }))} />
|
||||||
|
<Input label="Vigente hasta" type="date" value={reglaForm.fecha_hasta} onChange={(e) => setReglaForm((f) => ({ ...f, fecha_hasta: e.target.value }))} />
|
||||||
|
</div>
|
||||||
|
<TextArea label="Notas" value={reglaForm.notas} onChange={(e) => setReglaForm((f) => ({ ...f, notas: e.target.value }))} />
|
||||||
|
<Toggle label="Activa" checked={reglaForm.activo} onChange={(v) => setReglaForm((f) => ({ ...f, activo: v }))} />
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
{/* Editor de meta */}
|
||||||
|
<Modal
|
||||||
|
isOpen={metaOpen}
|
||||||
|
onClose={() => setMetaOpen(false)}
|
||||||
|
title={metaForm.id ? 'Editar meta' : 'Nueva meta'}
|
||||||
|
footer={<div className="flex justify-end gap-2"><Button variant="outline" onClick={() => setMetaOpen(false)}>Cancelar</Button><Button onClick={guardarMeta} loading={guardando}>Guardar</Button></div>}
|
||||||
|
>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Select label="Empleado" value={metaForm.empleado_id} onChange={(e) => setMetaForm((f) => ({ ...f, empleado_id: e.target.value }))} options={[{ value: '', label: 'Selecciona…' }, ...empleados.map((e) => ({ value: String(e.id), label: e.name }))]} />
|
||||||
|
<Input label="Periodo (AAAA-MM o 'mensual')" value={metaForm.periodo} onChange={(e) => setMetaForm((f) => ({ ...f, periodo: e.target.value }))} />
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<Input label="Monto objetivo" type="number" step="0.01" value={metaForm.monto_objetivo} onChange={(e) => setMetaForm((f) => ({ ...f, monto_objetivo: e.target.value }))} />
|
||||||
|
<Select label="Tipo de bono" value={metaForm.tipo_bono} onChange={(e) => setMetaForm((f) => ({ ...f, tipo_bono: e.target.value }))} options={[{ value: 'fijo', label: 'Monto fijo' }, { value: 'porcentaje', label: 'Porcentaje de la venta' }]} />
|
||||||
|
</div>
|
||||||
|
<Input label={metaForm.tipo_bono === 'fijo' ? 'Bono (m.n.)' : 'Bono (%)'} type="number" step="0.5" value={metaForm.bono} onChange={(e) => setMetaForm((f) => ({ ...f, bono: e.target.value }))} />
|
||||||
|
<TextArea label="Notas" value={metaForm.notas} onChange={(e) => setMetaForm((f) => ({ ...f, notas: e.target.value }))} />
|
||||||
|
<Toggle label="Activa" checked={metaForm.activo} onChange={(v) => setMetaForm((f) => ({ ...f, activo: v }))} />
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ComisionesPanel;
|
||||||
@@ -115,13 +115,14 @@ const MENU_SECTIONS = [
|
|||||||
|
|
||||||
const HOME_MEMBRESIA_ITEM = { label: 'Membresías', icon: Repeat, href: '/membresias' };
|
const HOME_MEMBRESIA_ITEM = { label: 'Membresías', icon: Repeat, href: '/membresias' };
|
||||||
const HOME_COMPRAS_ITEM = { label: 'Compras', icon: Truck, href: '/compras' };
|
const HOME_COMPRAS_ITEM = { label: 'Compras', icon: Truck, href: '/compras' };
|
||||||
|
const HOME_COMISIONES_ITEM = { label: 'Liquidaciones', icon: Percent, href: '/comisiones/liquidaciones' };
|
||||||
|
|
||||||
export const HomeNestLayout: FC<LayoutProps> = ({ children, title: _title, subtitle: _subtitle }) => {
|
export const HomeNestLayout: FC<LayoutProps> = ({ children, title: _title, subtitle: _subtitle }) => {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { user, canSee, hasRole, logout, brand } = useAuth();
|
const { user, canSee, hasRole, logout, brand } = useAuth();
|
||||||
const { term } = useGlosario();
|
const { term } = useGlosario();
|
||||||
const { membresias: membresiasCfg, compras: comprasCfg } = useClinicSettings();
|
const { membresias: membresiasCfg, compras: comprasCfg, comisiones: comisionesCfg } = useClinicSettings();
|
||||||
const topNavItems = useMemo(
|
const topNavItems = useMemo(
|
||||||
() => TOP_NAV_ITEMS.map((it) => ({ ...it, label: traducirEtiqueta(it.label, term) })),
|
() => TOP_NAV_ITEMS.map((it) => ({ ...it, label: traducirEtiqueta(it.label, term) })),
|
||||||
[term]
|
[term]
|
||||||
@@ -138,8 +139,13 @@ export const HomeNestLayout: FC<LayoutProps> = ({ children, title: _title, subti
|
|||||||
? { ...s, items: [...s.items, HOME_COMPRAS_ITEM] }
|
? { ...s, items: [...s.items, HOME_COMPRAS_ITEM] }
|
||||||
: s));
|
: s));
|
||||||
}
|
}
|
||||||
|
if (comisionesCfg.activo) {
|
||||||
|
base = base.map((s) => (s.label === 'Analítica y más'
|
||||||
|
? { ...s, items: [...s.items, HOME_COMISIONES_ITEM] }
|
||||||
|
: s));
|
||||||
|
}
|
||||||
return traducirMenu(base, term);
|
return traducirMenu(base, term);
|
||||||
}, [term, membresiasCfg.activo, comprasCfg.activo]);
|
}, [term, membresiasCfg.activo, comprasCfg.activo, comisionesCfg.activo]);
|
||||||
const [menuOpen, setMenuOpen] = useState(false);
|
const [menuOpen, setMenuOpen] = useState(false);
|
||||||
const [expanded, setExpanded] = useState<string[]>(['Clínica']);
|
const [expanded, setExpanded] = useState<string[]>(['Clínica']);
|
||||||
const chatUnread = useChatUnread();
|
const chatUnread = useChatUnread();
|
||||||
|
|||||||
@@ -127,13 +127,15 @@ const menuGroups = [
|
|||||||
const MEMBRESIA_ITEM = { label: 'Membresías', icon: Repeat, href: '/membresias' };
|
const MEMBRESIA_ITEM = { label: 'Membresías', icon: Repeat, href: '/membresias' };
|
||||||
// La sección Compras solo existe con `compras.activo=true`.
|
// La sección Compras solo existe con `compras.activo=true`.
|
||||||
const COMPRAS_ITEM = { label: 'Compras', icon: Truck, href: '/compras' };
|
const COMPRAS_ITEM = { label: 'Compras', icon: Truck, href: '/compras' };
|
||||||
|
// La sección Liquidaciones solo existe con `comisiones.activo=true`.
|
||||||
|
const COMISIONES_ITEM = { label: 'Liquidaciones', icon: Percent, href: '/comisiones/liquidaciones' };
|
||||||
|
|
||||||
export const SkeenLayout: FC<LayoutProps> = ({ children, title, subtitle }) => {
|
export const SkeenLayout: FC<LayoutProps> = ({ children, title, subtitle }) => {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { user, canSee, hasRole, logout, brand } = useAuth();
|
const { user, canSee, hasRole, logout, brand } = useAuth();
|
||||||
const { term } = useGlosario();
|
const { term } = useGlosario();
|
||||||
const { membresias: membresiasCfg, compras: comprasCfg } = useClinicSettings();
|
const { membresias: membresiasCfg, compras: comprasCfg, comisiones: comisionesCfg } = useClinicSettings();
|
||||||
const menuGroupsT = useMemo(() => {
|
const menuGroupsT = useMemo(() => {
|
||||||
let base = menuGroups;
|
let base = menuGroups;
|
||||||
if (membresiasCfg.activo) {
|
if (membresiasCfg.activo) {
|
||||||
@@ -146,8 +148,13 @@ export const SkeenLayout: FC<LayoutProps> = ({ children, title, subtitle }) => {
|
|||||||
? { ...g, items: [...g.items, COMPRAS_ITEM] }
|
? { ...g, items: [...g.items, COMPRAS_ITEM] }
|
||||||
: g));
|
: g));
|
||||||
}
|
}
|
||||||
|
if (comisionesCfg.activo) {
|
||||||
|
base = base.map((g) => (g.label === 'Analítica'
|
||||||
|
? { ...g, items: [...g.items, COMISIONES_ITEM] }
|
||||||
|
: g));
|
||||||
|
}
|
||||||
return traducirMenu(base, term);
|
return traducirMenu(base, term);
|
||||||
}, [term, membresiasCfg.activo, comprasCfg.activo]);
|
}, [term, membresiasCfg.activo, comprasCfg.activo, comisionesCfg.activo]);
|
||||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||||
const chatUnread = useChatUnread();
|
const chatUnread = useChatUnread();
|
||||||
// Sidebar colapsable (modo iconos). Preferencia persistente; en pantallas
|
// Sidebar colapsable (modo iconos). Preferencia persistente; en pantallas
|
||||||
|
|||||||
@@ -1,18 +1,21 @@
|
|||||||
import type { FC } from 'react';
|
import type { FC } from 'react';
|
||||||
import { useEffect, useState, useCallback } from 'react';
|
import { useEffect, useState, useCallback } from 'react';
|
||||||
import { Stethoscope } from 'lucide-react';
|
import { Stethoscope, ChevronDown, ChevronRight, Coins, Target } from 'lucide-react';
|
||||||
import { Card, Input, EmptyState, Skeleton, toast } from '../ui';
|
import { Card, Input, EmptyState, Skeleton, toast } from '../ui';
|
||||||
import { ExportButton } from '../ui/ExportButton';
|
import { ExportButton } from '../ui/ExportButton';
|
||||||
import { exportToExcel } from '../../lib/exporter';
|
import { exportToExcel } from '../../lib/exporter';
|
||||||
import { odooApi, type ComisionRow } from '../../services/odoo';
|
import { odooApi, type ComisionRow, type ComisionReporteReglas, type ComisionEmpleadoCalc } from '../../services/odoo';
|
||||||
import { useReportRange } from '../../lib/reportRange';
|
import { useReportRange } from '../../lib/reportRange';
|
||||||
|
import { useClinicSettings } from '../../lib/clinic-settings';
|
||||||
|
|
||||||
const formatCurrency = (value: number) =>
|
const formatCurrency = (value: number) =>
|
||||||
new Intl.NumberFormat('es-MX', { style: 'currency', currency: 'MXN', maximumFractionDigits: 0 }).format(value);
|
new Intl.NumberFormat('es-MX', { style: 'currency', currency: 'MXN', maximumFractionDigits: 0 }).format(value || 0);
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
const ComisionesReport: FC = () => {
|
// Reporte "de siempre": porcentaje plano por empleado (`/reports/comisiones`).
|
||||||
// Rango global de Reportes; los campos Inicio/Fin fijan uno propio
|
// Se conserva intacto para cuando `comisiones.activo=false`.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
const ReportePlano: FC = () => {
|
||||||
const { start, end, setStart, setEnd } = useReportRange('comisiones');
|
const { start, end, setStart, setEnd } = useReportRange('comisiones');
|
||||||
const [rows, setRows] = useState<ComisionRow[]>([]);
|
const [rows, setRows] = useState<ComisionRow[]>([]);
|
||||||
const [totales, setTotales] = useState({ vendido: 0, comision: 0 });
|
const [totales, setTotales] = useState({ vendido: 0, comision: 0 });
|
||||||
@@ -74,7 +77,6 @@ const ComisionesReport: FC = () => {
|
|||||||
<div className="flex flex-col sm:flex-row gap-3 items-end">
|
<div className="flex flex-col sm:flex-row gap-3 items-end">
|
||||||
<Input label="Inicio" type="date" value={start} onChange={(e) => setStart(e.target.value)} className="sm:max-w-[160px]" />
|
<Input label="Inicio" type="date" value={start} onChange={(e) => setStart(e.target.value)} className="sm:max-w-[160px]" />
|
||||||
<Input label="Fin" type="date" value={end} onChange={(e) => setEnd(e.target.value)} className="sm:max-w-[160px]" />
|
<Input label="Fin" type="date" value={end} onChange={(e) => setEnd(e.target.value)} className="sm:max-w-[160px]" />
|
||||||
|
|
||||||
<ExportButton onClick={exportar} loading={exporting} />
|
<ExportButton onClick={exportar} loading={exporting} />
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-theme-muted">
|
<p className="text-sm text-theme-muted">
|
||||||
@@ -129,4 +131,216 @@ const ComisionesReport: FC = () => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Reporte del motor (`/reports/comisiones_reglas`): desglose por regla/línea,
|
||||||
|
// por servicio/categoría y comparativa contra metas.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
const FilaEmpleado: FC<{ emp: ComisionEmpleadoCalc }> = ({ emp }) => {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<tr className="hover:bg-theme-bg cursor-pointer" onClick={() => setOpen((v) => !v)}>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{open ? <ChevronDown size={15} className="text-theme-muted" /> : <ChevronRight size={15} className="text-theme-muted" />}
|
||||||
|
<div>
|
||||||
|
<p className="font-medium text-theme-heading">{emp.empleado}</p>
|
||||||
|
<p className="text-xs text-theme-muted">{emp.staff_type} · {emp.citas_done} cita(s) · {emp.lineas_count} línea(s)</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-right tabular-nums">{formatCurrency(emp.base)}</td>
|
||||||
|
<td className="px-4 py-3 text-right tabular-nums">{formatCurrency(emp.comision)}</td>
|
||||||
|
<td className="px-4 py-3 text-right tabular-nums">{formatCurrency(emp.bonos)}</td>
|
||||||
|
<td className="px-4 py-3 text-right tabular-nums">{formatCurrency(emp.ajustes)}</td>
|
||||||
|
<td className="px-4 py-3 text-right font-semibold tabular-nums">{formatCurrency(emp.total)}</td>
|
||||||
|
</tr>
|
||||||
|
{open && (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={6} className="bg-theme-bg px-4 py-4">
|
||||||
|
{emp.metas.length > 0 && (
|
||||||
|
<div className="mb-3">
|
||||||
|
<p className="text-xs font-semibold text-theme-heading uppercase mb-1 flex items-center gap-1"><Target size={13} />Metas</p>
|
||||||
|
{emp.metas.map((m, i) => (
|
||||||
|
<p key={i} className="text-xs text-theme-muted">
|
||||||
|
{m.periodo}: objetivo {formatCurrency(m.monto_objetivo)} · {m.alcanzada ? 'alcanzada' : 'no alcanzada'} · bono {formatCurrency(m.bono)}
|
||||||
|
</p>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{emp.por_regla.length > 0 && (
|
||||||
|
<div className="mb-3">
|
||||||
|
<p className="text-xs font-semibold text-theme-heading uppercase mb-1 flex items-center gap-1"><Coins size={13} />Por regla</p>
|
||||||
|
<table className="w-full text-xs">
|
||||||
|
<tbody className="divide-y divide-theme-border">
|
||||||
|
{emp.por_regla.map((r, i) => (
|
||||||
|
<tr key={i}><td className="py-1">{r.regla} <span className="text-theme-muted">({r.lineas} línea/s)</span></td><td className="py-1 text-right tabular-nums">{formatCurrency(r.base)}</td><td className="py-1 text-right tabular-nums">{formatCurrency(r.comision)}</td></tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<p className="text-xs font-semibold text-theme-heading uppercase mb-1">Desglose por línea</p>
|
||||||
|
<div className="max-h-60 overflow-y-auto">
|
||||||
|
<table className="w-full text-xs">
|
||||||
|
<thead className="text-theme-muted sticky top-0 bg-theme-bg"><tr><th className="text-left py-1">Referencia</th><th className="text-right py-1">Base</th><th className="text-right py-1">Comisión</th></tr></thead>
|
||||||
|
<tbody className="divide-y divide-theme-border">
|
||||||
|
{emp.desglose.map((d, i) => (
|
||||||
|
<tr key={i}><td className="py-1 pr-2">{d.referencia}</td><td className="py-1 text-right tabular-nums">{formatCurrency(d.base)}</td><td className="py-1 text-right tabular-nums">{formatCurrency(d.comision)}</td></tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const ReporteReglas: FC = () => {
|
||||||
|
const { start, end, setStart, setEnd } = useReportRange('comisiones');
|
||||||
|
const [data, setData] = useState<ComisionReporteReglas | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [exporting, setExporting] = useState(false);
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const res = await odooApi.getComisionesReglasReport(start, end);
|
||||||
|
if (res.status === 'success') setData(res);
|
||||||
|
} catch (err) {
|
||||||
|
const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message;
|
||||||
|
toast.error(msg || 'Error al cargar comisiones');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [start, end]);
|
||||||
|
useEffect(() => { load(); }, [load]);
|
||||||
|
|
||||||
|
const exportar = async () => {
|
||||||
|
if (!data) return;
|
||||||
|
const filas = data.empleados.flatMap((e) => e.desglose.map((d) => ({ empleado: e.empleado, ...d })));
|
||||||
|
if (filas.length === 0) { toast.info('Sin líneas que exportar'); return; }
|
||||||
|
setExporting(true);
|
||||||
|
try {
|
||||||
|
await exportToExcel({
|
||||||
|
filename: `comisiones-reglas-${start}_${end}`,
|
||||||
|
sheetName: 'Comisiones',
|
||||||
|
title: 'Comisiones por reglas',
|
||||||
|
subtitle: `Del ${start} al ${end} · base ${data.config.base}`,
|
||||||
|
columns: [
|
||||||
|
{ header: 'Empleado', key: 'empleado' },
|
||||||
|
{ header: 'Fecha', key: 'fecha' },
|
||||||
|
{ header: 'Folio', key: 'folio' },
|
||||||
|
{ header: 'Servicio', key: 'servicio' },
|
||||||
|
{ header: 'Categoría', key: 'categoria' },
|
||||||
|
{ header: 'Regla', key: 'regla' },
|
||||||
|
{ header: 'Base', key: 'base', format: 'currency' },
|
||||||
|
{ header: 'Comisión', key: 'comision', format: 'currency' },
|
||||||
|
{ header: 'Referencia', key: 'referencia' },
|
||||||
|
],
|
||||||
|
rows: filas as unknown as Record<string, unknown>[],
|
||||||
|
});
|
||||||
|
toast.success('Excel descargado');
|
||||||
|
} catch { toast.error('Error al exportar'); }
|
||||||
|
finally { setExporting(false); }
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Card>
|
||||||
|
<Card.Body>
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-end sm:justify-between gap-3">
|
||||||
|
<div className="flex flex-col sm:flex-row gap-3 items-end">
|
||||||
|
<Input label="Inicio" type="date" value={start} onChange={(e) => setStart(e.target.value)} className="sm:max-w-[160px]" />
|
||||||
|
<Input label="Fin" type="date" value={end} onChange={(e) => setEnd(e.target.value)} className="sm:max-w-[160px]" />
|
||||||
|
<ExportButton onClick={exportar} loading={exporting} />
|
||||||
|
</div>
|
||||||
|
{data && (
|
||||||
|
<p className="text-sm text-theme-muted">
|
||||||
|
Base: <span className="font-semibold text-theme-heading">{formatCurrency(data.totales.base)}</span>
|
||||||
|
{' · '}Comisión: <span className="font-semibold text-theme-heading">{formatCurrency(data.totales.comision)}</span>
|
||||||
|
{' · '}Total: <span className="font-semibold text-theme-heading">{formatCurrency(data.totales.total)}</span>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-theme-muted mt-2">
|
||||||
|
Motor activo · base {data?.config.base} · {data?.config.incluir_impuestos ? 'con impuestos' : 'sin impuestos'} ·
|
||||||
|
{' '}{data?.config.neto_de_devoluciones ? 'neto de devoluciones' : 'sin descontar devoluciones'}
|
||||||
|
</p>
|
||||||
|
</Card.Body>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<Card><Card.Body><Skeleton count={6} className="h-12 w-full" /></Card.Body></Card>
|
||||||
|
) : !data || data.empleados.length === 0 ? (
|
||||||
|
<Card><Card.Body>
|
||||||
|
<EmptyState title="Sin comisiones" subtitle="No hay líneas recetadas ni reglas aplicables en el periodo." icon={<Stethoscope size={28} />} />
|
||||||
|
</Card.Body></Card>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Card>
|
||||||
|
<Card.Body noPadding>
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead className="bg-theme-bg text-theme-muted text-xs uppercase">
|
||||||
|
<tr>
|
||||||
|
<th className="text-left px-4 py-3">Empleado</th>
|
||||||
|
<th className="text-right px-4 py-3">Base</th>
|
||||||
|
<th className="text-right px-4 py-3">Comisión</th>
|
||||||
|
<th className="text-right px-4 py-3">Bonos</th>
|
||||||
|
<th className="text-right px-4 py-3">Ajustes</th>
|
||||||
|
<th className="text-right px-4 py-3">Total</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-theme-border">
|
||||||
|
{data.empleados.map((e) => <FilaEmpleado key={e.empleado_id} emp={e} />)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</Card.Body>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<div className="grid md:grid-cols-2 gap-4">
|
||||||
|
<Card>
|
||||||
|
<Card.Body>
|
||||||
|
<p className="text-sm font-semibold text-theme-heading mb-2">Por servicio</p>
|
||||||
|
<table className="w-full text-xs">
|
||||||
|
<thead className="text-theme-muted"><tr><th className="text-left py-1">Servicio</th><th className="text-right py-1">Base</th><th className="text-right py-1">Comisión</th></tr></thead>
|
||||||
|
<tbody className="divide-y divide-theme-border">
|
||||||
|
{(data.por_servicio || []).map((s, i) => (
|
||||||
|
<tr key={i}><td className="py-1">{s.servicio}</td><td className="py-1 text-right tabular-nums">{formatCurrency(s.base)}</td><td className="py-1 text-right tabular-nums">{formatCurrency(s.comision)}</td></tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</Card.Body>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<Card.Body>
|
||||||
|
<p className="text-sm font-semibold text-theme-heading mb-2">Por categoría</p>
|
||||||
|
<table className="w-full text-xs">
|
||||||
|
<thead className="text-theme-muted"><tr><th className="text-left py-1">Categoría</th><th className="text-right py-1">Base</th><th className="text-right py-1">Comisión</th></tr></thead>
|
||||||
|
<tbody className="divide-y divide-theme-border">
|
||||||
|
{(data.por_categoria || []).map((c, i) => (
|
||||||
|
<tr key={i}><td className="py-1">{c.categoria}</td><td className="py-1 text-right tabular-nums">{formatCurrency(c.base)}</td><td className="py-1 text-right tabular-nums">{formatCurrency(c.comision)}</td></tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</Card.Body>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const ComisionesReport: FC = () => {
|
||||||
|
const { comisiones } = useClinicSettings();
|
||||||
|
// Con el motor apagado se muestra EXACTAMENTE el reporte de siempre.
|
||||||
|
if (!comisiones.activo) return <ReportePlano />;
|
||||||
|
return <ReporteReglas />;
|
||||||
|
};
|
||||||
|
|
||||||
export default ComisionesReport;
|
export default ComisionesReport;
|
||||||
|
|||||||
@@ -40,6 +40,8 @@ const MENU_ROLES: Record<string, FrontendRole[]> = {
|
|||||||
// Membresías (apagado por defecto: la sección solo se pinta con
|
// Membresías (apagado por defecto: la sección solo se pinta con
|
||||||
// membresias.activo=true). Se lista aquí para que canSee la reconozca.
|
// membresias.activo=true). Se lista aquí para que canSee la reconozca.
|
||||||
'/membresias': ['admin', 'recepcion', 'medico', 'lectura', 'gerencia'],
|
'/membresias': ['admin', 'recepcion', 'medico', 'lectura', 'gerencia'],
|
||||||
|
// Liquidaciones de comisiones (solo se pinta con comisiones.activo=true).
|
||||||
|
'/comisiones/liquidaciones': ['admin', 'recepcion', 'gerencia', 'medico'],
|
||||||
'/productos': ['admin', 'recepcion', 'lectura', 'gerencia'], // deprecated: redirige a /inventario
|
'/productos': ['admin', 'recepcion', 'lectura', 'gerencia'], // deprecated: redirige a /inventario
|
||||||
'/ventas': ['admin', 'recepcion', 'gerencia'],
|
'/ventas': ['admin', 'recepcion', 'gerencia'],
|
||||||
'/tickets': ['admin', 'gerencia'],
|
'/tickets': ['admin', 'gerencia'],
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { odooApi, type ClinicSettings, CLINIC_SETTINGS_DEFAULT, IMPUESTOS_DEFAULT, NOTIFICACIONES_DEFAULT, MEMBRESIAS_DEFAULT, RECURSOS_DEFAULT, COMPRAS_DEFAULT } from '../services/odoo';
|
import { odooApi, type ClinicSettings, CLINIC_SETTINGS_DEFAULT, IMPUESTOS_DEFAULT, NOTIFICACIONES_DEFAULT, MEMBRESIAS_DEFAULT, RECURSOS_DEFAULT, COMPRAS_DEFAULT, COMISIONES_DEFAULT } from '../services/odoo';
|
||||||
|
|
||||||
// Ajustes de la clínica compartidos por la sesión (nombre, moneda e impuestos).
|
// 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.
|
// Se leen una sola vez del servidor y se avisa a quien los use cuando cambian.
|
||||||
@@ -13,6 +13,7 @@ const normalizar = (settings?: Partial<ClinicSettings> | null): ClinicSettings =
|
|||||||
membresias: { ...MEMBRESIAS_DEFAULT, ...(settings?.membresias || {}) },
|
membresias: { ...MEMBRESIAS_DEFAULT, ...(settings?.membresias || {}) },
|
||||||
recursos: { ...RECURSOS_DEFAULT, ...(settings?.recursos || {}) },
|
recursos: { ...RECURSOS_DEFAULT, ...(settings?.recursos || {}) },
|
||||||
compras: { ...COMPRAS_DEFAULT, ...(settings?.compras || {}) },
|
compras: { ...COMPRAS_DEFAULT, ...(settings?.compras || {}) },
|
||||||
|
comisiones: { ...COMISIONES_DEFAULT, ...(settings?.comisiones || {}) },
|
||||||
});
|
});
|
||||||
|
|
||||||
let actual: ClinicSettings = normalizar(null);
|
let actual: ClinicSettings = normalizar(null);
|
||||||
|
|||||||
@@ -32,6 +32,9 @@ export const MENU_ITEMS: MenuItem[] = [
|
|||||||
{ label: 'Recursos', href: '/reportes?tab=recursos', termKey: 'room_plural' },
|
{ label: 'Recursos', href: '/reportes?tab=recursos', termKey: 'room_plural' },
|
||||||
{ label: 'Paquetes', href: '/reportes?tab=paquetes', termKey: 'package_plural' },
|
{ label: 'Paquetes', href: '/reportes?tab=paquetes', termKey: 'package_plural' },
|
||||||
{ label: 'Membresías', href: '/membresias' },
|
{ label: 'Membresías', href: '/membresias' },
|
||||||
|
// Liquidaciones de comisiones (solo se pinta en el menú con
|
||||||
|
// `comisiones.activo=true`; de lo contrario queda idéntico al de siempre).
|
||||||
|
{ label: 'Liquidaciones', href: '/comisiones/liquidaciones' },
|
||||||
{ label: 'Vendedores', href: '/reportes?tab=vendedores' },
|
{ label: 'Vendedores', href: '/reportes?tab=vendedores' },
|
||||||
{ label: 'Concentrado', href: '/reportes?tab=concentrado' },
|
{ label: 'Concentrado', href: '/reportes?tab=concentrado' },
|
||||||
{ label: 'Recomendaciones', href: '/reportes?tab=recomendaciones' },
|
{ label: 'Recomendaciones', href: '/reportes?tab=recomendaciones' },
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ export const PERM_KEYS: string[] = [
|
|||||||
'membresias.cancelar',
|
'membresias.cancelar',
|
||||||
'compras.ver', 'compras.crear', 'compras.recibir', 'compras.pagar',
|
'compras.ver', 'compras.crear', 'compras.recibir', 'compras.pagar',
|
||||||
'compras.editar', 'compras.cancelar', 'proveedores.ver', 'proveedores.editar',
|
'compras.editar', 'compras.cancelar', 'proveedores.ver', 'proveedores.editar',
|
||||||
|
'comisiones.ver', 'comisiones.editar', 'comisiones.liquidar',
|
||||||
'config.usuarios', 'config.roles', 'config.editar', 'config.auditoria',
|
'config.usuarios', 'config.roles', 'config.editar', 'config.auditoria',
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -50,6 +51,7 @@ const READ_PERM_KEYS = new Set([
|
|||||||
'membresias.ver',
|
'membresias.ver',
|
||||||
'compras.ver',
|
'compras.ver',
|
||||||
'proveedores.ver',
|
'proveedores.ver',
|
||||||
|
'comisiones.ver',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const DEFAULT_PERM_ROLES: Record<string, string[]> = {
|
const DEFAULT_PERM_ROLES: Record<string, string[]> = {
|
||||||
@@ -80,6 +82,7 @@ const DEFAULT_PERM_ROLES: Record<string, string[]> = {
|
|||||||
'compras.pagar': ['admin', 'recepcion', 'gerencia'],
|
'compras.pagar': ['admin', 'recepcion', 'gerencia'],
|
||||||
'compras.editar': AG, 'compras.cancelar': AG,
|
'compras.editar': AG, 'compras.cancelar': AG,
|
||||||
'proveedores.ver': ALL, 'proveedores.editar': AG,
|
'proveedores.ver': ALL, 'proveedores.editar': AG,
|
||||||
|
'comisiones.ver': ALL, 'comisiones.editar': AG, 'comisiones.liquidar': AG,
|
||||||
'config.usuarios': AG, 'config.roles': AG, 'config.editar': AG, 'config.auditoria': AD,
|
'config.usuarios': AG, 'config.roles': AG, 'config.editar': AG, 'config.auditoria': AD,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import NotificacionesEnviadasPanel from '../components/NotificacionesEnviadasPan
|
|||||||
import FormulariosPanel from '../components/FormulariosPanel';
|
import FormulariosPanel from '../components/FormulariosPanel';
|
||||||
import MembresiasPanel from '../components/MembresiasPanel';
|
import MembresiasPanel from '../components/MembresiasPanel';
|
||||||
import ComprasPanel from '../components/ComprasPanel';
|
import ComprasPanel from '../components/ComprasPanel';
|
||||||
|
import ComisionesPanel from '../components/ComisionesPanel';
|
||||||
import { Card, Button, Input, PageHeader, toast } from '../components/ui';
|
import { Card, Button, Input, PageHeader, toast } from '../components/ui';
|
||||||
import { odooApi, type Doctor, type ClinicSettings, type ImpuestosSettings, BRAND_DEFAULT, CLINIC_SETTINGS_DEFAULT, IMPUESTOS_DEFAULT, RESERVAS_ONLINE_DEFAULT, NOTIFICACIONES_DEFAULT } from '../services/odoo';
|
import { odooApi, type Doctor, type ClinicSettings, type ImpuestosSettings, BRAND_DEFAULT, CLINIC_SETTINGS_DEFAULT, IMPUESTOS_DEFAULT, RESERVAS_ONLINE_DEFAULT, NOTIFICACIONES_DEFAULT } from '../services/odoo';
|
||||||
import { useAuth } from '../lib/auth';
|
import { useAuth } from '../lib/auth';
|
||||||
@@ -36,6 +37,7 @@ const TABS = [
|
|||||||
{ key: 'recursos', label: 'Recursos' },
|
{ key: 'recursos', label: 'Recursos' },
|
||||||
{ key: 'membresias', label: 'Membresías' },
|
{ key: 'membresias', label: 'Membresías' },
|
||||||
{ key: 'compras', label: 'Compras' },
|
{ key: 'compras', label: 'Compras' },
|
||||||
|
{ key: 'comisiones', label: 'Comisiones' },
|
||||||
{ key: 'terminologia', label: 'Terminología' },
|
{ key: 'terminologia', label: 'Terminología' },
|
||||||
{ key: 'usuarios', label: 'Usuarios' },
|
{ key: 'usuarios', label: 'Usuarios' },
|
||||||
{ key: 'roles', label: 'Roles' },
|
{ key: 'roles', label: 'Roles' },
|
||||||
@@ -46,7 +48,7 @@ const TABS = [
|
|||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
// Secciones que ve gerencia (admin del sistema ve todas).
|
// Secciones que ve gerencia (admin del sistema ve todas).
|
||||||
const TABS_GERENCIA = ['reservas', 'notificaciones', 'notif_enviadas', 'formularios', 'sucursales', 'categorias', 'recursos', 'membresias', 'compras', 'terminologia', 'usuarios', 'roles', 'tickets'];
|
const TABS_GERENCIA = ['reservas', 'notificaciones', 'notif_enviadas', 'formularios', 'sucursales', 'categorias', 'recursos', 'membresias', 'compras', 'comisiones', 'terminologia', 'usuarios', 'roles', 'tickets'];
|
||||||
|
|
||||||
const SUBCATALOGOS = [
|
const SUBCATALOGOS = [
|
||||||
{ key: 'diagnosticos', label: 'Diagnósticos' },
|
{ key: 'diagnosticos', label: 'Diagnósticos' },
|
||||||
@@ -72,6 +74,7 @@ const Configuracion: FC = () => {
|
|||||||
formularios: 'formularios.ver',
|
formularios: 'formularios.ver',
|
||||||
membresias: 'membresias.editar',
|
membresias: 'membresias.editar',
|
||||||
compras: 'compras.editar',
|
compras: 'compras.editar',
|
||||||
|
comisiones: 'comisiones.editar',
|
||||||
recursos: 'recursos.editar',
|
recursos: 'recursos.editar',
|
||||||
};
|
};
|
||||||
const tabsVisibles = useMemo(
|
const tabsVisibles = useMemo(
|
||||||
@@ -406,6 +409,12 @@ const Configuracion: FC = () => {
|
|||||||
<ComprasPanel settings={settings} onSaved={setSettings} />
|
<ComprasPanel settings={settings} onSaved={setSettings} />
|
||||||
</Card.Body>
|
</Card.Body>
|
||||||
</Card>
|
</Card>
|
||||||
|
) : tab === 'comisiones' ? (
|
||||||
|
<Card>
|
||||||
|
<Card.Body>
|
||||||
|
<ComisionesPanel settings={settings} onSaved={setSettings} />
|
||||||
|
</Card.Body>
|
||||||
|
</Card>
|
||||||
) : tab === 'terminologia' ? (
|
) : tab === 'terminologia' ? (
|
||||||
<Card>
|
<Card>
|
||||||
<Card.Body>
|
<Card.Body>
|
||||||
|
|||||||
601
frontend/src/pages/Liquidaciones.tsx
Normal file
601
frontend/src/pages/Liquidaciones.tsx
Normal file
@@ -0,0 +1,601 @@
|
|||||||
|
import type { FC } from 'react';
|
||||||
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
|
import { Coins, Download, Eye, HandCoins, Plus, RefreshCw, Receipt, Wallet, XCircle } from 'lucide-react';
|
||||||
|
import Layout from '../components/Layout';
|
||||||
|
import { Card, Button, Input, Select, Modal, PageHeader, EmptyState, toast } from '../components/ui';
|
||||||
|
import {
|
||||||
|
odooApi,
|
||||||
|
type ComisionCalculo,
|
||||||
|
type ComisionEmpleado,
|
||||||
|
type ComisionEmpleadoCalc,
|
||||||
|
type ComisionLiquidacion,
|
||||||
|
} from '../services/odoo';
|
||||||
|
import { useAuth } from '../lib/auth';
|
||||||
|
import { useClinicSettings } from '../lib/clinic-settings';
|
||||||
|
import { exportToExcel } from '../lib/exporter';
|
||||||
|
|
||||||
|
const money = (v: number) =>
|
||||||
|
new Intl.NumberFormat('es-MX', { style: 'currency', currency: 'MXN', maximumFractionDigits: 2 }).format(v || 0);
|
||||||
|
|
||||||
|
const mesActual = () => {
|
||||||
|
const d = new Date();
|
||||||
|
return {
|
||||||
|
start: `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-01`,
|
||||||
|
end: `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(new Date(d.getFullYear(), d.getMonth() + 1, 0).getDate()).padStart(2, '0')}`,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const apiMessage = (err: unknown, fallback: string): string =>
|
||||||
|
(err as { response?: { data?: { message?: string } } })?.response?.data?.message || fallback;
|
||||||
|
|
||||||
|
const ESTADO_CHIP: Record<string, string> = {
|
||||||
|
borrador: 'bg-amber-100 text-amber-800',
|
||||||
|
pagada: 'bg-emerald-100 text-emerald-800',
|
||||||
|
cancelada: 'bg-rose-100 text-rose-700',
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Mi recibo (rol médico): solo sus liquidaciones pagadas.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
const MiRecibo: FC = () => {
|
||||||
|
const [rows, setRows] = useState<ComisionLiquidacion[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [sel, setSel] = useState<ComisionLiquidacion | null>(null);
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await odooApi.getMisLiquidaciones();
|
||||||
|
setRows(res.liquidaciones || []);
|
||||||
|
} catch {
|
||||||
|
toast.error('No se pudieron cargar tus liquidaciones');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
useEffect(() => { load(); }, [load]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Layout title="Mi liquidación" subtitle="Recibos de comisión pagados">
|
||||||
|
<PageHeader title="Mi liquidación" subtitle="Recibos de comisión de tu empleado">
|
||||||
|
<Button variant="outline" size="sm" onClick={load}><RefreshCw size={14} className="mr-1.5" />Actualizar</Button>
|
||||||
|
</PageHeader>
|
||||||
|
<Card>
|
||||||
|
<Card.Body noPadding>
|
||||||
|
{loading ? (
|
||||||
|
<div className="p-6 text-sm text-theme-muted">Cargando…</div>
|
||||||
|
) : rows.length === 0 ? (
|
||||||
|
<div className="p-6">
|
||||||
|
<EmptyState title="Sin recibos" subtitle="Aún no tienes liquidaciones pagadas." icon={<Receipt size={28} />} />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead className="bg-theme-bg text-theme-muted text-[11px] uppercase">
|
||||||
|
<tr>
|
||||||
|
<th className="text-left px-4 py-3">Folio</th>
|
||||||
|
<th className="text-left px-4 py-3">Periodo</th>
|
||||||
|
<th className="text-right px-4 py-3">Total</th>
|
||||||
|
<th className="text-left px-4 py-3">Pagada</th>
|
||||||
|
<th className="text-right px-4 py-3">Recibo</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-theme-border">
|
||||||
|
{rows.map((l) => (
|
||||||
|
<tr key={l.id}>
|
||||||
|
<td className="px-4 py-3 font-medium text-theme-heading">{l.folio}</td>
|
||||||
|
<td className="px-4 py-3 text-theme-muted">{l.periodo_inicio} a {l.periodo_fin}</td>
|
||||||
|
<td className="px-4 py-3 text-right tabular-nums font-semibold">{money(l.total)}</td>
|
||||||
|
<td className="px-4 py-3 text-theme-muted">{l.fecha_pago || '—'}</td>
|
||||||
|
<td className="px-4 py-3 text-right">
|
||||||
|
<button type="button" title="Ver recibo" onClick={() => setSel(l)} className="p-1.5 rounded-lg hover:bg-theme-bg text-theme-muted"><Eye size={15} /></button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Card.Body>
|
||||||
|
</Card>
|
||||||
|
<DetalleLiquidacion liq={sel} onClose={() => setSel(null)} />
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Detalle de una liquidación (desglose congelado + ajustes)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
const DetalleLiquidacion: FC<{ liq: ComisionLiquidacion | null; onClose: () => void }> = ({ liq, onClose }) => {
|
||||||
|
const det = liq?.detalle;
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
isOpen={!!liq}
|
||||||
|
onClose={onClose}
|
||||||
|
title={`Liquidación ${liq?.folio || ''} · ${liq?.empleado || ''}`}
|
||||||
|
footer={<div className="flex justify-end"><Button variant="outline" onClick={onClose}>Cerrar</Button></div>}
|
||||||
|
>
|
||||||
|
{liq && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
|
||||||
|
<div><p className="text-xs text-theme-muted">Periodo</p><p className="text-sm font-medium text-theme-heading">{liq.periodo_inicio} a {liq.periodo_fin}</p></div>
|
||||||
|
<div><p className="text-xs text-theme-muted">Comisión calculada</p><p className="text-sm tabular-nums">{money(liq.total_calculado)}</p></div>
|
||||||
|
<div><p className="text-xs text-theme-muted">Ajustes</p><p className="text-sm tabular-nums">{money(liq.total_ajustes)}</p></div>
|
||||||
|
<div><p className="text-xs text-theme-muted">Total</p><p className="text-sm font-semibold tabular-nums">{money(liq.total)}</p></div>
|
||||||
|
</div>
|
||||||
|
{liq.estado === 'pagada' && (
|
||||||
|
<p className="text-xs text-theme-muted">
|
||||||
|
Pagada el {liq.fecha_pago} · {liq.metodo_pago || 'sin método'} {liq.referencia ? `· ref ${liq.referencia}` : ''} · por {liq.pagada_por_login || '—'}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{det?.por_regla && det.por_regla.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-semibold text-theme-heading uppercase mb-1">Por regla</p>
|
||||||
|
<table className="w-full text-xs">
|
||||||
|
<thead className="text-theme-muted"><tr><th className="text-left py-1">Regla</th><th className="text-right py-1">Base</th><th className="text-right py-1">Comisión</th></tr></thead>
|
||||||
|
<tbody className="divide-y divide-theme-border">
|
||||||
|
{det.por_regla.map((r, i) => (
|
||||||
|
<tr key={i}><td className="py-1">{r.regla}</td><td className="py-1 text-right tabular-nums">{money(r.base)}</td><td className="py-1 text-right tabular-nums">{money(r.comision)}</td></tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{det?.desglose && det.desglose.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-semibold text-theme-heading uppercase mb-1">Desglose por línea</p>
|
||||||
|
<div className="max-h-56 overflow-y-auto">
|
||||||
|
<table className="w-full text-xs">
|
||||||
|
<thead className="text-theme-muted sticky top-0 bg-theme-surface"><tr><th className="text-left py-1">Referencia</th><th className="text-right py-1">Base</th><th className="text-right py-1">Comisión</th></tr></thead>
|
||||||
|
<tbody className="divide-y divide-theme-border">
|
||||||
|
{det.desglose.map((d, i) => (
|
||||||
|
<tr key={i}><td className="py-1 pr-2">{d.referencia}</td><td className="py-1 text-right tabular-nums">{money(d.base)}</td><td className="py-1 text-right tabular-nums">{money(d.comision)}</td></tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{det?.metas && det.metas.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-semibold text-theme-heading uppercase mb-1">Metas</p>
|
||||||
|
{det.metas.map((m, i) => (
|
||||||
|
<p key={i} className="text-xs text-theme-muted">
|
||||||
|
{m.periodo} · objetivo {money(m.monto_objetivo)} · {m.alcanzada ? 'alcanzada' : 'no alcanzada'} · bono {money(m.bono)}
|
||||||
|
</p>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{liq.ajustes && liq.ajustes.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-semibold text-theme-heading uppercase mb-1">Ajustes</p>
|
||||||
|
{liq.ajustes.map((a) => (
|
||||||
|
<p key={a.id} className="text-xs text-theme-muted">{money(a.monto)} · {a.motivo || 'sin motivo'} · {a.usuario || '—'}</p>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Página principal (admin/gerencia)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
const Liquidaciones: FC = () => {
|
||||||
|
const { user, can } = useAuth();
|
||||||
|
const { comisiones } = useClinicSettings();
|
||||||
|
const esMedico = user?.role === 'medico';
|
||||||
|
|
||||||
|
const [rango, setRango] = useState(mesActual);
|
||||||
|
const { start, end } = rango;
|
||||||
|
const [calc, setCalc] = useState<ComisionCalculo | null>(null);
|
||||||
|
const [empleados, setEmpleados] = useState<ComisionEmpleado[]>([]);
|
||||||
|
const [empFiltro, setEmpFiltro] = useState('');
|
||||||
|
const [liqs, setLiqs] = useState<ComisionLiquidacion[]>([]);
|
||||||
|
const [estadoFiltro, setEstadoFiltro] = useState('');
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [detalleCalc, setDetalleCalc] = useState<ComisionEmpleadoCalc | null>(null);
|
||||||
|
const [detalleLiq, setDetalleLiq] = useState<ComisionLiquidacion | null>(null);
|
||||||
|
const [ajustar, setAjustar] = useState<ComisionLiquidacion | null>(null);
|
||||||
|
const [ajusteMonto, setAjusteMonto] = useState('');
|
||||||
|
const [ajusteMotivo, setAjusteMotivo] = useState('');
|
||||||
|
const [pagar, setPagar] = useState<ComisionLiquidacion | null>(null);
|
||||||
|
const [pagoFecha, setPagoFecha] = useState('');
|
||||||
|
const [pagoMetodo, setPagoMetodo] = useState('transferencia');
|
||||||
|
const [pagoRef, setPagoRef] = useState('');
|
||||||
|
|
||||||
|
const loadCalc = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const res = await odooApi.getComisionCalculo(start, end, empFiltro ? Number(empFiltro) : undefined);
|
||||||
|
if (res.status === 'success') setCalc(res);
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(apiMessage(err, 'No se pudo calcular el periodo'));
|
||||||
|
}
|
||||||
|
}, [start, end, empFiltro]);
|
||||||
|
|
||||||
|
const loadLiqs = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const res = await odooApi.getComisionesLiquidaciones({
|
||||||
|
...(empFiltro ? { empleado_id: Number(empFiltro) } : {}),
|
||||||
|
...(estadoFiltro ? { estado: estadoFiltro as ComisionLiquidacion['estado'] } : {}),
|
||||||
|
});
|
||||||
|
setLiqs(res.liquidaciones || []);
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(apiMessage(err, 'No se pudieron cargar las liquidaciones'));
|
||||||
|
}
|
||||||
|
}, [empFiltro, estadoFiltro]);
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const [emps] = await Promise.all([odooApi.getComisionEmpleados()]);
|
||||||
|
setEmpleados(emps.empleados || []);
|
||||||
|
await Promise.all([loadCalc(), loadLiqs()]);
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(apiMessage(err, 'No se pudo cargar la información de comisiones'));
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [loadCalc, loadLiqs]);
|
||||||
|
|
||||||
|
useEffect(() => { if (!esMedico) load(); }, [load, esMedico]);
|
||||||
|
|
||||||
|
const generar = async () => {
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
const res = await odooApi.generarComisionesLiquidaciones({ start, end });
|
||||||
|
if (res.total_creadas > 0) toast.success(`Se generaron ${res.total_creadas} borrador(es)`);
|
||||||
|
else toast.info('No había nada nuevo que liquidar');
|
||||||
|
if (res.omitidas?.length) toast.info(`${res.omitidas.length} empleado(s) ya tenían liquidación del periodo`);
|
||||||
|
await Promise.all([loadLiqs(), loadCalc()]);
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(apiMessage(err, 'No se pudieron generar las liquidaciones'));
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const hacerAjuste = async () => {
|
||||||
|
if (!ajustar) return;
|
||||||
|
const monto = parseFloat(ajusteMonto);
|
||||||
|
if (Number.isNaN(monto) || monto === 0) { toast.error('Indica un monto distinto de cero'); return; }
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
await odooApi.ajustarComisionLiquidacion(ajustar.id, { monto, motivo: ajusteMotivo });
|
||||||
|
toast.success('Ajuste registrado');
|
||||||
|
setAjustar(null); setAjusteMonto(''); setAjusteMotivo('');
|
||||||
|
await Promise.all([loadLiqs(), loadCalc()]);
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(apiMessage(err, 'No se pudo registrar el ajuste'));
|
||||||
|
} finally { setBusy(false); }
|
||||||
|
};
|
||||||
|
|
||||||
|
const hacerPago = async () => {
|
||||||
|
if (!pagar) return;
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
await odooApi.pagarComisionLiquidacion(pagar.id, { fecha: pagoFecha || undefined, metodo_pago: pagoMetodo, referencia: pagoRef });
|
||||||
|
toast.success('Liquidación marcada como pagada');
|
||||||
|
setPagar(null); setPagoRef('');
|
||||||
|
await loadLiqs();
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(apiMessage(err, 'No se pudo pagar'));
|
||||||
|
} finally { setBusy(false); }
|
||||||
|
};
|
||||||
|
|
||||||
|
const cancelarLiq = async (l: ComisionLiquidacion) => {
|
||||||
|
const motivo = window.prompt('Motivo de cancelación', '') || '';
|
||||||
|
try {
|
||||||
|
await odooApi.cancelarComisionLiquidacion(l.id, motivo);
|
||||||
|
toast.success('Liquidación cancelada');
|
||||||
|
await loadLiqs();
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(apiMessage(err, 'No se pudo cancelar'));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const verDetalle = async (l: ComisionLiquidacion) => {
|
||||||
|
try {
|
||||||
|
const res = await odooApi.getComisionLiquidacion(l.id);
|
||||||
|
setDetalleLiq(res.liquidacion);
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(apiMessage(err, 'No se pudo abrir la liquidación'));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const exportarCalc = async () => {
|
||||||
|
if (!calc) return;
|
||||||
|
try {
|
||||||
|
await exportToExcel({
|
||||||
|
filename: `comisiones-calculo-${start}_${end}`,
|
||||||
|
sheetName: 'Cálculo',
|
||||||
|
title: 'Cálculo de comisiones del periodo',
|
||||||
|
subtitle: `Del ${start} al ${end} · base ${comisiones.base}${comisiones.incluir_impuestos ? ' con impuestos' : ''}`,
|
||||||
|
columns: [
|
||||||
|
{ header: 'Empleado', key: 'empleado' },
|
||||||
|
{ header: 'Base', key: 'base', format: 'currency' },
|
||||||
|
{ header: 'Comisión', key: 'comision', format: 'currency' },
|
||||||
|
{ header: 'Bonos', key: 'bonos', format: 'currency' },
|
||||||
|
{ header: 'Ajustes', key: 'ajustes', format: 'currency' },
|
||||||
|
{ header: 'Total', key: 'total', format: 'currency' },
|
||||||
|
],
|
||||||
|
rows: calc.empleados as unknown as Record<string, unknown>[],
|
||||||
|
totals: { empleado: 'Totales', base: calc.totales.base, comision: calc.totales.comision, bonos: calc.totales.bonos, ajustes: calc.totales.ajustes, total: calc.totales.total },
|
||||||
|
});
|
||||||
|
toast.success('Excel descargado');
|
||||||
|
} catch { toast.error('No se pudo exportar'); }
|
||||||
|
};
|
||||||
|
|
||||||
|
const exportarDesglose = async () => {
|
||||||
|
if (!calc) return;
|
||||||
|
const filas = calc.empleados.flatMap((e) => e.desglose.map((d) => ({ empleado: e.empleado, ...d })));
|
||||||
|
if (filas.length === 0) { toast.info('Sin líneas que exportar'); return; }
|
||||||
|
try {
|
||||||
|
await exportToExcel({
|
||||||
|
filename: `comisiones-desglose-${start}_${end}`,
|
||||||
|
sheetName: 'Desglose',
|
||||||
|
title: 'Desglose de comisiones por línea',
|
||||||
|
subtitle: `Del ${start} al ${end}`,
|
||||||
|
columns: [
|
||||||
|
{ header: 'Empleado', key: 'empleado' },
|
||||||
|
{ header: 'Fecha', key: 'fecha' },
|
||||||
|
{ header: 'Folio', key: 'folio' },
|
||||||
|
{ header: 'Servicio', key: 'servicio' },
|
||||||
|
{ header: 'Categoría', key: 'categoria' },
|
||||||
|
{ header: 'Regla', key: 'regla' },
|
||||||
|
{ header: 'Base', key: 'base', format: 'currency' },
|
||||||
|
{ header: 'Comisión', key: 'comision', format: 'currency' },
|
||||||
|
{ header: 'Referencia', key: 'referencia' },
|
||||||
|
],
|
||||||
|
rows: filas as unknown as Record<string, unknown>[],
|
||||||
|
});
|
||||||
|
toast.success('Excel descargado');
|
||||||
|
} catch { toast.error('No se pudo exportar'); }
|
||||||
|
};
|
||||||
|
|
||||||
|
const empleadoOptions = useMemo(
|
||||||
|
() => [{ value: '', label: 'Todos los empleados' }, ...empleados.map((e) => ({ value: String(e.id), label: e.name }))],
|
||||||
|
[empleados],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (esMedico) return <MiRecibo />;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Layout title="Liquidaciones" subtitle="Cálculo del periodo, ajustes y pago de comisiones">
|
||||||
|
<PageHeader title="Liquidaciones de comisiones" subtitle="Genera el borrador del periodo, ajusta y marca lo pagado">
|
||||||
|
{can('comisiones.liquidar') && (
|
||||||
|
<Button onClick={generar} loading={busy}><Plus size={16} className="mr-1.5" />Generar borradores</Button>
|
||||||
|
)}
|
||||||
|
{can('reportes.exportar') && (
|
||||||
|
<>
|
||||||
|
<Button variant="outline" onClick={exportarCalc} disabled={!calc || calc.empleados.length === 0}><Download size={16} className="mr-1.5" />Excel cálculo</Button>
|
||||||
|
<Button variant="outline" onClick={exportarDesglose} disabled={!calc}><Download size={16} className="mr-1.5" />Excel desglose</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</PageHeader>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-end gap-3 mb-4">
|
||||||
|
<Input label="Inicio" type="date" value={start} onChange={(e) => setRango((r) => ({ ...r, start: e.target.value }))} className="sm:max-w-[170px]" />
|
||||||
|
<Input label="Fin" type="date" value={end} onChange={(e) => setRango((r) => ({ ...r, end: e.target.value }))} className="sm:max-w-[170px]" />
|
||||||
|
<Select label="Empleado" options={empleadoOptions} value={empFiltro} onChange={(e) => setEmpFiltro(e.target.value)} className="sm:max-w-[240px]" />
|
||||||
|
<Button variant="outline" size="sm" onClick={load}><RefreshCw size={14} className="mr-1.5" />Actualizar</Button>
|
||||||
|
<p className="text-xs text-theme-muted ml-auto">
|
||||||
|
Base: <span className="font-medium text-theme-heading">{comisiones.base}</span>
|
||||||
|
{' · '}Impuestos: <span className="font-medium text-theme-heading">{comisiones.incluir_impuestos ? 'incluidos' : 'no'}</span>
|
||||||
|
{' · '}Devoluciones: <span className="font-medium text-theme-heading">{comisiones.neto_de_devoluciones ? 'netas' : 'brutas'}</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Cálculo del periodo */}
|
||||||
|
<Card className="mb-6">
|
||||||
|
<Card.Body noPadding>
|
||||||
|
<div className="px-4 py-3 border-b border-theme-border flex items-center justify-between">
|
||||||
|
<p className="text-sm font-semibold text-theme-heading flex items-center gap-2"><Coins size={16} />Cálculo del periodo ({start} a {end})</p>
|
||||||
|
{calc && (
|
||||||
|
<p className="text-xs text-theme-muted">
|
||||||
|
Base {money(calc.totales.base)} · Comisión {money(calc.totales.comision)} · Bonos {money(calc.totales.bonos)} · Total {money(calc.totales.total)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{loading ? (
|
||||||
|
<div className="p-6 text-sm text-theme-muted">Calculando…</div>
|
||||||
|
) : !calc || calc.empleados.length === 0 ? (
|
||||||
|
<div className="p-6"><EmptyState title="Sin comisiones en el periodo" subtitle="No hay líneas recetadas ni reglas aplicables en el rango." icon={<Wallet size={28} />} /></div>
|
||||||
|
) : (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead className="bg-theme-bg text-theme-muted text-[11px] uppercase">
|
||||||
|
<tr>
|
||||||
|
<th className="text-left px-4 py-3">Empleado</th>
|
||||||
|
<th className="text-right px-4 py-3">Base</th>
|
||||||
|
<th className="text-right px-4 py-3">Comisión</th>
|
||||||
|
<th className="text-right px-4 py-3 hidden sm:table-cell">Bonos</th>
|
||||||
|
<th className="text-right px-4 py-3 hidden sm:table-cell">Ajustes</th>
|
||||||
|
<th className="text-right px-4 py-3">Total</th>
|
||||||
|
<th className="text-right px-4 py-3">Detalle</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-theme-border">
|
||||||
|
{calc.empleados.map((e) => (
|
||||||
|
<tr key={e.empleado_id}>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<p className="font-medium text-theme-heading">{e.empleado}</p>
|
||||||
|
<p className="text-xs text-theme-muted">{e.staff_type} · {e.lineas_count} línea(s){e.pagable ? '' : ' · bajo el mínimo'}</p>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-right tabular-nums">{money(e.base)}</td>
|
||||||
|
<td className="px-4 py-3 text-right tabular-nums">{money(e.comision)}</td>
|
||||||
|
<td className="px-4 py-3 text-right tabular-nums hidden sm:table-cell">{money(e.bonos)}</td>
|
||||||
|
<td className="px-4 py-3 text-right tabular-nums hidden sm:table-cell">{money(e.ajustes)}</td>
|
||||||
|
<td className="px-4 py-3 text-right tabular-nums font-semibold">{money(e.total)}</td>
|
||||||
|
<td className="px-4 py-3 text-right">
|
||||||
|
<button type="button" title="Ver desglose" onClick={() => setDetalleCalc(e)} className="p-1.5 rounded-lg hover:bg-theme-bg text-theme-muted"><Eye size={15} /></button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Card.Body>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Liquidaciones */}
|
||||||
|
<div className="flex flex-wrap items-center gap-2 mb-3">
|
||||||
|
<p className="text-sm font-semibold text-theme-heading flex items-center gap-2"><Receipt size={16} />Liquidaciones</p>
|
||||||
|
{['', 'borrador', 'pagada', 'cancelada'].map((e) => (
|
||||||
|
<button
|
||||||
|
key={e || 'todas'}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setEstadoFiltro(e)}
|
||||||
|
className={`px-3 py-1.5 text-xs font-medium rounded-full transition ${estadoFiltro === e ? 'bg-theme-accent text-theme-inverse' : 'bg-theme-bg text-theme-muted hover:text-theme-heading'}`}
|
||||||
|
>
|
||||||
|
{e === '' ? 'Todas' : e.charAt(0).toUpperCase() + e.slice(1)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<Card>
|
||||||
|
<Card.Body noPadding>
|
||||||
|
{liqs.length === 0 ? (
|
||||||
|
<div className="p-6"><EmptyState title="Sin liquidaciones" subtitle="Genera el borrador del periodo para comenzar." icon={<HandCoins size={28} />} /></div>
|
||||||
|
) : (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead className="bg-theme-bg text-theme-muted text-[11px] uppercase">
|
||||||
|
<tr>
|
||||||
|
<th className="text-left px-4 py-3">Folio</th>
|
||||||
|
<th className="text-left px-4 py-3">Empleado</th>
|
||||||
|
<th className="text-left px-4 py-3 hidden md:table-cell">Periodo</th>
|
||||||
|
<th className="text-right px-4 py-3">Calculado</th>
|
||||||
|
<th className="text-right px-4 py-3">Ajustes</th>
|
||||||
|
<th className="text-right px-4 py-3">Total</th>
|
||||||
|
<th className="text-left px-4 py-3">Estado</th>
|
||||||
|
<th className="text-right px-4 py-3">Acciones</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-theme-border">
|
||||||
|
{liqs.map((l) => (
|
||||||
|
<tr key={l.id}>
|
||||||
|
<td className="px-4 py-3 font-medium text-theme-heading">{l.folio}</td>
|
||||||
|
<td className="px-4 py-3 text-theme-heading">{l.empleado}</td>
|
||||||
|
<td className="px-4 py-3 text-theme-muted hidden md:table-cell">{l.periodo_inicio} a {l.periodo_fin}</td>
|
||||||
|
<td className="px-4 py-3 text-right tabular-nums">{money(l.total_calculado)}</td>
|
||||||
|
<td className="px-4 py-3 text-right tabular-nums">{money(l.total_ajustes)}</td>
|
||||||
|
<td className="px-4 py-3 text-right tabular-nums font-semibold">{money(l.total)}</td>
|
||||||
|
<td className="px-4 py-3"><span className={`inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium ${ESTADO_CHIP[l.estado] || 'bg-theme-bg text-theme-muted'}`}>{l.estado_label}</span></td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<div className="flex items-center justify-end gap-1">
|
||||||
|
<button type="button" title="Ver" onClick={() => verDetalle(l)} className="p-1.5 rounded-lg hover:bg-theme-bg text-theme-muted"><Eye size={15} /></button>
|
||||||
|
{can('comisiones.liquidar') && l.estado === 'borrador' && comisiones.permitir_ajustes_manuales && (
|
||||||
|
<button type="button" title="Ajustar" onClick={() => { setAjustar(l); setAjusteMonto(''); setAjusteMotivo(''); }} className="p-1.5 rounded-lg hover:bg-theme-bg text-theme-muted"><Coins size={15} /></button>
|
||||||
|
)}
|
||||||
|
{can('comisiones.liquidar') && l.estado === 'borrador' && (
|
||||||
|
<button type="button" title="Pagar" onClick={() => { setPagar(l); setPagoFecha(l.periodo_fin || ''); setPagoMetodo('transferencia'); setPagoRef(''); }} className="p-1.5 rounded-lg hover:bg-theme-bg text-theme-muted"><HandCoins size={15} /></button>
|
||||||
|
)}
|
||||||
|
{can('comisiones.liquidar') && l.estado === 'borrador' && (
|
||||||
|
<button type="button" title="Cancelar" onClick={() => cancelarLiq(l)} className="p-1.5 rounded-lg hover:bg-rose-50 text-rose-600"><XCircle size={15} /></button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Card.Body>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Detalle del cálculo en vivo */}
|
||||||
|
<Modal
|
||||||
|
isOpen={!!detalleCalc}
|
||||||
|
onClose={() => setDetalleCalc(null)}
|
||||||
|
title={`Comisión de ${detalleCalc?.empleado || ''}`}
|
||||||
|
footer={<div className="flex justify-end"><Button variant="outline" onClick={() => setDetalleCalc(null)}>Cerrar</Button></div>}
|
||||||
|
>
|
||||||
|
{detalleCalc && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
|
||||||
|
<div><p className="text-xs text-theme-muted">Base</p><p className="text-sm tabular-nums">{money(detalleCalc.base)}</p></div>
|
||||||
|
<div><p className="text-xs text-theme-muted">Comisión</p><p className="text-sm tabular-nums">{money(detalleCalc.comision)}</p></div>
|
||||||
|
<div><p className="text-xs text-theme-muted">Bonos</p><p className="text-sm tabular-nums">{money(detalleCalc.bonos)}</p></div>
|
||||||
|
<div><p className="text-xs text-theme-muted">Total</p><p className="text-sm font-semibold tabular-nums">{money(detalleCalc.total)}</p></div>
|
||||||
|
</div>
|
||||||
|
{detalleCalc.por_regla.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-semibold text-theme-heading uppercase mb-1">Por regla</p>
|
||||||
|
<table className="w-full text-xs">
|
||||||
|
<tbody className="divide-y divide-theme-border">
|
||||||
|
{detalleCalc.por_regla.map((r, i) => (
|
||||||
|
<tr key={i}><td className="py-1">{r.regla} <span className="text-theme-muted">({r.lineas})</span></td><td className="py-1 text-right tabular-nums">{money(r.base)}</td><td className="py-1 text-right tabular-nums">{money(r.comision)}</td></tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="max-h-60 overflow-y-auto">
|
||||||
|
<table className="w-full text-xs">
|
||||||
|
<thead className="text-theme-muted"><tr><th className="text-left py-1">Referencia</th><th className="text-right py-1">Base</th><th className="text-right py-1">Comisión</th></tr></thead>
|
||||||
|
<tbody className="divide-y divide-theme-border">
|
||||||
|
{detalleCalc.desglose.map((d, i) => (
|
||||||
|
<tr key={i}><td className="py-1 pr-2">{d.referencia}</td><td className="py-1 text-right tabular-nums">{money(d.base)}</td><td className="py-1 text-right tabular-nums">{money(d.comision)}</td></tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{detalleCalc.metas.map((m, i) => (
|
||||||
|
<p key={i} className="text-xs text-theme-muted">Meta {m.periodo}: objetivo {money(m.monto_objetivo)} · {m.alcanzada ? 'alcanzada' : 'no alcanzada'} · bono {money(m.bono)}</p>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
<DetalleLiquidacion liq={detalleLiq} onClose={() => setDetalleLiq(null)} />
|
||||||
|
|
||||||
|
{/* Ajuste */}
|
||||||
|
<Modal
|
||||||
|
isOpen={!!ajustar}
|
||||||
|
onClose={() => setAjustar(null)}
|
||||||
|
title={`Ajuste para ${ajustar?.empleado || ''}`}
|
||||||
|
footer={
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<Button variant="outline" onClick={() => setAjustar(null)}>Cancelar</Button>
|
||||||
|
<Button onClick={hacerAjuste} loading={busy}>Guardar ajuste</Button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<p className="text-sm text-theme-muted mb-3">Usa un monto positivo para sumar o negativo para descontar.</p>
|
||||||
|
<Input label="Monto" type="number" value={ajusteMonto} onChange={(e) => setAjusteMonto(e.target.value)} placeholder="Ej. 500 o -200" />
|
||||||
|
<div className="mt-3"><Input label="Motivo" value={ajusteMotivo} onChange={(e) => setAjusteMotivo(e.target.value)} /></div>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
{/* Pago */}
|
||||||
|
<Modal
|
||||||
|
isOpen={!!pagar}
|
||||||
|
onClose={() => setPagar(null)}
|
||||||
|
title={`Pagar ${pagar?.folio || ''} · ${pagar?.empleado || ''}`}
|
||||||
|
footer={
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<Button variant="outline" onClick={() => setPagar(null)}>Cancelar</Button>
|
||||||
|
<Button onClick={hacerPago} loading={busy}>Marcar pagada ({money(pagar?.total || 0)})</Button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<Input label="Fecha de pago" type="date" value={pagoFecha} onChange={(e) => setPagoFecha(e.target.value)} />
|
||||||
|
<Select label="Método" value={pagoMetodo} onChange={(e) => setPagoMetodo(e.target.value)} options={[
|
||||||
|
{ value: 'efectivo', label: 'Efectivo' },
|
||||||
|
{ value: 'transferencia', label: 'Transferencia' },
|
||||||
|
{ value: 'tarjeta', label: 'Tarjeta' },
|
||||||
|
{ value: 'otro', label: 'Otro' },
|
||||||
|
]} />
|
||||||
|
</div>
|
||||||
|
<div className="mt-3"><Input label="Referencia" value={pagoRef} onChange={(e) => setPagoRef(e.target.value)} /></div>
|
||||||
|
</Modal>
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Liquidaciones;
|
||||||
@@ -3,8 +3,9 @@ import { useEffect, useMemo, useState } from 'react';
|
|||||||
import { Mail, Phone, Stethoscope, Briefcase, Save, Percent, Eye, CalendarDays, FileText, ShoppingCart, Users, ClipboardList, CreditCard, DollarSign, Clock, Copy, Target } from 'lucide-react';
|
import { Mail, Phone, Stethoscope, Briefcase, Save, Percent, Eye, CalendarDays, FileText, ShoppingCart, Users, ClipboardList, CreditCard, DollarSign, Clock, Copy, Target } from 'lucide-react';
|
||||||
import Layout from '../components/Layout';
|
import Layout from '../components/Layout';
|
||||||
import { Card, Badge, EmptyState, PageHeader, Skeleton, MobileCard, Input, Button, Modal, Select, toast } from '../components/ui';
|
import { Card, Badge, EmptyState, PageHeader, Skeleton, MobileCard, Input, Button, Modal, Select, toast } from '../components/ui';
|
||||||
import { odooApi, type Doctor, type DoctorHistory, type HorarioDia } from '../services/odoo';
|
import { odooApi, type Doctor, type DoctorHistory, type HorarioDia, type ComisionEmpleadoCalc } from '../services/odoo';
|
||||||
import { useGlosario } from '../lib/glosario';
|
import { useGlosario } from '../lib/glosario';
|
||||||
|
import { useClinicSettings } from '../lib/clinic-settings';
|
||||||
|
|
||||||
const DIAS = ['Lunes', 'Martes', 'Miércoles', 'Jueves', 'Viernes', 'Sábado', 'Domingo'];
|
const DIAS = ['Lunes', 'Martes', 'Miércoles', 'Jueves', 'Viernes', 'Sábado', 'Domingo'];
|
||||||
|
|
||||||
@@ -190,12 +191,16 @@ const Medicos: FC = () => {
|
|||||||
const [pctDraft, setPctDraft] = useState<Record<number, string>>({});
|
const [pctDraft, setPctDraft] = useState<Record<number, string>>({});
|
||||||
const [savingPct, setSavingPct] = useState<number | null>(null);
|
const [savingPct, setSavingPct] = useState<number | null>(null);
|
||||||
|
|
||||||
|
const { comisiones } = useClinicSettings();
|
||||||
|
|
||||||
// Modal de detalle del médico
|
// Modal de detalle del médico
|
||||||
const [selected, setSelected] = useState<Doctor | null>(null);
|
const [selected, setSelected] = useState<Doctor | null>(null);
|
||||||
const [modalTab, setModalTab] = useState<'info' | 'historial' | 'horario'>('info');
|
const [modalTab, setModalTab] = useState<'info' | 'historial' | 'horario'>('info');
|
||||||
const [hist, setHist] = useState<DoctorHistory | null>(null);
|
const [hist, setHist] = useState<DoctorHistory | null>(null);
|
||||||
const [histLoading, setHistLoading] = useState(false);
|
const [histLoading, setHistLoading] = useState(false);
|
||||||
const [histSection, setHistSection] = useState<'citas' | 'visitas' | 'ventas'>('citas');
|
const [histSection, setHistSection] = useState<'citas' | 'visitas' | 'ventas'>('citas');
|
||||||
|
// Comisión del mes en curso calculada con el motor (solo con comisiones.activo).
|
||||||
|
const [comisionCalc, setComisionCalc] = useState<ComisionEmpleadoCalc | null>(null);
|
||||||
|
|
||||||
// Meta mensual (editable en el modal) y horario semanal
|
// Meta mensual (editable en el modal) y horario semanal
|
||||||
const [goalDraft, setGoalDraft] = useState('');
|
const [goalDraft, setGoalDraft] = useState('');
|
||||||
@@ -214,6 +219,7 @@ const Medicos: FC = () => {
|
|||||||
setModalTab('info');
|
setModalTab('info');
|
||||||
setHistSection('citas');
|
setHistSection('citas');
|
||||||
setHist(null);
|
setHist(null);
|
||||||
|
setComisionCalc(null);
|
||||||
setGoalDraft(String(d.monthly_goal ?? 0));
|
setGoalDraft(String(d.monthly_goal ?? 0));
|
||||||
setDias(draftDesdeHorario(d.horario));
|
setDias(draftDesdeHorario(d.horario));
|
||||||
setHistLoading(true);
|
setHistLoading(true);
|
||||||
@@ -221,6 +227,16 @@ const Medicos: FC = () => {
|
|||||||
.then((res) => { if (res.status === 'success') setHist(res); })
|
.then((res) => { if (res.status === 'success') setHist(res); })
|
||||||
.catch((err) => { toast.error('No se pudo cargar el historial'); console.error(err); })
|
.catch((err) => { toast.error('No se pudo cargar el historial'); console.error(err); })
|
||||||
.finally(() => setHistLoading(false));
|
.finally(() => setHistLoading(false));
|
||||||
|
if (comisiones.activo) {
|
||||||
|
const hoy = new Date();
|
||||||
|
const start = `${hoy.getFullYear()}-${String(hoy.getMonth() + 1).padStart(2, '0')}-01`;
|
||||||
|
const end = `${hoy.getFullYear()}-${String(hoy.getMonth() + 1).padStart(2, '0')}-${String(new Date(hoy.getFullYear(), hoy.getMonth() + 1, 0).getDate()).padStart(2, '0')}`;
|
||||||
|
odooApi.getComisionCalculo(start, end, d.id)
|
||||||
|
.then((res) => {
|
||||||
|
if (res.status === 'success') setComisionCalc(res.empleados[0] || null);
|
||||||
|
})
|
||||||
|
.catch(() => { /* motor apagado o sin permiso: se omite el bloque */ });
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const saveGoal = async (d: Doctor) => {
|
const saveGoal = async (d: Doctor) => {
|
||||||
@@ -638,6 +654,56 @@ const Medicos: FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{comisiones.activo && comisionCalc && (
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-medium text-theme-muted uppercase mb-2">Comisión del mes (motor de reglas)</p>
|
||||||
|
<div className="p-4 bg-theme-bg rounded-xl space-y-3">
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-theme-muted">Base</p>
|
||||||
|
<p className="font-heading text-lg text-theme-heading tabular-nums">{fmtMoney(comisionCalc.base)}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-theme-muted">Comisión</p>
|
||||||
|
<p className="font-heading text-lg text-theme-heading tabular-nums">{fmtMoney(comisionCalc.comision)}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-theme-muted">Bonos</p>
|
||||||
|
<p className="font-heading text-lg text-theme-heading tabular-nums">{fmtMoney(comisionCalc.bonos)}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-theme-muted">Total</p>
|
||||||
|
<p className="font-heading text-lg text-theme-heading tabular-nums">{fmtMoney(comisionCalc.total)}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{comisionCalc.por_regla.length > 0 ? (
|
||||||
|
<div>
|
||||||
|
<p className="text-[11px] font-medium text-theme-muted uppercase mb-1">Reglas aplicadas</p>
|
||||||
|
{comisionCalc.por_regla.map((r, i) => (
|
||||||
|
<p key={i} className="text-xs text-theme-muted">
|
||||||
|
{r.regla} · {fmtMoney(r.comision)} <span className="text-theme-muted">({r.lineas} línea/s)</span>
|
||||||
|
</p>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-xs text-theme-muted">
|
||||||
|
Sin reglas aplicables: se usa su % de respaldo ({selected.commission_pct ?? 0}%).
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{comisionCalc.metas.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<p className="text-[11px] font-medium text-theme-muted uppercase mb-1">Metas</p>
|
||||||
|
{comisionCalc.metas.map((m, i) => (
|
||||||
|
<p key={i} className="text-xs text-theme-muted">
|
||||||
|
{m.periodo}: objetivo {fmtMoney(m.monto_objetivo)} · {m.alcanzada ? 'alcanzada' : 'no alcanzada'} · bono {fmtMoney(m.bono)}
|
||||||
|
</p>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : modalTab === 'horario' ? (
|
) : modalTab === 'horario' ? (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
|
|||||||
@@ -1075,6 +1075,7 @@ export interface ClinicSettings {
|
|||||||
membresias: MembresiasSettings;
|
membresias: MembresiasSettings;
|
||||||
recursos: RecursosSettings;
|
recursos: RecursosSettings;
|
||||||
compras: ComprasSettings;
|
compras: ComprasSettings;
|
||||||
|
comisiones: ComisionesSettings;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- Reservas en línea (portal público de auto-agendado) ----
|
// ---- Reservas en línea (portal público de auto-agendado) ----
|
||||||
@@ -1268,6 +1269,211 @@ export const COMPRAS_DEFAULT: ComprasSettings = {
|
|||||||
permitir_compras_sin_orden: false,
|
permitir_compras_sin_orden: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ---- Comisiones por reglas y liquidación ----
|
||||||
|
export interface ComisionesSettings {
|
||||||
|
/** Enciende el motor de reglas, el reporte nuevo y las liquidaciones. */
|
||||||
|
activo: boolean;
|
||||||
|
/** 'cobrado' (sobre lo pagado) o 'vendido' (sobre el subtotal de la línea). */
|
||||||
|
base: 'cobrado' | 'vendido';
|
||||||
|
/** La base suma el impuesto de la línea. */
|
||||||
|
incluir_impuestos: boolean;
|
||||||
|
/** Resta la parte devuelta de la venta. */
|
||||||
|
neto_de_devoluciones: boolean;
|
||||||
|
comisionar_membresias: boolean;
|
||||||
|
comisionar_paquetes: boolean;
|
||||||
|
comisionar_productos: boolean;
|
||||||
|
/** Habilita sumar/restar ajustes en la liquidación. */
|
||||||
|
permitir_ajustes_manuales: boolean;
|
||||||
|
/** Día del mes de corte sugerido (1-28). */
|
||||||
|
dia_corte: number;
|
||||||
|
/** Decimales a los que se redondea la comisión. */
|
||||||
|
redondeo: number;
|
||||||
|
/** Total mínimo a liquidar (por debajo no es pagable). */
|
||||||
|
pago_minimo: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const COMISIONES_DEFAULT: ComisionesSettings = {
|
||||||
|
activo: false,
|
||||||
|
base: 'cobrado',
|
||||||
|
incluir_impuestos: false,
|
||||||
|
neto_de_devoluciones: true,
|
||||||
|
comisionar_membresias: true,
|
||||||
|
comisionar_paquetes: true,
|
||||||
|
comisionar_productos: true,
|
||||||
|
permitir_ajustes_manuales: true,
|
||||||
|
dia_corte: 1,
|
||||||
|
redondeo: 2,
|
||||||
|
pago_minimo: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AlcanceComision = 'global' | 'categoria' | 'servicio' | 'articulo' | 'membresia' | 'paquete';
|
||||||
|
export type TipoComision = 'porcentaje' | 'monto_fijo' | 'escalonado';
|
||||||
|
export type TipoBonoComision = 'fijo' | 'porcentaje';
|
||||||
|
export type EstadoLiquidacion = 'borrador' | 'pagada' | 'cancelada';
|
||||||
|
|
||||||
|
export interface EscalonComision {
|
||||||
|
desde: number;
|
||||||
|
hasta: number | null;
|
||||||
|
porcentaje: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ComisionRegla {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
activo: boolean;
|
||||||
|
prioridad: number;
|
||||||
|
empleado_id: number | null;
|
||||||
|
empleado: string;
|
||||||
|
staff_type: '' | 'doctor' | 'cosmetologa';
|
||||||
|
alcance: AlcanceComision;
|
||||||
|
alcance_label: string;
|
||||||
|
categoria_code: string;
|
||||||
|
servicio_id: number | null;
|
||||||
|
servicio: string;
|
||||||
|
item_id: number | null;
|
||||||
|
item: string;
|
||||||
|
plan_id: number | null;
|
||||||
|
plan: string;
|
||||||
|
tipo: TipoComision;
|
||||||
|
tipo_label: string;
|
||||||
|
valor: number;
|
||||||
|
escalones: EscalonComision[];
|
||||||
|
base_override: '' | 'cobrado' | 'vendido';
|
||||||
|
fecha_desde: string | null;
|
||||||
|
fecha_hasta: string | null;
|
||||||
|
notas: string;
|
||||||
|
resumen: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ComisionMeta {
|
||||||
|
id: number;
|
||||||
|
empleado_id: number;
|
||||||
|
empleado: string;
|
||||||
|
periodo: string;
|
||||||
|
monto_objetivo: number;
|
||||||
|
bono: number;
|
||||||
|
tipo_bono: TipoBonoComision;
|
||||||
|
activo: boolean;
|
||||||
|
notas: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ComisionEmpleado {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
staff_type: 'doctor' | 'cosmetologa';
|
||||||
|
job_title: string;
|
||||||
|
commission_pct: number;
|
||||||
|
reglas_count: number;
|
||||||
|
metas_count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ComisionDesgloseLinea {
|
||||||
|
venta_id: number;
|
||||||
|
folio: string;
|
||||||
|
fecha: string | null;
|
||||||
|
linea_id: number;
|
||||||
|
servicio: string;
|
||||||
|
categoria: string;
|
||||||
|
tipo_linea: string;
|
||||||
|
base: number;
|
||||||
|
regla_id: number | null;
|
||||||
|
regla: string;
|
||||||
|
tipo: TipoComision;
|
||||||
|
porcentaje: number | null;
|
||||||
|
comision: number;
|
||||||
|
referencia: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ComisionPorRegla {
|
||||||
|
regla_id: number | null;
|
||||||
|
regla: string;
|
||||||
|
base: number;
|
||||||
|
comision: number;
|
||||||
|
lineas: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ComisionMetaEval {
|
||||||
|
meta_id: number;
|
||||||
|
periodo: string;
|
||||||
|
monto_objetivo: number;
|
||||||
|
alcanzada: boolean;
|
||||||
|
bono: number;
|
||||||
|
tipo_bono: TipoBonoComision;
|
||||||
|
bono_config: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ComisionAjuste {
|
||||||
|
id: number;
|
||||||
|
liquidacion_id: number | null;
|
||||||
|
empleado_id: number;
|
||||||
|
empleado: string;
|
||||||
|
periodo: string;
|
||||||
|
monto: number;
|
||||||
|
motivo: string;
|
||||||
|
usuario: string;
|
||||||
|
fecha: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ComisionEmpleadoCalc {
|
||||||
|
empleado_id: number;
|
||||||
|
empleado: string;
|
||||||
|
staff_type: 'doctor' | 'cosmetologa';
|
||||||
|
job_title: string;
|
||||||
|
commission_pct: number;
|
||||||
|
citas_done: number;
|
||||||
|
lineas_count: number;
|
||||||
|
base: number;
|
||||||
|
comision: number;
|
||||||
|
bonos: number;
|
||||||
|
ajustes: number;
|
||||||
|
total: number;
|
||||||
|
pagable: boolean;
|
||||||
|
desglose: ComisionDesgloseLinea[];
|
||||||
|
por_regla: ComisionPorRegla[];
|
||||||
|
metas: ComisionMetaEval[];
|
||||||
|
ajustes_detalle: ComisionAjuste[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ComisionCalculo {
|
||||||
|
status: string;
|
||||||
|
start: string;
|
||||||
|
end: string;
|
||||||
|
config: ComisionesSettings;
|
||||||
|
empleados: ComisionEmpleadoCalc[];
|
||||||
|
totales: { base: number; comision: number; bonos: number; ajustes: number; total: number };
|
||||||
|
medico_sin_employee?: boolean;
|
||||||
|
message?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ComisionReporteReglas extends ComisionCalculo {
|
||||||
|
por_servicio: { servicio: string; base: number; comision: number; lineas: number }[];
|
||||||
|
por_categoria: { categoria: string; base: number; comision: number; lineas: number }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ComisionLiquidacion {
|
||||||
|
id: number;
|
||||||
|
folio: string;
|
||||||
|
empleado_id: number;
|
||||||
|
empleado: string;
|
||||||
|
staff_type: 'doctor' | 'cosmetologa';
|
||||||
|
periodo_inicio: string | null;
|
||||||
|
periodo_fin: string | null;
|
||||||
|
periodo: string;
|
||||||
|
total_calculado: number;
|
||||||
|
total_ajustes: number;
|
||||||
|
total: number;
|
||||||
|
estado: EstadoLiquidacion;
|
||||||
|
estado_label: string;
|
||||||
|
fecha_pago: string | null;
|
||||||
|
metodo_pago: string;
|
||||||
|
referencia: string;
|
||||||
|
notas: string;
|
||||||
|
creado_por_login: string;
|
||||||
|
pagada_por_login: string;
|
||||||
|
ajustes: ComisionAjuste[];
|
||||||
|
detalle?: ComisionEmpleadoCalc;
|
||||||
|
}
|
||||||
|
|
||||||
export type EstadoCompra = 'borrador' | 'enviada' | 'recibida_parcial' | 'recibida' | 'cancelada';
|
export type EstadoCompra = 'borrador' | 'enviada' | 'recibida_parcial' | 'recibida' | 'cancelada';
|
||||||
export type MonedaCompra = 'MXN' | 'USD';
|
export type MonedaCompra = 'MXN' | 'USD';
|
||||||
export type MetodoPagoCompra = 'efectivo' | 'transferencia' | 'tarjeta' | 'otro';
|
export type MetodoPagoCompra = 'efectivo' | 'transferencia' | 'tarjeta' | 'otro';
|
||||||
@@ -1875,6 +2081,7 @@ export const CLINIC_SETTINGS_DEFAULT: ClinicSettings = {
|
|||||||
membresias: MEMBRESIAS_DEFAULT,
|
membresias: MEMBRESIAS_DEFAULT,
|
||||||
recursos: RECURSOS_DEFAULT,
|
recursos: RECURSOS_DEFAULT,
|
||||||
compras: COMPRAS_DEFAULT,
|
compras: COMPRAS_DEFAULT,
|
||||||
|
comisiones: COMISIONES_DEFAULT,
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface FrontendUser {
|
export interface FrontendUser {
|
||||||
@@ -2659,6 +2866,113 @@ export const odooApi = {
|
|||||||
return data;
|
return data;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// ---- Comisiones por reglas y liquidación ----
|
||||||
|
// Config (reglas y metas) disponible aunque el motor esté apagado; las rutas
|
||||||
|
// operativas (cálculo, liquidaciones) responden 403 con `comisiones.activo=false`.
|
||||||
|
async getComisionesReglas(params?: { empleado_id?: number; alcance?: string; activo?: boolean; q?: string }): Promise<{ status: string; reglas: ComisionRegla[]; total: number; message?: string }> {
|
||||||
|
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/comisiones/reglas`, {
|
||||||
|
params: {
|
||||||
|
...(params?.empleado_id ? { empleado_id: params.empleado_id } : {}),
|
||||||
|
...(params?.alcance ? { alcance: params.alcance } : {}),
|
||||||
|
...(params?.activo ? { activo: 1 } : {}),
|
||||||
|
...(params?.q ? { q: params.q } : {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async createComisionRegla(payload: Partial<ComisionRegla>): Promise<{ status: string; regla: ComisionRegla; message?: string }> {
|
||||||
|
const { data } = await axios.post(`${ODOO_BASE}/skeen/frontend/v1/comisiones/reglas`, payload);
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async updateComisionRegla(id: number, payload: Partial<ComisionRegla>): Promise<{ status: string; regla: ComisionRegla; message?: string }> {
|
||||||
|
const { data } = await axios.put(`${ODOO_BASE}/skeen/frontend/v1/comisiones/reglas/${id}`, payload);
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async deleteComisionRegla(id: number): Promise<{ status: string; message?: string }> {
|
||||||
|
const { data } = await axios.delete(`${ODOO_BASE}/skeen/frontend/v1/comisiones/reglas/${id}`);
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async getComisionesMetas(params?: { empleado_id?: number; activo?: boolean }): Promise<{ status: string; metas: ComisionMeta[]; total: number; message?: string }> {
|
||||||
|
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/comisiones/metas`, {
|
||||||
|
params: {
|
||||||
|
...(params?.empleado_id ? { empleado_id: params.empleado_id } : {}),
|
||||||
|
...(params?.activo ? { activo: 1 } : {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async createComisionMeta(payload: Partial<ComisionMeta>): Promise<{ status: string; meta: ComisionMeta; message?: string }> {
|
||||||
|
const { data } = await axios.post(`${ODOO_BASE}/skeen/frontend/v1/comisiones/metas`, payload);
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async updateComisionMeta(id: number, payload: Partial<ComisionMeta>): Promise<{ status: string; meta: ComisionMeta; message?: string }> {
|
||||||
|
const { data } = await axios.put(`${ODOO_BASE}/skeen/frontend/v1/comisiones/metas/${id}`, payload);
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async deleteComisionMeta(id: number): Promise<{ status: string; message?: string }> {
|
||||||
|
const { data } = await axios.delete(`${ODOO_BASE}/skeen/frontend/v1/comisiones/metas/${id}`);
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async getComisionEmpleados(): Promise<{ status: string; empleados: ComisionEmpleado[]; message?: string }> {
|
||||||
|
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/comisiones/empleados`);
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async getComisionCalculo(start: string, end: string, empleadoId?: number): Promise<ComisionCalculo> {
|
||||||
|
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/comisiones/calculo`, {
|
||||||
|
params: { start, end, ...(empleadoId ? { empleado_id: empleadoId } : {}) },
|
||||||
|
});
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async getComisionesReglasReport(start: string, end: string): Promise<ComisionReporteReglas> {
|
||||||
|
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/reports/comisiones_reglas`, { params: { start, end } });
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async getComisionesLiquidaciones(params?: { empleado_id?: number; periodo?: string; estado?: EstadoLiquidacion }): Promise<{ status: string; liquidaciones: ComisionLiquidacion[]; total: number; message?: string }> {
|
||||||
|
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/comisiones/liquidaciones`, { params });
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async getComisionLiquidacion(id: number): Promise<{ status: string; liquidacion: ComisionLiquidacion; message?: string }> {
|
||||||
|
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/comisiones/liquidaciones/${id}`);
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async getMisLiquidaciones(): Promise<{ status: string; empleado_id?: number; empleado?: string; liquidaciones: ComisionLiquidacion[]; medico_sin_employee?: boolean; message?: string }> {
|
||||||
|
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/comisiones/mis-liquidaciones`);
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async generarComisionesLiquidaciones(payload: { start: string; end: string; empleado_id?: number; empleado_ids?: number[]; notas?: string }): Promise<{ status: string; liquidaciones: ComisionLiquidacion[]; omitidas: { empleado_id: number; empleado: string; folio: string; motivo: string }[]; total_creadas: number; message?: string }> {
|
||||||
|
const { data } = await axios.post(`${ODOO_BASE}/skeen/frontend/v1/comisiones/liquidaciones`, payload);
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async ajustarComisionLiquidacion(id: number, payload: { monto: number; motivo?: string }): Promise<{ status: string; liquidacion: ComisionLiquidacion; message?: string }> {
|
||||||
|
const { data } = await axios.post(`${ODOO_BASE}/skeen/frontend/v1/comisiones/liquidaciones/${id}/ajustar`, payload);
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async pagarComisionLiquidacion(id: number, payload: { fecha?: string; metodo_pago?: string; referencia?: string }): Promise<{ status: string; liquidacion: ComisionLiquidacion; message?: string }> {
|
||||||
|
const { data } = await axios.post(`${ODOO_BASE}/skeen/frontend/v1/comisiones/liquidaciones/${id}/pagar`, payload);
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async cancelarComisionLiquidacion(id: number, motivo?: string): Promise<{ status: string; liquidacion: ComisionLiquidacion; message?: string }> {
|
||||||
|
const { data } = await axios.post(`${ODOO_BASE}/skeen/frontend/v1/comisiones/liquidaciones/${id}/cancelar`, { motivo });
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
|
||||||
// ---- Membresías y suscripciones ----
|
// ---- Membresías y suscripciones ----
|
||||||
// Con `membresias.activo=false` (default) el backend responde 403 y estas
|
// Con `membresias.activo=false` (default) el backend responde 403 y estas
|
||||||
// llamadas nunca se hacen desde la UI (la sección no se monta).
|
// llamadas nunca se hacen desde la UI (la sección no se monta).
|
||||||
|
|||||||
@@ -751,6 +751,110 @@ def get_compras(env):
|
|||||||
"""Bloque de compras vigente (copia). Fuente única para modelos y API."""
|
"""Bloque de compras vigente (copia). Fuente única para modelos y API."""
|
||||||
return normalizar_compras(get_settings(env).get('compras'))
|
return normalizar_compras(get_settings(env).get('compras'))
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Comisiones por reglas y liquidación.
|
||||||
|
#
|
||||||
|
# Bloque anidado del mismo JSON de ajustes, igual que `impuestos`. La regla de
|
||||||
|
# oro: con `activo=False` (default) NADA cambia en la instancia — el reporte de
|
||||||
|
# comisiones de siempre (`/reports/comisiones` y `/reports/commissions`) sigue
|
||||||
|
# devolviendo el mismo JSON y los mismos números (porcentaje plano por empleado),
|
||||||
|
# no aparece ninguna sección nueva en el menú y no se ejecuta el motor de reglas.
|
||||||
|
#
|
||||||
|
# activo: enciende el motor de reglas y las liquidaciones
|
||||||
|
# base: 'cobrado' (sobre lo pagado) | 'vendido' (subtotal)
|
||||||
|
# incluir_impuestos: la base suma el impuesto de la línea
|
||||||
|
# neto_de_devoluciones: resta la parte devuelta de la venta
|
||||||
|
# comisionar_membresias: comisiona las ventas de membresía
|
||||||
|
# comisionar_paquetes: comisiona los paquetes
|
||||||
|
# comisionar_productos: comisiona los artículos de inventario
|
||||||
|
# permitir_ajustes_manuales: habilita sumar/restar ajustes en la liquidación
|
||||||
|
# dia_corte: día del mes de corte sugerido (1-28)
|
||||||
|
# redondeo: decimales a los que se redondea la comisión
|
||||||
|
# pago_minimo: total mínimo a liquidar (por debajo no es pagable)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
COMISIONES_DEFAULT = {
|
||||||
|
'activo': False,
|
||||||
|
'base': 'cobrado',
|
||||||
|
'incluir_impuestos': False,
|
||||||
|
'neto_de_devoluciones': True,
|
||||||
|
'comisionar_membresias': True,
|
||||||
|
'comisionar_paquetes': True,
|
||||||
|
'comisionar_productos': True,
|
||||||
|
'permitir_ajustes_manuales': True,
|
||||||
|
'dia_corte': 1,
|
||||||
|
'redondeo': 2,
|
||||||
|
'pago_minimo': 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
COMISIONES_BASES = ('cobrado', 'vendido')
|
||||||
|
COMISIONES_REDONDEO_MAX = 6
|
||||||
|
|
||||||
|
|
||||||
|
def normalizar_comisiones(raw):
|
||||||
|
"""Bloque `comisiones` completo, con defaults. Tolerante: nunca lanza."""
|
||||||
|
out = dict(COMISIONES_DEFAULT)
|
||||||
|
if not isinstance(raw, dict):
|
||||||
|
return out
|
||||||
|
for k in ('activo', 'incluir_impuestos', 'neto_de_devoluciones',
|
||||||
|
'comisionar_membresias', 'comisionar_paquetes', 'comisionar_productos',
|
||||||
|
'permitir_ajustes_manuales'):
|
||||||
|
out[k] = _bool(raw.get(k), COMISIONES_DEFAULT[k])
|
||||||
|
base = str(raw.get('base') or '').strip().lower()
|
||||||
|
out['base'] = base if base in COMISIONES_BASES else COMISIONES_DEFAULT['base']
|
||||||
|
out['dia_corte'] = min(max(_int(raw.get('dia_corte'), COMISIONES_DEFAULT['dia_corte']), 1), 28)
|
||||||
|
out['redondeo'] = min(max(_int(raw.get('redondeo'), COMISIONES_DEFAULT['redondeo']),
|
||||||
|
0), COMISIONES_REDONDEO_MAX)
|
||||||
|
out['pago_minimo'] = max(0.0, _num(raw.get('pago_minimo'), COMISIONES_DEFAULT['pago_minimo']))
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def validar_comisiones(raw):
|
||||||
|
"""Valida el bloque `comisiones` de un PUT. Lanza ValueError si algo no
|
||||||
|
cuadra (se traduce a 400). Devuelve SOLO las claves presentes y normalizadas
|
||||||
|
para que el merge anidado no reinicie lo que el PUT no tocó."""
|
||||||
|
if raw is None:
|
||||||
|
return None
|
||||||
|
if not isinstance(raw, dict):
|
||||||
|
raise ValueError('El bloque de comisiones debe ser un objeto')
|
||||||
|
out = {}
|
||||||
|
for k in ('activo', 'incluir_impuestos', 'neto_de_devoluciones',
|
||||||
|
'comisionar_membresias', 'comisionar_paquetes', 'comisionar_productos',
|
||||||
|
'permitir_ajustes_manuales'):
|
||||||
|
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 'base' in raw:
|
||||||
|
base = str(raw['base'] or '').strip().lower()
|
||||||
|
if base not in COMISIONES_BASES:
|
||||||
|
raise ValueError("La base debe ser 'cobrado' o 'vendido'")
|
||||||
|
out['base'] = base
|
||||||
|
if 'dia_corte' in raw:
|
||||||
|
if isinstance(raw['dia_corte'], bool) or not isinstance(raw['dia_corte'], int):
|
||||||
|
raise ValueError('El día de corte debe ser un número entero')
|
||||||
|
if not (1 <= raw['dia_corte'] <= 28):
|
||||||
|
raise ValueError('El día de corte debe estar entre 1 y 28')
|
||||||
|
out['dia_corte'] = raw['dia_corte']
|
||||||
|
if 'redondeo' in raw:
|
||||||
|
if isinstance(raw['redondeo'], bool) or not isinstance(raw['redondeo'], int):
|
||||||
|
raise ValueError('El redondeo debe ser un número entero de decimales')
|
||||||
|
if not (0 <= raw['redondeo'] <= COMISIONES_REDONDEO_MAX):
|
||||||
|
raise ValueError('El redondeo debe estar entre 0 y %d' % COMISIONES_REDONDEO_MAX)
|
||||||
|
out['redondeo'] = raw['redondeo']
|
||||||
|
if 'pago_minimo' in raw:
|
||||||
|
n = _num(raw['pago_minimo'], None)
|
||||||
|
if n is None:
|
||||||
|
raise ValueError('El pago mínimo debe ser un número')
|
||||||
|
if n < 0:
|
||||||
|
raise ValueError('El pago mínimo no puede ser negativo')
|
||||||
|
out['pago_minimo'] = n
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def get_comisiones(env):
|
||||||
|
"""Bloque de comisiones vigente (copia). Fuente única para modelos y API."""
|
||||||
|
return normalizar_comisiones(get_settings(env).get('comisiones'))
|
||||||
|
|
||||||
# Horas de agenda de respaldo: el comportamiento de siempre (8:00-19:00).
|
# Horas de agenda de respaldo: el comportamiento de siempre (8:00-19:00).
|
||||||
AGENDA_START_DEFAULT = 8.0
|
AGENDA_START_DEFAULT = 8.0
|
||||||
AGENDA_END_DEFAULT = 19.0
|
AGENDA_END_DEFAULT = 19.0
|
||||||
@@ -796,6 +900,7 @@ def get_settings(env):
|
|||||||
'membresias': normalizar_membresias(None),
|
'membresias': normalizar_membresias(None),
|
||||||
'recursos': dict(RECURSOS_DEFAULT),
|
'recursos': dict(RECURSOS_DEFAULT),
|
||||||
'compras': dict(COMPRAS_DEFAULT),
|
'compras': dict(COMPRAS_DEFAULT),
|
||||||
|
'comisiones': dict(COMISIONES_DEFAULT),
|
||||||
}
|
}
|
||||||
raw = env['ir.config_parameter'].sudo().get_param(SETTINGS_KEY)
|
raw = env['ir.config_parameter'].sudo().get_param(SETTINGS_KEY)
|
||||||
if raw:
|
if raw:
|
||||||
@@ -813,6 +918,7 @@ def get_settings(env):
|
|||||||
out['membresias'] = normalizar_membresias(guardado.get('membresias'))
|
out['membresias'] = normalizar_membresias(guardado.get('membresias'))
|
||||||
out['recursos'] = normalizar_recursos(guardado.get('recursos'))
|
out['recursos'] = normalizar_recursos(guardado.get('recursos'))
|
||||||
out['compras'] = normalizar_compras(guardado.get('compras'))
|
out['compras'] = normalizar_compras(guardado.get('compras'))
|
||||||
|
out['comisiones'] = normalizar_comisiones(guardado.get('comisiones'))
|
||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
@@ -850,6 +956,11 @@ def set_settings(env, vals):
|
|||||||
if bloque is not None:
|
if bloque is not None:
|
||||||
# Merge anidado: un PUT parcial (p. ej. solo activo) no borra el resto.
|
# Merge anidado: un PUT parcial (p. ej. solo activo) no borra el resto.
|
||||||
actual['compras'] = {**actual['compras'], **bloque}
|
actual['compras'] = {**actual['compras'], **bloque}
|
||||||
|
if 'comisiones' in vals:
|
||||||
|
bloque = validar_comisiones(vals['comisiones'])
|
||||||
|
if bloque is not None:
|
||||||
|
# Merge anidado: un PUT parcial (p. ej. solo activo) no borra el resto.
|
||||||
|
actual['comisiones'] = {**actual['comisiones'], **bloque}
|
||||||
env['ir.config_parameter'].sudo().set_param(
|
env['ir.config_parameter'].sudo().set_param(
|
||||||
SETTINGS_KEY, json.dumps(actual, ensure_ascii=False))
|
SETTINGS_KEY, json.dumps(actual, ensure_ascii=False))
|
||||||
return get_settings(env)
|
return get_settings(env)
|
||||||
|
|||||||
3
odoo-addons/skeen_comisiones/__init__.py
Normal file
3
odoo-addons/skeen_comisiones/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
from . import models
|
||||||
|
from . import controllers
|
||||||
42
odoo-addons/skeen_comisiones/__manifest__.py
Normal file
42
odoo-addons/skeen_comisiones/__manifest__.py
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
{
|
||||||
|
'name': 'SKEEN Comisiones por reglas y liquidación',
|
||||||
|
'version': '1.0.0',
|
||||||
|
'category': 'Healthcare',
|
||||||
|
'summary': 'Motor de reglas de comisión (servicio, categoría, monto fijo, escalonado, productos, membresías) y liquidación por periodo',
|
||||||
|
'description': """
|
||||||
|
Motor de comisiones configurable y liquidación por periodo.
|
||||||
|
|
||||||
|
Sustituye el porcentaje plano por empleado (`hr.employee.commission_pct`)
|
||||||
|
por un conjunto de reglas: por servicio, categoría, artículo, membresía o
|
||||||
|
paquete; porcentaje, monto fijo o escalonado por metas; base vendida o
|
||||||
|
cobrada, con o sin impuestos, neta de devoluciones. Agrega metas con bono,
|
||||||
|
ajustes manuales y liquidaciones (borrador/pagada/cancelada) que marcan lo
|
||||||
|
pagado.
|
||||||
|
|
||||||
|
El módulo está APAGADO por defecto (`comisiones.activo=false`): el reporte
|
||||||
|
de comisiones de siempre (`/reports/comisiones` y `/reports/commissions`)
|
||||||
|
sigue devolviendo EXACTAMENTE el mismo JSON y los mismos números, no
|
||||||
|
aparece ninguna sección nueva en el menú y las tablas nuevas quedan en 0
|
||||||
|
filas. El motor de reglas no se ejecuta en el camino del cálculo salvo que
|
||||||
|
la clínica lo encienda.
|
||||||
|
|
||||||
|
Cálculo EN VIVO: la comisión se calcula al momento y solo se persiste en
|
||||||
|
la liquidación (su desglose queda congelado en `detalle`). Así no hay
|
||||||
|
líneas de comisión obsoletas cuando cambian precios, reglas o devoluciones.
|
||||||
|
""",
|
||||||
|
'author': 'Consultoria Alcaraz Salazar, S.A.S.',
|
||||||
|
'website': 'https://skeen.mx',
|
||||||
|
'depends': [
|
||||||
|
'base', 'skeen_citas', 'skeen_pacientes', 'skeen_ventas',
|
||||||
|
'skeen_inventario', 'skeen_pagos', 'skeen_membresias', 'skeen_whatsapp',
|
||||||
|
],
|
||||||
|
'data': [
|
||||||
|
'security/ir.model.access.csv',
|
||||||
|
'data/sequences.xml',
|
||||||
|
],
|
||||||
|
'installable': True,
|
||||||
|
'application': False,
|
||||||
|
'auto_install': False,
|
||||||
|
'license': 'LGPL-3',
|
||||||
|
}
|
||||||
2
odoo-addons/skeen_comisiones/controllers/__init__.py
Normal file
2
odoo-addons/skeen_comisiones/controllers/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
from . import comisiones
|
||||||
703
odoo-addons/skeen_comisiones/controllers/comisiones.py
Normal file
703
odoo-addons/skeen_comisiones/controllers/comisiones.py
Normal file
@@ -0,0 +1,703 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""API del frontend React para Comisiones por reglas y liquidación.
|
||||||
|
|
||||||
|
Vive en su propio controlador (no en `skeen_whatsapp/controllers/frontend.py`)
|
||||||
|
para no chocar con otros trabajos. Las rutas cuelgan de
|
||||||
|
`/skeen/frontend/v1/comisiones` y `/skeen/frontend/v1/reports/comisiones_reglas`,
|
||||||
|
y exigen token (`@require_role`) más el permiso por acción (`@require_perm`).
|
||||||
|
|
||||||
|
Dos familias de rutas:
|
||||||
|
|
||||||
|
* CONFIGURACIÓN (reglas y metas): disponibles aunque `comisiones.activo=false`,
|
||||||
|
porque la pestaña Configuración → Comisiones es la excepción que se muestra
|
||||||
|
siempre (para poder dejar todo listo antes de encender). Exigen
|
||||||
|
`comisiones.editar` (admin/gerencia).
|
||||||
|
* OPERACIÓN (cálculo, reporte de reglas y liquidaciones): exigen
|
||||||
|
`comisiones.activo=true`; con el módulo apagado responden 403 y no tocan nada.
|
||||||
|
Así apagar el motor devuelve el sistema al estado anterior.
|
||||||
|
|
||||||
|
OJO: las rutas operativas NO viven bajo `/reports/` para que el rol médico pueda
|
||||||
|
ver sus propias liquidaciones (el veto de 'medico' bloquea `/reports/`).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import functools
|
||||||
|
import json
|
||||||
|
from datetime import date, timedelta
|
||||||
|
|
||||||
|
from odoo import fields, http
|
||||||
|
from odoo.exceptions import UserError, ValidationError
|
||||||
|
from odoo.http import request, Response
|
||||||
|
|
||||||
|
from odoo.addons.skeen_whatsapp.controllers.auth import require_perm, require_role
|
||||||
|
from odoo.addons.skeen_citas.clinic_settings import get_comisiones
|
||||||
|
|
||||||
|
BASE = '/skeen/frontend/v1'
|
||||||
|
COM = BASE + '/comisiones'
|
||||||
|
|
||||||
|
METODOS_PAGO = ('efectivo', 'transferencia', 'tarjeta', 'otro')
|
||||||
|
|
||||||
|
|
||||||
|
def _json(data, status=200):
|
||||||
|
return Response(
|
||||||
|
json.dumps(data, default=str),
|
||||||
|
status=status,
|
||||||
|
mimetype='application/json',
|
||||||
|
headers={
|
||||||
|
'Access-Control-Allow-Origin': '*',
|
||||||
|
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
|
||||||
|
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _body():
|
||||||
|
try:
|
||||||
|
raw = request.httprequest.get_data(as_text=True)
|
||||||
|
return json.loads(raw) if raw else {}
|
||||||
|
except Exception:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def _cfg():
|
||||||
|
return get_comisiones(request.env)
|
||||||
|
|
||||||
|
|
||||||
|
def _activo():
|
||||||
|
return bool(_cfg().get('activo'))
|
||||||
|
|
||||||
|
|
||||||
|
def comisiones_activas(func):
|
||||||
|
"""Niega la operación si la instancia no tiene el módulo encendido."""
|
||||||
|
@functools.wraps(func)
|
||||||
|
def wrapper(self, *args, **kw):
|
||||||
|
if request.httprequest.method == 'OPTIONS':
|
||||||
|
return _json({}, 200)
|
||||||
|
if not _activo():
|
||||||
|
return _json({'status': 'error',
|
||||||
|
'message': 'Las comisiones por reglas no están activadas '
|
||||||
|
'en esta clínica.'}, 403)
|
||||||
|
return func(self, *args, **kw)
|
||||||
|
return wrapper
|
||||||
|
|
||||||
|
|
||||||
|
def _login():
|
||||||
|
return getattr(request.frontend_user, 'login', '') if hasattr(request, 'frontend_user') else ''
|
||||||
|
|
||||||
|
|
||||||
|
def _num(v, d=None):
|
||||||
|
try:
|
||||||
|
return float(v)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return d
|
||||||
|
|
||||||
|
|
||||||
|
def _linked_employee(user):
|
||||||
|
"""hr.employee vinculado a un usuario frontend (por login)."""
|
||||||
|
if user is None:
|
||||||
|
return request.env['hr.employee'].sudo().browse()
|
||||||
|
Emp = request.env['hr.employee'].sudo()
|
||||||
|
ru = request.env['res.users'].sudo().search([('login', '=', user.login)], limit=1)
|
||||||
|
emp = ru.employee_ids[:1] if ru else Emp.browse()
|
||||||
|
if not emp:
|
||||||
|
emp = Emp.search([('user_id', '=', ru.id)], limit=1) if ru else Emp.browse()
|
||||||
|
return emp
|
||||||
|
|
||||||
|
|
||||||
|
class SkeenComisionesController(http.Controller):
|
||||||
|
|
||||||
|
# ==================================================================
|
||||||
|
# Serializadores
|
||||||
|
# ==================================================================
|
||||||
|
def _regla_to_dict(self, r):
|
||||||
|
return {
|
||||||
|
'id': r.id,
|
||||||
|
'name': r.name or '',
|
||||||
|
'activo': r.activo,
|
||||||
|
'prioridad': r.prioridad,
|
||||||
|
'empleado_id': r.empleado_id.id if r.empleado_id else None,
|
||||||
|
'empleado': r.empleado_id.name if r.empleado_id else '',
|
||||||
|
'staff_type': r.staff_type or '',
|
||||||
|
'alcance': r.alcance,
|
||||||
|
'alcance_label': dict(r._fields['alcance'].selection).get(r.alcance, r.alcance),
|
||||||
|
'categoria_code': r.categoria_code or '',
|
||||||
|
'servicio_id': r.servicio_id.id if r.servicio_id else None,
|
||||||
|
'servicio': r.servicio_id.name if r.servicio_id else '',
|
||||||
|
'item_id': r.item_id.id if r.item_id else None,
|
||||||
|
'item': r.item_id.name if r.item_id else '',
|
||||||
|
'plan_id': r.plan_id.id if r.plan_id else None,
|
||||||
|
'plan': r.plan_id.name if r.plan_id else '',
|
||||||
|
'tipo': r.tipo,
|
||||||
|
'tipo_label': dict(r._fields['tipo'].selection).get(r.tipo, r.tipo),
|
||||||
|
'valor': r.valor,
|
||||||
|
'escalones': r.escalones_ordenados(),
|
||||||
|
'base_override': r.base_override or '',
|
||||||
|
'fecha_desde': r.fecha_desde.strftime('%Y-%m-%d') if r.fecha_desde else None,
|
||||||
|
'fecha_hasta': r.fecha_hasta.strftime('%Y-%m-%d') if r.fecha_hasta else None,
|
||||||
|
'notas': r.notas or '',
|
||||||
|
'resumen': r._etiqueta_corta(),
|
||||||
|
}
|
||||||
|
|
||||||
|
def _meta_to_dict(self, m):
|
||||||
|
return {
|
||||||
|
'id': m.id,
|
||||||
|
'empleado_id': m.empleado_id.id,
|
||||||
|
'empleado': m.empleado_id.name or '',
|
||||||
|
'periodo': m.periodo or '',
|
||||||
|
'monto_objetivo': m.monto_objetivo,
|
||||||
|
'bono': m.bono,
|
||||||
|
'tipo_bono': m.tipo_bono,
|
||||||
|
'activo': m.activo,
|
||||||
|
'notas': m.notas or '',
|
||||||
|
}
|
||||||
|
|
||||||
|
def _ajuste_to_dict(self, a):
|
||||||
|
return {
|
||||||
|
'id': a.id,
|
||||||
|
'liquidacion_id': a.liquidacion_id.id if a.liquidacion_id else None,
|
||||||
|
'empleado_id': a.empleado_id.id,
|
||||||
|
'empleado': a.empleado_id.name or '',
|
||||||
|
'periodo': a.periodo or '',
|
||||||
|
'monto': a.monto,
|
||||||
|
'motivo': a.motivo or '',
|
||||||
|
'usuario': a.usuario or '',
|
||||||
|
'fecha': a.fecha.strftime('%Y-%m-%d %H:%M') if a.fecha else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
def _liq_to_dict(self, l, with_detalle=False):
|
||||||
|
d = {
|
||||||
|
'id': l.id,
|
||||||
|
'folio': l.name or '',
|
||||||
|
'empleado_id': l.empleado_id.id,
|
||||||
|
'empleado': l.empleado_id.name or '',
|
||||||
|
'staff_type': l.empleado_id.staff_type or 'doctor',
|
||||||
|
'periodo_inicio': l.periodo_inicio.strftime('%Y-%m-%d') if l.periodo_inicio else None,
|
||||||
|
'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_ajustes': l.total_ajustes,
|
||||||
|
'total': l.total,
|
||||||
|
'estado': l.estado,
|
||||||
|
'estado_label': dict(l._fields['estado'].selection).get(l.estado, l.estado),
|
||||||
|
'fecha_pago': l.fecha_pago.strftime('%Y-%m-%d') if l.fecha_pago else None,
|
||||||
|
'metodo_pago': l.metodo_pago or '',
|
||||||
|
'referencia': l.referencia or '',
|
||||||
|
'notas': l.notas or '',
|
||||||
|
'creado_por_login': l.creado_por_login or '',
|
||||||
|
'pagada_por_login': l.pagada_por_login or '',
|
||||||
|
'ajustes': [self._ajuste_to_dict(a) for a in l.ajuste_ids],
|
||||||
|
}
|
||||||
|
if with_detalle:
|
||||||
|
d['detalle'] = l.detalle_dict()
|
||||||
|
return d
|
||||||
|
|
||||||
|
def _rango(self, kw, defecto_dias=30):
|
||||||
|
start = fields.Date.to_date(kw.get('start')) or (fields.Date.today() - timedelta(days=defecto_dias))
|
||||||
|
end = fields.Date.to_date(kw.get('end')) or fields.Date.today()
|
||||||
|
if end < start:
|
||||||
|
start, end = end, start
|
||||||
|
return start, end
|
||||||
|
|
||||||
|
def _solo_propio(self, user):
|
||||||
|
"""True si el usuario debe ver solo sus propias comisiones (rol médico)."""
|
||||||
|
return bool(user and user.role == 'medico')
|
||||||
|
|
||||||
|
# ==================================================================
|
||||||
|
# CONFIGURACIÓN: reglas (disponibles aunque el motor esté apagado)
|
||||||
|
# ==================================================================
|
||||||
|
@http.route(COM + '/reglas', type='http', auth='none',
|
||||||
|
methods=['GET', 'OPTIONS'], csrf=False)
|
||||||
|
@require_role('lectura')
|
||||||
|
@require_perm('comisiones.editar')
|
||||||
|
def list_reglas(self, **kw):
|
||||||
|
try:
|
||||||
|
domain = []
|
||||||
|
if kw.get('empleado_id'):
|
||||||
|
try:
|
||||||
|
domain = ['|', ('empleado_id', '=', int(kw['empleado_id'])),
|
||||||
|
('empleado_id', '=', False)]
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return _json({'status': 'error', 'message': 'empleado_id inválido'}, 400)
|
||||||
|
if kw.get('alcance'):
|
||||||
|
domain.append(('alcance', '=', kw['alcance']))
|
||||||
|
if kw.get('activo') in ('1', 'true', 'True'):
|
||||||
|
domain.append(('activo', '=', True))
|
||||||
|
if kw.get('q'):
|
||||||
|
domain.append('|')
|
||||||
|
domain.append(('name', 'ilike', kw['q']))
|
||||||
|
domain.append(('categoria_code', 'ilike', kw['q']))
|
||||||
|
reglas = request.env['skeen.comision.regla'].sudo().search(domain)
|
||||||
|
return _json({'status': 'success',
|
||||||
|
'reglas': [self._regla_to_dict(r) for r in reglas],
|
||||||
|
'total': len(reglas)})
|
||||||
|
except Exception as e:
|
||||||
|
return _json({'status': 'error', 'message': str(e)}, 500)
|
||||||
|
|
||||||
|
@http.route(COM + '/reglas', type='http', auth='none',
|
||||||
|
methods=['POST', 'OPTIONS'], csrf=False)
|
||||||
|
@require_role('lectura')
|
||||||
|
@require_perm('comisiones.editar')
|
||||||
|
def create_regla(self, **kw):
|
||||||
|
try:
|
||||||
|
data = _body()
|
||||||
|
if not (data.get('name') or '').strip():
|
||||||
|
return _json({'status': 'error', 'message': 'El nombre es obligatorio'}, 400)
|
||||||
|
vals = self._regla_vals(data)
|
||||||
|
regla = request.env['skeen.comision.regla'].sudo().create(vals)
|
||||||
|
return _json({'status': 'success', 'regla': self._regla_to_dict(regla)}, 201)
|
||||||
|
except (ValidationError, UserError) as e:
|
||||||
|
request.env.cr.rollback()
|
||||||
|
return _json({'status': 'error', 'message': str(e)}, 400)
|
||||||
|
except Exception as e:
|
||||||
|
request.env.cr.rollback()
|
||||||
|
return _json({'status': 'error', 'message': str(e)}, 500)
|
||||||
|
|
||||||
|
@http.route(COM + '/reglas/<int:rid>', type='http', auth='none',
|
||||||
|
methods=['PUT', 'OPTIONS'], csrf=False)
|
||||||
|
@require_role('lectura')
|
||||||
|
@require_perm('comisiones.editar')
|
||||||
|
def update_regla(self, rid, **kw):
|
||||||
|
try:
|
||||||
|
regla = request.env['skeen.comision.regla'].sudo().browse(rid)
|
||||||
|
if not regla.exists():
|
||||||
|
return _json({'status': 'error', 'message': 'Regla no encontrada'}, 404)
|
||||||
|
regla.write(self._regla_vals(_body(), parcial=True))
|
||||||
|
return _json({'status': 'success', 'regla': self._regla_to_dict(regla)})
|
||||||
|
except (ValidationError, UserError) as e:
|
||||||
|
request.env.cr.rollback()
|
||||||
|
return _json({'status': 'error', 'message': str(e)}, 400)
|
||||||
|
except Exception as e:
|
||||||
|
request.env.cr.rollback()
|
||||||
|
return _json({'status': 'error', 'message': str(e)}, 500)
|
||||||
|
|
||||||
|
@http.route(COM + '/reglas/<int:rid>', type='http', auth='none',
|
||||||
|
methods=['DELETE', 'OPTIONS'], csrf=False)
|
||||||
|
@require_role('lectura')
|
||||||
|
@require_perm('comisiones.editar')
|
||||||
|
def delete_regla(self, rid, **kw):
|
||||||
|
try:
|
||||||
|
regla = request.env['skeen.comision.regla'].sudo().browse(rid)
|
||||||
|
if not regla.exists():
|
||||||
|
return _json({'status': 'error', 'message': 'Regla no encontrada'}, 404)
|
||||||
|
regla.write({'activo': False})
|
||||||
|
return _json({'status': 'success', 'message': 'Regla desactivada',
|
||||||
|
'regla': self._regla_to_dict(regla)})
|
||||||
|
except Exception as e:
|
||||||
|
request.env.cr.rollback()
|
||||||
|
return _json({'status': 'error', 'message': str(e)}, 500)
|
||||||
|
|
||||||
|
def _regla_vals(self, data, parcial=False):
|
||||||
|
campos = ('name', 'activo', 'prioridad', 'empleado_id', 'staff_type', 'alcance',
|
||||||
|
'categoria_code', 'servicio_id', 'item_id', 'plan_id', 'tipo', 'valor',
|
||||||
|
'base_override', 'fecha_desde', 'fecha_hasta', 'notas')
|
||||||
|
vals = {}
|
||||||
|
for k in campos:
|
||||||
|
if k not in data:
|
||||||
|
continue
|
||||||
|
v = data[k]
|
||||||
|
if k in ('empleado_id', 'servicio_id', 'item_id', 'plan_id'):
|
||||||
|
vals[k] = int(v) if v else False
|
||||||
|
elif k in ('fecha_desde', 'fecha_hasta'):
|
||||||
|
vals[k] = fields.Date.to_date(v) if v else False
|
||||||
|
elif v is None:
|
||||||
|
vals[k] = False if k in ('base_override', 'staff_type') else v
|
||||||
|
else:
|
||||||
|
vals[k] = v
|
||||||
|
if 'escalones' in data:
|
||||||
|
esc = data['escalones']
|
||||||
|
vals['escalones'] = json.dumps(esc, ensure_ascii=False) if esc else ''
|
||||||
|
if not parcial and 'alcance' not in vals:
|
||||||
|
vals['alcance'] = 'global'
|
||||||
|
return vals
|
||||||
|
|
||||||
|
# ==================================================================
|
||||||
|
# CONFIGURACIÓN: metas
|
||||||
|
# ==================================================================
|
||||||
|
@http.route(COM + '/metas', type='http', auth='none',
|
||||||
|
methods=['GET', 'OPTIONS'], csrf=False)
|
||||||
|
@require_role('lectura')
|
||||||
|
@require_perm('comisiones.editar')
|
||||||
|
def list_metas(self, **kw):
|
||||||
|
try:
|
||||||
|
domain = []
|
||||||
|
if kw.get('empleado_id'):
|
||||||
|
domain.append(('empleado_id', '=', int(kw['empleado_id'])))
|
||||||
|
if kw.get('activo') in ('1', 'true', 'True'):
|
||||||
|
domain.append(('activo', '=', True))
|
||||||
|
metas = request.env['skeen.comision.meta'].sudo().search(domain)
|
||||||
|
return _json({'status': 'success',
|
||||||
|
'metas': [self._meta_to_dict(m) for m in metas],
|
||||||
|
'total': len(metas)})
|
||||||
|
except Exception as e:
|
||||||
|
return _json({'status': 'error', 'message': str(e)}, 500)
|
||||||
|
|
||||||
|
@http.route(COM + '/metas', type='http', auth='none',
|
||||||
|
methods=['POST', 'OPTIONS'], csrf=False)
|
||||||
|
@require_role('lectura')
|
||||||
|
@require_perm('comisiones.editar')
|
||||||
|
def create_meta(self, **kw):
|
||||||
|
try:
|
||||||
|
data = _body()
|
||||||
|
vals = self._meta_vals(data)
|
||||||
|
if not vals.get('empleado_id') or not vals.get('periodo'):
|
||||||
|
return _json({'status': 'error',
|
||||||
|
'message': 'empleado_id y periodo son obligatorios'}, 400)
|
||||||
|
meta = request.env['skeen.comision.meta'].sudo().create(vals)
|
||||||
|
return _json({'status': 'success', 'meta': self._meta_to_dict(meta)}, 201)
|
||||||
|
except (ValidationError, UserError) as e:
|
||||||
|
request.env.cr.rollback()
|
||||||
|
return _json({'status': 'error', 'message': str(e)}, 400)
|
||||||
|
except Exception as e:
|
||||||
|
request.env.cr.rollback()
|
||||||
|
return _json({'status': 'error', 'message': str(e)}, 500)
|
||||||
|
|
||||||
|
@http.route(COM + '/metas/<int:mid>', type='http', auth='none',
|
||||||
|
methods=['PUT', 'OPTIONS'], csrf=False)
|
||||||
|
@require_role('lectura')
|
||||||
|
@require_perm('comisiones.editar')
|
||||||
|
def update_meta(self, mid, **kw):
|
||||||
|
try:
|
||||||
|
meta = request.env['skeen.comision.meta'].sudo().browse(mid)
|
||||||
|
if not meta.exists():
|
||||||
|
return _json({'status': 'error', 'message': 'Meta no encontrada'}, 404)
|
||||||
|
meta.write(self._meta_vals(_body()))
|
||||||
|
return _json({'status': 'success', 'meta': self._meta_to_dict(meta)})
|
||||||
|
except (ValidationError, UserError) as e:
|
||||||
|
request.env.cr.rollback()
|
||||||
|
return _json({'status': 'error', 'message': str(e)}, 400)
|
||||||
|
except Exception as e:
|
||||||
|
request.env.cr.rollback()
|
||||||
|
return _json({'status': 'error', 'message': str(e)}, 500)
|
||||||
|
|
||||||
|
@http.route(COM + '/metas/<int:mid>', type='http', auth='none',
|
||||||
|
methods=['DELETE', 'OPTIONS'], csrf=False)
|
||||||
|
@require_role('lectura')
|
||||||
|
@require_perm('comisiones.editar')
|
||||||
|
def delete_meta(self, mid, **kw):
|
||||||
|
try:
|
||||||
|
meta = request.env['skeen.comision.meta'].sudo().browse(mid)
|
||||||
|
if not meta.exists():
|
||||||
|
return _json({'status': 'error', 'message': 'Meta no encontrada'}, 404)
|
||||||
|
meta.write({'activo': False})
|
||||||
|
return _json({'status': 'success', 'message': 'Meta desactivada'})
|
||||||
|
except Exception as e:
|
||||||
|
request.env.cr.rollback()
|
||||||
|
return _json({'status': 'error', 'message': str(e)}, 500)
|
||||||
|
|
||||||
|
def _meta_vals(self, data):
|
||||||
|
campos = ('empleado_id', 'periodo', 'monto_objetivo', 'bono', 'tipo_bono',
|
||||||
|
'activo', 'notas')
|
||||||
|
vals = {}
|
||||||
|
for k in campos:
|
||||||
|
if k not in data:
|
||||||
|
continue
|
||||||
|
v = data[k]
|
||||||
|
if k == 'empleado_id':
|
||||||
|
vals[k] = int(v) if v else False
|
||||||
|
elif k in ('monto_objetivo', 'bono'):
|
||||||
|
n = _num(v)
|
||||||
|
vals[k] = n if n is not None else 0.0
|
||||||
|
elif k == 'periodo':
|
||||||
|
vals[k] = str(v or '').strip()
|
||||||
|
else:
|
||||||
|
vals[k] = v
|
||||||
|
return vals
|
||||||
|
|
||||||
|
@http.route(COM + '/empleados', type='http', auth='none',
|
||||||
|
methods=['GET', 'OPTIONS'], csrf=False)
|
||||||
|
@require_role('lectura')
|
||||||
|
@require_perm('comisiones.ver')
|
||||||
|
def list_empleados(self, **kw):
|
||||||
|
"""Empleados con su commission_pct de respaldo, reglas y metas activas."""
|
||||||
|
try:
|
||||||
|
Emp = request.env['hr.employee'].sudo()
|
||||||
|
emps = Emp.search([('active', '=', True)], order='name')
|
||||||
|
Regla = request.env['skeen.comision.regla'].sudo()
|
||||||
|
Meta = request.env['skeen.comision.meta'].sudo()
|
||||||
|
data = []
|
||||||
|
for e in emps:
|
||||||
|
reglas = Regla.search_count([
|
||||||
|
('activo', '=', True),
|
||||||
|
'|', ('empleado_id', '=', e.id),
|
||||||
|
'&', ('empleado_id', '=', False),
|
||||||
|
'|', ('staff_type', '=', False), ('staff_type', '=', e.staff_type or 'doctor'),
|
||||||
|
])
|
||||||
|
metas = Meta.search_count([('activo', '=', True), ('empleado_id', '=', e.id)])
|
||||||
|
data.append({
|
||||||
|
'id': e.id,
|
||||||
|
'name': e.name,
|
||||||
|
'staff_type': e.staff_type or 'doctor',
|
||||||
|
'job_title': e.job_title or '',
|
||||||
|
'commission_pct': e.commission_pct or 0.0,
|
||||||
|
'reglas_count': reglas,
|
||||||
|
'metas_count': metas,
|
||||||
|
})
|
||||||
|
return _json({'status': 'success', 'empleados': data})
|
||||||
|
except Exception as e:
|
||||||
|
return _json({'status': 'error', 'message': str(e)}, 500)
|
||||||
|
|
||||||
|
# ==================================================================
|
||||||
|
# OPERACIÓN: cálculo en vivo
|
||||||
|
# ==================================================================
|
||||||
|
@http.route(COM + '/calculo', type='http', auth='none',
|
||||||
|
methods=['GET', 'OPTIONS'], csrf=False)
|
||||||
|
@require_role('lectura')
|
||||||
|
@require_perm('comisiones.ver')
|
||||||
|
@comisiones_activas
|
||||||
|
def calculo(self, **kw):
|
||||||
|
try:
|
||||||
|
start, end = self._rango(kw)
|
||||||
|
user = request.frontend_user
|
||||||
|
emp_ids = None
|
||||||
|
if self._solo_propio(user):
|
||||||
|
emp = _linked_employee(user)
|
||||||
|
if not emp:
|
||||||
|
return _json({'status': 'success', 'start': start.strftime('%Y-%m-%d'),
|
||||||
|
'end': end.strftime('%Y-%m-%d'), 'empleados': [],
|
||||||
|
'totales': {'base': 0, 'comision': 0, 'bonos': 0,
|
||||||
|
'ajustes': 0, 'total': 0},
|
||||||
|
'medico_sin_employee': True})
|
||||||
|
emp_ids = [emp.id]
|
||||||
|
elif kw.get('empleado_id'):
|
||||||
|
emp_ids = [int(kw['empleado_id'])]
|
||||||
|
motor = request.env['skeen.comision.motor'].sudo()
|
||||||
|
data = motor.calcular(start, end, empleado_ids=emp_ids)
|
||||||
|
data['status'] = 'success'
|
||||||
|
return _json(data)
|
||||||
|
except Exception as e:
|
||||||
|
return _json({'status': 'error', 'message': str(e)}, 500)
|
||||||
|
|
||||||
|
@http.route(BASE + '/reports/comisiones_reglas', type='http', auth='none',
|
||||||
|
methods=['GET', 'OPTIONS'], csrf=False)
|
||||||
|
@require_role('lectura')
|
||||||
|
@require_perm('comisiones.ver')
|
||||||
|
@comisiones_activas
|
||||||
|
def reporte_reglas(self, **kw):
|
||||||
|
"""Reporte por empleado, por regla, por servicio/categoría + metas."""
|
||||||
|
try:
|
||||||
|
start, end = self._rango(kw)
|
||||||
|
motor = request.env['skeen.comision.motor'].sudo()
|
||||||
|
data = motor.calcular(start, end)
|
||||||
|
por_servicio = {}
|
||||||
|
por_categoria = {}
|
||||||
|
for emp in data['empleados']:
|
||||||
|
for d in emp['desglose']:
|
||||||
|
svc = d['servicio'] or 'Sin servicio'
|
||||||
|
por_servicio.setdefault(svc, {'servicio': svc, 'base': 0.0,
|
||||||
|
'comision': 0.0, 'lineas': 0})
|
||||||
|
por_servicio[svc]['base'] += d['base']
|
||||||
|
por_servicio[svc]['comision'] += d['comision']
|
||||||
|
por_servicio[svc]['lineas'] += 1
|
||||||
|
cat = d['categoria'] or 'Sin categoría'
|
||||||
|
por_categoria.setdefault(cat, {'categoria': cat, 'base': 0.0,
|
||||||
|
'comision': 0.0, 'lineas': 0})
|
||||||
|
por_categoria[cat]['base'] += d['base']
|
||||||
|
por_categoria[cat]['comision'] += d['comision']
|
||||||
|
por_categoria[cat]['lineas'] += 1
|
||||||
|
return _json({
|
||||||
|
'status': 'success',
|
||||||
|
'start': data['start'], 'end': data['end'],
|
||||||
|
'config': data['config'],
|
||||||
|
'empleados': data['empleados'],
|
||||||
|
'por_servicio': sorted(por_servicio.values(), key=lambda x: -x['comision']),
|
||||||
|
'por_categoria': sorted(por_categoria.values(), key=lambda x: -x['comision']),
|
||||||
|
'totales': data['totales'],
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
return _json({'status': 'error', 'message': str(e)}, 500)
|
||||||
|
|
||||||
|
# ==================================================================
|
||||||
|
# OPERACIÓN: liquidaciones
|
||||||
|
# ==================================================================
|
||||||
|
@http.route(COM + '/liquidaciones', type='http', auth='none',
|
||||||
|
methods=['GET', 'OPTIONS'], csrf=False)
|
||||||
|
@require_role('lectura')
|
||||||
|
@require_perm('comisiones.ver')
|
||||||
|
@comisiones_activas
|
||||||
|
def list_liquidaciones(self, **kw):
|
||||||
|
try:
|
||||||
|
user = request.frontend_user
|
||||||
|
domain = []
|
||||||
|
if self._solo_propio(user):
|
||||||
|
emp = _linked_employee(user)
|
||||||
|
domain.append(('empleado_id', '=', emp.id if emp else 0))
|
||||||
|
# El médico ve solo sus liquidaciones PAGADAS (recibo). El
|
||||||
|
# borrador es información interna de administración.
|
||||||
|
domain.append(('estado', '=', 'pagada'))
|
||||||
|
else:
|
||||||
|
if kw.get('empleado_id'):
|
||||||
|
domain.append(('empleado_id', '=', int(kw['empleado_id'])))
|
||||||
|
if kw.get('estado'):
|
||||||
|
domain.append(('estado', '=', kw['estado']))
|
||||||
|
if kw.get('periodo'):
|
||||||
|
domain.append(('periodo', '=', kw['periodo']))
|
||||||
|
liqs = request.env['skeen.comision.liquidacion'].sudo().search(domain)
|
||||||
|
return _json({'status': 'success',
|
||||||
|
'liquidaciones': [self._liq_to_dict(l) for l in liqs],
|
||||||
|
'total': len(liqs)})
|
||||||
|
except Exception as e:
|
||||||
|
return _json({'status': 'error', 'message': str(e)}, 500)
|
||||||
|
|
||||||
|
@http.route(COM + '/liquidaciones/<int:lid>', type='http', auth='none',
|
||||||
|
methods=['GET', 'OPTIONS'], csrf=False)
|
||||||
|
@require_role('lectura')
|
||||||
|
@require_perm('comisiones.ver')
|
||||||
|
@comisiones_activas
|
||||||
|
def get_liquidacion(self, lid, **kw):
|
||||||
|
try:
|
||||||
|
liq = request.env['skeen.comision.liquidacion'].sudo().browse(lid)
|
||||||
|
if not liq.exists():
|
||||||
|
return _json({'status': 'error', 'message': 'Liquidación no encontrada'}, 404)
|
||||||
|
user = request.frontend_user
|
||||||
|
if self._solo_propio(user):
|
||||||
|
emp = _linked_employee(user)
|
||||||
|
if not emp or liq.empleado_id.id != emp.id or liq.estado != 'pagada':
|
||||||
|
return _json({'status': 'error', 'message': 'Sin permiso'}, 403)
|
||||||
|
return _json({'status': 'success', 'liquidacion': self._liq_to_dict(liq, True)})
|
||||||
|
except Exception as e:
|
||||||
|
return _json({'status': 'error', 'message': str(e)}, 500)
|
||||||
|
|
||||||
|
@http.route(COM + '/mis-liquidaciones', type='http', auth='none',
|
||||||
|
methods=['GET', 'OPTIONS'], csrf=False)
|
||||||
|
@require_role('lectura')
|
||||||
|
@require_perm('comisiones.ver')
|
||||||
|
@comisiones_activas
|
||||||
|
def mis_liquidaciones(self, **kw):
|
||||||
|
"""Recibo propio del empleado vinculado (rol médico). Solo pagadas."""
|
||||||
|
try:
|
||||||
|
emp = _linked_employee(request.frontend_user)
|
||||||
|
if not emp:
|
||||||
|
return _json({'status': 'success', 'liquidaciones': [],
|
||||||
|
'medico_sin_employee': True})
|
||||||
|
liqs = request.env['skeen.comision.liquidacion'].sudo().search([
|
||||||
|
('empleado_id', '=', emp.id), ('estado', '=', 'pagada')])
|
||||||
|
return _json({'status': 'success', 'empleado_id': emp.id,
|
||||||
|
'empleado': emp.name,
|
||||||
|
'liquidaciones': [self._liq_to_dict(l, True) for l in liqs]})
|
||||||
|
except Exception as e:
|
||||||
|
return _json({'status': 'error', 'message': str(e)}, 500)
|
||||||
|
|
||||||
|
@http.route(COM + '/liquidaciones', type='http', auth='none',
|
||||||
|
methods=['POST', 'OPTIONS'], csrf=False)
|
||||||
|
@require_role('lectura')
|
||||||
|
@require_perm('comisiones.liquidar')
|
||||||
|
@comisiones_activas
|
||||||
|
def generar_liquidaciones(self, **kw):
|
||||||
|
"""Genera borradores del periodo para uno, varios o todos los empleados."""
|
||||||
|
try:
|
||||||
|
data = _body()
|
||||||
|
start, end = self._rango(data)
|
||||||
|
Liq = request.env['skeen.comision.liquidacion'].sudo()
|
||||||
|
motor = request.env['skeen.comision.motor'].sudo()
|
||||||
|
|
||||||
|
emp_ids = None
|
||||||
|
if data.get('empleado_ids'):
|
||||||
|
emp_ids = [int(x) for x in data['empleado_ids']]
|
||||||
|
elif data.get('empleado_id'):
|
||||||
|
emp_ids = [int(data['empleado_id'])]
|
||||||
|
calc = motor.calcular(start, end, empleado_ids=emp_ids)
|
||||||
|
|
||||||
|
creadas, omitidas = [], []
|
||||||
|
for emp in calc['empleados']:
|
||||||
|
if emp['lineas_count'] == 0 and abs(emp['total']) < 1e-9:
|
||||||
|
continue
|
||||||
|
existente = Liq.search([
|
||||||
|
('empleado_id', '=', emp['empleado_id']),
|
||||||
|
('periodo_inicio', '=', start),
|
||||||
|
('periodo_fin', '=', end)], limit=1)
|
||||||
|
if existente:
|
||||||
|
omitidas.append({'empleado_id': emp['empleado_id'],
|
||||||
|
'empleado': emp['empleado'],
|
||||||
|
'folio': existente.name,
|
||||||
|
'motivo': 'ya existe liquidación del periodo'})
|
||||||
|
continue
|
||||||
|
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),
|
||||||
|
'creado_por_login': _login(),
|
||||||
|
'notas': (data.get('notas') or '').strip(),
|
||||||
|
})
|
||||||
|
creadas.append(self._liq_to_dict(liq, True))
|
||||||
|
return _json({'status': 'success',
|
||||||
|
'liquidaciones': creadas, 'omitidas': omitidas,
|
||||||
|
'total_creadas': len(creadas)}, 201)
|
||||||
|
except (ValidationError, UserError) as e:
|
||||||
|
request.env.cr.rollback()
|
||||||
|
return _json({'status': 'error', 'message': str(e)}, 400)
|
||||||
|
except Exception as e:
|
||||||
|
request.env.cr.rollback()
|
||||||
|
return _json({'status': 'error', 'message': str(e)}, 500)
|
||||||
|
|
||||||
|
@http.route(COM + '/liquidaciones/<int:lid>/ajustar', type='http', auth='none',
|
||||||
|
methods=['POST', 'OPTIONS'], csrf=False)
|
||||||
|
@require_role('lectura')
|
||||||
|
@require_perm('comisiones.liquidar')
|
||||||
|
@comisiones_activas
|
||||||
|
def ajustar_liquidacion(self, lid, **kw):
|
||||||
|
try:
|
||||||
|
cfg = _cfg()
|
||||||
|
if not cfg.get('permitir_ajustes_manuales', True):
|
||||||
|
return _json({'status': 'error',
|
||||||
|
'message': 'Los ajustes manuales están desactivados'}, 400)
|
||||||
|
liq = request.env['skeen.comision.liquidacion'].sudo().browse(lid)
|
||||||
|
if not liq.exists():
|
||||||
|
return _json({'status': 'error', 'message': 'Liquidación no encontrada'}, 404)
|
||||||
|
data = _body()
|
||||||
|
monto = _num(data.get('monto'))
|
||||||
|
if monto is None:
|
||||||
|
return _json({'status': 'error', 'message': 'Monto inválido'}, 400)
|
||||||
|
liq.ajustar(monto, motivo=data.get('motivo') or '', usuario=_login())
|
||||||
|
return _json({'status': 'success', 'liquidacion': self._liq_to_dict(liq, True)})
|
||||||
|
except (ValidationError, UserError) as e:
|
||||||
|
request.env.cr.rollback()
|
||||||
|
return _json({'status': 'error', 'message': str(e)}, 400)
|
||||||
|
except Exception as e:
|
||||||
|
request.env.cr.rollback()
|
||||||
|
return _json({'status': 'error', 'message': str(e)}, 500)
|
||||||
|
|
||||||
|
@http.route(COM + '/liquidaciones/<int:lid>/pagar', type='http', auth='none',
|
||||||
|
methods=['POST', 'OPTIONS'], csrf=False)
|
||||||
|
@require_role('lectura')
|
||||||
|
@require_perm('comisiones.liquidar')
|
||||||
|
@comisiones_activas
|
||||||
|
def pagar_liquidacion(self, lid, **kw):
|
||||||
|
try:
|
||||||
|
liq = request.env['skeen.comision.liquidacion'].sudo().browse(lid)
|
||||||
|
if not liq.exists():
|
||||||
|
return _json({'status': 'error', 'message': 'Liquidación no encontrada'}, 404)
|
||||||
|
data = _body()
|
||||||
|
fecha = fields.Date.to_date(data.get('fecha')) or fields.Date.today()
|
||||||
|
metodo = data.get('metodo_pago') or data.get('metodo') or ''
|
||||||
|
if metodo and metodo not in METODOS_PAGO:
|
||||||
|
return _json({'status': 'error', 'message': 'Método de pago inválido'}, 400)
|
||||||
|
liq.pagar(fecha=fecha, metodo=metodo,
|
||||||
|
referencia=data.get('referencia') or '', usuario=_login())
|
||||||
|
return _json({'status': 'success', 'liquidacion': self._liq_to_dict(liq, True)})
|
||||||
|
except (ValidationError, UserError) as e:
|
||||||
|
request.env.cr.rollback()
|
||||||
|
return _json({'status': 'error', 'message': str(e)}, 400)
|
||||||
|
except Exception as e:
|
||||||
|
request.env.cr.rollback()
|
||||||
|
return _json({'status': 'error', 'message': str(e)}, 500)
|
||||||
|
|
||||||
|
@http.route(COM + '/liquidaciones/<int:lid>/cancelar', type='http', auth='none',
|
||||||
|
methods=['POST', 'OPTIONS'], csrf=False)
|
||||||
|
@require_role('lectura')
|
||||||
|
@require_perm('comisiones.liquidar')
|
||||||
|
@comisiones_activas
|
||||||
|
def cancelar_liquidacion(self, lid, **kw):
|
||||||
|
try:
|
||||||
|
liq = request.env['skeen.comision.liquidacion'].sudo().browse(lid)
|
||||||
|
if not liq.exists():
|
||||||
|
return _json({'status': 'error', 'message': 'Liquidación no encontrada'}, 404)
|
||||||
|
data = _body()
|
||||||
|
liq.cancelar(motivo=data.get('motivo') or '')
|
||||||
|
return _json({'status': 'success', 'liquidacion': self._liq_to_dict(liq, True)})
|
||||||
|
except (ValidationError, UserError) as e:
|
||||||
|
request.env.cr.rollback()
|
||||||
|
return _json({'status': 'error', 'message': str(e)}, 400)
|
||||||
|
except Exception as e:
|
||||||
|
request.env.cr.rollback()
|
||||||
|
return _json({'status': 'error', 'message': str(e)}, 500)
|
||||||
14
odoo-addons/skeen_comisiones/data/sequences.xml
Normal file
14
odoo-addons/skeen_comisiones/data/sequences.xml
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<odoo>
|
||||||
|
<data noupdate="1">
|
||||||
|
<!-- Folio de liquidación: LIQ-0001, LIQ-0002, ... -->
|
||||||
|
<record id="seq_skeen_comision_liquidacion" model="ir.sequence">
|
||||||
|
<field name="name">Liquidación de comisiones SKEEN</field>
|
||||||
|
<field name="code">skeen.comision.liquidacion</field>
|
||||||
|
<field name="prefix">LIQ-</field>
|
||||||
|
<field name="padding">4</field>
|
||||||
|
<field name="number_next">1</field>
|
||||||
|
<field name="number_increment">1</field>
|
||||||
|
</record>
|
||||||
|
</data>
|
||||||
|
</odoo>
|
||||||
5
odoo-addons/skeen_comisiones/models/__init__.py
Normal file
5
odoo-addons/skeen_comisiones/models/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
from . import regla
|
||||||
|
from . import meta
|
||||||
|
from . import liquidacion
|
||||||
|
from . import motor
|
||||||
148
odoo-addons/skeen_comisiones/models/liquidacion.py
Normal file
148
odoo-addons/skeen_comisiones/models/liquidacion.py
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""Liquidación de comisiones por periodo.
|
||||||
|
|
||||||
|
Es el único lugar donde la comisión se PERSISTE: al generar el borrador se corre
|
||||||
|
el motor y el desglose completo (por venta/línea/regla y metas alcanzadas) queda
|
||||||
|
congelado en `detalle` (JSON). Así el recibo de un periodo ya cerrado no cambia
|
||||||
|
aunque después se editen reglas, precios o devoluciones.
|
||||||
|
|
||||||
|
Estados: borrador -> pagada, o borrador -> cancelada. Una liquidación pagada o
|
||||||
|
cancelada es inmutable (se protege el histórico de lo ya entregado).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
|
||||||
|
from odoo import models, fields, api
|
||||||
|
from odoo.exceptions import UserError
|
||||||
|
|
||||||
|
ESTADOS = ('borrador', 'pagada', 'cancelada')
|
||||||
|
ESTADO_LABEL = {'borrador': 'Borrador', 'pagada': 'Pagada', 'cancelada': 'Cancelada'}
|
||||||
|
|
||||||
|
|
||||||
|
class SkeenComisionLiquidacion(models.Model):
|
||||||
|
_name = 'skeen.comision.liquidacion'
|
||||||
|
_description = 'Liquidación de comisiones SKEEN'
|
||||||
|
_order = 'periodo_inicio desc, id desc'
|
||||||
|
|
||||||
|
name = fields.Char(string='Folio', readonly=True, copy=False, index=True)
|
||||||
|
empleado_id = fields.Many2one('hr.employee', string='Empleado', required=True,
|
||||||
|
ondelete='restrict', index=True)
|
||||||
|
periodo_inicio = fields.Date(string='Periodo desde', required=True)
|
||||||
|
periodo_fin = fields.Date(string='Periodo hasta', required=True)
|
||||||
|
periodo = fields.Char(string='Periodo (AAAA-MM)', compute='_compute_periodo',
|
||||||
|
store=True, index=True)
|
||||||
|
|
||||||
|
total_calculado = fields.Float(string='Comisión calculada', 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)
|
||||||
|
|
||||||
|
estado = fields.Selection([(e, ESTADO_LABEL[e]) for e in ESTADOS],
|
||||||
|
string='Estado', default='borrador', required=True,
|
||||||
|
index=True)
|
||||||
|
fecha_pago = fields.Date(string='Fecha de pago')
|
||||||
|
metodo_pago = fields.Char(string='Método de pago')
|
||||||
|
referencia = fields.Char(string='Referencia')
|
||||||
|
notas = fields.Text(string='Notas')
|
||||||
|
detalle = fields.Text(string='Desglose (JSON)')
|
||||||
|
creado_por_login = fields.Char(string='Creada por')
|
||||||
|
pagada_por_login = fields.Char(string='Pagada por')
|
||||||
|
|
||||||
|
ajuste_ids = fields.One2many('skeen.comision.ajuste', 'liquidacion_id',
|
||||||
|
string='Ajustes')
|
||||||
|
|
||||||
|
_sql_constraints = [
|
||||||
|
('unique_empleado_periodo',
|
||||||
|
'unique(empleado_id, periodo_inicio, periodo_fin)',
|
||||||
|
'Ya existe una liquidación de este empleado para el mismo periodo.'),
|
||||||
|
]
|
||||||
|
|
||||||
|
@api.depends('periodo_inicio')
|
||||||
|
def _compute_periodo(self):
|
||||||
|
for l in self:
|
||||||
|
l.periodo = l.periodo_inicio.strftime('%Y-%m') if l.periodo_inicio else ''
|
||||||
|
|
||||||
|
@api.depends('ajuste_ids.monto', 'total_calculado')
|
||||||
|
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
|
||||||
|
|
||||||
|
@api.model_create_multi
|
||||||
|
def create(self, vals_list):
|
||||||
|
seq = self.env['ir.sequence'].sudo().search(
|
||||||
|
[('code', '=', 'skeen.comision.liquidacion')], limit=1)
|
||||||
|
for vals in vals_list:
|
||||||
|
if not vals.get('name'):
|
||||||
|
vals['name'] = (seq.next_by_id() if seq else False) or 'LIQ-0001'
|
||||||
|
return super().create(vals_list)
|
||||||
|
|
||||||
|
def ajustar(self, monto, motivo='', usuario=''):
|
||||||
|
"""Agrega un ajuste manual con signo (+/-)."""
|
||||||
|
self.ensure_one()
|
||||||
|
if self.estado != 'borrador':
|
||||||
|
raise UserError('Solo una liquidación en borrador admite ajustes.')
|
||||||
|
try:
|
||||||
|
monto = float(monto)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
raise UserError('El monto del ajuste debe ser un número.')
|
||||||
|
if abs(monto) < 1e-9:
|
||||||
|
raise UserError('El monto del ajuste no puede ser cero.')
|
||||||
|
return self.env['skeen.comision.ajuste'].create({
|
||||||
|
'liquidacion_id': self.id,
|
||||||
|
'empleado_id': self.empleado_id.id,
|
||||||
|
'periodo': self.periodo,
|
||||||
|
'monto': monto,
|
||||||
|
'motivo': (motivo or '').strip(),
|
||||||
|
'usuario': usuario or '',
|
||||||
|
'fecha': fields.Datetime.now(),
|
||||||
|
})
|
||||||
|
|
||||||
|
def pagar(self, fecha=None, metodo='', referencia='', usuario=''):
|
||||||
|
self.ensure_one()
|
||||||
|
if self.estado != 'borrador':
|
||||||
|
raise UserError('Solo una liquidación en borrador se puede pagar.')
|
||||||
|
self.write({
|
||||||
|
'estado': 'pagada',
|
||||||
|
'fecha_pago': fecha or fields.Date.today(),
|
||||||
|
'metodo_pago': (metodo or '').strip(),
|
||||||
|
'referencia': (referencia or '').strip(),
|
||||||
|
'pagada_por_login': usuario or '',
|
||||||
|
})
|
||||||
|
return True
|
||||||
|
|
||||||
|
def cancelar(self, motivo=''):
|
||||||
|
self.ensure_one()
|
||||||
|
if self.estado == 'cancelada':
|
||||||
|
raise UserError('La liquidación ya está cancelada.')
|
||||||
|
if self.estado == 'pagada':
|
||||||
|
raise UserError('Una liquidación pagada no se cancela; registra un ajuste.')
|
||||||
|
texto = (self.notas or '').strip()
|
||||||
|
self.write({'estado': 'cancelada',
|
||||||
|
'notas': (texto + ('\n' if texto else '') +
|
||||||
|
'Cancelada: ' + (motivo or 'sin motivo')).strip()})
|
||||||
|
return True
|
||||||
|
|
||||||
|
def detalle_dict(self):
|
||||||
|
self.ensure_one()
|
||||||
|
try:
|
||||||
|
return json.loads(self.detalle or '{}')
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
class SkeenComisionAjuste(models.Model):
|
||||||
|
_name = 'skeen.comision.ajuste'
|
||||||
|
_description = 'Ajuste manual de comisión'
|
||||||
|
_order = 'fecha desc, id desc'
|
||||||
|
|
||||||
|
liquidacion_id = fields.Many2one('skeen.comision.liquidacion', string='Liquidación',
|
||||||
|
ondelete='cascade', index=True)
|
||||||
|
empleado_id = fields.Many2one('hr.employee', string='Empleado', required=True,
|
||||||
|
ondelete='cascade', index=True)
|
||||||
|
periodo = fields.Char(string='Periodo (AAAA-MM)')
|
||||||
|
monto = fields.Float(string='Monto', default=0.0,
|
||||||
|
help='Positivo suma a la comisión; negativo la reduce.')
|
||||||
|
motivo = fields.Char(string='Motivo')
|
||||||
|
usuario = fields.Char(string='Usuario')
|
||||||
|
fecha = fields.Datetime(string='Fecha', default=fields.Datetime.now)
|
||||||
59
odoo-addons/skeen_comisiones/models/meta.py
Normal file
59
odoo-addons/skeen_comisiones/models/meta.py
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""Metas de comisión por empleado y periodo.
|
||||||
|
|
||||||
|
Una meta compara la venta (base comisionable del periodo, según la base
|
||||||
|
configurada) del empleado contra `monto_objetivo`. Al alcanzarla se paga el
|
||||||
|
bono: `fijo` (monto en m.n.) o `porcentaje` (sobre la base del empleado). Se
|
||||||
|
evalúa al cerrar el periodo (al generar la liquidación) y también en vivo.
|
||||||
|
|
||||||
|
`periodo` acepta 'AAAA-MM' (mes concreto) o 'mensual' (cualquier mes). Se
|
||||||
|
documenta así para no ambigüedad: una meta 'mensual' aplica a todo periodo
|
||||||
|
consultado; una meta 'AAAA-MM' solo al mes que coincide con el inicio del rango.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from odoo import models, fields, api
|
||||||
|
from odoo.exceptions import ValidationError
|
||||||
|
|
||||||
|
TIPOS_BONO = ('fijo', 'porcentaje')
|
||||||
|
|
||||||
|
|
||||||
|
class SkeenComisionMeta(models.Model):
|
||||||
|
_name = 'skeen.comision.meta'
|
||||||
|
_description = 'Meta de comisión SKEEN'
|
||||||
|
_order = 'empleado_id, periodo'
|
||||||
|
|
||||||
|
empleado_id = fields.Many2one('hr.employee', string='Empleado', required=True,
|
||||||
|
ondelete='cascade', index=True)
|
||||||
|
periodo = fields.Char(string='Periodo', required=True,
|
||||||
|
help="'AAAA-MM' para un mes concreto o 'mensual' para cualquiera.")
|
||||||
|
monto_objetivo = fields.Float(string='Monto objetivo', default=0.0)
|
||||||
|
bono = fields.Float(string='Bono', default=0.0)
|
||||||
|
tipo_bono = fields.Selection(
|
||||||
|
[('fijo', 'Monto fijo'), ('porcentaje', 'Porcentaje sobre la venta')],
|
||||||
|
string='Tipo de bono', default='fijo', required=True)
|
||||||
|
activo = fields.Boolean(string='Activa', default=True)
|
||||||
|
notas = fields.Text(string='Notas')
|
||||||
|
company_id = fields.Many2one('res.company', string='Compañía',
|
||||||
|
default=lambda self: self.env.company)
|
||||||
|
|
||||||
|
@api.constrains('periodo')
|
||||||
|
def _check_periodo(self):
|
||||||
|
for m in self:
|
||||||
|
p = (m.periodo or '').strip()
|
||||||
|
if p == 'mensual':
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
anio, mes = p.split('-')
|
||||||
|
int(anio)
|
||||||
|
mes_i = int(mes)
|
||||||
|
if not (1 <= mes_i <= 12) or len(mes) != 2:
|
||||||
|
raise ValueError
|
||||||
|
except (ValueError, AttributeError):
|
||||||
|
raise ValidationError(
|
||||||
|
"El periodo debe ser 'AAAA-MM' (p. ej. 2026-10) o 'mensual'.")
|
||||||
|
|
||||||
|
def aplica_a(self, periodo_mes):
|
||||||
|
"""True si la meta cubre el mes 'AAAA-MM' dado."""
|
||||||
|
self.ensure_one()
|
||||||
|
p = (self.periodo or '').strip()
|
||||||
|
return p == 'mensual' or p == periodo_mes
|
||||||
346
odoo-addons/skeen_comisiones/models/motor.py
Normal file
346
odoo-addons/skeen_comisiones/models/motor.py
Normal file
@@ -0,0 +1,346 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""Motor de comisiones: cálculo en vivo del periodo.
|
||||||
|
|
||||||
|
Reglas de negocio (documentadas para que el resultado sea predecible):
|
||||||
|
|
||||||
|
* Universal: se comisiona la LÍNEA recetada (`skeen.venta.line.prescribed_by_id`)
|
||||||
|
de ventas en estado confirmed/paid/partial dentro del rango. El empleado de la
|
||||||
|
comisión es el `prescribed_by_id` de la línea.
|
||||||
|
* Base: `vendido` = subtotal de la línea; `cobrado` = esa base multiplicada por
|
||||||
|
`amount_paid / total` de la venta (lo efectivamente pagado). Con
|
||||||
|
`incluir_impuestos` la base suma el impuesto de la línea.
|
||||||
|
* `neto_de_devoluciones`: si la venta está devuelta, la base se reduce en la
|
||||||
|
proporción devuelta (`refund_amount / total`).
|
||||||
|
* Regla ganadora: mayor `prioridad`; a igual prioridad, alcance más específico;
|
||||||
|
a igual alcance, regla por empleado > por staff_type > genérica.
|
||||||
|
* Escalonado: el porcentaje se decide con la venta TOTAL del periodo del empleado
|
||||||
|
(suma de las bases que aplican a esa regla) y se aplica a cada línea cubierta.
|
||||||
|
* Monto fijo: `valor × cantidad` de la línea.
|
||||||
|
* Sin ninguna regla aplicable a una línea: respaldo `hr.employee.commission_pct`.
|
||||||
|
* Metas: si la base del empleado alcanza `monto_objetivo` se suma el bono
|
||||||
|
(`fijo` = monto; `porcentaje` = % sobre la base del empleado).
|
||||||
|
* Ajustes: manuales, del empleado y mes, firmados (+/-).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
from collections import defaultdict
|
||||||
|
|
||||||
|
from odoo import models
|
||||||
|
|
||||||
|
from odoo.addons.skeen_citas.clinic_settings import get_comisiones
|
||||||
|
|
||||||
|
from .regla import ESPECIFICIDAD
|
||||||
|
|
||||||
|
|
||||||
|
def _num(v, d=0.0):
|
||||||
|
try:
|
||||||
|
return float(v)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return d
|
||||||
|
|
||||||
|
|
||||||
|
class SkeenComisionMotor(models.AbstractModel):
|
||||||
|
_name = 'skeen.comision.motor'
|
||||||
|
_description = 'Motor de cálculo de comisiones SKEEN'
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Regla ganadora
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
def _emp_rank(self, regla):
|
||||||
|
if regla.empleado_id:
|
||||||
|
return 2
|
||||||
|
if regla.staff_type:
|
||||||
|
return 1
|
||||||
|
return 0
|
||||||
|
|
||||||
|
def _match(self, regla, line, venta, emp):
|
||||||
|
if not regla.activo:
|
||||||
|
return False
|
||||||
|
if regla.empleado_id and regla.empleado_id.id != emp.id:
|
||||||
|
return False
|
||||||
|
if regla.staff_type and regla.staff_type != (emp.staff_type or 'doctor'):
|
||||||
|
return False
|
||||||
|
fecha = venta.date
|
||||||
|
if regla.fecha_desde and fecha and fecha < regla.fecha_desde:
|
||||||
|
return False
|
||||||
|
if regla.fecha_hasta and fecha and fecha > regla.fecha_hasta:
|
||||||
|
return False
|
||||||
|
a = regla.alcance
|
||||||
|
if a == 'global':
|
||||||
|
return True
|
||||||
|
if a == 'categoria':
|
||||||
|
return bool(line.service_id) and regla.categoria_code == line.service_id.category
|
||||||
|
if a == 'servicio':
|
||||||
|
return bool(line.service_id) and regla.servicio_id.id == line.service_id.id
|
||||||
|
if a == 'articulo':
|
||||||
|
return bool(line.item_id) and regla.item_id.id == line.item_id.id
|
||||||
|
if a == 'membresia':
|
||||||
|
if 'membresia_id' not in venta._fields or not venta.membresia_id:
|
||||||
|
return False
|
||||||
|
return bool(regla.plan_id) and regla.plan_id.id == venta.membresia_id.plan_id.id
|
||||||
|
if a == 'paquete':
|
||||||
|
return bool(line.paquete_id)
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _mejor_regla(self, reglas, line, venta, emp):
|
||||||
|
candidatas = [r for r in reglas if self._match(r, line, venta, emp)]
|
||||||
|
if not candidatas:
|
||||||
|
return None
|
||||||
|
candidatas.sort(
|
||||||
|
key=lambda r: (r.prioridad or 0, ESPECIFICIDAD.get(r.alcance, 0),
|
||||||
|
self._emp_rank(r)))
|
||||||
|
return candidatas[-1]
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Cálculo de la base de una línea
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
def _base_linea(self, line, venta, cfg, base_forzada=None):
|
||||||
|
"""Base comisionable de la línea, ya neta de devolución si aplica."""
|
||||||
|
base = line.subtotal or 0.0
|
||||||
|
if cfg.get('incluir_impuestos'):
|
||||||
|
base += (line.tax_amount or 0.0)
|
||||||
|
base_efectiva = base_forzada or cfg.get('base') or 'vendido'
|
||||||
|
if base_efectiva == 'cobrado':
|
||||||
|
total = venta.total or 0.0
|
||||||
|
if total <= 0:
|
||||||
|
return 0.0
|
||||||
|
factor = max(0.0, min(1.0, (venta.amount_paid or 0.0) / total))
|
||||||
|
base *= factor
|
||||||
|
if cfg.get('neto_de_devoluciones') and venta.refunded and (venta.refund_amount or 0.0) > 0:
|
||||||
|
total = venta.total or 0.0
|
||||||
|
if total > 0:
|
||||||
|
base *= max(0.0, 1.0 - (venta.refund_amount / total))
|
||||||
|
return base
|
||||||
|
|
||||||
|
def _comisionable(self, line, venta, cfg):
|
||||||
|
"""(incluye, tipo_linea) según los interruptores del bloque."""
|
||||||
|
if line.paquete_id:
|
||||||
|
return bool(cfg.get('comisionar_paquetes')), 'paquete'
|
||||||
|
if 'membresia_id' in venta._fields and venta.membresia_id and not line.item_id:
|
||||||
|
# La línea de una venta de membresía (cargo del plan).
|
||||||
|
return bool(cfg.get('comisionar_membresias')), 'membresia'
|
||||||
|
if line.item_id:
|
||||||
|
return bool(cfg.get('comisionar_productos')), 'producto'
|
||||||
|
return True, 'servicio'
|
||||||
|
|
||||||
|
def _referencia(self, line, venta, regla, tipo_linea):
|
||||||
|
partes = [venta.name or ('Venta #%s' % venta.id)]
|
||||||
|
if line.service_id:
|
||||||
|
partes.append(line.service_id.name)
|
||||||
|
elif line.item_id:
|
||||||
|
partes.append(line.item_id.name)
|
||||||
|
elif line.description:
|
||||||
|
partes.append(line.description)
|
||||||
|
if regla:
|
||||||
|
partes.append('Regla «%s»' % regla.name)
|
||||||
|
else:
|
||||||
|
partes.append('Respaldo (% comisión del empleado)')
|
||||||
|
if tipo_linea:
|
||||||
|
partes.append(tipo_linea)
|
||||||
|
return ' · '.join([p for p in partes if p])
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Cálculo del periodo
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
def calcular(self, start, end, empleado_ids=None, cfg=None):
|
||||||
|
env = self.env
|
||||||
|
cfg = cfg or get_comisiones(env)
|
||||||
|
redondeo = int(cfg.get('redondeo', 2) or 0)
|
||||||
|
|
||||||
|
def rnd(v):
|
||||||
|
return round(_num(v), redondeo)
|
||||||
|
|
||||||
|
vdomain = [('state', 'in', ['confirmed', 'paid', 'partial']),
|
||||||
|
('date', '>=', start), ('date', '<=', end)]
|
||||||
|
if empleado_ids:
|
||||||
|
vdomain.append(('line_ids.prescribed_by_id', 'in', list(empleado_ids)))
|
||||||
|
ventas = env['skeen.venta'].sudo().search(vdomain)
|
||||||
|
lineas = env['skeen.venta.line'].sudo().search([
|
||||||
|
('venta_id', 'in', ventas.ids),
|
||||||
|
('prescribed_by_id', '!=', False),
|
||||||
|
])
|
||||||
|
reglas = env['skeen.comision.regla'].sudo().search([('activo', '=', True)])
|
||||||
|
|
||||||
|
items = [] # dicts por línea calculada
|
||||||
|
empleados_vistos = {}
|
||||||
|
for line in lineas:
|
||||||
|
emp = line.prescribed_by_id
|
||||||
|
if empleado_ids and emp.id not in empleado_ids:
|
||||||
|
continue
|
||||||
|
venta = line.venta_id
|
||||||
|
incluye, tipo_linea = self._comisionable(line, venta, cfg)
|
||||||
|
if not incluye:
|
||||||
|
continue
|
||||||
|
regla = self._mejor_regla(reglas, line, venta, emp)
|
||||||
|
base_forzada = regla.base_override if regla else None
|
||||||
|
base = self._base_linea(line, venta, cfg, base_forzada)
|
||||||
|
items.append({
|
||||||
|
'emp': emp, 'line': line, 'venta': venta, 'regla': regla,
|
||||||
|
'tipo_linea': tipo_linea, 'base': base, 'cantidad': line.quantity or 1.0,
|
||||||
|
})
|
||||||
|
empleados_vistos[emp.id] = emp
|
||||||
|
|
||||||
|
# Total de venta por (empleado, regla) para escalonados.
|
||||||
|
totales_esc = defaultdict(float)
|
||||||
|
for it in items:
|
||||||
|
if it['regla'] and it['regla'].tipo == 'escalonado':
|
||||||
|
totales_esc[(it['emp'].id, it['regla'].id)] += it['base']
|
||||||
|
|
||||||
|
# Comisión por línea.
|
||||||
|
for it in items:
|
||||||
|
regla = it['regla']
|
||||||
|
base = it['base']
|
||||||
|
pct = None
|
||||||
|
monto = 0.0
|
||||||
|
if regla:
|
||||||
|
if regla.tipo == 'porcentaje':
|
||||||
|
pct = regla.valor or 0.0
|
||||||
|
monto = base * pct / 100.0
|
||||||
|
elif regla.tipo == 'monto_fijo':
|
||||||
|
monto = (regla.valor or 0.0) * it['cantidad']
|
||||||
|
elif regla.tipo == 'escalonado':
|
||||||
|
pct = regla.porcentaje_para(totales_esc[(it['emp'].id, regla.id)])
|
||||||
|
pct = pct if pct is not None else 0.0
|
||||||
|
monto = base * pct / 100.0
|
||||||
|
else:
|
||||||
|
pct = it['emp'].commission_pct or 0.0
|
||||||
|
monto = base * pct / 100.0
|
||||||
|
it['pct'] = pct
|
||||||
|
it['comision'] = rnd(monto)
|
||||||
|
|
||||||
|
# Citas completadas por doctor.
|
||||||
|
citas = {}
|
||||||
|
for g in env['skeen.cita'].sudo().read_group(
|
||||||
|
[('state', '=', 'done'), ('date', '>=', start), ('date', '<=', end),
|
||||||
|
('doctor_id', '!=', False)], ['doctor_id'], ['doctor_id']):
|
||||||
|
citas[g['doctor_id'][0]] = g.get('__count') or g.get('doctor_id_count', 0)
|
||||||
|
|
||||||
|
# Metas y ajustes.
|
||||||
|
periodo_mes = start.strftime('%Y-%m') if hasattr(start, 'strftime') else str(start)[:7]
|
||||||
|
metas = env['skeen.comision.meta'].sudo().search([('activo', '=', True)])
|
||||||
|
ajustes = env['skeen.comision.ajuste'].sudo().search([
|
||||||
|
('periodo', '=', periodo_mes),
|
||||||
|
'|', ('liquidacion_id', '=', False), ('liquidacion_id.estado', '=', 'borrador'),
|
||||||
|
])
|
||||||
|
|
||||||
|
# Agregación por empleado.
|
||||||
|
por_emp = defaultdict(list)
|
||||||
|
for it in items:
|
||||||
|
por_emp[it['emp'].id].append(it)
|
||||||
|
|
||||||
|
resultado = []
|
||||||
|
for emp_id in set(list(por_emp.keys()) + list(empleados_vistos.keys())
|
||||||
|
+ [m.empleado_id.id for m in metas]
|
||||||
|
+ [a.empleado_id.id for a in ajustes]):
|
||||||
|
emp = env['hr.employee'].sudo().browse(emp_id)
|
||||||
|
if not emp.exists():
|
||||||
|
continue
|
||||||
|
if empleado_ids and emp_id not in empleado_ids:
|
||||||
|
continue
|
||||||
|
lins = por_emp.get(emp_id, [])
|
||||||
|
base_total = rnd(sum(i['base'] for i in lins))
|
||||||
|
comision_total = rnd(sum(i['comision'] for i in lins))
|
||||||
|
desglose = [{
|
||||||
|
'venta_id': i['venta'].id,
|
||||||
|
'folio': i['venta'].name,
|
||||||
|
'fecha': i['venta'].date.strftime('%Y-%m-%d') if i['venta'].date else None,
|
||||||
|
'linea_id': i['line'].id,
|
||||||
|
'servicio': (i['line'].service_id.name or i['line'].item_id.name
|
||||||
|
or i['line'].description or ''),
|
||||||
|
'categoria': i['line'].service_id.category if i['line'].service_id else '',
|
||||||
|
'tipo_linea': i['tipo_linea'],
|
||||||
|
'base': rnd(i['base']),
|
||||||
|
'regla_id': i['regla'].id if i['regla'] else None,
|
||||||
|
'regla': i['regla'].name if i['regla'] else 'Respaldo (% comisión)',
|
||||||
|
'tipo': i['regla'].tipo if i['regla'] else 'porcentaje',
|
||||||
|
'porcentaje': i['pct'],
|
||||||
|
'comision': i['comision'],
|
||||||
|
'referencia': self._referencia(i['line'], i['venta'], i['regla'], i['tipo_linea']),
|
||||||
|
} for i in lins]
|
||||||
|
desglose.sort(key=lambda d: d['fecha'] or '')
|
||||||
|
|
||||||
|
# Comisión por regla.
|
||||||
|
por_regla = defaultdict(lambda: {'base': 0.0, 'comision': 0.0, 'lineas': 0})
|
||||||
|
for d in desglose:
|
||||||
|
key = (d['regla_id'], d['regla'])
|
||||||
|
por_regla[key]['base'] += d['base']
|
||||||
|
por_regla[key]['comision'] += d['comision']
|
||||||
|
por_regla[key]['lineas'] += 1
|
||||||
|
por_regla_list = [{
|
||||||
|
'regla_id': k[0], 'regla': k[1],
|
||||||
|
'base': rnd(v['base']), 'comision': rnd(v['comision']),
|
||||||
|
'lineas': v['lineas'],
|
||||||
|
} for k, v in por_regla.items()]
|
||||||
|
por_regla_list.sort(key=lambda x: -x['comision'])
|
||||||
|
|
||||||
|
# Metas.
|
||||||
|
metas_emp = []
|
||||||
|
bonos = 0.0
|
||||||
|
for m in metas.filtered(lambda m: m.empleado_id.id == emp_id and m.aplica_a(periodo_mes)):
|
||||||
|
alcanzada = base_total >= (m.monto_objetivo or 0.0)
|
||||||
|
bono = 0.0
|
||||||
|
if alcanzada:
|
||||||
|
bono = (m.bono or 0.0) if m.tipo_bono == 'fijo' \
|
||||||
|
else base_total * (m.bono or 0.0) / 100.0
|
||||||
|
bonos += bono
|
||||||
|
metas_emp.append({
|
||||||
|
'meta_id': m.id, 'periodo': m.periodo,
|
||||||
|
'monto_objetivo': m.monto_objetivo, 'alcanzada': alcanzada,
|
||||||
|
'bono': rnd(bono), 'tipo_bono': m.tipo_bono,
|
||||||
|
'bono_config': m.bono,
|
||||||
|
})
|
||||||
|
bonos = rnd(bonos)
|
||||||
|
|
||||||
|
ajustes_emp = ajustes.filtered(lambda a: a.empleado_id.id == emp_id)
|
||||||
|
total_ajustes = rnd(sum(a.monto for a in ajustes_emp))
|
||||||
|
total = rnd(comision_total + bonos + total_ajustes)
|
||||||
|
|
||||||
|
resultado.append({
|
||||||
|
'empleado_id': emp.id,
|
||||||
|
'empleado': emp.name,
|
||||||
|
'staff_type': emp.staff_type or 'doctor',
|
||||||
|
'job_title': emp.job_title or '',
|
||||||
|
'commission_pct': emp.commission_pct or 0.0,
|
||||||
|
'citas_done': citas.get(emp_id, 0),
|
||||||
|
'lineas_count': len(lins),
|
||||||
|
'base': base_total,
|
||||||
|
'comision': comision_total,
|
||||||
|
'bonos': bonos,
|
||||||
|
'ajustes': total_ajustes,
|
||||||
|
'total': total,
|
||||||
|
'pagable': total >= _num(cfg.get('pago_minimo', 0)),
|
||||||
|
'desglose': desglose,
|
||||||
|
'por_regla': por_regla_list,
|
||||||
|
'metas': metas_emp,
|
||||||
|
'ajustes_detalle': [{
|
||||||
|
'id': a.id, 'monto': a.monto, 'motivo': a.motivo or '',
|
||||||
|
'usuario': a.usuario or '',
|
||||||
|
'fecha': a.fecha.strftime('%Y-%m-%d %H:%M') if a.fecha else None,
|
||||||
|
} for a in ajustes_emp],
|
||||||
|
})
|
||||||
|
|
||||||
|
resultado.sort(key=lambda r: -r['total'])
|
||||||
|
return {
|
||||||
|
'periodo': periodo_mes,
|
||||||
|
'start': start.strftime('%Y-%m-%d') if hasattr(start, 'strftime') else str(start),
|
||||||
|
'end': end.strftime('%Y-%m-%d') if hasattr(end, 'strftime') else str(end),
|
||||||
|
'config': cfg,
|
||||||
|
'empleados': resultado,
|
||||||
|
'totales': {
|
||||||
|
'base': rnd(sum(r['base'] for r in resultado)),
|
||||||
|
'comision': rnd(sum(r['comision'] for r in resultado)),
|
||||||
|
'bonos': rnd(sum(r['bonos'] for r in resultado)),
|
||||||
|
'ajustes': rnd(sum(r['ajustes'] for r in resultado)),
|
||||||
|
'total': rnd(sum(r['total'] for r in resultado)),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
def generar_detalle_liquidacion(self, start, end, empleado_id, cfg=None):
|
||||||
|
"""Desglose congelado para una liquidación (un empleado)."""
|
||||||
|
data = self.calcular(start, end, empleado_ids=[empleado_id], cfg=cfg)
|
||||||
|
emp = data['empleados'][0] if data['empleados'] else None
|
||||||
|
if not emp:
|
||||||
|
return {'empleado_id': empleado_id, 'base': 0.0, 'comision': 0.0,
|
||||||
|
'bonos': 0.0, 'ajustes': 0.0, 'total': 0.0,
|
||||||
|
'desglose': [], 'por_regla': [], 'metas': []}
|
||||||
|
return emp
|
||||||
199
odoo-addons/skeen_comisiones/models/regla.py
Normal file
199
odoo-addons/skeen_comisiones/models/regla.py
Normal file
@@ -0,0 +1,199 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""Reglas de comisión.
|
||||||
|
|
||||||
|
Una regla decide CÓMO se comisiona una línea de venta (una línea recetada por un
|
||||||
|
empleado). El motor (`skeen.comision.motor`) elige, entre todas las reglas
|
||||||
|
aplicables a una línea, la ganadora. La precedencia es:
|
||||||
|
|
||||||
|
1. `prioridad` más alta.
|
||||||
|
2. A igual prioridad, el alcance más específico:
|
||||||
|
articulo > servicio > membresia = paquete > categoria > global.
|
||||||
|
3. A igual alcance, la regla dirigida al empleado concreto gana sobre la
|
||||||
|
dirigida a su `staff_type`, y esta sobre la genérica.
|
||||||
|
|
||||||
|
Si NINGUNA regla aplica a una línea, se usa el respaldo de compatibilidad:
|
||||||
|
`hr.employee.commission_pct` sobre la base de esa línea (el comportamiento de
|
||||||
|
siempre).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
|
||||||
|
from odoo import models, fields, api
|
||||||
|
from odoo.exceptions import ValidationError
|
||||||
|
|
||||||
|
ALCANCES = ('global', 'categoria', 'servicio', 'articulo', 'membresia', 'paquete')
|
||||||
|
ALCANCE_LABEL = {
|
||||||
|
'global': 'Global (todo)',
|
||||||
|
'categoria': 'Categoría de servicio',
|
||||||
|
'servicio': 'Servicio',
|
||||||
|
'articulo': 'Artículo de inventario',
|
||||||
|
'membresia': 'Membresía / plan',
|
||||||
|
'paquete': 'Paquete',
|
||||||
|
}
|
||||||
|
TIPOS = ('porcentaje', 'monto_fijo', 'escalonado')
|
||||||
|
TIPO_LABEL = {
|
||||||
|
'porcentaje': 'Porcentaje',
|
||||||
|
'monto_fijo': 'Monto fijo',
|
||||||
|
'escalonado': 'Escalonado por metas',
|
||||||
|
}
|
||||||
|
# Rango de especificidad del alcance (mayor = más específico).
|
||||||
|
ESPECIFICIDAD = {
|
||||||
|
'global': 10,
|
||||||
|
'categoria': 20,
|
||||||
|
'paquete': 25,
|
||||||
|
'membresia': 25,
|
||||||
|
'servicio': 30,
|
||||||
|
'articulo': 40,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class SkeenComisionRegla(models.Model):
|
||||||
|
_name = 'skeen.comision.regla'
|
||||||
|
_description = 'Regla de comisión SKEEN'
|
||||||
|
_order = 'prioridad desc, id desc'
|
||||||
|
|
||||||
|
name = fields.Char(string='Nombre', required=True)
|
||||||
|
activo = fields.Boolean(string='Activa', default=True)
|
||||||
|
prioridad = fields.Integer(string='Prioridad', default=10,
|
||||||
|
help='A mayor prioridad, gana. A igual prioridad '
|
||||||
|
'gana el alcance más específico.')
|
||||||
|
empleado_id = fields.Many2one('hr.employee', string='Empleado', ondelete='cascade',
|
||||||
|
index=True,
|
||||||
|
help='Si se deja vacío, la regla aplica a todos los empleados.')
|
||||||
|
staff_type = fields.Selection(
|
||||||
|
[('doctor', 'Doctor'), ('cosmetologa', 'Cosmetóloga')],
|
||||||
|
string='Tipo de staff',
|
||||||
|
help='Si se define, la regla solo aplica a ese tipo de personal.')
|
||||||
|
alcance = fields.Selection(
|
||||||
|
[(a, ALCANCE_LABEL[a]) for a in ALCANCES], string='Alcance',
|
||||||
|
default='global', required=True)
|
||||||
|
categoria_code = fields.Char(string='Categoría (código)')
|
||||||
|
servicio_id = fields.Many2one('skeen.servicio', string='Servicio', ondelete='cascade')
|
||||||
|
item_id = fields.Many2one('skeen.inventario.item', string='Artículo', ondelete='cascade')
|
||||||
|
plan_id = fields.Many2one('skeen.membresia.plan', string='Plan de membresía',
|
||||||
|
ondelete='cascade')
|
||||||
|
tipo = fields.Selection([(t, TIPO_LABEL[t]) for t in TIPOS], string='Tipo',
|
||||||
|
default='porcentaje', required=True)
|
||||||
|
valor = fields.Float(string='Valor (% o monto)', default=0.0,
|
||||||
|
help='Porcentaje (%) o monto fijo (m.n.) según el tipo.')
|
||||||
|
escalones = fields.Text(
|
||||||
|
string='Escalones (JSON)',
|
||||||
|
help='Para el tipo escalonado: [{"desde":0,"hasta":50000,"porcentaje":5},'
|
||||||
|
' {"desde":50000,"hasta":null,"porcentaje":8}]. Se evalúa sobre la '
|
||||||
|
'venta del periodo del empleado.')
|
||||||
|
base_override = fields.Selection(
|
||||||
|
[('vendido', 'Vendido'), ('cobrado', 'Cobrado')],
|
||||||
|
string='Base (forzar)',
|
||||||
|
help='Si se define, esta regla usa su propia base en vez de la del bloque general.')
|
||||||
|
fecha_desde = fields.Date(string='Vigente desde')
|
||||||
|
fecha_hasta = fields.Date(string='Vigente hasta')
|
||||||
|
notas = fields.Text(string='Notas')
|
||||||
|
company_id = fields.Many2one('res.company', string='Compañía',
|
||||||
|
default=lambda self: self.env.company)
|
||||||
|
|
||||||
|
@api.constrains('fecha_desde', 'fecha_hasta')
|
||||||
|
def _check_vigencia(self):
|
||||||
|
for r in self:
|
||||||
|
if r.fecha_desde and r.fecha_hasta and r.fecha_hasta < r.fecha_desde:
|
||||||
|
raise ValidationError('La fecha "hasta" no puede ser anterior a la "desde".')
|
||||||
|
|
||||||
|
@api.constrains('alcance', 'categoria_code', 'servicio_id', 'item_id', 'plan_id')
|
||||||
|
def _check_coherencia(self):
|
||||||
|
for r in self:
|
||||||
|
requeridos = {
|
||||||
|
'global': (),
|
||||||
|
'categoria': ('categoria_code',),
|
||||||
|
'servicio': ('servicio_id',),
|
||||||
|
'articulo': ('item_id',),
|
||||||
|
'membresia': ('plan_id',),
|
||||||
|
'paquete': (),
|
||||||
|
}.get(r.alcance, ())
|
||||||
|
faltan = [c for c in requeridos if not r[c]]
|
||||||
|
if faltan:
|
||||||
|
raise ValidationError(
|
||||||
|
'El alcance "%s" requiere: %s.'
|
||||||
|
% (ALCANCE_LABEL.get(r.alcance, r.alcance), ', '.join(faltan)))
|
||||||
|
|
||||||
|
@api.constrains('tipo', 'escalones', 'valor')
|
||||||
|
def _check_tipo(self):
|
||||||
|
for r in self:
|
||||||
|
if r.tipo == 'escalonado':
|
||||||
|
if not r._escalones_validos():
|
||||||
|
raise ValidationError(
|
||||||
|
'Los escalones deben ser una lista JSON de objetos con '
|
||||||
|
'"desde", "hasta" (o null) y "porcentaje".')
|
||||||
|
elif r.tipo == 'porcentaje' and not (0 <= (r.valor or 0.0) <= 100):
|
||||||
|
raise ValidationError('El porcentaje debe estar entre 0 y 100.')
|
||||||
|
|
||||||
|
def _escalones_validos(self):
|
||||||
|
self.ensure_one()
|
||||||
|
try:
|
||||||
|
data = json.loads(self.escalones or '[]')
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return False
|
||||||
|
if not isinstance(data, list) or not data:
|
||||||
|
return False
|
||||||
|
for e in data:
|
||||||
|
if not isinstance(e, dict):
|
||||||
|
return False
|
||||||
|
if e.get('desde') is None:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
float(e.get('desde'))
|
||||||
|
pct = float(e.get('porcentaje'))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return False
|
||||||
|
if not (0 <= pct <= 100):
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
def escalones_ordenados(self):
|
||||||
|
"""Escalones parseados y ordenados por `desde` (o lista vacía)."""
|
||||||
|
self.ensure_one()
|
||||||
|
try:
|
||||||
|
data = json.loads(self.escalones or '[]')
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return []
|
||||||
|
limpios = []
|
||||||
|
for e in (data if isinstance(data, list) else []):
|
||||||
|
if not isinstance(e, dict):
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
desde = float(e.get('desde'))
|
||||||
|
pct = float(e.get('porcentaje'))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
continue
|
||||||
|
hasta = e.get('hasta')
|
||||||
|
hasta = float(hasta) if hasta is not None else None
|
||||||
|
limpios.append({'desde': desde, 'hasta': hasta, 'porcentaje': pct})
|
||||||
|
limpios.sort(key=lambda x: x['desde'])
|
||||||
|
return limpios
|
||||||
|
|
||||||
|
def porcentaje_para(self, monto_empleado):
|
||||||
|
"""Porcentaje del escalón que aplica a la venta del periodo del empleado.
|
||||||
|
|
||||||
|
Devuelve `None` si la regla no es escalonada o no hay escalones válidos.
|
||||||
|
"""
|
||||||
|
self.ensure_one()
|
||||||
|
if self.tipo != 'escalonado':
|
||||||
|
return None
|
||||||
|
monto = float(monto_empleado or 0.0)
|
||||||
|
for e in self.escalones_ordenados():
|
||||||
|
if monto >= e['desde'] and (e['hasta'] is None or monto < e['hasta']):
|
||||||
|
return e['porcentaje']
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _etiqueta_corta(self):
|
||||||
|
self.ensure_one()
|
||||||
|
if self.alcance == 'categoria':
|
||||||
|
detalle = self.categoria_code or ''
|
||||||
|
elif self.alcance == 'servicio':
|
||||||
|
detalle = self.servicio_id.name or ''
|
||||||
|
elif self.alcance == 'articulo':
|
||||||
|
detalle = self.item_id.name or ''
|
||||||
|
elif self.alcance == 'membresia':
|
||||||
|
detalle = self.plan_id.name or ''
|
||||||
|
else:
|
||||||
|
detalle = ''
|
||||||
|
alcance = ALCANCE_LABEL.get(self.alcance, self.alcance)
|
||||||
|
return '%s%s' % (alcance, (' · %s' % detalle) if detalle else '')
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
|
||||||
|
access_skeen_comision_regla_user,skeen.comision.regla.user,model_skeen_comision_regla,base.group_user,1,1,1,1
|
||||||
|
access_skeen_comision_meta_user,skeen.comision.meta.user,model_skeen_comision_meta,base.group_user,1,1,1,1
|
||||||
|
access_skeen_comision_liquidacion_user,skeen.comision.liquidacion.user,model_skeen_comision_liquidacion,base.group_user,1,1,1,1
|
||||||
|
access_skeen_comision_ajuste_user,skeen.comision.ajuste.user,model_skeen_comision_ajuste,base.group_user,1,1,1,1
|
||||||
|
@@ -116,6 +116,11 @@ PERMISSION_GROUPS = [
|
|||||||
('proveedores.ver', 'Ver proveedores'),
|
('proveedores.ver', 'Ver proveedores'),
|
||||||
('proveedores.editar', 'Administrar proveedores'),
|
('proveedores.editar', 'Administrar proveedores'),
|
||||||
]),
|
]),
|
||||||
|
('comisiones', 'Comisiones y liquidaciones', [
|
||||||
|
('comisiones.ver', 'Ver comisiones y liquidaciones'),
|
||||||
|
('comisiones.editar', 'Administrar reglas y metas de comisiones'),
|
||||||
|
('comisiones.liquidar', 'Generar, ajustar y pagar liquidaciones'),
|
||||||
|
]),
|
||||||
('config', 'Usuarios y configuración', [
|
('config', 'Usuarios y configuración', [
|
||||||
('config.usuarios', 'Administrar usuarios'),
|
('config.usuarios', 'Administrar usuarios'),
|
||||||
('config.roles', 'Administrar roles y menús'),
|
('config.roles', 'Administrar roles y menús'),
|
||||||
@@ -148,6 +153,7 @@ READ_PERM_KEYS = (
|
|||||||
'membresias.ver',
|
'membresias.ver',
|
||||||
'compras.ver',
|
'compras.ver',
|
||||||
'proveedores.ver',
|
'proveedores.ver',
|
||||||
|
'comisiones.ver',
|
||||||
)
|
)
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -257,6 +263,12 @@ DEFAULT_PERM_ROLES = {
|
|||||||
'compras.cancelar': AG,
|
'compras.cancelar': AG,
|
||||||
'proveedores.ver': ALL,
|
'proveedores.ver': ALL,
|
||||||
'proveedores.editar': AG,
|
'proveedores.editar': AG,
|
||||||
|
# Comisiones y liquidaciones. Ver es de todos (el rol médico ve SOLO las
|
||||||
|
# suyas: el backend filtra por su empleado); administrar el motor de reglas y
|
||||||
|
# metas, y liquidar/pagar, es de admin/gerencia (tocan dinero del personal).
|
||||||
|
'comisiones.ver': ALL,
|
||||||
|
'comisiones.editar': AG,
|
||||||
|
'comisiones.liquidar': AG,
|
||||||
# Usuarios y configuración
|
# Usuarios y configuración
|
||||||
'config.usuarios': AG,
|
'config.usuarios': AG,
|
||||||
'config.roles': AG,
|
'config.roles': AG,
|
||||||
|
|||||||
Reference in New Issue
Block a user