Rol medico: pagina Mis ventas con KPIs y comision estimada, menu restringido y bloqueo de endpoints de dinero global
This commit is contained in:
@@ -23,6 +23,7 @@ import Inventario from './pages/Inventario';
|
|||||||
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';
|
||||||
|
import MisVentas from './pages/MisVentas';
|
||||||
import Login from './pages/Login';
|
import Login from './pages/Login';
|
||||||
import './App.css';
|
import './App.css';
|
||||||
|
|
||||||
@@ -64,6 +65,7 @@ const App: FC = () => {
|
|||||||
<Route path="/login" element={<LoginRoute />} />
|
<Route path="/login" element={<LoginRoute />} />
|
||||||
<Route path="/" element={<RequireAuth href="/"><HomeRoute /></RequireAuth>} />
|
<Route path="/" element={<RequireAuth href="/"><HomeRoute /></RequireAuth>} />
|
||||||
<Route path="/agenda" element={<RequireAuth href="/agenda"><Agenda /></RequireAuth>} />
|
<Route path="/agenda" element={<RequireAuth href="/agenda"><Agenda /></RequireAuth>} />
|
||||||
|
<Route path="/mis-ventas" element={<RequireAuth href="/mis-ventas"><MisVentas /></RequireAuth>} />
|
||||||
<Route path="/pacientes" element={<RequireAuth href="/pacientes"><Pacientes /></RequireAuth>} />
|
<Route path="/pacientes" element={<RequireAuth href="/pacientes"><Pacientes /></RequireAuth>} />
|
||||||
<Route path="/pos" element={<RequireAuth href="/pos"><Pos /></RequireAuth>} />
|
<Route path="/pos" element={<RequireAuth href="/pos"><Pos /></RequireAuth>} />
|
||||||
<Route path="/visitas" element={<RequireAuth href="/visitas"><Visitas /></RequireAuth>} />
|
<Route path="/visitas" element={<RequireAuth href="/visitas"><Visitas /></RequireAuth>} />
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import {
|
|||||||
ChevronUp,
|
ChevronUp,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
DollarSign,
|
DollarSign,
|
||||||
|
CircleDollarSign,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { Button, Modal, Input } from './ui';
|
import { Button, Modal, Input } from './ui';
|
||||||
import QuickLinksDock, { QuickLinksModePicker } from './QuickLinksDock';
|
import QuickLinksDock, { QuickLinksModePicker } from './QuickLinksDock';
|
||||||
@@ -133,6 +134,7 @@ const menuTree: MenuEntry[] = [
|
|||||||
{ label: 'Ventas del día', icon: ShoppingCart, href: '/ventas' },
|
{ label: 'Ventas del día', icon: ShoppingCart, href: '/ventas' },
|
||||||
{ label: 'Punto de Venta', icon: Store, href: '/pos' },
|
{ label: 'Punto de Venta', icon: Store, href: '/pos' },
|
||||||
{ label: 'Clientes', icon: Users, href: '/pacientes' },
|
{ label: 'Clientes', icon: Users, href: '/pacientes' },
|
||||||
|
{ label: 'Mis ventas', icon: CircleDollarSign, href: '/mis-ventas' },
|
||||||
];
|
];
|
||||||
|
|
||||||
// Hojas planas (para accesos rápidos y dock)
|
// Hojas planas (para accesos rápidos y dock)
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ import {
|
|||||||
Pencil,
|
Pencil,
|
||||||
ChevronUp,
|
ChevronUp,
|
||||||
MessageCircle,
|
MessageCircle,
|
||||||
|
CircleDollarSign,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { Button, Modal, Input } from './ui';
|
import { Button, Modal, Input } from './ui';
|
||||||
import QuickLinksDock, { QuickLinksModePicker } from './QuickLinksDock';
|
import QuickLinksDock, { QuickLinksModePicker } from './QuickLinksDock';
|
||||||
@@ -66,6 +67,7 @@ const menuSections = [
|
|||||||
{ label: 'Visitas', icon: ClipboardList, href: '/visitas' },
|
{ label: 'Visitas', icon: ClipboardList, href: '/visitas' },
|
||||||
{ label: 'Consultas', icon: ClipboardPlus, href: '/consultas' },
|
{ label: 'Consultas', icon: ClipboardPlus, href: '/consultas' },
|
||||||
{ label: 'Expedientes', icon: FolderClock, href: '/expedientes' },
|
{ label: 'Expedientes', icon: FolderClock, href: '/expedientes' },
|
||||||
|
{ label: 'Mis ventas', icon: CircleDollarSign, href: '/mis-ventas' },
|
||||||
{ label: 'Médicos', icon: Stethoscope, href: '/medicos' },
|
{ label: 'Médicos', icon: Stethoscope, href: '/medicos' },
|
||||||
{ label: 'Servicios', icon: Briefcase, href: '/servicios' },
|
{ label: 'Servicios', icon: Briefcase, href: '/servicios' },
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ import {
|
|||||||
ChevronUp,
|
ChevronUp,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
MessageCircle,
|
MessageCircle,
|
||||||
|
CircleDollarSign,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { Button, Modal, Input } from './ui';
|
import { Button, Modal, Input } from './ui';
|
||||||
import QuickLinksDock, { QuickLinksModePicker } from './QuickLinksDock';
|
import QuickLinksDock, { QuickLinksModePicker } from './QuickLinksDock';
|
||||||
@@ -66,6 +67,7 @@ const menuGroups = [
|
|||||||
{ label: 'Visitas', icon: ClipboardList, href: '/visitas' },
|
{ label: 'Visitas', icon: ClipboardList, href: '/visitas' },
|
||||||
{ label: 'Consultas', icon: ClipboardPlus, href: '/consultas' },
|
{ label: 'Consultas', icon: ClipboardPlus, href: '/consultas' },
|
||||||
{ label: 'Expedientes', icon: FolderClock, href: '/expedientes' },
|
{ label: 'Expedientes', icon: FolderClock, href: '/expedientes' },
|
||||||
|
{ label: 'Mis ventas', icon: CircleDollarSign, href: '/mis-ventas' },
|
||||||
{ label: 'Médicos', icon: Stethoscope, href: '/medicos' },
|
{ label: 'Médicos', icon: Stethoscope, href: '/medicos' },
|
||||||
{ label: 'Servicios', icon: Briefcase, href: '/servicios' },
|
{ label: 'Servicios', icon: Briefcase, href: '/servicios' },
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ const ROLE_RANK: Record<FrontendRole, number> = {
|
|||||||
const MENU_ROLES: Record<string, FrontendRole[]> = {
|
const MENU_ROLES: Record<string, FrontendRole[]> = {
|
||||||
'/': ['admin', 'recepcion', 'medico', 'lectura'],
|
'/': ['admin', 'recepcion', 'medico', 'lectura'],
|
||||||
'/agenda': ['admin', 'recepcion', 'medico'],
|
'/agenda': ['admin', 'recepcion', 'medico'],
|
||||||
|
'/mis-ventas': ['medico'],
|
||||||
'/pacientes': ['admin', 'recepcion', 'medico', 'lectura'],
|
'/pacientes': ['admin', 'recepcion', 'medico', 'lectura'],
|
||||||
'/pos': ['admin', 'recepcion'],
|
'/pos': ['admin', 'recepcion'],
|
||||||
'/visitas': ['admin', 'recepcion', 'medico'],
|
'/visitas': ['admin', 'recepcion', 'medico'],
|
||||||
|
|||||||
179
frontend/src/pages/MisVentas.tsx
Normal file
179
frontend/src/pages/MisVentas.tsx
Normal file
@@ -0,0 +1,179 @@
|
|||||||
|
import type { FC } from 'react';
|
||||||
|
import { useEffect, useState, useCallback } from 'react';
|
||||||
|
import { CircleDollarSign, CreditCard, ShoppingCart, Percent, DollarSign } from 'lucide-react';
|
||||||
|
import Layout from '../components/Layout';
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
Select,
|
||||||
|
EmptyState,
|
||||||
|
PageHeader,
|
||||||
|
Skeleton,
|
||||||
|
toast,
|
||||||
|
} from '../components/ui';
|
||||||
|
import { odooApi, type MySalesResponse } from '../services/odoo';
|
||||||
|
|
||||||
|
const rangeOptions = [
|
||||||
|
{ value: '30d', label: 'Últimos 30 días' },
|
||||||
|
{ value: 'month', label: 'Este mes' },
|
||||||
|
{ value: 'last_month', label: 'Mes pasado' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const mxn = (n: number) =>
|
||||||
|
`$${n.toLocaleString('es-MX', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
|
||||||
|
|
||||||
|
const MisVentas: FC = () => {
|
||||||
|
const [range, setRange] = useState<'30d' | 'month' | 'last_month'>('30d');
|
||||||
|
const [data, setData] = useState<MySalesResponse | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
const res = await odooApi.getMySales(range);
|
||||||
|
if (res.status === 'success') setData(res);
|
||||||
|
} catch (err) {
|
||||||
|
setError('Error al cargar tus ventas');
|
||||||
|
toast.error('Error al cargar tus ventas');
|
||||||
|
console.error(err);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [range]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
load();
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
|
const kpis = data?.kpis;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Layout title="Mis ventas" subtitle="Tus ventas y comisiones">
|
||||||
|
<PageHeader title="Mis ventas" subtitle="Ventas atribuidas a ti (líneas recetadas)">
|
||||||
|
<Select
|
||||||
|
options={rangeOptions}
|
||||||
|
value={range}
|
||||||
|
onChange={(e) => setRange(e.target.value as '30d' | 'month' | 'last_month')}
|
||||||
|
className="sm:max-w-[200px]"
|
||||||
|
/>
|
||||||
|
</PageHeader>
|
||||||
|
|
||||||
|
{error && <p className="text-rose-600 text-sm mb-4">{error}</p>}
|
||||||
|
|
||||||
|
{loading || !data ? (
|
||||||
|
<Skeleton count={5} className="h-12 w-full" />
|
||||||
|
) : data.medico_sin_employee ? (
|
||||||
|
<Card>
|
||||||
|
<Card.Body>
|
||||||
|
<p className="text-sm text-amber-700 bg-amber-50 border border-amber-200 rounded-xl px-3 py-2 inline-block">
|
||||||
|
Tu usuario no está vinculado a un médico (hr.employee). Pide a un administrador que vincule tu usuario.
|
||||||
|
</p>
|
||||||
|
</Card.Body>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{/* KPIs del período */}
|
||||||
|
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3 mb-4">
|
||||||
|
<Card>
|
||||||
|
<Card.Body className="flex items-center gap-3">
|
||||||
|
<ShoppingCart size={20} className="text-theme-muted shrink-0" />
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-theme-muted">Ventas</p>
|
||||||
|
<p className="text-lg font-semibold text-theme-heading">{kpis?.ventas ?? 0}</p>
|
||||||
|
</div>
|
||||||
|
</Card.Body>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<Card.Body className="flex items-center gap-3">
|
||||||
|
<CircleDollarSign size={20} className="text-theme-muted shrink-0" />
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-theme-muted">Total MXN</p>
|
||||||
|
<p className="text-lg font-semibold text-theme-heading">{mxn(kpis?.total_mxn ?? 0)}</p>
|
||||||
|
</div>
|
||||||
|
</Card.Body>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<Card.Body className="flex items-center gap-3">
|
||||||
|
<DollarSign size={20} className="text-theme-muted shrink-0" />
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-theme-muted">Total USD</p>
|
||||||
|
<p className="text-lg font-semibold text-theme-heading">
|
||||||
|
{kpis?.total_usd != null ? `$${kpis.total_usd.toLocaleString('en-US', { minimumFractionDigits: 2 })}` : 'Sin TC'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</Card.Body>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<Card.Body className="flex items-center gap-3">
|
||||||
|
<CreditCard size={20} className="text-theme-muted shrink-0" />
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-theme-muted">Con tarjeta</p>
|
||||||
|
<p className="text-lg font-semibold text-theme-heading">{mxn(kpis?.total_card ?? 0)}</p>
|
||||||
|
</div>
|
||||||
|
</Card.Body>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Comisión estimada */}
|
||||||
|
<Card className="mb-4">
|
||||||
|
<Card.Body className="flex flex-wrap items-center gap-3">
|
||||||
|
<Percent size={20} className="text-theme-muted shrink-0" />
|
||||||
|
<div className="flex-1 min-w-[200px]">
|
||||||
|
<p className="text-sm font-semibold text-theme-heading">
|
||||||
|
Comisión estimada: {mxn(data.commission_estimate)}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-theme-muted">
|
||||||
|
{data.commission_pct}% {data.commission_source === 'empleado' ? '(tu porcentaje asignado)' : '(porcentaje global)'}
|
||||||
|
{' '}sobre {mxn(kpis?.total_mxn ?? 0)} del período
|
||||||
|
{data.date_from && data.date_to ? ` · ${data.date_from} a ${data.date_to}` : ''}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</Card.Body>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Tabla de ventas */}
|
||||||
|
<Card>
|
||||||
|
<Card.Body>
|
||||||
|
{data.sales.length === 0 ? (
|
||||||
|
<EmptyState
|
||||||
|
title="Sin ventas en el período"
|
||||||
|
subtitle="Las ventas se atribuyen por las líneas recetadas a tu nombre al cobrar."
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full">
|
||||||
|
<thead className="bg-theme-bg">
|
||||||
|
<tr>
|
||||||
|
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase">Fecha</th>
|
||||||
|
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase">Venta</th>
|
||||||
|
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase">Paciente</th>
|
||||||
|
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase hidden md:table-cell">Servicio / artículo</th>
|
||||||
|
<th className="text-right p-3 text-xs font-medium text-theme-muted uppercase">Monto</th>
|
||||||
|
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase hidden sm:table-cell">Método de pago</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y">
|
||||||
|
{data.sales.map((s) => (
|
||||||
|
<tr key={s.id} className="hover:bg-theme-bg">
|
||||||
|
<td className="p-3 text-sm text-theme-heading whitespace-nowrap">{s.date}</td>
|
||||||
|
<td className="p-3 text-sm text-theme-muted">{s.name}</td>
|
||||||
|
<td className="p-3 text-sm font-medium text-theme-heading">{s.patient}</td>
|
||||||
|
<td className="p-3 text-sm text-theme-muted hidden md:table-cell">{s.items.filter(Boolean).join(', ')}</td>
|
||||||
|
<td className="p-3 text-sm text-theme-heading text-right whitespace-nowrap">{mxn(s.amount)}</td>
|
||||||
|
<td className="p-3 text-sm text-theme-muted hidden sm:table-cell">{s.payment_method}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Card.Body>
|
||||||
|
</Card>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default MisVentas;
|
||||||
@@ -379,6 +379,31 @@ export interface Sale {
|
|||||||
refunded_at?: string | null;
|
refunded_at?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface MySaleItem {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
date: string;
|
||||||
|
patient: string;
|
||||||
|
items: string[];
|
||||||
|
amount: number;
|
||||||
|
payment_method: string;
|
||||||
|
state: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MySalesResponse {
|
||||||
|
status: string;
|
||||||
|
medico_sin_employee?: boolean;
|
||||||
|
doctor?: { id: number; name: string };
|
||||||
|
range?: string;
|
||||||
|
date_from?: string;
|
||||||
|
date_to?: string;
|
||||||
|
kpis: { ventas: number; total_mxn: number; total_usd: number | null; total_card: number };
|
||||||
|
sales: MySaleItem[];
|
||||||
|
commission_pct: number;
|
||||||
|
commission_source?: string;
|
||||||
|
commission_estimate: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface CashClosing {
|
export interface CashClosing {
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -1247,6 +1272,12 @@ export const odooApi = {
|
|||||||
return data;
|
return data;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// "Mis ventas" (rol medico): el servidor filtra por el employee del token
|
||||||
|
async getMySales(range: '30d' | 'month' | 'last_month' = '30d'): Promise<MySalesResponse> {
|
||||||
|
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/my/sales`, { params: { range } });
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
|
||||||
async createSale(sale: Omit<Partial<Sale>, 'lines'> & { lines: Omit<SaleLine, 'subtotal'>[] }): Promise<{ status: string; sale: Sale }> {
|
async createSale(sale: Omit<Partial<Sale>, 'lines'> & { lines: Omit<SaleLine, 'subtotal'>[] }): Promise<{ status: string; sale: Sale }> {
|
||||||
const { data } = await axios.post(`${ODOO_BASE}/skeen/frontend/v1/sales`, sale);
|
const { data } = await axios.post(`${ODOO_BASE}/skeen/frontend/v1/sales`, sale);
|
||||||
return data;
|
return data;
|
||||||
|
|||||||
@@ -22,6 +22,19 @@ SECRET_KEY = 'skeen.frontend.secret'
|
|||||||
|
|
||||||
RANK = ROLE_RANKS
|
RANK = ROLE_RANKS
|
||||||
|
|
||||||
|
# Módulos de dinero globales vetados para el rol 'medico' por API: su menú ya
|
||||||
|
# los oculta (MENU_ROLES) y aquí se cierra la vía directa con token. Su propio
|
||||||
|
# reporte (/my/sales) no pasa por esta lista.
|
||||||
|
MEDICO_BLOCKED_PREFIXES = (
|
||||||
|
'/skeen/frontend/v1/reports/',
|
||||||
|
'/skeen/frontend/v1/sales',
|
||||||
|
'/skeen/frontend/v1/payments',
|
||||||
|
'/skeen/frontend/v1/wallets',
|
||||||
|
'/skeen/frontend/v1/pos/',
|
||||||
|
'/skeen/frontend/v1/cash_closings',
|
||||||
|
'/skeen/frontend/v1/doctors/',
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _cors_headers():
|
def _cors_headers():
|
||||||
return {
|
return {
|
||||||
@@ -118,6 +131,8 @@ def require_role(min_role='lectura'):
|
|||||||
return _json({'status': 'error', 'message': 'No autenticado'}, 401)
|
return _json({'status': 'error', 'message': 'No autenticado'}, 401)
|
||||||
if user.role_rank() < required:
|
if user.role_rank() < required:
|
||||||
return _json({'status': 'error', 'message': 'Sin permiso'}, 403)
|
return _json({'status': 'error', 'message': 'Sin permiso'}, 403)
|
||||||
|
if user.role == 'medico' and request.httprequest.path.startswith(MEDICO_BLOCKED_PREFIXES):
|
||||||
|
return _json({'status': 'error', 'message': 'Sin permiso'}, 403)
|
||||||
request.frontend_user = user
|
request.frontend_user = user
|
||||||
return func(self, *args, **kw)
|
return func(self, *args, **kw)
|
||||||
return wrapper
|
return wrapper
|
||||||
|
|||||||
@@ -1843,6 +1843,10 @@ class SkeenFrontendController(http.Controller):
|
|||||||
venta, solo a la cita). Stripe/MercadoPago no cuentan como tarjeta.
|
venta, solo a la cita). Stripe/MercadoPago no cuentan como tarjeta.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
|
# Rol medico: la agenda necesita la lista de doctores, pero los KPIs
|
||||||
|
# de dinero por médico son del módulo Médicos (admin/recepción).
|
||||||
|
fu = current_user(request.env)
|
||||||
|
hide_money = bool(fu and fu.role == 'medico')
|
||||||
doctors = request.env['hr.employee'].sudo().search([('active', '=', True)], limit=100)
|
doctors = request.env['hr.employee'].sudo().search([('active', '=', True)], limit=100)
|
||||||
# Pacientes asignados por médico (primary_doctor_id)
|
# Pacientes asignados por médico (primary_doctor_id)
|
||||||
counts = {}
|
counts = {}
|
||||||
@@ -1904,13 +1908,13 @@ class SkeenFrontendController(http.Controller):
|
|||||||
'job_title': d.job_title or '',
|
'job_title': d.job_title or '',
|
||||||
'work_phone': d.work_phone or '',
|
'work_phone': d.work_phone or '',
|
||||||
'work_email': d.work_email or '',
|
'work_email': d.work_email or '',
|
||||||
'commission_pct': d.commission_pct or 0.0,
|
'commission_pct': 0.0 if hide_money else (d.commission_pct or 0.0),
|
||||||
'patient_count': counts.get(d.id, 0),
|
'patient_count': counts.get(d.id, 0),
|
||||||
'recomendaciones_30d': recomendaciones.get(d.id, 0),
|
'recomendaciones_30d': recomendaciones.get(d.id, 0),
|
||||||
'ventas_30d': len(stats.get(d.id, {}).get('ventas', set())),
|
'ventas_30d': 0 if hide_money else len(stats.get(d.id, {}).get('ventas', set())),
|
||||||
'total_30d': round(stats.get(d.id, {}).get('total', 0.0), 2),
|
'total_30d': 0.0 if hide_money else round(stats.get(d.id, {}).get('total', 0.0), 2),
|
||||||
'total_30d_usd': round(stats.get(d.id, {}).get('total', 0.0) / rate, 2) if rate else None,
|
'total_30d_usd': None if hide_money else (round(stats.get(d.id, {}).get('total', 0.0) / rate, 2) if rate else None),
|
||||||
'card_30d': round(sum(card_by_venta.get(vid, 0.0) for vid in stats.get(d.id, {}).get('ventas', set())), 2),
|
'card_30d': 0.0 if hide_money else round(sum(card_by_venta.get(vid, 0.0) for vid in stats.get(d.id, {}).get('ventas', set())), 2),
|
||||||
} for d in doctors],
|
} for d in doctors],
|
||||||
})
|
})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -2006,6 +2010,128 @@ class SkeenFrontendController(http.Controller):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
return json_response({'status': 'error', 'message': str(e)}, 500)
|
return json_response({'status': 'error', 'message': str(e)}, 500)
|
||||||
|
|
||||||
|
@http.route('/skeen/frontend/v1/my/sales', type='http', auth='none', methods=['GET', 'OPTIONS'], csrf=False)
|
||||||
|
@require_role('medico')
|
||||||
|
def my_sales(self, **kw):
|
||||||
|
""""Mis ventas": ventas y comisión del médico autenticado.
|
||||||
|
|
||||||
|
Atribución: líneas de venta recetadas por su hr.employee
|
||||||
|
(prescribed_by_id), misma métrica que el módulo Médicos y el reporte
|
||||||
|
de comisiones. NUNCA acepta un doctor_id externo: siempre usa el
|
||||||
|
usuario del token, así un médico no puede ver ventas de otro.
|
||||||
|
|
||||||
|
range: 30d (default) | month (este mes) | last_month (mes pasado).
|
||||||
|
Comisión: hr.employee.commission_pct si > 0; si no, el parámetro
|
||||||
|
global ir.config_parameter 'skeen.commission_pct' (default 0).
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
user = request.frontend_user
|
||||||
|
if not user or user.role != 'medico':
|
||||||
|
return json_response({'status': 'error', 'message': 'Solo disponible para rol médico'}, 403)
|
||||||
|
emp = self._linked_employee(user)
|
||||||
|
if not emp:
|
||||||
|
return json_response({'status': 'success', 'medico_sin_employee': True,
|
||||||
|
'sales': [], 'kpis': {'ventas': 0, 'total_mxn': 0.0, 'total_usd': None, 'total_card': 0.0},
|
||||||
|
'commission_pct': 0.0, 'commission_estimate': 0.0})
|
||||||
|
|
||||||
|
today = date.today()
|
||||||
|
range_ = kw.get('range', '30d')
|
||||||
|
if range_ == 'month':
|
||||||
|
date_from, date_to = today.replace(day=1), today
|
||||||
|
elif range_ == 'last_month':
|
||||||
|
date_to = today.replace(day=1) - timedelta(days=1)
|
||||||
|
date_from = date_to.replace(day=1)
|
||||||
|
else:
|
||||||
|
range_ = '30d'
|
||||||
|
date_from, date_to = today - timedelta(days=30), today
|
||||||
|
|
||||||
|
Lineas = request.env['skeen.venta.line'].sudo()
|
||||||
|
rows = Lineas.search_read([
|
||||||
|
('prescribed_by_id', '=', emp.id),
|
||||||
|
('venta_id.state', '!=', 'cancelled'),
|
||||||
|
('venta_id.date', '>=', date_from.strftime('%Y-%m-%d')),
|
||||||
|
('venta_id.date', '<=', date_to.strftime('%Y-%m-%d')),
|
||||||
|
], ['venta_id', 'subtotal', 'description', 'quantity'])
|
||||||
|
|
||||||
|
venta_ids = list({r['venta_id'][0] for r in rows})
|
||||||
|
ventas = request.env['skeen.venta'].sudo().browse(venta_ids)
|
||||||
|
|
||||||
|
# Tarjeta: exacto (card_amount) + aprox histórica por cita (misma
|
||||||
|
# lógica que list_doctors)
|
||||||
|
card_by_venta = {v.id: v.card_amount or 0.0 for v in ventas}
|
||||||
|
citas = ventas.mapped('cita_id')
|
||||||
|
if citas:
|
||||||
|
pgroup = request.env['skeen.pago'].sudo().read_group(
|
||||||
|
[('cita_id', 'in', citas.ids), ('payment_method', '=', 'card'),
|
||||||
|
('state', '=', 'completed')],
|
||||||
|
['cita_id', 'amount:sum'], ['cita_id'])
|
||||||
|
card_by_cita = {g['cita_id'][0]: g['amount'] for g in pgroup}
|
||||||
|
for v in ventas:
|
||||||
|
if v.cita_id:
|
||||||
|
aprox = min(card_by_cita.get(v.cita_id.id, 0.0), v.amount_paid or 0.0)
|
||||||
|
card_by_venta[v.id] = max(v.card_amount or 0.0, aprox)
|
||||||
|
|
||||||
|
lines_by_venta = {}
|
||||||
|
total_mxn = 0.0
|
||||||
|
for r in rows:
|
||||||
|
vid = r['venta_id'][0]
|
||||||
|
total_mxn += r['subtotal'] or 0.0
|
||||||
|
lines_by_venta.setdefault(vid, []).append(r)
|
||||||
|
|
||||||
|
def metodo_pago(v):
|
||||||
|
paid = v.amount_paid or 0.0
|
||||||
|
card = card_by_venta.get(v.id, 0.0)
|
||||||
|
if paid <= 0:
|
||||||
|
return 'Sin pagar'
|
||||||
|
if card >= paid:
|
||||||
|
return 'Tarjeta'
|
||||||
|
if card > 0:
|
||||||
|
return 'Mixto'
|
||||||
|
return 'Efectivo/Otro'
|
||||||
|
|
||||||
|
sales = sorted(({
|
||||||
|
'id': v.id,
|
||||||
|
'name': v.name,
|
||||||
|
'date': v.date.strftime('%Y-%m-%d') if v.date else None,
|
||||||
|
'patient': v.partner_id.name,
|
||||||
|
'items': [l['description'] or '' for l in lines_by_venta.get(v.id, [])],
|
||||||
|
'amount': round(sum(l['subtotal'] or 0.0 for l in lines_by_venta.get(v.id, [])), 2),
|
||||||
|
'payment_method': metodo_pago(v),
|
||||||
|
'state': v.state,
|
||||||
|
} for v in ventas), key=lambda s: (s['date'] or '', s['id']), reverse=True)
|
||||||
|
|
||||||
|
# TC vigente (equivalente USD)
|
||||||
|
tc = request.env['skeen.tipo.cambio'].sudo().search([], order='date desc, id desc', limit=1)
|
||||||
|
rate = tc.rate if tc else None
|
||||||
|
|
||||||
|
# % de comisión: por empleado; fallback al parámetro global
|
||||||
|
ICP = request.env['ir.config_parameter'].sudo()
|
||||||
|
global_pct = float(ICP.get_param('skeen.commission_pct', 0) or 0)
|
||||||
|
if emp.commission_pct:
|
||||||
|
pct, pct_source = emp.commission_pct, 'empleado'
|
||||||
|
else:
|
||||||
|
pct, pct_source = global_pct, 'global'
|
||||||
|
|
||||||
|
return json_response({
|
||||||
|
'status': 'success',
|
||||||
|
'doctor': {'id': emp.id, 'name': emp.name},
|
||||||
|
'range': range_,
|
||||||
|
'date_from': date_from.strftime('%Y-%m-%d'),
|
||||||
|
'date_to': date_to.strftime('%Y-%m-%d'),
|
||||||
|
'kpis': {
|
||||||
|
'ventas': len(venta_ids),
|
||||||
|
'total_mxn': round(total_mxn, 2),
|
||||||
|
'total_usd': round(total_mxn / rate, 2) if rate else None,
|
||||||
|
'total_card': round(sum(card_by_venta.values()), 2),
|
||||||
|
},
|
||||||
|
'sales': sales,
|
||||||
|
'commission_pct': pct,
|
||||||
|
'commission_source': pct_source,
|
||||||
|
'commission_estimate': round(total_mxn * pct / 100.0, 2),
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
return json_response({'status': 'error', 'message': str(e)}, 500)
|
||||||
|
|
||||||
# ============================================================
|
# ============================================================
|
||||||
# Productos (plantilla de producto de Odoo)
|
# Productos (plantilla de producto de Odoo)
|
||||||
# ============================================================
|
# ============================================================
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ MENU_ROLES_KEY = 'skeen.menu_roles'
|
|||||||
DEFAULT_MENU_ROLES = {
|
DEFAULT_MENU_ROLES = {
|
||||||
'/': ['admin', 'recepcion', 'medico', 'lectura'],
|
'/': ['admin', 'recepcion', 'medico', 'lectura'],
|
||||||
'/agenda': ['admin', 'recepcion', 'medico'],
|
'/agenda': ['admin', 'recepcion', 'medico'],
|
||||||
|
'/mis-ventas': ['medico'],
|
||||||
'/pacientes': ['admin', 'recepcion', 'medico', 'lectura'],
|
'/pacientes': ['admin', 'recepcion', 'medico', 'lectura'],
|
||||||
'/pos': ['admin', 'recepcion'],
|
'/pos': ['admin', 'recepcion'],
|
||||||
'/visitas': ['admin', 'recepcion', 'medico'],
|
'/visitas': ['admin', 'recepcion', 'medico'],
|
||||||
|
|||||||
Reference in New Issue
Block a user