From 2523635716149f345864d650b10b411461bb1dc7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Consultor=C3=ADa=20Alcaraz=20Salazar?= Date: Sat, 10 Oct 2026 09:24:57 +0000 Subject: [PATCH] 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 --- .../components/reportes/ConcentradoReport.tsx | 430 +++++++++++++----- frontend/src/index.css | 33 +- frontend/src/pages/Pos.tsx | 1 + frontend/src/services/odoo.ts | 37 ++ odoo-addons/skeen_citas/__manifest__.py | 2 +- odoo-addons/skeen_citas/models/cita.py | 6 +- odoo-addons/skeen_pagos/__manifest__.py | 2 +- odoo-addons/skeen_pagos/models/pago.py | 1 + odoo-addons/skeen_ventas/__manifest__.py | 2 +- odoo-addons/skeen_ventas/models/venta.py | 5 + .../skeen_whatsapp/controllers/frontend.py | 163 ++++++- 11 files changed, 548 insertions(+), 134 deletions(-) diff --git a/frontend/src/components/reportes/ConcentradoReport.tsx b/frontend/src/components/reportes/ConcentradoReport.tsx index 9e653da..a816b5b 100644 --- a/frontend/src/components/reportes/ConcentradoReport.tsx +++ b/frontend/src/components/reportes/ConcentradoReport.tsx @@ -1,94 +1,225 @@ -import type { FC } from 'react'; -import { useEffect, useState, useCallback } from 'react'; -import { LayoutGrid } from 'lucide-react'; -import { Card, Input, Badge, Skeleton, toast } from '../ui'; +import type { FC, ReactNode } from 'react'; +import { useEffect, useMemo, useRef, useState, useCallback } from 'react'; +import { Printer, Search, Stethoscope, Tag, ClipboardList } from 'lucide-react'; +import { Card, Button, EmptyState, Skeleton, toast } from '../ui'; import { ExportButton } from '../ui/ExportButton'; import { exportToExcel } from '../../lib/exporter'; -import { odooApi, type ConcentradoReport as ConcentradoData } from '../../services/odoo'; -import type { BadgeVariant } from '../ui/Badge'; +import { odooApi, type ConcentradoDetalleReport, type Doctor, type Service } from '../../services/odoo'; import { useReportRange } from '../../lib/reportRange'; +import { cn } from '../../lib/utils'; 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 = { - pending: 'Pendiente', confirmed: 'Confirmada', arrived: 'Llegó', - in_progress: 'En curso', done: 'Completada', cancelled: 'Cancelada', no_show: 'No show', -}; -const visitaStateLabels: Record = { - en_curso: 'En curso', completada: 'Completada', cancelada: 'Cancelada', -}; -const metodoLabels: Record = { - 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'; +const fmtMoney = (v: number) => + `$${(v || 0).toLocaleString('es-MX', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`; +// 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 }) => ( - -

{label}

-

{value}

-
-); +const MONEY_COLUMNS = METODOS.flatMap((m) => [ + { key: `${m.key}_usd`, header: `${m.label} USD` }, + { key: `${m.key}_mxn`, header: `${m.label} M.N.` }, +]); + +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 = ({ label, icon, options, selected, onChange, searchable }) => { + const [open, setOpen] = useState(false); + const [query, setQuery] = useState(''); + const ref = useRef(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 ( +
+ + {open && ( +
+ {searchable && ( +
+ + 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" + /> +
+ )} +
+ {visibles.length === 0 &&

Sin opciones

} + {visibles.map((o) => ( + + ))} +
+ {selected.length > 0 && ( + + )} +
+ )} +
+ ); +}; const ConcentradoReport: FC = () => { - // Rango global de Reportes; los campos Inicio/Fin fijan uno propio - const { start, end, setStart, setEnd } = useReportRange('concentrado'); - const [data, setData] = useState(null); + // Rango global de Reportes (sin selector propio de fechas) + const { start, end } = useReportRange('concentrado'); + const [data, setData] = useState(null); const [loading, setLoading] = useState(true); + // Catálogos para los filtros + const [doctors, setDoctors] = useState([]); + const [services, setServices] = useState([]); + + // Filtros + const [selDoctores, setSelDoctores] = useState([]); + const [selCategorias, setSelCategorias] = useState([]); + const [selServicios, setSelServicios] = useState([]); + 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 () => { try { setLoading(true); - const res = await odooApi.getConcentrado(start, end); - if (res.status === 'success') { - const { citas, ventas, pagos, devoluciones, pacientes_nuevos, visitas, start: s, end: e } = res; - setData({ citas, ventas, pagos, devoluciones, pacientes_nuevos, visitas, start: s, end: e }); - } + const res = await odooApi.getConcentradoDetalle({ + start, + end, + 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) { toast.error('Error al cargar concentrado'); console.error(err); } finally { setLoading(false); } - }, [start, end]); + }, [start, end, selDoctores, selCategorias, selServicios, vip]); useEffect(() => { load(); }, [load]); + const rows = data?.rows ?? []; + const totals = data?.totals ?? {}; + const [exporting, setExporting] = useState(false); const exportar = async () => { if (!data) return; try { setExporting(true); - const rows: Record[] = [ - { 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 }, - ]; + const money = { format: 'currency' as const }; await exportToExcel({ filename: `concentrado-${start}_${end}`, sheetName: 'Concentrado', - title: 'Concentrado del periodo', - subtitle: `Del ${start} al ${end}`, + title: 'Concentrado de ventas', + subtitle: `Del ${start} al ${end} · Pacientes atendidos: ${data.pacientes_atendidos} · Ticket promedio: ${formatCurrency(data.ticket_promedio)}`, 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: 'Cantidad', key: 'cantidad', format: 'number' }, - { header: 'Monto', key: 'monto', format: 'currency' }, + ...MONEY_COLUMNS.map((c) => ({ header: c.header, key: c.key, ...money })), ], - rows, + rows: rows as unknown as Record[], + totals: { + folio: 'Totales', fecha: '', paciente: '', folio_dr: '', medico: '', vendedor: '', concepto: '', + ...totals, + }, }); toast.success('Excel descargado'); } 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 (
-
- setStart(e.target.value)} className="sm:max-w-[160px]" /> - setEnd(e.target.value)} className="sm:max-w-[160px]" /> - -
+
+
+ } + 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 + /> +
+
+ } + options={CATEGORIAS} + selected={selCategorias} + onChange={setSelCategorias} + /> +
+
+ } + options={serviciosFiltrados.map((s) => ({ value: String(s.id), label: s.name }))} + selected={selServicios} + onChange={setSelServicios} + searchable + /> +
+ + + {start} — {end} · {rows.length} venta{rows.length === 1 ? '' : 's'} + +
+
- {loading ? ( -
- {Array.from({ length: 8 }).map((_, i) => )} -
- ) : data ? ( - <> -
- - - - - - - 0} /> - 0} /> -
- -
- - -

- Citas por estado -

-
- {Object.entries(data.citas.by_state).map(([state, count]) => ( - - {citaStateLabels[state] || state}: {count} - - ))} - {data.citas.total === 0 &&

Sin citas.

} -
-
-
- - -

Cobros por método

- {Object.keys(data.pagos.by_method).length === 0 ? ( -

Sin cobros.

- ) : ( -
- {Object.entries(data.pagos.by_method).map(([method, amount]) => ( -
- {metodoLabels[method] || method} - {formatCurrency(amount)} -
+ + + {loading ? ( + + ) : rows.length === 0 ? ( + + ) : ( +
+ + + + + + + + + + + {METODOS.map((m) => ( + ))} - - )} - - - - -

Visitas por estado

-
- {Object.entries(data.visitas.by_state).map(([state, count]) => ( - - {visitaStateLabels[state] || state}: {count} - +
+ + {METODOS.flatMap((m) => [ + , + , + ])} + + + + {rows.map((r) => ( + + + + + + + + + {MONEY_COLUMNS.map((c, i) => ( + + ))} + ))} - {data.visitas.total === 0 &&

Sin visitas.

} - - - - - - ) : null} + + + + + {MONEY_COLUMNS.map((c, i) => ( + + ))} + + + + + +
FolioFechaPacienteFolio DrAtendido porVendedorConcepto + {m.label} +
USDM.N.
{r.folio}{r.fecha}{r.paciente}{r.folio_dr || '-'}{r.medico || '-'}{r.vendedor || '-'}{r.concepto} + {moneyCell(r[c.key as keyof typeof r] as number | undefined)} +
Totales + {fmtMoney(totals[c.key])} +
+ Pacientes atendidos: {data?.pacientes_atendidos ?? 0} · Ticket promedio: {formatCurrency(data?.ticket_promedio ?? 0)} +
+
+ )} +
+
); }; diff --git a/frontend/src/index.css b/frontend/src/index.css index b280ed2..af3537e 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -261,13 +261,16 @@ p { .print-agenda, .print-agenda *, .print-recibo, - .print-recibo * { + .print-recibo *, + .print-concentrado, + .print-concentrado * { visibility: visible; } .print-ticket, .print-receta, .print-agenda, - .print-recibo { + .print-recibo, + .print-concentrado { position: absolute; top: 0; left: 0; @@ -321,4 +324,30 @@ p { .print-recibo { 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; } diff --git a/frontend/src/pages/Pos.tsx b/frontend/src/pages/Pos.tsx index a5be7db..afe181b 100644 --- a/frontend/src/pages/Pos.tsx +++ b/frontend/src/pages/Pos.tsx @@ -33,6 +33,7 @@ const METODO_OPTIONS = [ { value: 'transfer', label: 'Transferencia' }, { value: 'stripe', label: 'Stripe' }, { value: 'mercadopago', label: 'MercadoPago' }, + { value: 'saldo_favor', label: 'Saldo a favor' }, ]; const metodoLabel = (m: string) => METODO_OPTIONS.find((o) => o.value === m)?.label || m; diff --git a/frontend/src/services/odoo.ts b/frontend/src/services/odoo.ts index f05bb98..1ac7701 100644 --- a/frontend/src/services/odoo.ts +++ b/frontend/src/services/odoo.ts @@ -1205,6 +1205,38 @@ export interface ConcentradoReport { visitas: { total: number; by_state: Record }; } +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; + +export interface ConcentradoDetalleReport { + status: string; + start: string; + end: string; + rows: ConcentradoDetalleRow[]; + totals: ConcentradoDetalleTotals; + pacientes_atendidos: number; + ticket_promedio: number; +} + export interface RecomendacionRow { recomendador: string; pacientes: number; @@ -2139,6 +2171,11 @@ export const odooApi = { return data; }, + async getConcentradoDetalle(params: { start?: string; end?: string; doctor_ids?: string; categoria_ids?: string; servicio_ids?: string; vip?: boolean }): Promise { + 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 }> { const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/reports/recomendaciones`); return data; diff --git a/odoo-addons/skeen_citas/__manifest__.py b/odoo-addons/skeen_citas/__manifest__.py index df4a2cb..0625c0a 100644 --- a/odoo-addons/skeen_citas/__manifest__.py +++ b/odoo-addons/skeen_citas/__manifest__.py @@ -1,7 +1,7 @@ # -*- coding: utf-8 -*- { 'name': 'SKEEN Citas', - 'version': '1.0.0', + 'version': '1.0.1', 'category': 'Healthcare', 'summary': 'Agendamiento de citas para SKEEN Derma Experts', 'description': """ diff --git a/odoo-addons/skeen_citas/models/cita.py b/odoo-addons/skeen_citas/models/cita.py index bc9cdb0..f0fcf14 100644 --- a/odoo-addons/skeen_citas/models/cita.py +++ b/odoo-addons/skeen_citas/models/cita.py @@ -79,7 +79,11 @@ class SkeenCita(models.Model): # Identificación 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 partner_id = fields.Many2one('res.partner', string='Paciente', required=True, domain=[('is_patient', '=', True)]) diff --git a/odoo-addons/skeen_pagos/__manifest__.py b/odoo-addons/skeen_pagos/__manifest__.py index 13dfbdd..e329459 100644 --- a/odoo-addons/skeen_pagos/__manifest__.py +++ b/odoo-addons/skeen_pagos/__manifest__.py @@ -1,7 +1,7 @@ # -*- coding: utf-8 -*- { 'name': 'SKEEN Pagos', - 'version': '1.0.0', + 'version': '1.0.1', 'category': 'Healthcare', 'summary': 'Integración de pagos Stripe y MercadoPago para SKEEN', 'description': """ diff --git a/odoo-addons/skeen_pagos/models/pago.py b/odoo-addons/skeen_pagos/models/pago.py index c1bfcf6..d400f25 100644 --- a/odoo-addons/skeen_pagos/models/pago.py +++ b/odoo-addons/skeen_pagos/models/pago.py @@ -29,6 +29,7 @@ class SkeenPago(models.Model): ('cash', 'Efectivo'), ('card', 'Tarjeta'), ('transfer', 'Transferencia'), + ('saldo_favor', 'Saldo a favor'), ], string='Método de Pago', required=True) # Estado diff --git a/odoo-addons/skeen_ventas/__manifest__.py b/odoo-addons/skeen_ventas/__manifest__.py index 783e3a1..23a1b17 100644 --- a/odoo-addons/skeen_ventas/__manifest__.py +++ b/odoo-addons/skeen_ventas/__manifest__.py @@ -1,7 +1,7 @@ # -*- coding: utf-8 -*- { 'name': 'SKEEN Ventas y Cortes de Caja', - 'version': '1.0.0', + 'version': '1.0.1', 'category': 'Healthcare', 'summary': 'Ventas, adeudos y cortes de caja para SKEEN Derma Experts', 'description': """ diff --git a/odoo-addons/skeen_ventas/models/venta.py b/odoo-addons/skeen_ventas/models/venta.py index cdc690b..593ed4a 100644 --- a/odoo-addons/skeen_ventas/models/venta.py +++ b/odoo-addons/skeen_ventas/models/venta.py @@ -25,6 +25,10 @@ class SkeenVenta(models.Model): # Referencia 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 partner_id = fields.Many2one('res.partner', string='Paciente', required=True, domain=[('is_patient', '=', True)]) @@ -84,6 +88,7 @@ class SkeenVenta(models.Model): ('transfer', 'Transferencia'), ('stripe', 'Stripe'), ('mercadopago', 'MercadoPago'), + ('saldo_favor', 'Saldo a favor'), ('points', 'Puntos / saldo a favor'), ('mixed', 'Mixto'), ('none', 'Sin pago'), diff --git a/odoo-addons/skeen_whatsapp/controllers/frontend.py b/odoo-addons/skeen_whatsapp/controllers/frontend.py index f857f97..b65850b 100644 --- a/odoo-addons/skeen_whatsapp/controllers/frontend.py +++ b/odoo-addons/skeen_whatsapp/controllers/frontend.py @@ -4809,7 +4809,7 @@ class SkeenFrontendController(http.Controller): return json_response({'status': 'error', 'message': f'Puntos insuficientes (saldo {saldo} pts)'}, 400) 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) 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 if rate_used: cambio_usd = round(cambio / rate_used, 2) - pago = request.env['skeen.pago'].sudo().create({ + pago_vals = { 'partner_id': partner.id, 'venta_id': venta.id, 'amount': pay_amount, @@ -4898,7 +4898,14 @@ class SkeenFrontendController(http.Controller): 'state': 'completed', 'payment_date': fields.Datetime.now(), '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í vals = {'amount_paid': venta.amount_paid + pay_amount} if method == 'card': @@ -5743,6 +5750,156 @@ class SkeenFrontendController(http.Controller): except Exception as e: 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) @require_role('lectura') def report_recomendaciones(self, **kw):