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:
2026-09-17 20:31:52 +00:00
parent ac5896b1fc
commit 9ee3343e22
20 changed files with 2173 additions and 165 deletions

View File

@@ -10,6 +10,7 @@
'data': [
'security/ir.model.access.csv',
'views/inventario_views.xml',
'data/sequences.xml',
'data/cron.xml',
],
'installable': True,

View File

@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data noupdate="1">
<record id="seq_skeen_inventario_conteo" model="ir.sequence">
<field name="name">SKEEN Conteo físico de inventario</field>
<field name="code">skeen.inventario.conteo</field>
<field name="prefix">CONTEO-</field>
<field name="padding">4</field>
<field name="number_next">1</field>
<field name="number_increment">1</field>
</record>
</data>
</odoo>

View File

@@ -107,22 +107,119 @@ class SkeenInventarioMove(models.Model):
@api.model_create_multi
def create(self, vals_list):
moves = super().create(vals_list)
for m in moves:
item = m.item_id
if not item:
# Las existencias anterior/resultante se calculan ANTES del INSERT: si
# se escribieran después con write(), Odoo omite el UPDATE cuando el
# valor coincide con el de la caché (p. ej. after_qty = 0) y la columna
# quedaría NULL en la base.
Item = self.env['skeen.inventario.item'].sudo()
for vals in vals_list:
item = Item.browse(vals.get('item_id'))
if not item.exists():
continue
before = item.qty or 0.0
q = m.qty or 0.0
if m.type in ('compra', 'cancelacion'):
q = vals.get('qty') or 0.0
mtype = vals.get('type')
if mtype in ('compra', 'cancelacion'):
after = before + q
elif m.type in ('venta', 'baja'):
elif mtype in ('venta', 'baja'):
after = before - q
elif m.type == 'ajuste':
elif mtype == 'ajuste':
after = q # ajuste fija la existencia al valor del conteo
else:
after = before
after = max(0.0, after)
item.write({'qty': after, 'last_count_date': fields.Date.today() if m.type == 'ajuste' else item.last_count_date})
m.write({'before_qty': before, 'after_qty': after})
vals['before_qty'] = before
vals['after_qty'] = max(0.0, after)
moves = super().create(vals_list)
for m in moves:
if not m.item_id:
continue
vals_item = {'qty': m.after_qty}
if m.type == 'ajuste':
vals_item['last_count_date'] = fields.Date.today()
m.item_id.write(vals_item)
return moves
class SkeenInventarioConteo(models.Model):
"""Conteo físico cíclico de inventario.
Guarda la foto del conteo (sistema vs contado por item) y los ajustes
generados, para poder consultar el historial y reimprimir el resumen.
"""
_name = 'skeen.inventario.conteo'
_description = 'Conteo físico de inventario SKEEN'
_order = 'date desc, id desc'
name = fields.Char(string='Referencia', readonly=True, copy=False)
date = fields.Datetime(string='Fecha', default=fields.Datetime.now)
user_name = fields.Char(string='Realizado por')
notes = fields.Text(string='Notas')
line_ids = fields.One2many('skeen.inventario.conteo.line', 'conteo_id', string='Líneas')
total_items = fields.Integer(string='Artículos contados', compute='_compute_totals', store=True)
total_diferencias = fields.Integer(string='Con diferencia', compute='_compute_totals', store=True)
total_faltante = fields.Float(string='Faltante', compute='_compute_totals', store=True)
total_sobrante = fields.Float(string='Sobrante', compute='_compute_totals', store=True)
total_value_diff = fields.Float(string='Diferencia valorizada', compute='_compute_totals', store=True)
@api.depends('line_ids.difference', 'line_ids.value_diff')
def _compute_totals(self):
for c in self:
c.total_items = len(c.line_ids)
c.total_diferencias = len(c.line_ids.filtered(lambda l: abs(l.difference) > 1e-9))
c.total_faltante = sum(l.value_diff for l in c.line_ids if l.difference < 0)
c.total_sobrante = sum(l.value_diff for l in c.line_ids if l.difference > 0)
c.total_value_diff = c.total_faltante + c.total_sobrante
@api.model_create_multi
def create(self, vals_list):
for vals in vals_list:
if not vals.get('name'):
vals['name'] = self.env['ir.sequence'].next_by_code('skeen.inventario.conteo') or 'CONTEO-001'
return super().create(vals_list)
def apply_count(self):
"""Genera un movimiento type='ajuste' por cada línea con diferencia.
El movimiento de ajuste fija la existencia del artículo al valor
contado (ver SkeenInventarioMove.create) y sella la fecha de conteo.
Devuelve la cantidad de ajustes creados.
"""
Move = self.env['skeen.inventario.move']
ajustes = 0
for line in self.line_ids:
if abs(line.difference) <= 1e-9:
continue
move = Move.create({
'item_id': line.item_id.id,
'type': 'ajuste',
'qty': line.qty_contado,
'reference': self.name,
'notes': f'Conteo físico {self.name}',
})
line.write({'move_id': move.id})
ajustes += 1
return ajustes
class SkeenInventarioConteoLine(models.Model):
_name = 'skeen.inventario.conteo.line'
_description = 'Línea de conteo físico'
_order = 'id'
conteo_id = fields.Many2one('skeen.inventario.conteo', string='Conteo', required=True, ondelete='cascade')
item_id = fields.Many2one('skeen.inventario.item', string='Artículo', required=True, ondelete='cascade')
unit = fields.Char(related='item_id.unit', string='Unidad', readonly=True)
qty_sistema = fields.Float(string='Sistema')
qty_contado = fields.Float(string='Contado')
difference = fields.Float(string='Diferencia', compute='_compute_difference', store=True)
cost = fields.Float(string='Costo unitario')
value_diff = fields.Float(string='Diferencia valorizada', compute='_compute_difference', store=True)
move_id = fields.Many2one('skeen.inventario.move', string='Ajuste generado', readonly=True)
@api.depends('qty_sistema', 'qty_contado', 'cost')
def _compute_difference(self):
for line in self:
line.difference = (line.qty_contado or 0.0) - (line.qty_sistema or 0.0)
line.value_diff = line.difference * (line.cost or 0.0)

View File

@@ -1,3 +1,5 @@
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_skeen_inventario_item_user,Acceso a Items de Inventario SKEEN,model_skeen_inventario_item,base.group_user,1,1,1,1
access_skeen_inventario_move_user,Acceso a Movimientos de Inventario SKEEN,model_skeen_inventario_move,base.group_user,1,1,1,1
access_skeen_inventario_conteo_user,Acceso a Conteos de Inventario SKEEN,model_skeen_inventario_conteo,base.group_user,1,1,1,1
access_skeen_inventario_conteo_line_user,Acceso a Lineas de Conteo SKEEN,model_skeen_inventario_conteo_line,base.group_user,1,1,1,1
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_skeen_inventario_item_user Acceso a Items de Inventario SKEEN model_skeen_inventario_item base.group_user 1 1 1 1
3 access_skeen_inventario_move_user Acceso a Movimientos de Inventario SKEEN model_skeen_inventario_move base.group_user 1 1 1 1
4 access_skeen_inventario_conteo_user Acceso a Conteos de Inventario SKEEN model_skeen_inventario_conteo base.group_user 1 1 1 1
5 access_skeen_inventario_conteo_line_user Acceso a Lineas de Conteo SKEEN model_skeen_inventario_conteo_line base.group_user 1 1 1 1

View File

@@ -54,7 +54,7 @@ class SkeenMonedero(models.Model):
'history': history,
}
def add_points(self, points, description='', reference=''):
def add_points(self, points, description='', reference='', operator=''):
"""Agregar puntos al monedero"""
self.ensure_one()
self.write({'points': self.points + points})
@@ -64,10 +64,11 @@ class SkeenMonedero(models.Model):
'points': points,
'description': description or 'Acumulación de puntos',
'reference': reference,
'operator': operator,
})
return True
def redeem_points(self, points, description='', reference=''):
def redeem_points(self, points, description='', reference='', operator=''):
"""Redimir puntos del monedero"""
self.ensure_one()
if points > self.points:
@@ -80,10 +81,11 @@ class SkeenMonedero(models.Model):
'points': -points,
'description': description or 'Redención de puntos',
'reference': reference,
'operator': operator,
})
return True
def adjust_points(self, delta, description='', reference=''):
def adjust_points(self, delta, description='', reference='', operator=''):
"""Ajuste de puntos (puede ser negativo). Hace piso en 0 para no dejar saldo negativo.
Usado por devoluciones para revertir puntos acumulados por una venta.
@@ -103,6 +105,7 @@ class SkeenMonedero(models.Model):
'points': effective_delta,
'description': description or 'Ajuste de puntos',
'reference': reference,
'operator': operator,
})
return effective_delta
@@ -126,6 +129,9 @@ class SkeenMonederoTransaction(models.Model):
points = fields.Integer(string='Puntos', required=True)
description = fields.Text(string='Descripción')
reference = fields.Char(string='Referencia')
# Usuario del frontend que capturó el movimiento (los controladores
# auth='none' no dejan uid de Odoo, así que se guarda explícito).
operator = fields.Char(string='Registrado por')
# Fecha
create_date = fields.Datetime(string='Fecha', readonly=True, default=fields.Datetime.now)

View File

@@ -97,6 +97,15 @@ class ResPartner(models.Model):
total_visits = fields.Integer(string='Total Visitas', default=0)
total_spent = fields.Float(string='Total Gastado', default=0.0)
# Plan de pagos del adeudo (acuerdo manual, sin recordatorios automáticos)
payment_plan_count = fields.Integer(string='Pagos Acordados', default=0)
payment_plan_amount = fields.Float(string='Monto por Pago', default=0.0)
next_payment_date = fields.Date(string='Siguiente Pago')
payment_plan_note = fields.Text(string='Nota del Plan de Pagos')
payment_plan_start = fields.Date(string='Inicio del Plan')
payment_plan_since = fields.Datetime(string='Plan vigente desde')
payment_plan_by = fields.Char(string='Plan registrado por')
# Adjuntos (expediente escaneado y galería de imágenes)
adjunto_ids = fields.One2many('skeen.patient.adjunto', 'partner_id', string='Documentos')

View File

@@ -13,6 +13,7 @@
'depends': ['base', 'skeen_citas'],
'data': [
'security/ir.model.access.csv',
'data/pago_sequence.xml',
'views/pago_views.xml',
],
'installable': True,

View File

@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data noupdate="1">
<record id="seq_skeen_pago" model="ir.sequence">
<field name="name">SKEEN Pago</field>
<field name="code">skeen.pago</field>
<field name="prefix">PAGO-</field>
<field name="padding">4</field>
<field name="number_next">1</field>
<field name="number_increment">1</field>
<field name="company_id" eval="False"/>
</record>
</data>
</odoo>

View File

@@ -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)
# ============================================================