From 4c3eb0b11dca56156b07f7d34604bf43ed76ff27 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Consultor=C3=ADa=20Alcaraz=20Salazar?= Date: Fri, 14 Aug 2026 09:02:22 +0000 Subject: [PATCH] =?UTF-8?q?M=C3=A9dicos:=20ventas=2030d=20por=20m=C3=A9dic?= =?UTF-8?q?o=20(count,=20total=20MXN/USD,=20pagado=20tarjeta)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - card_amount en ventas (pagos con terminal física) - Stats por médico vía líneas recetadas, métrica consistente con comisiones - 4 columnas nuevas en la página de Médicos --- frontend/src/pages/Medicos.tsx | 18 +++++ frontend/src/services/odoo.ts | 4 ++ odoo-addons/skeen_ventas/models/venta.py | 4 ++ .../skeen_whatsapp/controllers/frontend.py | 66 ++++++++++++++++++- 4 files changed, 90 insertions(+), 2 deletions(-) diff --git a/frontend/src/pages/Medicos.tsx b/frontend/src/pages/Medicos.tsx index ea930f9..1c2ec21 100644 --- a/frontend/src/pages/Medicos.tsx +++ b/frontend/src/pages/Medicos.tsx @@ -13,6 +13,12 @@ const isMedicalTitle = (jobTitle?: string) => { return medicalTerms.some((term) => normalized.includes(term)); }; +const fmtMoney = (n: number) => + (n || 0).toLocaleString('es-MX', { style: 'currency', currency: 'MXN', maximumFractionDigits: 0 }); + +const fmtUsd = (n: number | null | undefined) => + n == null ? '—' : n.toLocaleString('en-US', { style: 'currency', currency: 'USD', maximumFractionDigits: 0 }); + const Medicos: FC = () => { const [employees, setEmployees] = useState([]); const [loading, setLoading] = useState(true); @@ -89,6 +95,10 @@ const Medicos: FC = () => { Teléfono Email % Comisión + Ventas 30d + Total 30d + Total USD + Pagado tarjeta @@ -134,6 +144,10 @@ const Medicos: FC = () => { + {d.ventas_30d ?? 0} + {fmtMoney(d.total_30d ?? 0)} + {fmtUsd(d.total_30d_usd)} + {fmtMoney(d.card_30d ?? 0)} ))} @@ -155,6 +169,10 @@ const Medicos: FC = () => { { label: 'Teléfono', value: d.work_phone || '-' }, { label: 'Email', value: d.work_email || '-' }, { label: '% Comisión', value: `${d.commission_pct ?? 0}%` }, + { label: 'Ventas 30d', value: String(d.ventas_30d ?? 0) }, + { label: 'Total 30d', value: fmtMoney(d.total_30d ?? 0) }, + { label: 'Total USD', value: fmtUsd(d.total_30d_usd) }, + { label: 'Pagado tarjeta', value: fmtMoney(d.card_30d ?? 0) }, ]} /> ))} diff --git a/frontend/src/services/odoo.ts b/frontend/src/services/odoo.ts index 79b11d8..08fbf45 100644 --- a/frontend/src/services/odoo.ts +++ b/frontend/src/services/odoo.ts @@ -250,6 +250,10 @@ export interface Doctor { work_email: string; commission_pct: number; patient_count?: number; + ventas_30d?: number; + total_30d?: number; + total_30d_usd?: number | null; + card_30d?: number; } export interface Product { diff --git a/odoo-addons/skeen_ventas/models/venta.py b/odoo-addons/skeen_ventas/models/venta.py index 6a52445..24e771f 100644 --- a/odoo-addons/skeen_ventas/models/venta.py +++ b/odoo-addons/skeen_ventas/models/venta.py @@ -31,6 +31,10 @@ class SkeenVenta(models.Model): total = fields.Float(string='Total', compute='_compute_totals', store=True) amount_paid = fields.Float(string='Pagado', default=0.0) amount_due = fields.Float(string='Adeudo', compute='_compute_totals', store=True) + card_amount = fields.Float( + string='Pagado con tarjeta', default=0.0, + help="Monto pagado con terminal física (payment_method='card'). " + "Stripe y MercadoPago NO cuentan como tarjeta aquí.") # Estado state = fields.Selection([ diff --git a/odoo-addons/skeen_whatsapp/controllers/frontend.py b/odoo-addons/skeen_whatsapp/controllers/frontend.py index 2a4e214..d5e7105 100644 --- a/odoo-addons/skeen_whatsapp/controllers/frontend.py +++ b/odoo-addons/skeen_whatsapp/controllers/frontend.py @@ -1632,6 +1632,20 @@ class SkeenFrontendController(http.Controller): # ============================================================ @http.route('/skeen/frontend/v1/doctors', type='http', auth='none', methods=['GET', 'OPTIONS'], csrf=False) def list_doctors(self, **kw): + """Médicos + estadísticas de ventas de los últimos 30 días. + + - ventas_30d: ventas distintas (state != cancelled) con >=1 línea + recetada por el médico (prescribed_by_id), fecha >= hoy-30. + - total_30d: suma del subtotal de las líneas recetadas (misma métrica + que el reporte de comisiones). + - total_30d_usd: total_30d / TC vigente (None si no hay TC). + - card_30d: pagado con tarjeta física. Exacto para ventas nuevas vía + venta.card_amount (se llena en pay_sale y POS checkout). Aproximación + histórica para ventas anteriores: si la venta tiene cita_id, se suman + los pagos completed con method='card' ligados a esa cita, topados al + amount_paid de la venta (por eso es aproximada: el pago no liga a la + venta, solo a la cita). Stripe/MercadoPago no cuentan como tarjeta. + """ try: doctors = request.env['hr.employee'].sudo().search([('active', '=', True)], limit=100) # Pacientes asignados por médico (primary_doctor_id) @@ -1640,6 +1654,42 @@ class SkeenFrontendController(http.Controller): [('is_patient', '=', True), ('primary_doctor_id', 'in', doctors.ids)], [], ['primary_doctor_id']): counts[g['primary_doctor_id'][0]] = g['primary_doctor_id_count'] + + # ---- Ventas de los últimos 30 días por médico ---- + since = (date.today() - timedelta(days=30)).strftime('%Y-%m-%d') + stats = {} # doctor_id -> {'ventas': set, 'total': float} + card_by_venta = {} + ventas = request.env['skeen.venta'].sudo().search([ + ('state', '!=', 'cancelled'), ('date', '>=', since), + ]) + if ventas: + for r in request.env['skeen.venta.line'].sudo().search_read( + [('venta_id', 'in', ventas.ids), ('prescribed_by_id', '!=', False)], + ['prescribed_by_id', 'venta_id', 'subtotal']): + did = r['prescribed_by_id'][0] + st = stats.setdefault(did, {'ventas': set(), 'total': 0.0}) + st['ventas'].add(r['venta_id'][0]) + st['total'] += r['subtotal'] or 0.0 + # Tarjeta: exacto (card_amount) + aprox histórica por cita + ventas_by_id = {v.id: v for v in ventas} + for vid, v in ventas_by_id.items(): + card_by_venta[vid] = v.card_amount or 0.0 + 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) + + # TC vigente (para el equivalente USD) + tc = request.env['skeen.tipo.cambio'].sudo().search([], order='date desc, id desc', limit=1) + rate = tc.rate if tc else None + return json_response({ 'status': 'success', 'doctors': [{ @@ -1650,6 +1700,10 @@ class SkeenFrontendController(http.Controller): 'work_email': d.work_email or '', 'commission_pct': d.commission_pct or 0.0, 'patient_count': counts.get(d.id, 0), + 'ventas_30d': len(stats.get(d.id, {}).get('ventas', set())), + 'total_30d': 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, + 'card_30d': round(sum(card_by_venta.get(vid, 0.0) for vid in stats.get(d.id, {}).get('ventas', set())), 2), } for d in doctors], }) except Exception as e: @@ -1930,7 +1984,11 @@ class SkeenFrontendController(http.Controller): 'state': 'completed', 'payment_date': fields.Datetime.now(), }) - sale.write({'amount_paid': sale.amount_paid + amount}) + # 'card' = terminal física; stripe/mercadopago no cuentan como tarjeta aquí + vals = {'amount_paid': sale.amount_paid + amount} + if method == 'card': + vals['card_amount'] = sale.card_amount + amount + sale.write(vals) if sale.amount_paid >= sale.total: sale.action_mark_paid() else: @@ -2133,7 +2191,11 @@ class SkeenFrontendController(http.Controller): 'payment_date': fields.Datetime.now(), 'provider_reference': reference, }) - venta.write({'amount_paid': venta.amount_paid + pay_amount}) + # 'card' = terminal física; stripe/mercadopago no cuentan como tarjeta aquí + vals = {'amount_paid': venta.amount_paid + pay_amount} + if method == 'card': + vals['card_amount'] = venta.card_amount + pay_amount + venta.write(vals) if venta.amount_paid >= venta.total: venta.action_mark_paid() else: