Médicos: ventas 30d por médico (count, total MXN/USD, pagado tarjeta)

- 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
This commit is contained in:
2026-08-14 09:02:22 +00:00
parent 281fb34588
commit 4c3eb0b11d
4 changed files with 90 additions and 2 deletions

View File

@@ -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<Doctor[]>([]);
const [loading, setLoading] = useState(true);
@@ -89,6 +95,10 @@ const Medicos: FC = () => {
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase hidden md:table-cell">Teléfono</th>
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase hidden lg:table-cell">Email</th>
<th className="text-left p-3 text-xs font-medium text-theme-muted uppercase">% Comisión</th>
<th className="text-right p-3 text-xs font-medium text-theme-muted uppercase" title="Ventas con líneas recetadas por el médico, últimos 30 días">Ventas 30d</th>
<th className="text-right p-3 text-xs font-medium text-theme-muted uppercase hidden md:table-cell" title="Subtotal de líneas recetadas, últimos 30 días">Total 30d</th>
<th className="text-right p-3 text-xs font-medium text-theme-muted uppercase hidden lg:table-cell" title="Equivalente en USD al TC vigente">Total USD</th>
<th className="text-right p-3 text-xs font-medium text-theme-muted uppercase hidden md:table-cell" title="Pagado con tarjeta (terminal física), últimos 30 días">Pagado tarjeta</th>
</tr>
</thead>
<tbody className="divide-y">
@@ -134,6 +144,10 @@ const Medicos: FC = () => {
</Button>
</div>
</td>
<td className="p-3 text-sm text-right font-medium text-theme-heading">{d.ventas_30d ?? 0}</td>
<td className="p-3 text-sm text-right text-theme-heading hidden md:table-cell">{fmtMoney(d.total_30d ?? 0)}</td>
<td className="p-3 text-sm text-right text-theme-muted hidden lg:table-cell">{fmtUsd(d.total_30d_usd)}</td>
<td className="p-3 text-sm text-right text-theme-heading hidden md:table-cell">{fmtMoney(d.card_30d ?? 0)}</td>
</tr>
))}
</tbody>
@@ -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) },
]}
/>
))}

View File

@@ -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 {

View File

@@ -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([

View File

@@ -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: