reporte concentrado estilo legacy: tabla detalle ventas x metodo x moneda + legacy_id en cita/venta + metodo saldo_favor + fix moneda USD en pagos del POS
This commit is contained in:
@@ -1,94 +1,225 @@
|
|||||||
import type { FC } from 'react';
|
import type { FC, ReactNode } from 'react';
|
||||||
import { useEffect, useState, useCallback } from 'react';
|
import { useEffect, useMemo, useRef, useState, useCallback } from 'react';
|
||||||
import { LayoutGrid } from 'lucide-react';
|
import { Printer, Search, Stethoscope, Tag, ClipboardList } from 'lucide-react';
|
||||||
import { Card, Input, Badge, Skeleton, toast } from '../ui';
|
import { Card, Button, 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 ConcentradoReport as ConcentradoData } from '../../services/odoo';
|
import { odooApi, type ConcentradoDetalleReport, type Doctor, type Service } from '../../services/odoo';
|
||||||
import type { BadgeVariant } from '../ui/Badge';
|
|
||||||
import { useReportRange } from '../../lib/reportRange';
|
import { useReportRange } from '../../lib/reportRange';
|
||||||
|
import { cn } from '../../lib/utils';
|
||||||
|
|
||||||
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', minimumFractionDigits: 2 }).format(value);
|
||||||
|
|
||||||
const citaStateLabels: Record<string, string> = {
|
const fmtMoney = (v: number) =>
|
||||||
pending: 'Pendiente', confirmed: 'Confirmada', arrived: 'Llegó',
|
`$${(v || 0).toLocaleString('es-MX', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
|
||||||
in_progress: 'En curso', done: 'Completada', cancelled: 'Cancelada', no_show: 'No show',
|
|
||||||
};
|
|
||||||
const visitaStateLabels: Record<string, string> = {
|
|
||||||
en_curso: 'En curso', completada: 'Completada', cancelada: 'Cancelada',
|
|
||||||
};
|
|
||||||
const metodoLabels: Record<string, string> = {
|
|
||||||
cash: 'Efectivo', card: 'Tarjeta', transfer: 'Transferencia',
|
|
||||||
stripe: 'Stripe', mercadopago: 'MercadoPago',
|
|
||||||
};
|
|
||||||
const stateBadge = (state: string): BadgeVariant =>
|
|
||||||
state === 'done' || state === 'completada' ? 'success'
|
|
||||||
: state === 'cancelled' || state === 'no_show' || state === 'cancelada' ? 'danger'
|
|
||||||
: state === 'pending' || state === 'en_curso' ? 'warning' : 'info';
|
|
||||||
|
|
||||||
|
// Desglose legacy: 5 métodos de pago x 2 monedas
|
||||||
|
const METODOS = [
|
||||||
|
{ key: 'efectivo', label: 'Efectivo' },
|
||||||
|
{ key: 'tarjeta', label: 'Tarjeta' },
|
||||||
|
{ key: 'transferencia', label: 'Transferencia' },
|
||||||
|
{ key: 'saldo_favor', label: 'Saldo a favor' },
|
||||||
|
{ key: 'monedero', label: 'Monedero' },
|
||||||
|
] as const;
|
||||||
|
|
||||||
const Kpi: FC<{ label: string; value: React.ReactNode; rose?: boolean }> = ({ label, value, rose }) => (
|
const MONEY_COLUMNS = METODOS.flatMap((m) => [
|
||||||
<Card className="p-4 bg-theme-bg border-0">
|
{ key: `${m.key}_usd`, header: `${m.label} USD` },
|
||||||
<p className="text-xs text-theme-muted">{label}</p>
|
{ key: `${m.key}_mxn`, header: `${m.label} M.N.` },
|
||||||
<p className={`text-xl font-heading font-semibold ${rose ? 'text-rose-600' : 'text-theme-heading'}`}>{value}</p>
|
]);
|
||||||
</Card>
|
|
||||||
|
const CATEGORIAS = [
|
||||||
|
{ value: 'consulta', label: 'Consulta' },
|
||||||
|
{ value: 'tratamiento', label: 'Tratamiento' },
|
||||||
|
{ value: 'procedimiento', label: 'Procedimiento' },
|
||||||
|
{ value: 'paquete', label: 'Paquete' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------
|
||||||
|
// Multiselect compacto (mismo lenguaje visual que los Select):
|
||||||
|
// botón con el conteo + panel con buscador y checkboxes.
|
||||||
|
// ---------------------------------------------------------------
|
||||||
|
interface MultiSelectProps {
|
||||||
|
label: string;
|
||||||
|
icon?: ReactNode;
|
||||||
|
options: { value: string; label: string }[];
|
||||||
|
selected: string[];
|
||||||
|
onChange: (values: string[]) => void;
|
||||||
|
searchable?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MultiSelect: FC<MultiSelectProps> = ({ label, icon, options, selected, onChange, searchable }) => {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [query, setQuery] = useState('');
|
||||||
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
const onClick = (e: MouseEvent) => {
|
||||||
|
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
|
||||||
|
};
|
||||||
|
document.addEventListener('mousedown', onClick);
|
||||||
|
return () => document.removeEventListener('mousedown', onClick);
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
const visibles = query
|
||||||
|
? options.filter((o) => o.label.toLowerCase().includes(query.toLowerCase()))
|
||||||
|
: options;
|
||||||
|
|
||||||
|
const toggle = (value: string) => {
|
||||||
|
onChange(selected.includes(value) ? selected.filter((v) => v !== value) : [...selected, value]);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={ref} className="relative">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setOpen((o) => !o)}
|
||||||
|
className={cn(
|
||||||
|
'w-full rounded-xl border bg-theme-surface px-3 py-2 text-left text-sm transition appearance-none',
|
||||||
|
'focus:outline-none focus:ring-2 focus:ring-theme-accent/30 focus:border-theme-accent',
|
||||||
|
selected.length > 0 ? 'border-theme-accent text-theme-heading' : 'border-theme-border-strong text-theme-heading'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="flex items-center gap-1.5">
|
||||||
|
{icon}
|
||||||
|
<span className="truncate">
|
||||||
|
{label}
|
||||||
|
{selected.length > 0 && <span className="text-theme-muted"> ({selected.length})</span>}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
{open && (
|
||||||
|
<div className="absolute z-30 mt-1 w-64 max-w-[80vw] rounded-xl border border-theme-border bg-theme-surface shadow-lg p-2">
|
||||||
|
{searchable && (
|
||||||
|
<div className="relative mb-1.5">
|
||||||
|
<Search size={13} className="absolute left-2.5 top-1/2 -translate-y-1/2 text-theme-muted" />
|
||||||
|
<input
|
||||||
|
autoFocus
|
||||||
|
value={query}
|
||||||
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
|
placeholder="Buscar..."
|
||||||
|
className="w-full rounded-lg border border-theme-border-strong bg-theme-bg pl-7 pr-2 py-1.5 text-xs text-theme-heading focus:outline-none focus:border-theme-accent"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="max-h-56 overflow-auto">
|
||||||
|
{visibles.length === 0 && <p className="px-2 py-1.5 text-xs text-theme-muted">Sin opciones</p>}
|
||||||
|
{visibles.map((o) => (
|
||||||
|
<label key={o.value} className="flex items-center gap-2 px-2 py-1.5 rounded-lg hover:bg-theme-bg cursor-pointer text-sm text-theme-heading">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={selected.includes(o.value)}
|
||||||
|
onChange={() => toggle(o.value)}
|
||||||
|
className="rounded border-theme-border-strong accent-[var(--accent)]"
|
||||||
|
/>
|
||||||
|
<span className="truncate">{o.label}</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{selected.length > 0 && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onChange([])}
|
||||||
|
className="mt-1 w-full text-center text-xs text-theme-muted hover:text-theme-heading py-1"
|
||||||
|
>
|
||||||
|
Limpiar selección
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const ConcentradoReport: FC = () => {
|
const ConcentradoReport: FC = () => {
|
||||||
// Rango global de Reportes; los campos Inicio/Fin fijan uno propio
|
// Rango global de Reportes (sin selector propio de fechas)
|
||||||
const { start, end, setStart, setEnd } = useReportRange('concentrado');
|
const { start, end } = useReportRange('concentrado');
|
||||||
const [data, setData] = useState<ConcentradoData | null>(null);
|
const [data, setData] = useState<ConcentradoDetalleReport | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
// Catálogos para los filtros
|
||||||
|
const [doctors, setDoctors] = useState<Doctor[]>([]);
|
||||||
|
const [services, setServices] = useState<Service[]>([]);
|
||||||
|
|
||||||
|
// Filtros
|
||||||
|
const [selDoctores, setSelDoctores] = useState<string[]>([]);
|
||||||
|
const [selCategorias, setSelCategorias] = useState<string[]>([]);
|
||||||
|
const [selServicios, setSelServicios] = useState<string[]>([]);
|
||||||
|
const [vip, setVip] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let alive = true;
|
||||||
|
Promise.all([odooApi.getDoctors(), odooApi.getServices({ page_size: 200 })])
|
||||||
|
.then(([d, s]) => {
|
||||||
|
if (!alive) return;
|
||||||
|
setDoctors(d.doctors || []);
|
||||||
|
setServices(s.services || []);
|
||||||
|
})
|
||||||
|
.catch((err) => console.error('Error al cargar filtros', err));
|
||||||
|
return () => { alive = false; };
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Al cambiar las categorías, los servicios seleccionados que ya no aplican se sueltan
|
||||||
|
const serviciosFiltrados = useMemo(
|
||||||
|
() => (selCategorias.length ? services.filter((s) => selCategorias.includes(s.category)) : services),
|
||||||
|
[services, selCategorias]
|
||||||
|
);
|
||||||
|
useEffect(() => {
|
||||||
|
setSelServicios((prev) => prev.filter((id) => serviciosFiltrados.some((s) => String(s.id) === id)));
|
||||||
|
}, [serviciosFiltrados]);
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
const res = await odooApi.getConcentrado(start, end);
|
const res = await odooApi.getConcentradoDetalle({
|
||||||
if (res.status === 'success') {
|
start,
|
||||||
const { citas, ventas, pagos, devoluciones, pacientes_nuevos, visitas, start: s, end: e } = res;
|
end,
|
||||||
setData({ citas, ventas, pagos, devoluciones, pacientes_nuevos, visitas, start: s, end: e });
|
doctor_ids: selDoctores.join(',') || undefined,
|
||||||
}
|
categoria_ids: selCategorias.join(',') || undefined,
|
||||||
|
servicio_ids: selServicios.join(',') || undefined,
|
||||||
|
vip: vip || undefined,
|
||||||
|
});
|
||||||
|
if (res.status === 'success') setData(res);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error('Error al cargar concentrado');
|
toast.error('Error al cargar concentrado');
|
||||||
console.error(err);
|
console.error(err);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, [start, end]);
|
}, [start, end, selDoctores, selCategorias, selServicios, vip]);
|
||||||
|
|
||||||
useEffect(() => { load(); }, [load]);
|
useEffect(() => { load(); }, [load]);
|
||||||
|
|
||||||
|
const rows = data?.rows ?? [];
|
||||||
|
const totals = data?.totals ?? {};
|
||||||
|
|
||||||
const [exporting, setExporting] = useState(false);
|
const [exporting, setExporting] = useState(false);
|
||||||
const exportar = async () => {
|
const exportar = async () => {
|
||||||
if (!data) return;
|
if (!data) return;
|
||||||
try {
|
try {
|
||||||
setExporting(true);
|
setExporting(true);
|
||||||
const rows: Record<string, unknown>[] = [
|
const money = { format: 'currency' as const };
|
||||||
{ seccion: 'Citas', concepto: 'Total', cantidad: data.citas.total, monto: null },
|
|
||||||
...Object.entries(data.citas.by_state).map(([k, v]) => ({ seccion: 'Citas', concepto: citaStateLabels[k] || k, cantidad: v, monto: null })),
|
|
||||||
{ seccion: 'Visitas', concepto: 'Total', cantidad: data.visitas.total, monto: null },
|
|
||||||
...Object.entries(data.visitas.by_state).map(([k, v]) => ({ seccion: 'Visitas', concepto: visitaStateLabels[k] || k, cantidad: v, monto: null })),
|
|
||||||
{ seccion: 'Ventas', concepto: 'Cantidad', cantidad: data.ventas.count, monto: null },
|
|
||||||
{ seccion: 'Ventas', concepto: 'Total vendido', cantidad: null, monto: data.ventas.total },
|
|
||||||
{ seccion: 'Ventas', concepto: 'Por cobrar', cantidad: null, monto: data.ventas.total_due },
|
|
||||||
{ seccion: 'Cobros', concepto: 'Total cobrado', cantidad: data.pagos.count, monto: data.pagos.total },
|
|
||||||
...Object.entries(data.pagos.by_method).map(([k, v]) => ({ seccion: 'Cobros', concepto: metodoLabels[k] || k, cantidad: null, monto: v })),
|
|
||||||
{ seccion: 'Devoluciones', concepto: 'Devuelto', cantidad: data.devoluciones.count, monto: data.devoluciones.total },
|
|
||||||
{ seccion: 'Pacientes', concepto: 'Nuevos en el periodo', cantidad: data.pacientes_nuevos, monto: null },
|
|
||||||
];
|
|
||||||
await exportToExcel({
|
await exportToExcel({
|
||||||
filename: `concentrado-${start}_${end}`,
|
filename: `concentrado-${start}_${end}`,
|
||||||
sheetName: 'Concentrado',
|
sheetName: 'Concentrado',
|
||||||
title: 'Concentrado del periodo',
|
title: 'Concentrado de ventas',
|
||||||
subtitle: `Del ${start} al ${end}`,
|
subtitle: `Del ${start} al ${end} · Pacientes atendidos: ${data.pacientes_atendidos} · Ticket promedio: ${formatCurrency(data.ticket_promedio)}`,
|
||||||
columns: [
|
columns: [
|
||||||
{ header: 'Sección', key: 'seccion' },
|
{ header: 'Folio', key: 'folio' },
|
||||||
|
{ header: 'Fecha', key: 'fecha' },
|
||||||
|
{ header: 'Paciente', key: 'paciente' },
|
||||||
|
{ header: 'Folio Dr', key: 'folio_dr' },
|
||||||
|
{ header: 'Atendido por', key: 'medico' },
|
||||||
|
{ header: 'Vendedor', key: 'vendedor' },
|
||||||
{ header: 'Concepto', key: 'concepto' },
|
{ header: 'Concepto', key: 'concepto' },
|
||||||
{ header: 'Cantidad', key: 'cantidad', format: 'number' },
|
...MONEY_COLUMNS.map((c) => ({ header: c.header, key: c.key, ...money })),
|
||||||
{ header: 'Monto', key: 'monto', format: 'currency' },
|
|
||||||
],
|
],
|
||||||
rows,
|
rows: rows as unknown as Record<string, unknown>[],
|
||||||
|
totals: {
|
||||||
|
folio: 'Totales', fecha: '', paciente: '', folio_dr: '', medico: '', vendedor: '', concepto: '',
|
||||||
|
...totals,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
toast.success('Excel descargado');
|
toast.success('Excel descargado');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -99,87 +230,136 @@ const ConcentradoReport: FC = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const thBase = 'text-left px-3 py-2 font-medium';
|
||||||
|
const tdBase = 'px-3 py-2';
|
||||||
|
const moneyCell = (v: number | undefined) => (v ? fmtMoney(v) : '');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4 sm:space-y-6">
|
<div className="space-y-4 sm:space-y-6">
|
||||||
<Card>
|
<Card>
|
||||||
<Card.Body>
|
<Card.Body>
|
||||||
<div className="flex flex-col sm:flex-row gap-3 items-end">
|
<div className="flex flex-wrap items-end gap-3">
|
||||||
<Input label="Inicio" type="date" value={start} onChange={(e) => setStart(e.target.value)} className="sm:max-w-[160px]" />
|
<div className="w-full sm:w-56">
|
||||||
<Input label="Fin" type="date" value={end} onChange={(e) => setEnd(e.target.value)} className="sm:max-w-[160px]" />
|
<MultiSelect
|
||||||
|
label="Doctores"
|
||||||
<div className="sm:ml-auto">
|
icon={<Stethoscope size={14} className="text-theme-muted shrink-0" />}
|
||||||
|
options={doctors.map((d) => ({
|
||||||
|
value: String(d.id),
|
||||||
|
label: (d.staff_type ?? 'doctor') === 'cosmetologa' ? `${d.name} (Cosmetóloga)` : d.name,
|
||||||
|
}))}
|
||||||
|
selected={selDoctores}
|
||||||
|
onChange={setSelDoctores}
|
||||||
|
searchable
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="w-full sm:w-52">
|
||||||
|
<MultiSelect
|
||||||
|
label="Categorías"
|
||||||
|
icon={<Tag size={14} className="text-theme-muted shrink-0" />}
|
||||||
|
options={CATEGORIAS}
|
||||||
|
selected={selCategorias}
|
||||||
|
onChange={setSelCategorias}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="w-full sm:w-64">
|
||||||
|
<MultiSelect
|
||||||
|
label="Servicios"
|
||||||
|
icon={<ClipboardList size={14} className="text-theme-muted shrink-0" />}
|
||||||
|
options={serviciosFiltrados.map((s) => ({ value: String(s.id), label: s.name }))}
|
||||||
|
selected={selServicios}
|
||||||
|
onChange={setSelServicios}
|
||||||
|
searchable
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button variant={vip ? 'primary' : 'outline'} size="sm" onClick={() => setVip((v) => !v)}>
|
||||||
|
VIP{vip ? ' ✓' : ''}
|
||||||
|
</Button>
|
||||||
|
<span className="text-xs text-theme-muted ml-auto">
|
||||||
|
{start} — {end} · {rows.length} venta{rows.length === 1 ? '' : 's'}
|
||||||
|
</span>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button variant="outline" size="sm" onClick={() => window.print()}>
|
||||||
|
<Printer size={14} className="mr-1.5" />
|
||||||
|
Imprimir
|
||||||
|
</Button>
|
||||||
<ExportButton onClick={exportar} loading={exporting} />
|
<ExportButton onClick={exportar} loading={exporting} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Card.Body>
|
</Card.Body>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
<Card className="print-concentrado">
|
||||||
|
<Card.Body>
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
<Skeleton count={8} className="h-10 w-full" />
|
||||||
{Array.from({ length: 8 }).map((_, i) => <Skeleton key={i} className="h-24 w-full" />)}
|
) : rows.length === 0 ? (
|
||||||
</div>
|
<EmptyState
|
||||||
) : data ? (
|
title="Sin ventas en el periodo"
|
||||||
<>
|
subtitle="No hay ventas que coincidan con los filtros seleccionados."
|
||||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
/>
|
||||||
<Kpi label="Citas" value={data.citas.total} />
|
|
||||||
<Kpi label="Visitas clínicas" value={data.visitas.total} />
|
|
||||||
<Kpi label="Pacientes nuevos" value={data.pacientes_nuevos} />
|
|
||||||
<Kpi label="Ventas" value={data.ventas.count} />
|
|
||||||
<Kpi label="Total vendido" value={formatCurrency(data.ventas.total)} />
|
|
||||||
<Kpi label="Cobrado" value={formatCurrency(data.pagos.total)} />
|
|
||||||
<Kpi label="Por cobrar" value={formatCurrency(data.ventas.total_due)} rose={data.ventas.total_due > 0} />
|
|
||||||
<Kpi label="Devoluciones" value={`${data.devoluciones.count} · ${formatCurrency(data.devoluciones.total)}`} rose={data.devoluciones.total > 0} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4 sm:gap-6">
|
|
||||||
<Card>
|
|
||||||
<Card.Body>
|
|
||||||
<h3 className="font-heading text-lg text-theme-heading mb-3 flex items-center">
|
|
||||||
<LayoutGrid size={18} className="mr-2" /> Citas por estado
|
|
||||||
</h3>
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
{Object.entries(data.citas.by_state).map(([state, count]) => (
|
|
||||||
<Badge key={state} variant={stateBadge(state)}>
|
|
||||||
{citaStateLabels[state] || state}: {count}
|
|
||||||
</Badge>
|
|
||||||
))}
|
|
||||||
{data.citas.total === 0 && <p className="text-sm text-theme-muted">Sin citas.</p>}
|
|
||||||
</div>
|
|
||||||
</Card.Body>
|
|
||||||
</Card>
|
|
||||||
<Card>
|
|
||||||
<Card.Body>
|
|
||||||
<h3 className="font-heading text-lg text-theme-heading mb-3">Cobros por método</h3>
|
|
||||||
{Object.keys(data.pagos.by_method).length === 0 ? (
|
|
||||||
<p className="text-sm text-theme-muted">Sin cobros.</p>
|
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-2">
|
<div className="overflow-auto max-h-[62vh] rounded-xl border border-theme-border">
|
||||||
{Object.entries(data.pagos.by_method).map(([method, amount]) => (
|
<table className="w-full text-xs whitespace-nowrap">
|
||||||
<div key={method} className="flex items-center justify-between p-2.5 bg-theme-bg rounded-lg text-sm">
|
<thead className="sticky top-0 z-10">
|
||||||
<span className="text-theme-heading">{metodoLabels[method] || method}</span>
|
<tr className="bg-theme-bg text-theme-muted uppercase">
|
||||||
<span className="font-medium text-theme-heading">{formatCurrency(amount)}</span>
|
<th rowSpan={2} className={cn(thBase, 'sticky left-0 bg-theme-bg z-20')}>Folio</th>
|
||||||
</div>
|
<th rowSpan={2} className={thBase}>Fecha</th>
|
||||||
|
<th rowSpan={2} className={thBase}>Paciente</th>
|
||||||
|
<th rowSpan={2} className={thBase}>Folio Dr</th>
|
||||||
|
<th rowSpan={2} className={thBase}>Atendido por</th>
|
||||||
|
<th rowSpan={2} className={thBase}>Vendedor</th>
|
||||||
|
<th rowSpan={2} className={thBase}>Concepto</th>
|
||||||
|
{METODOS.map((m) => (
|
||||||
|
<th key={m.key} colSpan={2} className="text-center px-3 py-2 border-l border-theme-border font-medium">
|
||||||
|
{m.label}
|
||||||
|
</th>
|
||||||
))}
|
))}
|
||||||
|
</tr>
|
||||||
|
<tr className="bg-theme-bg text-theme-muted uppercase">
|
||||||
|
{METODOS.flatMap((m) => [
|
||||||
|
<th key={`${m.key}_usd`} className="text-right px-3 py-1.5 border-l border-theme-border font-medium">USD</th>,
|
||||||
|
<th key={`${m.key}_mxn`} className="text-right px-3 py-1.5 font-medium">M.N.</th>,
|
||||||
|
])}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-theme-border bg-theme-surface">
|
||||||
|
{rows.map((r) => (
|
||||||
|
<tr key={r.folio} className="hover:bg-theme-bg">
|
||||||
|
<td className={cn(tdBase, 'sticky left-0 bg-theme-surface hover:bg-theme-bg z-10 font-medium text-theme-heading')}>{r.folio}</td>
|
||||||
|
<td className={cn(tdBase, 'text-theme-muted')}>{r.fecha}</td>
|
||||||
|
<td className={cn(tdBase, 'text-theme-heading')}>{r.paciente}</td>
|
||||||
|
<td className={cn(tdBase, 'text-theme-muted')}>{r.folio_dr || '-'}</td>
|
||||||
|
<td className={cn(tdBase, 'text-theme-muted')}>{r.medico || '-'}</td>
|
||||||
|
<td className={cn(tdBase, 'text-theme-muted')}>{r.vendedor || '-'}</td>
|
||||||
|
<td className={cn(tdBase, 'text-theme-muted max-w-[280px] truncate')} title={r.concepto}>{r.concepto}</td>
|
||||||
|
{MONEY_COLUMNS.map((c, i) => (
|
||||||
|
<td key={c.key} className={cn(tdBase, 'text-right', i % 2 === 0 && 'border-l border-theme-border', r[c.key as keyof typeof r] ? 'text-theme-heading' : 'text-theme-muted/50')}>
|
||||||
|
{moneyCell(r[c.key as keyof typeof r] as number | undefined)}
|
||||||
|
</td>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
<tfoot className="sticky bottom-0 bg-theme-bg font-semibold text-theme-heading">
|
||||||
|
<tr>
|
||||||
|
<td colSpan={7} className={cn(tdBase, 'uppercase text-xs tracking-wide')}>Totales</td>
|
||||||
|
{MONEY_COLUMNS.map((c, i) => (
|
||||||
|
<td key={c.key} className={cn(tdBase, 'text-right', i % 2 === 0 && 'border-l border-theme-border')}>
|
||||||
|
{fmtMoney(totals[c.key])}
|
||||||
|
</td>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
<tr className="font-normal">
|
||||||
|
<td colSpan={17} className={cn(tdBase, 'text-theme-muted')}>
|
||||||
|
Pacientes atendidos: {data?.pacientes_atendidos ?? 0} · Ticket promedio: {formatCurrency(data?.ticket_promedio ?? 0)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tfoot>
|
||||||
|
</table>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</Card.Body>
|
</Card.Body>
|
||||||
</Card>
|
</Card>
|
||||||
<Card>
|
|
||||||
<Card.Body>
|
|
||||||
<h3 className="font-heading text-lg text-theme-heading mb-3">Visitas por estado</h3>
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
{Object.entries(data.visitas.by_state).map(([state, count]) => (
|
|
||||||
<Badge key={state} variant={stateBadge(state)}>
|
|
||||||
{visitaStateLabels[state] || state}: {count}
|
|
||||||
</Badge>
|
|
||||||
))}
|
|
||||||
{data.visitas.total === 0 && <p className="text-sm text-theme-muted">Sin visitas.</p>}
|
|
||||||
</div>
|
|
||||||
</Card.Body>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
) : null}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -261,13 +261,16 @@ p {
|
|||||||
.print-agenda,
|
.print-agenda,
|
||||||
.print-agenda *,
|
.print-agenda *,
|
||||||
.print-recibo,
|
.print-recibo,
|
||||||
.print-recibo * {
|
.print-recibo *,
|
||||||
|
.print-concentrado,
|
||||||
|
.print-concentrado * {
|
||||||
visibility: visible;
|
visibility: visible;
|
||||||
}
|
}
|
||||||
.print-ticket,
|
.print-ticket,
|
||||||
.print-receta,
|
.print-receta,
|
||||||
.print-agenda,
|
.print-agenda,
|
||||||
.print-recibo {
|
.print-recibo,
|
||||||
|
.print-concentrado {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 0;
|
top: 0;
|
||||||
left: 0;
|
left: 0;
|
||||||
@@ -321,4 +324,30 @@ p {
|
|||||||
.print-recibo {
|
.print-recibo {
|
||||||
position: static;
|
position: static;
|
||||||
}
|
}
|
||||||
|
/* Concentrado de ventas: tabla ancha en hoja horizontal. Página con nombre
|
||||||
|
propia para no afectar la orientación de los demás documentos. */
|
||||||
|
.print-concentrado {
|
||||||
|
page: concentrado-landscape;
|
||||||
|
border: none;
|
||||||
|
border-radius: 0;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
.print-concentrado .overflow-auto {
|
||||||
|
max-height: none !important;
|
||||||
|
overflow: visible !important;
|
||||||
|
}
|
||||||
|
.print-concentrado table {
|
||||||
|
font-size: 7.5pt;
|
||||||
|
white-space: normal;
|
||||||
|
}
|
||||||
|
.print-concentrado thead,
|
||||||
|
.print-concentrado tfoot,
|
||||||
|
.print-concentrado th,
|
||||||
|
.print-concentrado td {
|
||||||
|
position: static !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@page concentrado-landscape {
|
||||||
|
size: A4 landscape;
|
||||||
|
margin: 8mm;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ const METODO_OPTIONS = [
|
|||||||
{ value: 'transfer', label: 'Transferencia' },
|
{ value: 'transfer', label: 'Transferencia' },
|
||||||
{ value: 'stripe', label: 'Stripe' },
|
{ value: 'stripe', label: 'Stripe' },
|
||||||
{ value: 'mercadopago', label: 'MercadoPago' },
|
{ value: 'mercadopago', label: 'MercadoPago' },
|
||||||
|
{ value: 'saldo_favor', label: 'Saldo a favor' },
|
||||||
];
|
];
|
||||||
const metodoLabel = (m: string) => METODO_OPTIONS.find((o) => o.value === m)?.label || m;
|
const metodoLabel = (m: string) => METODO_OPTIONS.find((o) => o.value === m)?.label || m;
|
||||||
|
|
||||||
|
|||||||
@@ -1205,6 +1205,38 @@ export interface ConcentradoReport {
|
|||||||
visitas: { total: number; by_state: Record<string, number> };
|
visitas: { total: number; by_state: Record<string, number> };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ConcentradoDetalleRow {
|
||||||
|
folio: string;
|
||||||
|
fecha: string;
|
||||||
|
paciente: string;
|
||||||
|
folio_dr: string | null;
|
||||||
|
medico: string;
|
||||||
|
vendedor: string;
|
||||||
|
concepto: string;
|
||||||
|
efectivo_usd: number;
|
||||||
|
efectivo_mxn: number;
|
||||||
|
tarjeta_usd: number;
|
||||||
|
tarjeta_mxn: number;
|
||||||
|
transferencia_usd: number;
|
||||||
|
transferencia_mxn: number;
|
||||||
|
saldo_favor_usd: number;
|
||||||
|
saldo_favor_mxn: number;
|
||||||
|
monedero_usd: number;
|
||||||
|
monedero_mxn: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ConcentradoDetalleTotals = Record<string, number>;
|
||||||
|
|
||||||
|
export interface ConcentradoDetalleReport {
|
||||||
|
status: string;
|
||||||
|
start: string;
|
||||||
|
end: string;
|
||||||
|
rows: ConcentradoDetalleRow[];
|
||||||
|
totals: ConcentradoDetalleTotals;
|
||||||
|
pacientes_atendidos: number;
|
||||||
|
ticket_promedio: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface RecomendacionRow {
|
export interface RecomendacionRow {
|
||||||
recomendador: string;
|
recomendador: string;
|
||||||
pacientes: number;
|
pacientes: number;
|
||||||
@@ -2139,6 +2171,11 @@ export const odooApi = {
|
|||||||
return data;
|
return data;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async getConcentradoDetalle(params: { start?: string; end?: string; doctor_ids?: string; categoria_ids?: string; servicio_ids?: string; vip?: boolean }): Promise<ConcentradoDetalleReport> {
|
||||||
|
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/reports/concentrado/detalle`, { params });
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
|
||||||
async getRecomendaciones(): Promise<{ status: string; recomendaciones: RecomendacionRow[]; total: number }> {
|
async getRecomendaciones(): Promise<{ status: string; recomendaciones: RecomendacionRow[]; total: number }> {
|
||||||
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/reports/recomendaciones`);
|
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/reports/recomendaciones`);
|
||||||
return data;
|
return data;
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
{
|
{
|
||||||
'name': 'SKEEN Citas',
|
'name': 'SKEEN Citas',
|
||||||
'version': '1.0.0',
|
'version': '1.0.1',
|
||||||
'category': 'Healthcare',
|
'category': 'Healthcare',
|
||||||
'summary': 'Agendamiento de citas para SKEEN Derma Experts',
|
'summary': 'Agendamiento de citas para SKEEN Derma Experts',
|
||||||
'description': """
|
'description': """
|
||||||
|
|||||||
@@ -80,6 +80,10 @@ class SkeenCita(models.Model):
|
|||||||
# Identificación
|
# Identificación
|
||||||
name = fields.Char(string='Referencia', readonly=True, copy=False)
|
name = fields.Char(string='Referencia', readonly=True, copy=False)
|
||||||
|
|
||||||
|
# ID en plataforma legacy (migración)
|
||||||
|
legacy_id = fields.Integer(string='ID Legacy', index=True, copy=False,
|
||||||
|
help='ID en plataforma legacy (migración)')
|
||||||
|
|
||||||
# Paciente
|
# Paciente
|
||||||
partner_id = fields.Many2one('res.partner', string='Paciente', required=True,
|
partner_id = fields.Many2one('res.partner', string='Paciente', required=True,
|
||||||
domain=[('is_patient', '=', True)])
|
domain=[('is_patient', '=', True)])
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
{
|
{
|
||||||
'name': 'SKEEN Pagos',
|
'name': 'SKEEN Pagos',
|
||||||
'version': '1.0.0',
|
'version': '1.0.1',
|
||||||
'category': 'Healthcare',
|
'category': 'Healthcare',
|
||||||
'summary': 'Integración de pagos Stripe y MercadoPago para SKEEN',
|
'summary': 'Integración de pagos Stripe y MercadoPago para SKEEN',
|
||||||
'description': """
|
'description': """
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ class SkeenPago(models.Model):
|
|||||||
('cash', 'Efectivo'),
|
('cash', 'Efectivo'),
|
||||||
('card', 'Tarjeta'),
|
('card', 'Tarjeta'),
|
||||||
('transfer', 'Transferencia'),
|
('transfer', 'Transferencia'),
|
||||||
|
('saldo_favor', 'Saldo a favor'),
|
||||||
], string='Método de Pago', required=True)
|
], string='Método de Pago', required=True)
|
||||||
|
|
||||||
# Estado
|
# Estado
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
{
|
{
|
||||||
'name': 'SKEEN Ventas y Cortes de Caja',
|
'name': 'SKEEN Ventas y Cortes de Caja',
|
||||||
'version': '1.0.0',
|
'version': '1.0.1',
|
||||||
'category': 'Healthcare',
|
'category': 'Healthcare',
|
||||||
'summary': 'Ventas, adeudos y cortes de caja para SKEEN Derma Experts',
|
'summary': 'Ventas, adeudos y cortes de caja para SKEEN Derma Experts',
|
||||||
'description': """
|
'description': """
|
||||||
|
|||||||
@@ -25,6 +25,10 @@ class SkeenVenta(models.Model):
|
|||||||
# Referencia
|
# Referencia
|
||||||
name = fields.Char(string='Referencia', readonly=True, copy=False)
|
name = fields.Char(string='Referencia', readonly=True, copy=False)
|
||||||
|
|
||||||
|
# ID en plataforma legacy (migración)
|
||||||
|
legacy_id = fields.Integer(string='ID Legacy', index=True, copy=False,
|
||||||
|
help='ID en plataforma legacy (migración)')
|
||||||
|
|
||||||
# Paciente
|
# Paciente
|
||||||
partner_id = fields.Many2one('res.partner', string='Paciente', required=True,
|
partner_id = fields.Many2one('res.partner', string='Paciente', required=True,
|
||||||
domain=[('is_patient', '=', True)])
|
domain=[('is_patient', '=', True)])
|
||||||
@@ -84,6 +88,7 @@ class SkeenVenta(models.Model):
|
|||||||
('transfer', 'Transferencia'),
|
('transfer', 'Transferencia'),
|
||||||
('stripe', 'Stripe'),
|
('stripe', 'Stripe'),
|
||||||
('mercadopago', 'MercadoPago'),
|
('mercadopago', 'MercadoPago'),
|
||||||
|
('saldo_favor', 'Saldo a favor'),
|
||||||
('points', 'Puntos / saldo a favor'),
|
('points', 'Puntos / saldo a favor'),
|
||||||
('mixed', 'Mixto'),
|
('mixed', 'Mixto'),
|
||||||
('none', 'Sin pago'),
|
('none', 'Sin pago'),
|
||||||
|
|||||||
@@ -4809,7 +4809,7 @@ class SkeenFrontendController(http.Controller):
|
|||||||
return json_response({'status': 'error', 'message': f'Puntos insuficientes (saldo {saldo} pts)'}, 400)
|
return json_response({'status': 'error', 'message': f'Puntos insuficientes (saldo {saldo} pts)'}, 400)
|
||||||
|
|
||||||
method = data.get('payment_method', 'cash')
|
method = data.get('payment_method', 'cash')
|
||||||
if method not in ('cash', 'card', 'transfer', 'stripe', 'mercadopago'):
|
if method not in ('cash', 'card', 'transfer', 'stripe', 'mercadopago', 'saldo_favor'):
|
||||||
return json_response({'status': 'error', 'message': 'Método de pago inválido'}, 400)
|
return json_response({'status': 'error', 'message': 'Método de pago inválido'}, 400)
|
||||||
|
|
||||||
cobrador = getattr(request.frontend_user, 'login', '') if hasattr(request, 'frontend_user') else ''
|
cobrador = getattr(request.frontend_user, 'login', '') if hasattr(request, 'frontend_user') else ''
|
||||||
@@ -4890,7 +4890,7 @@ class SkeenFrontendController(http.Controller):
|
|||||||
cambio = max(0.0, round(received - restante, 2)) if received > restante else 0.0
|
cambio = max(0.0, round(received - restante, 2)) if received > restante else 0.0
|
||||||
if rate_used:
|
if rate_used:
|
||||||
cambio_usd = round(cambio / rate_used, 2)
|
cambio_usd = round(cambio / rate_used, 2)
|
||||||
pago = request.env['skeen.pago'].sudo().create({
|
pago_vals = {
|
||||||
'partner_id': partner.id,
|
'partner_id': partner.id,
|
||||||
'venta_id': venta.id,
|
'venta_id': venta.id,
|
||||||
'amount': pay_amount,
|
'amount': pay_amount,
|
||||||
@@ -4898,7 +4898,14 @@ class SkeenFrontendController(http.Controller):
|
|||||||
'state': 'completed',
|
'state': 'completed',
|
||||||
'payment_date': fields.Datetime.now(),
|
'payment_date': fields.Datetime.now(),
|
||||||
'provider_reference': reference,
|
'provider_reference': reference,
|
||||||
})
|
}
|
||||||
|
if currency == 'USD' and rate_used:
|
||||||
|
# Cobro en dólares: registrar el pago en su moneda real
|
||||||
|
# (USD). Los totales de la venta (amount_paid) siguen en
|
||||||
|
# MXN; el concentrado pivotea por moneda del pago.
|
||||||
|
pago_vals['amount'] = round(pay_amount / rate_used, 2)
|
||||||
|
pago_vals['currency_id'] = request.env.ref('base.USD').id
|
||||||
|
pago = request.env['skeen.pago'].sudo().create(pago_vals)
|
||||||
# 'card' = terminal física; stripe/mercadopago no cuentan como tarjeta aquí
|
# 'card' = terminal física; stripe/mercadopago no cuentan como tarjeta aquí
|
||||||
vals = {'amount_paid': venta.amount_paid + pay_amount}
|
vals = {'amount_paid': venta.amount_paid + pay_amount}
|
||||||
if method == 'card':
|
if method == 'card':
|
||||||
@@ -5743,6 +5750,156 @@ 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/reports/concentrado/detalle', type='http', auth='none', methods=['GET', 'OPTIONS'], csrf=False)
|
||||||
|
@require_role('lectura')
|
||||||
|
def report_concentrado_detalle(self, **kw):
|
||||||
|
"""Concentrado estilo legacy: una fila por venta con desglose de pagos
|
||||||
|
por método (efectivo, tarjeta, transferencia, saldo a favor, monedero)
|
||||||
|
en cada moneda (USD / M.N.), más totales, pacientes atendidos y
|
||||||
|
ticket promedio en MXN.
|
||||||
|
|
||||||
|
Filtros opcionales: doctor_ids (líneas prescribed_by_id, o el médico
|
||||||
|
de la cita origen si la venta no trae recetado), categoria_ids
|
||||||
|
(category de skeen.servicio), servicio_ids y vip (etiqueta VIP del
|
||||||
|
paciente).
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
start = kw.get('start', (date.today().replace(day=1)).strftime('%Y-%m-%d'))
|
||||||
|
end = kw.get('end', date.today().strftime('%Y-%m-%d'))
|
||||||
|
|
||||||
|
def _parse_ints(raw):
|
||||||
|
out = []
|
||||||
|
for chunk in str(raw or '').split(','):
|
||||||
|
chunk = chunk.strip()
|
||||||
|
if chunk:
|
||||||
|
try:
|
||||||
|
out.append(int(chunk))
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
return out
|
||||||
|
|
||||||
|
doctor_ids = _parse_ints(kw.get('doctor_ids'))
|
||||||
|
categoria_ids = [c.strip() for c in str(kw.get('categoria_ids') or '').split(',') if c.strip()]
|
||||||
|
servicio_ids = _parse_ints(kw.get('servicio_ids'))
|
||||||
|
solo_vip = str(kw.get('vip') or '').lower() in ('1', 'true', 'yes')
|
||||||
|
|
||||||
|
ventas = request.env['skeen.venta'].sudo().search([
|
||||||
|
('state', '!=', 'cancelled'),
|
||||||
|
('date', '>=', start), ('date', '<=', end),
|
||||||
|
], order='date asc, id asc')
|
||||||
|
|
||||||
|
if doctor_ids:
|
||||||
|
ventas = ventas.filtered(lambda v: (
|
||||||
|
any(l.prescribed_by_id.id in doctor_ids for l in v.line_ids)
|
||||||
|
or (not any(l.prescribed_by_id for l in v.line_ids)
|
||||||
|
and v.cita_id and v.cita_id.doctor_id
|
||||||
|
and v.cita_id.doctor_id.id in doctor_ids)
|
||||||
|
))
|
||||||
|
if servicio_ids:
|
||||||
|
ventas = ventas.filtered(lambda v: any(
|
||||||
|
l.service_id and l.service_id.id in servicio_ids for l in v.line_ids))
|
||||||
|
if categoria_ids:
|
||||||
|
ventas = ventas.filtered(lambda v: any(
|
||||||
|
l.service_id and l.service_id.category in categoria_ids for l in v.line_ids))
|
||||||
|
if solo_vip:
|
||||||
|
ventas = ventas.filtered(lambda v: v.partner_id.is_vip)
|
||||||
|
|
||||||
|
# Pagos cobrados de las ventas (una venta puede tener varios: pago
|
||||||
|
# mixto). Se pivotea por método de pago y por moneda del pago.
|
||||||
|
pagos_by_venta = {}
|
||||||
|
if ventas:
|
||||||
|
pagos = request.env['skeen.pago'].sudo().search([
|
||||||
|
('venta_id', 'in', ventas.ids), ('state', '=', 'completed'),
|
||||||
|
])
|
||||||
|
for p in pagos:
|
||||||
|
pagos_by_venta.setdefault(p.venta_id.id, []).append(p)
|
||||||
|
|
||||||
|
METODOS = ('efectivo', 'tarjeta', 'transferencia', 'saldo_favor', 'monedero')
|
||||||
|
METODO_COL = {
|
||||||
|
'cash': 'efectivo',
|
||||||
|
'card': 'tarjeta', 'stripe': 'tarjeta', 'mercadopago': 'tarjeta',
|
||||||
|
'transfer': 'transferencia',
|
||||||
|
'saldo_favor': 'saldo_favor',
|
||||||
|
'points': 'monedero',
|
||||||
|
}
|
||||||
|
COLUMNAS = [f'{m}_{cur}' for m in METODOS for cur in ('usd', 'mxn')]
|
||||||
|
|
||||||
|
def _desglose(venta):
|
||||||
|
vals = {c: 0.0 for c in COLUMNAS}
|
||||||
|
for p in pagos_by_venta.get(venta.id, []):
|
||||||
|
col = METODO_COL.get(p.payment_method)
|
||||||
|
if not col:
|
||||||
|
continue
|
||||||
|
sufijo = 'usd' if p.currency_id and p.currency_id.name == 'USD' else 'mxn'
|
||||||
|
vals[f'{col}_{sufijo}'] += p.amount
|
||||||
|
return {c: round(v, 2) for c, v in vals.items()}
|
||||||
|
|
||||||
|
# Vendedor: user_login (los controladores auth='none' no dejan uid
|
||||||
|
# en create_uid) -> nombre completo del usuario del frontend.
|
||||||
|
logins = list({v.user_login for v in ventas if v.user_login})
|
||||||
|
nombres_vendedor = {}
|
||||||
|
if logins:
|
||||||
|
for u in request.env['skeen.frontend.user'].sudo().search([('login', 'in', logins)]):
|
||||||
|
nombres_vendedor[u.login] = u.name
|
||||||
|
|
||||||
|
# Tipo de cambio del día de cada venta (o el más reciente
|
||||||
|
# disponible) para expresar el ticket promedio en MXN.
|
||||||
|
TipoCambio = request.env['skeen.tipo.cambio'].sudo()
|
||||||
|
tc_por_fecha = {}
|
||||||
|
|
||||||
|
def _tc(fecha):
|
||||||
|
key = str(fecha)
|
||||||
|
if key not in tc_por_fecha:
|
||||||
|
tc = TipoCambio.search([('date', '<=', key)], order='date desc, id desc', limit=1)
|
||||||
|
if not tc:
|
||||||
|
tc = TipoCambio.search([], order='date desc, id desc', limit=1)
|
||||||
|
tc_por_fecha[key] = tc.rate if tc else 0.0
|
||||||
|
return tc_por_fecha[key]
|
||||||
|
|
||||||
|
rows = []
|
||||||
|
equiv_mxn = []
|
||||||
|
for v in ventas:
|
||||||
|
desglose = _desglose(v)
|
||||||
|
medico = next((l.prescribed_by_id.name for l in v.line_ids if l.prescribed_by_id), None)
|
||||||
|
if not medico and v.cita_id and v.cita_id.doctor_id:
|
||||||
|
medico = v.cita_id.doctor_id.name
|
||||||
|
concepto = ', '.join(
|
||||||
|
(l.service_id.name or l.description or '') if l.service_id
|
||||||
|
else (l.item_id.name or l.description or '')
|
||||||
|
for l in v.line_ids)
|
||||||
|
fecha = v.date.strftime('%Y-%m-%d') if v.date else ''
|
||||||
|
if fecha and v.create_date:
|
||||||
|
fecha = f"{fecha} {v.create_date.strftime('%H:%M')}"
|
||||||
|
rows.append({
|
||||||
|
'folio': v.name or '',
|
||||||
|
'fecha': fecha,
|
||||||
|
'paciente': v.partner_id.name or '',
|
||||||
|
'folio_dr': v.partner_id.patient_id or None,
|
||||||
|
'medico': medico or '',
|
||||||
|
'vendedor': nombres_vendedor.get(v.user_login, v.user_login or '') if v.user_login else '',
|
||||||
|
'concepto': concepto,
|
||||||
|
**desglose,
|
||||||
|
})
|
||||||
|
mxn = sum(desglose[f'{m}_mxn'] for m in METODOS)
|
||||||
|
usd = sum(desglose[f'{m}_usd'] for m in METODOS)
|
||||||
|
equiv_mxn.append(mxn + usd * _tc(v.date))
|
||||||
|
|
||||||
|
totals = {c: round(sum(r[c] for r in rows), 2) for c in COLUMNAS}
|
||||||
|
pacientes_atendidos = len({v.partner_id.id for v in ventas})
|
||||||
|
ticket_promedio = round(sum(equiv_mxn) / len(equiv_mxn), 2) if equiv_mxn else 0.0
|
||||||
|
|
||||||
|
return json_response({
|
||||||
|
'status': 'success',
|
||||||
|
'start': start,
|
||||||
|
'end': end,
|
||||||
|
'rows': rows,
|
||||||
|
'totals': totals,
|
||||||
|
'pacientes_atendidos': pacientes_atendidos,
|
||||||
|
'ticket_promedio': ticket_promedio,
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
return json_response({'status': 'error', 'message': str(e)}, 500)
|
||||||
|
|
||||||
@http.route('/skeen/frontend/v1/reports/recomendaciones', type='http', auth='none', methods=['GET', 'OPTIONS'], csrf=False)
|
@http.route('/skeen/frontend/v1/reports/recomendaciones', type='http', auth='none', methods=['GET', 'OPTIONS'], csrf=False)
|
||||||
@require_role('lectura')
|
@require_role('lectura')
|
||||||
def report_recomendaciones(self, **kw):
|
def report_recomendaciones(self, **kw):
|
||||||
|
|||||||
Reference in New Issue
Block a user