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:
@@ -13,6 +13,12 @@ const isMedicalTitle = (jobTitle?: string) => {
|
|||||||
return medicalTerms.some((term) => normalized.includes(term));
|
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 Medicos: FC = () => {
|
||||||
const [employees, setEmployees] = useState<Doctor[]>([]);
|
const [employees, setEmployees] = useState<Doctor[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
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 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 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-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>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y">
|
<tbody className="divide-y">
|
||||||
@@ -134,6 +144,10 @@ const Medicos: FC = () => {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</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>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
@@ -155,6 +169,10 @@ const Medicos: FC = () => {
|
|||||||
{ label: 'Teléfono', value: d.work_phone || '-' },
|
{ label: 'Teléfono', value: d.work_phone || '-' },
|
||||||
{ label: 'Email', value: d.work_email || '-' },
|
{ label: 'Email', value: d.work_email || '-' },
|
||||||
{ label: '% Comisión', value: `${d.commission_pct ?? 0}%` },
|
{ 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) },
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -250,6 +250,10 @@ export interface Doctor {
|
|||||||
work_email: string;
|
work_email: string;
|
||||||
commission_pct: number;
|
commission_pct: number;
|
||||||
patient_count?: number;
|
patient_count?: number;
|
||||||
|
ventas_30d?: number;
|
||||||
|
total_30d?: number;
|
||||||
|
total_30d_usd?: number | null;
|
||||||
|
card_30d?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Product {
|
export interface Product {
|
||||||
|
|||||||
@@ -31,6 +31,10 @@ class SkeenVenta(models.Model):
|
|||||||
total = fields.Float(string='Total', compute='_compute_totals', store=True)
|
total = fields.Float(string='Total', compute='_compute_totals', store=True)
|
||||||
amount_paid = fields.Float(string='Pagado', default=0.0)
|
amount_paid = fields.Float(string='Pagado', default=0.0)
|
||||||
amount_due = fields.Float(string='Adeudo', compute='_compute_totals', store=True)
|
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
|
# Estado
|
||||||
state = fields.Selection([
|
state = fields.Selection([
|
||||||
|
|||||||
@@ -1632,6 +1632,20 @@ class SkeenFrontendController(http.Controller):
|
|||||||
# ============================================================
|
# ============================================================
|
||||||
@http.route('/skeen/frontend/v1/doctors', type='http', auth='none', methods=['GET', 'OPTIONS'], csrf=False)
|
@http.route('/skeen/frontend/v1/doctors', type='http', auth='none', methods=['GET', 'OPTIONS'], csrf=False)
|
||||||
def list_doctors(self, **kw):
|
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:
|
try:
|
||||||
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)
|
||||||
@@ -1640,6 +1654,42 @@ class SkeenFrontendController(http.Controller):
|
|||||||
[('is_patient', '=', True), ('primary_doctor_id', 'in', doctors.ids)],
|
[('is_patient', '=', True), ('primary_doctor_id', 'in', doctors.ids)],
|
||||||
[], ['primary_doctor_id']):
|
[], ['primary_doctor_id']):
|
||||||
counts[g['primary_doctor_id'][0]] = g['primary_doctor_id_count']
|
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({
|
return json_response({
|
||||||
'status': 'success',
|
'status': 'success',
|
||||||
'doctors': [{
|
'doctors': [{
|
||||||
@@ -1650,6 +1700,10 @@ class SkeenFrontendController(http.Controller):
|
|||||||
'work_email': d.work_email or '',
|
'work_email': d.work_email or '',
|
||||||
'commission_pct': d.commission_pct or 0.0,
|
'commission_pct': d.commission_pct or 0.0,
|
||||||
'patient_count': counts.get(d.id, 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],
|
} for d in doctors],
|
||||||
})
|
})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -1930,7 +1984,11 @@ class SkeenFrontendController(http.Controller):
|
|||||||
'state': 'completed',
|
'state': 'completed',
|
||||||
'payment_date': fields.Datetime.now(),
|
'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:
|
if sale.amount_paid >= sale.total:
|
||||||
sale.action_mark_paid()
|
sale.action_mark_paid()
|
||||||
else:
|
else:
|
||||||
@@ -2133,7 +2191,11 @@ class SkeenFrontendController(http.Controller):
|
|||||||
'payment_date': fields.Datetime.now(),
|
'payment_date': fields.Datetime.now(),
|
||||||
'provider_reference': reference,
|
'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:
|
if venta.amount_paid >= venta.total:
|
||||||
venta.action_mark_paid()
|
venta.action_mark_paid()
|
||||||
else:
|
else:
|
||||||
|
|||||||
Reference in New Issue
Block a user