Oleada UX 4: Pagos/Adeudos (cobro en 1 click con recibo, plan de pagos), Monedero (movimientos narrados, ajustes auditados, MXN+USD), Inventario (alta rapida, conteo fisico con ajuste e historial, alertas accionables) + fix de folios de pago y cantidades de movimientos
This commit is contained in:
@@ -1526,6 +1526,182 @@ class SkeenFrontendController(http.Controller):
|
||||
except Exception as e:
|
||||
return json_response({'status': 'error', 'message': str(e)}, 500)
|
||||
|
||||
# ============================================================
|
||||
# Adeudos: cobro con recibo y plan de pagos
|
||||
# ============================================================
|
||||
def _adeudo_ventas(self, patient_id):
|
||||
"""Ventas con saldo pendiente del paciente, más antiguas primero."""
|
||||
return request.env['skeen.venta'].sudo().search([
|
||||
('partner_id', '=', patient_id),
|
||||
('state', '!=', 'cancelled'),
|
||||
('amount_due', '>', 0),
|
||||
], order='date asc, id asc')
|
||||
|
||||
@http.route('/skeen/frontend/v1/patients/<int:patient_id>/pay_adeudo', type='http', auth='none', methods=['POST', 'OPTIONS'], csrf=False)
|
||||
@require_role('recepcion')
|
||||
def pay_patient_adeudo(self, patient_id, **kw):
|
||||
"""Registra un pago del adeudo (parcial o total) y devuelve el recibo.
|
||||
|
||||
El pago se aplica a las ventas con saldo pendiente, de la más antigua a
|
||||
la más reciente, replicando la lógica de /sales/<id>/pay (incluye los
|
||||
puntos de monedero al liquidar una venta).
|
||||
"""
|
||||
try:
|
||||
data = _parse_json_body()
|
||||
try:
|
||||
amount = float(data.get('amount') or 0)
|
||||
except (TypeError, ValueError):
|
||||
return json_response({'status': 'error', 'message': 'Monto inválido'}, 400)
|
||||
method = data.get('payment_method', 'cash')
|
||||
if method not in ('cash', 'card', 'transfer', 'stripe', 'mercadopago'):
|
||||
return json_response({'status': 'error', 'message': 'Método de pago inválido'}, 400)
|
||||
if amount <= 0:
|
||||
return json_response({'status': 'error', 'message': 'El monto debe ser mayor a 0'}, 400)
|
||||
|
||||
partner = request.env['res.partner'].sudo().browse(patient_id)
|
||||
if not partner.exists() or not partner.is_patient:
|
||||
return json_response({'status': 'error', 'message': 'Paciente no encontrado'}, 404)
|
||||
|
||||
ventas = self._adeudo_ventas(patient_id)
|
||||
total_adeudo = round(sum(v.amount_due for v in ventas), 2)
|
||||
if total_adeudo <= 0:
|
||||
return json_response({'status': 'error', 'message': 'El paciente no tiene adeudo pendiente'}, 400)
|
||||
if round(amount, 2) > total_adeudo + 0.01:
|
||||
return json_response({
|
||||
'status': 'error',
|
||||
'message': f'El monto excede el adeudo pendiente (${total_adeudo:,.2f})',
|
||||
}, 400)
|
||||
|
||||
payment = request.env['skeen.pago'].sudo().create({
|
||||
'partner_id': partner.id,
|
||||
'amount': amount,
|
||||
'payment_method': method,
|
||||
'state': 'completed',
|
||||
'payment_date': fields.Datetime.now(),
|
||||
})
|
||||
|
||||
restante = round(amount, 2)
|
||||
aplicado = []
|
||||
for venta in ventas:
|
||||
if restante <= 0:
|
||||
break
|
||||
por_aplicar = round(min(restante, venta.amount_due), 2)
|
||||
if por_aplicar <= 0:
|
||||
continue
|
||||
nuevo_pagado = round(venta.amount_paid + por_aplicar, 2)
|
||||
if nuevo_pagado >= venta.total - 0.005:
|
||||
venta.write({'amount_paid': nuevo_pagado})
|
||||
venta.action_mark_paid() # otorga puntos y liquida la venta
|
||||
nuevo_estado = 'paid'
|
||||
else:
|
||||
venta.write({'amount_paid': nuevo_pagado, 'state': 'partial'})
|
||||
nuevo_estado = 'partial'
|
||||
restante = round(restante - por_aplicar, 2)
|
||||
aplicado.append({
|
||||
'id': venta.id,
|
||||
'name': venta.name,
|
||||
'date': venta.date.strftime('%Y-%m-%d') if venta.date else None,
|
||||
'applied': por_aplicar,
|
||||
'total': venta.total,
|
||||
'state': nuevo_estado,
|
||||
'amount_due': venta.amount_due,
|
||||
})
|
||||
|
||||
saldo = round(sum(v.amount_due for v in self._adeudo_ventas(patient_id)), 2)
|
||||
monedero = request.env['skeen.monedero'].sudo().search([('partner_id', '=', partner.id)], limit=1)
|
||||
return json_response({
|
||||
'status': 'success',
|
||||
'payment': self._payment_to_dict(payment),
|
||||
'receipt': {
|
||||
'folio': payment.name,
|
||||
'date': fields.Datetime.now().strftime('%Y-%m-%d %H:%M'),
|
||||
'patient_id': partner.id,
|
||||
'patient': partner.name,
|
||||
'patient_code': partner.patient_id or '',
|
||||
'phone': partner.phone or '',
|
||||
'amount': amount,
|
||||
'payment_method': method,
|
||||
'adeudo_anterior': total_adeudo,
|
||||
'saldo_restante': saldo,
|
||||
'aplicado': aplicado,
|
||||
'operator': getattr(request.frontend_user, 'login', '') if hasattr(request, 'frontend_user') else '',
|
||||
'wallet_points': monedero.points if monedero else 0,
|
||||
},
|
||||
})
|
||||
except Exception as e:
|
||||
request.env.cr.rollback()
|
||||
return json_response({'status': 'error', 'message': str(e)}, 500)
|
||||
|
||||
@http.route('/skeen/frontend/v1/patients/<int:patient_id>/payment_plan', type='http', auth='none', methods=['PUT', 'POST', 'OPTIONS'], csrf=False)
|
||||
@require_role('recepcion')
|
||||
def set_payment_plan(self, patient_id, **kw):
|
||||
"""Acuerdo de pagos del adeudo: N pagos de $X, siguiente fecha y nota.
|
||||
|
||||
Enviar count<=0 limpia el plan. No hay recordatorios automáticos: es
|
||||
solo registro y visualización.
|
||||
"""
|
||||
try:
|
||||
partner = request.env['res.partner'].sudo().browse(patient_id)
|
||||
if not partner.exists() or not partner.is_patient:
|
||||
return json_response({'status': 'error', 'message': 'Paciente no encontrado'}, 404)
|
||||
data = _parse_json_body()
|
||||
try:
|
||||
count = int(data.get('count') or 0)
|
||||
plan_amount = float(data.get('amount') or 0)
|
||||
except (TypeError, ValueError):
|
||||
return json_response({'status': 'error', 'message': 'Valores inválidos'}, 400)
|
||||
if count > 0:
|
||||
if plan_amount <= 0:
|
||||
return json_response({'status': 'error', 'message': 'El monto por pago debe ser mayor a 0'}, 400)
|
||||
partner.write({
|
||||
'payment_plan_count': count,
|
||||
'payment_plan_amount': plan_amount,
|
||||
'next_payment_date': data.get('next_payment_date') or False,
|
||||
'payment_plan_note': data.get('note') or '',
|
||||
'payment_plan_start': fields.Date.today(),
|
||||
'payment_plan_since': fields.Datetime.now(),
|
||||
'payment_plan_by': getattr(request.frontend_user, 'login', '') if hasattr(request, 'frontend_user') else '',
|
||||
})
|
||||
else:
|
||||
partner.write({
|
||||
'payment_plan_count': 0,
|
||||
'payment_plan_amount': 0.0,
|
||||
'next_payment_date': False,
|
||||
'payment_plan_note': '',
|
||||
'payment_plan_start': False,
|
||||
'payment_plan_since': False,
|
||||
'payment_plan_by': '',
|
||||
})
|
||||
return json_response({'status': 'success', 'plan': self._payment_plan_dict(partner)})
|
||||
except Exception as e:
|
||||
request.env.cr.rollback()
|
||||
return json_response({'status': 'error', 'message': str(e)}, 500)
|
||||
|
||||
def _payment_plan_dict(self, partner):
|
||||
"""Plan del paciente + pagos ya cubiertos desde que se acordó.
|
||||
|
||||
Sólo cuentan los pagos posteriores al acuerdo (payment_plan_since): los
|
||||
que ya se habían cobrado antes no abonan al plan recién registrado.
|
||||
"""
|
||||
count = partner.payment_plan_count or 0
|
||||
pagados = 0
|
||||
if count > 0 and partner.payment_plan_since:
|
||||
pagados = request.env['skeen.pago'].sudo().search_count([
|
||||
('partner_id', '=', partner.id),
|
||||
('state', '=', 'completed'),
|
||||
('create_date', '>=', partner.payment_plan_since),
|
||||
])
|
||||
return {
|
||||
'count': count,
|
||||
'amount': partner.payment_plan_amount or 0.0,
|
||||
'next_payment_date': partner.next_payment_date.strftime('%Y-%m-%d') if partner.next_payment_date else None,
|
||||
'note': partner.payment_plan_note or '',
|
||||
'start': partner.payment_plan_start.strftime('%Y-%m-%d') if partner.payment_plan_start else None,
|
||||
'by': partner.payment_plan_by or '',
|
||||
'paid_count': min(pagados, count) if count else 0,
|
||||
'remaining_count': max(0, count - pagados) if count else 0,
|
||||
}
|
||||
|
||||
@require_role('lectura')
|
||||
def expedientes_recientes(self, **kw):
|
||||
"""Pacientes con visitas recientes (últimos 30 días por default)"""
|
||||
@@ -2569,6 +2745,146 @@ class SkeenFrontendController(http.Controller):
|
||||
except Exception as e:
|
||||
return json_response({'status': 'error', 'message': str(e)}, 500)
|
||||
|
||||
def _tx_narration(self, tx, totales_ventas):
|
||||
"""Narración legible del movimiento, generada del tipo + referencia."""
|
||||
pts = tx.points or 0
|
||||
abs_pts = abs(pts)
|
||||
ref = (tx.reference or '').strip()
|
||||
desc = (tx.description or '').strip()
|
||||
desc_low = desc.lower()
|
||||
operador = f' · por {tx.operator}' if tx.operator else ''
|
||||
total = totales_ventas.get(ref)
|
||||
|
||||
if ref.startswith('VENTA-'):
|
||||
monto = f' (${total:,.2f})' if total else ''
|
||||
if tx.type == 'accrual':
|
||||
return f'Ganó {abs_pts} pts por la venta {ref}{monto}'
|
||||
if tx.type == 'redemption':
|
||||
return f'Usó {abs_pts} pts en la venta {ref}{monto}'
|
||||
if tx.type == 'adjustment':
|
||||
if 'cancelaci' in desc_low or 'reversi' in desc_low:
|
||||
return f'Reversión de {abs_pts} pts por cancelación de la venta {ref}'
|
||||
if 'devoluci' in desc_low:
|
||||
return f'Reversión de {abs_pts} pts por devolución de la venta {ref}'
|
||||
return (desc or f'Ajuste de {pts} pts en la venta {ref}') + operador
|
||||
if tx.type == 'expiration':
|
||||
return f'Vencieron {abs_pts} pts de la venta {ref}'
|
||||
|
||||
if tx.type == 'adjustment':
|
||||
if desc_low.startswith('ajuste manual'):
|
||||
return f'{desc}{operador}'
|
||||
return f'Ajuste manual: {desc}' if desc else f'Ajuste manual de {pts} pts{operador}'
|
||||
if tx.type == 'accrual':
|
||||
if desc and desc_low != 'acumulación de puntos':
|
||||
return f'Acumulación de {abs_pts} pts · {desc}{operador}'
|
||||
return f'Acumulación de {abs_pts} pts{operador}'
|
||||
if tx.type == 'redemption':
|
||||
if desc and desc_low != 'redención de puntos':
|
||||
return f'Redención de {abs_pts} pts · {desc}{operador}'
|
||||
return f'Redención de {abs_pts} pts{operador}'
|
||||
if tx.type == 'expiration':
|
||||
return f'Vencieron {abs_pts} pts'
|
||||
return desc or f'Movimiento de {pts} pts'
|
||||
|
||||
@http.route('/skeen/frontend/v1/wallets/<int:wallet_id>/transactions', type='http', auth='none', methods=['GET', 'OPTIONS'], csrf=False)
|
||||
@require_role('lectura')
|
||||
def wallet_transactions(self, wallet_id, **kw):
|
||||
"""Movimientos del monedero con narración legible y filtros."""
|
||||
try:
|
||||
monedero = request.env['skeen.monedero'].sudo().browse(wallet_id)
|
||||
if not monedero.exists():
|
||||
return json_response({'status': 'error', 'message': 'Monedero no encontrado'}, 404)
|
||||
domain = [('monedero_id', '=', monedero.id)]
|
||||
ttype = kw.get('type')
|
||||
if ttype in ('accrual', 'redemption', 'adjustment', 'expiration'):
|
||||
domain.append(('type', '=', ttype))
|
||||
if kw.get('date_from'):
|
||||
domain.append(('create_date', '>=', f"{kw['date_from']} 00:00:00"))
|
||||
if kw.get('date_to'):
|
||||
domain.append(('create_date', '<=', f"{kw['date_to']} 23:59:59"))
|
||||
tx = request.env['skeen.monedero.transaction'].sudo().search(
|
||||
domain, order='create_date desc, id desc', limit=300)
|
||||
|
||||
# Totales de las ventas referenciadas (para narrar el monto)
|
||||
refs = {t.reference for t in tx if t.reference and t.reference.startswith('VENTA-')}
|
||||
totales = {}
|
||||
if refs:
|
||||
for v in request.env['skeen.venta'].sudo().search([('name', 'in', list(refs))]):
|
||||
totales[v.name] = v.total
|
||||
|
||||
movimientos = [{
|
||||
'id': t.id,
|
||||
'type': t.type,
|
||||
'type_label': dict(request.env['skeen.monedero.transaction']._fields['type'].selection).get(t.type, t.type),
|
||||
'points': t.points,
|
||||
'narration': self._tx_narration(t, totales),
|
||||
'description': t.description or '',
|
||||
'reference': t.reference or '',
|
||||
'operator': t.operator or '',
|
||||
'date': t.create_date.strftime('%Y-%m-%d %H:%M') if t.create_date else None,
|
||||
} for t in tx]
|
||||
|
||||
return json_response({
|
||||
'status': 'success',
|
||||
'wallet': self._wallet_to_dict(monedero),
|
||||
'movimientos': movimientos,
|
||||
'total': len(movimientos),
|
||||
'sum_positive': sum(m['points'] for m in movimientos if m['points'] > 0),
|
||||
'sum_negative': sum(m['points'] for m in movimientos if m['points'] < 0),
|
||||
})
|
||||
except Exception as e:
|
||||
return json_response({'status': 'error', 'message': str(e)}, 500)
|
||||
|
||||
@http.route('/skeen/frontend/v1/wallets/<int:wallet_id>/adjust', type='http', auth='none', methods=['POST', 'OPTIONS'], csrf=False)
|
||||
@require_role('recepcion')
|
||||
def wallet_adjust(self, wallet_id, **kw):
|
||||
"""Ajuste manual auditado de puntos (permiso admin/gerencia).
|
||||
|
||||
Body: {"points": 50 | -20, "reason": "cortesía"}. El motivo es
|
||||
obligatorio y queda en la descripción del movimiento junto con el
|
||||
usuario del frontend que lo capturó.
|
||||
"""
|
||||
try:
|
||||
if request.frontend_user.role not in ('admin', 'gerencia'):
|
||||
return json_response({'status': 'error', 'message': 'Sin permiso'}, 403)
|
||||
monedero = request.env['skeen.monedero'].sudo().browse(wallet_id)
|
||||
if not monedero.exists():
|
||||
return json_response({'status': 'error', 'message': 'Monedero no encontrado'}, 404)
|
||||
data = _parse_json_body()
|
||||
reason = (data.get('reason') or '').strip()
|
||||
if not reason:
|
||||
return json_response({'status': 'error', 'message': 'El motivo del ajuste es obligatorio'}, 400)
|
||||
try:
|
||||
points = int(data.get('points') or 0)
|
||||
except (TypeError, ValueError):
|
||||
return json_response({'status': 'error', 'message': 'Cantidad de puntos inválida'}, 400)
|
||||
if points == 0:
|
||||
return json_response({'status': 'error', 'message': 'La cantidad de puntos no puede ser 0'}, 400)
|
||||
operador = getattr(request.frontend_user, 'login', '') if hasattr(request, 'frontend_user') else ''
|
||||
aplicado = monedero.adjust_points(
|
||||
points,
|
||||
description=f'Ajuste manual: {reason}',
|
||||
reference='AJUSTE-MANUAL',
|
||||
operator=operador,
|
||||
)
|
||||
tx = request.env['skeen.monedero.transaction'].sudo().search(
|
||||
[('monedero_id', '=', monedero.id)], order='id desc', limit=1)
|
||||
return json_response({
|
||||
'status': 'success',
|
||||
'wallet': self._wallet_to_dict(monedero),
|
||||
'applied': aplicado,
|
||||
'movimiento': {
|
||||
'id': tx.id,
|
||||
'points': tx.points,
|
||||
'date': tx.create_date.strftime('%Y-%m-%d %H:%M') if tx.create_date else None,
|
||||
'operator': tx.operator or '',
|
||||
'narration': self._tx_narration(tx, {}),
|
||||
},
|
||||
})
|
||||
except Exception as e:
|
||||
request.env.cr.rollback()
|
||||
return json_response({'status': 'error', 'message': str(e)}, 500)
|
||||
|
||||
def _wallet_to_dict(self, w):
|
||||
return {
|
||||
'id': w.id,
|
||||
@@ -3225,11 +3541,15 @@ class SkeenFrontendController(http.Controller):
|
||||
'ultima_venta': str(r['date'])[:10] if r.get('date') else None,
|
||||
})
|
||||
phones = {}
|
||||
partners = {}
|
||||
if items:
|
||||
for p in request.env['res.partner'].sudo().browse([i['id'] for i in items]):
|
||||
phones[p.id] = p.phone or ''
|
||||
partners[p.id] = p
|
||||
for i in items:
|
||||
i['phone'] = phones.get(i['id'], '')
|
||||
plan = self._payment_plan_dict(partners[i['id']]) if i['id'] in partners else None
|
||||
i['plan'] = plan if plan and plan['count'] > 0 else None
|
||||
items.sort(key=lambda x: -x['total_adeudo'])
|
||||
total_cartera = sum(i['total_adeudo'] for i in items)
|
||||
return json_response({
|
||||
@@ -4592,6 +4912,102 @@ class SkeenFrontendController(http.Controller):
|
||||
except Exception as e:
|
||||
return json_response({'status': 'error', 'message': str(e)}, 500)
|
||||
|
||||
# ============================================================
|
||||
# Conteo físico cíclico de inventario
|
||||
# ============================================================
|
||||
def _conteo_to_dict(self, c, with_lines=False):
|
||||
d = {
|
||||
'id': c.id,
|
||||
'name': c.name,
|
||||
'date': c.date.strftime('%Y-%m-%d %H:%M') if c.date else None,
|
||||
'user_name': c.user_name or '',
|
||||
'notes': c.notes or '',
|
||||
'total_items': c.total_items,
|
||||
'total_diferencias': c.total_diferencias,
|
||||
'total_faltante': c.total_faltante,
|
||||
'total_sobrante': c.total_sobrante,
|
||||
'total_value_diff': c.total_value_diff,
|
||||
}
|
||||
if with_lines:
|
||||
d['lines'] = [{
|
||||
'id': l.id,
|
||||
'item_id': l.item_id.id,
|
||||
'item': l.item_id.name,
|
||||
'unit': l.unit or 'pieza',
|
||||
'qty_sistema': l.qty_sistema,
|
||||
'qty_contado': l.qty_contado,
|
||||
'difference': l.difference,
|
||||
'cost': l.cost,
|
||||
'value_diff': l.value_diff,
|
||||
'move_id': l.move_id.id if l.move_id else None,
|
||||
} for l in c.line_ids]
|
||||
return d
|
||||
|
||||
@http.route('/skeen/frontend/v1/inventario/conteos', type='http', auth='none', methods=['GET', 'OPTIONS'], csrf=False)
|
||||
@require_role('lectura')
|
||||
def list_conteos(self, **kw):
|
||||
try:
|
||||
limit = min(max(int(kw.get('limit', 30) or 30), 1), 100)
|
||||
conteos = request.env['skeen.inventario.conteo'].sudo().search([], order='date desc, id desc', limit=limit)
|
||||
return json_response({'status': 'success', 'conteos': [self._conteo_to_dict(c) for c in conteos]})
|
||||
except Exception as e:
|
||||
return json_response({'status': 'error', 'message': str(e)}, 500)
|
||||
|
||||
@http.route('/skeen/frontend/v1/inventario/conteos/<int:conteo_id>', type='http', auth='none', methods=['GET', 'OPTIONS'], csrf=False)
|
||||
@require_role('lectura')
|
||||
def get_conteo(self, conteo_id, **kw):
|
||||
try:
|
||||
conteo = request.env['skeen.inventario.conteo'].sudo().browse(conteo_id)
|
||||
if not conteo.exists():
|
||||
return json_response({'status': 'error', 'message': 'Conteo no encontrado'}, 404)
|
||||
return json_response({'status': 'success', 'conteo': self._conteo_to_dict(conteo, with_lines=True)})
|
||||
except Exception as e:
|
||||
return json_response({'status': 'error', 'message': str(e)}, 500)
|
||||
|
||||
@http.route('/skeen/frontend/v1/inventario/conteos', type='http', auth='none', methods=['POST', 'OPTIONS'], csrf=False)
|
||||
@require_role('recepcion')
|
||||
def create_conteo(self, **kw):
|
||||
"""Registra un conteo físico y aplica los ajustes por diferencia.
|
||||
|
||||
Body: {"notes": "...", "lines": [{"item_id": 1, "qty_contado": 7.5}, ...]}
|
||||
Solo se genera movimiento para las líneas con diferencia; el ajuste
|
||||
fija la existencia al valor contado.
|
||||
"""
|
||||
try:
|
||||
data = _parse_json_body()
|
||||
raw_lines = data.get('lines') or []
|
||||
if not isinstance(raw_lines, list) or not raw_lines:
|
||||
return json_response({'status': 'error', 'message': 'Falta la lista de líneas contadas'}, 400)
|
||||
Item = request.env['skeen.inventario.item'].sudo()
|
||||
line_vals = []
|
||||
for l in raw_lines:
|
||||
try:
|
||||
item_id = int(l.get('item_id') or 0)
|
||||
qty_contado = float(l.get('qty_contado') or 0)
|
||||
except (TypeError, ValueError):
|
||||
return json_response({'status': 'error', 'message': 'Línea con valores inválidos'}, 400)
|
||||
item = Item.browse(item_id)
|
||||
if not item.exists():
|
||||
return json_response({'status': 'error', 'message': f'Artículo {item_id} no encontrado'}, 404)
|
||||
line_vals.append((0, 0, {
|
||||
'item_id': item.id,
|
||||
'qty_sistema': item.qty,
|
||||
'qty_contado': qty_contado,
|
||||
'cost': item.cost,
|
||||
}))
|
||||
operador = getattr(request.frontend_user, 'name', '') or (
|
||||
getattr(request.frontend_user, 'login', '') if hasattr(request, 'frontend_user') else '')
|
||||
conteo = request.env['skeen.inventario.conteo'].sudo().create({
|
||||
'user_name': operador,
|
||||
'notes': data.get('notes', ''),
|
||||
'line_ids': line_vals,
|
||||
})
|
||||
ajustes = conteo.apply_count()
|
||||
return json_response({'status': 'success', 'conteo': self._conteo_to_dict(conteo, with_lines=True), 'ajustes': ajustes}, 201)
|
||||
except Exception as e:
|
||||
request.env.cr.rollback()
|
||||
return json_response({'status': 'error', 'message': str(e)}, 500)
|
||||
|
||||
# ============================================================
|
||||
# Reporte de comisiones (venta de artículos recetados por médico)
|
||||
# ============================================================
|
||||
|
||||
Reference in New Issue
Block a user