Punto 7: gerencia puede corregir tickets sin cancelarlos (lineas, precios, descuento y notas) con ajuste automatico de monedero e inventario
This commit is contained in:
@@ -6,6 +6,16 @@ from odoo.exceptions import ValidationError
|
||||
from markupsafe import Markup
|
||||
|
||||
|
||||
def _fmt_mxn(v):
|
||||
"""Importe con formato mexicano ($1,234.56) para los avisos del chatter."""
|
||||
return '$%s' % format(round(float(v or 0.0), 2), ',.2f')
|
||||
|
||||
|
||||
def _etiqueta_line(line):
|
||||
"""Nombre legible de una línea de venta (descripción, servicio o artículo)."""
|
||||
return line.description or line.service_id.name or line.item_id.name or 'la línea'
|
||||
|
||||
|
||||
class SkeenVenta(models.Model):
|
||||
_name = 'skeen.venta'
|
||||
_description = 'Venta SKEEN'
|
||||
@@ -247,6 +257,346 @@ class SkeenVenta(models.Model):
|
||||
'stock_moves': len(movimientos),
|
||||
}
|
||||
|
||||
# ============================================================
|
||||
# Edición de un ticket ya emitido (gerencia / administrador)
|
||||
# ============================================================
|
||||
|
||||
def _wallet_points_expected(self):
|
||||
"""Puntos que DEBIÓ generar esta venta con sus líneas y total actuales.
|
||||
|
||||
Es la misma fórmula de action_mark_paid: el total se reparte
|
||||
proporcionalmente entre las líneas que generan monedero ($10 = 1 pt).
|
||||
"""
|
||||
self.ensure_one()
|
||||
subtotal_total = sum(l.subtotal for l in self.line_ids)
|
||||
elegible = sum(
|
||||
l.subtotal for l in self.line_ids
|
||||
if (l.service_id and l.service_id.genera_monedero)
|
||||
or (l.item_id and l.item_id.genera_monedero)
|
||||
)
|
||||
return int(self.total * (elegible / subtotal_total) / 10) if subtotal_total else 0
|
||||
|
||||
def _wallet_points_accrued(self):
|
||||
"""Puntos realmente acumulados por esta venta (suma de abonos a su referencia)."""
|
||||
self.ensure_one()
|
||||
txs = self.env['skeen.monedero.transaction'].search([
|
||||
('monedero_id.partner_id', '=', self.partner_id.id),
|
||||
('reference', '=', self.name),
|
||||
('type', '=', 'accrual'),
|
||||
])
|
||||
return sum(t.points or 0 for t in txs)
|
||||
|
||||
def _sync_wallet_after_edit(self, prev_accrued, editor=''):
|
||||
"""Ajusta el monedero a los puntos que corresponde tras editar la venta.
|
||||
|
||||
Solo actúa si la venta ya había generado puntos (una venta no pagada no
|
||||
tiene nada que sincronizar). Devuelve el delta aplicado.
|
||||
"""
|
||||
self.ensure_one()
|
||||
if prev_accrued <= 0:
|
||||
return 0
|
||||
monedero = self.env['skeen.monedero'].search(
|
||||
[('partner_id', '=', self.partner_id.id)], limit=1)
|
||||
if not monedero:
|
||||
return 0
|
||||
delta = self._wallet_points_expected() - prev_accrued
|
||||
if not delta:
|
||||
return 0
|
||||
desc = f'Ajuste de puntos por edición del ticket {self.name}'
|
||||
if delta > 0:
|
||||
monedero.add_points(delta, description=desc, reference=self.name,
|
||||
operator=editor)
|
||||
else:
|
||||
monedero.adjust_points(delta, description=desc, reference=self.name,
|
||||
operator=editor)
|
||||
return delta
|
||||
|
||||
def _sync_stock_after_edit(self, prev_sold):
|
||||
"""Iguala el inventario a las líneas nuevas de la venta editada.
|
||||
|
||||
Los movimientos de esta venta se identifican por reference == folio
|
||||
(misma convención que el POS y la cancelación). Devuelve el número de
|
||||
movimientos compensatorios creados.
|
||||
"""
|
||||
self.ensure_one()
|
||||
Move = self.env['skeen.inventario.move']
|
||||
want = {}
|
||||
for line in self.line_ids:
|
||||
if line.item_id:
|
||||
want[line.item_id.id] = want.get(line.item_id.id, 0.0) + (line.quantity or 0.0)
|
||||
creados = Move.browse()
|
||||
for item_id in set(list(want) + list(prev_sold)):
|
||||
diff = want.get(item_id, 0.0) - prev_sold.get(item_id, 0.0)
|
||||
if abs(diff) < 0.0001:
|
||||
continue
|
||||
if diff > 0:
|
||||
mtype, qty = 'venta', diff
|
||||
else:
|
||||
mtype, qty = 'cancelacion', -diff
|
||||
creados |= Move.create({
|
||||
'item_id': item_id,
|
||||
'type': mtype,
|
||||
'qty': qty,
|
||||
'reference': self.name,
|
||||
'notes': f'Ajuste por edición del ticket {self.name}',
|
||||
})
|
||||
return creados
|
||||
|
||||
def _sold_by_item(self):
|
||||
"""Neto descontado en inventario por esta venta, por artículo."""
|
||||
self.ensure_one()
|
||||
moves = self.env['skeen.inventario.move'].search([
|
||||
('reference', '=', self.name),
|
||||
('type', 'in', ('venta', 'cancelacion')),
|
||||
])
|
||||
out = {}
|
||||
for m in moves:
|
||||
out[m.item_id.id] = out.get(m.item_id.id, 0.0) + (
|
||||
m.qty if m.type == 'venta' else -m.qty)
|
||||
return out
|
||||
|
||||
def action_edit(self, *, reason='', lines=None, discount=None, tax=None,
|
||||
notes=None, date=None, editor=''):
|
||||
"""Reescribe un ticket ya emitido corrigiendo líneas/importes/notas.
|
||||
|
||||
Uso: recepción se equivocó al cobrar y gerencia corrige el ticket sin
|
||||
cancelarlo y rehacerlo (que implicaría devolver dinero en caja).
|
||||
|
||||
Mantiene la coherencia de lo que la venta tocó al crearse:
|
||||
- Monedero: recalcula los puntos que debió generar y ajusta la
|
||||
diferencia con un movimiento 'adjustment' a la misma referencia.
|
||||
- Inventario: crea movimientos compensatorios por la diferencia de
|
||||
cantidades entre lo vendido y lo que queda tras la edición.
|
||||
- Paquetes: si cambia o se quita una línea de paquete, sincroniza o
|
||||
anula el saldo del paciente. Se niega si ya se consumieron sesiones.
|
||||
- Estados: si el nuevo total ya no está cubierto, el ticket pasa de
|
||||
'paid' a 'partial' (y se avisa); si sobra dinero, queda el sobre
|
||||
pago como aviso para devolverlo por separado.
|
||||
|
||||
Nada se borra: queda registro en el chatter con el motivo.
|
||||
"""
|
||||
self.ensure_one()
|
||||
reason = (reason or '').strip()
|
||||
if not reason:
|
||||
raise ValidationError(_('Indica el motivo de la corrección del ticket.'))
|
||||
if self.state == 'cancelled':
|
||||
raise ValidationError(_(
|
||||
'El ticket %s está cancelado y no se puede editar.', self.name))
|
||||
if lines is not None and not lines:
|
||||
raise ValidationError(_('El ticket debe conservar al menos un concepto.'))
|
||||
|
||||
# ---------- foto de antes ----------
|
||||
prev_total, prev_subtotal = self.total, self.subtotal
|
||||
prev_accrued = self._wallet_points_accrued()
|
||||
prev_sold = self._sold_by_item()
|
||||
prev_lines = {
|
||||
l.id: {'quantity': l.quantity, 'price_unit': l.price_unit,
|
||||
'paquete_id': l.paquete_id.id if l.paquete_id else None,
|
||||
'description': _etiqueta_line(l)}
|
||||
for l in self.line_ids
|
||||
}
|
||||
|
||||
# ---------- paquetes: validar y liberar los que salen ----------
|
||||
paquetes_afectados = self.env['skeen.paquete.compra']
|
||||
keep_ids = {int(p.get('id')) for p in (lines or []) if p.get('id')}
|
||||
if lines is not None:
|
||||
for line in self.line_ids:
|
||||
if line.paquete_id and line.id not in keep_ids:
|
||||
compras = self.env['skeen.paquete.compra'].search(
|
||||
[('sale_line_id', '=', line.id)])
|
||||
if any(c.sessions_used for c in compras):
|
||||
raise ValidationError(_(
|
||||
'No puedes quitar la línea "%s": el paciente ya '
|
||||
'consumió sesiones de ese paquete. Cancela el '
|
||||
'paquete desde el módulo Paquetes.',
|
||||
_etiqueta_line(line)))
|
||||
paquetes_afectados |= compras
|
||||
for payload in lines:
|
||||
if not payload.get('id') or not payload.get('paquete_id'):
|
||||
continue
|
||||
line = self.env['skeen.venta.line'].browse(payload['id'])
|
||||
if line not in self.line_ids or not line.paquete_id:
|
||||
continue
|
||||
compras = self.env['skeen.paquete.compra'].search(
|
||||
[('sale_line_id', '=', line.id)])
|
||||
for c in compras:
|
||||
if c.sessions_used and int(payload.get('quantity') or 1) != \
|
||||
(line.quantity or 0):
|
||||
raise ValidationError(_(
|
||||
'No puedes cambiar la cantidad de "%s": el paciente '
|
||||
'ya consumió sesiones de ese paquete.',
|
||||
_etiqueta_line(line)))
|
||||
|
||||
# ---------- líneas ----------
|
||||
# Solo se sobrescriben los campos que el payload trae: así una edición
|
||||
# que no menciona `paquete_id` no rompe el vínculo del saldo del
|
||||
# paciente, y el service_id (related en las líneas de paquete) sobrevive.
|
||||
if lines is not None:
|
||||
Linea = self.env['skeen.venta.line']
|
||||
current = {l.id: l for l in self.line_ids}
|
||||
vistos = set()
|
||||
for payload in lines:
|
||||
lid = payload.get('id')
|
||||
vieja = current.get(int(lid)) if lid else None
|
||||
if lid and not vieja:
|
||||
raise ValidationError(_('Ese concepto ya no existe en el ticket.'))
|
||||
|
||||
def valor(campo, por_defecto=None):
|
||||
if campo in payload:
|
||||
return payload[campo]
|
||||
return getattr(vieja, campo, por_defecto) if vieja else por_defecto
|
||||
|
||||
service_id = valor('service_id') or False
|
||||
item_id = valor('item_id') or False
|
||||
paquete_id = valor('paquete_id') or False
|
||||
if paquete_id and not service_id:
|
||||
service_id = (self.env['skeen.paquete'].browse(paquete_id)
|
||||
.service_id.id or False)
|
||||
description = valor('description') or ''
|
||||
quantity = float(valor('quantity') or 0.0)
|
||||
price_unit = float(valor('price_unit') or 0.0)
|
||||
prescribed = valor('prescribed_by_id') or False
|
||||
|
||||
if not service_id and not item_id:
|
||||
raise ValidationError(_(
|
||||
'Cada concepto necesita un servicio o un artículo.'))
|
||||
etiqueta = description or 'el concepto'
|
||||
if quantity <= 0:
|
||||
raise ValidationError(_(
|
||||
'La cantidad de "%s" debe ser mayor a cero.', etiqueta))
|
||||
if price_unit < 0:
|
||||
raise ValidationError(_(
|
||||
'El precio de "%s" no puede ser negativo.', etiqueta))
|
||||
|
||||
vals = {
|
||||
'service_id': service_id,
|
||||
'item_id': item_id,
|
||||
'paquete_id': paquete_id,
|
||||
'description': description,
|
||||
'quantity': quantity,
|
||||
'price_unit': price_unit,
|
||||
'prescribed_by_id': prescribed,
|
||||
'is_prescribed': bool(prescribed),
|
||||
}
|
||||
if vieja:
|
||||
vieja.write(vals)
|
||||
vistos.add(vieja.id)
|
||||
else:
|
||||
vals['venta_id'] = self.id
|
||||
vistos.add(Linea.create(vals).id)
|
||||
for line in self.line_ids:
|
||||
if line.id not in vistos:
|
||||
line.unlink()
|
||||
|
||||
# ---------- encabezado ----------
|
||||
head = {}
|
||||
if discount is not None:
|
||||
head['discount'] = float(discount or 0.0)
|
||||
if tax is not None:
|
||||
head['tax'] = float(tax or 0.0)
|
||||
if notes is not None:
|
||||
head['notes'] = notes or ''
|
||||
if date:
|
||||
head['date'] = date
|
||||
if head:
|
||||
self.write(head)
|
||||
|
||||
for c in paquetes_afectados:
|
||||
c.action_cancel()
|
||||
# action_cancel libera los consumos; sessions_total en 0 es lo que
|
||||
# deja el saldo en 'Anulado' (ver _compute_state).
|
||||
c.write({'sessions_total': 0})
|
||||
|
||||
# ---------- sincronizar efectos ----------
|
||||
puntos = self._sync_wallet_after_edit(prev_accrued, editor=editor)
|
||||
movimientos = self._sync_stock_after_edit(prev_sold)
|
||||
|
||||
warnings = []
|
||||
# El estado tiene que seguir a los números: si el total corregido ya no
|
||||
# está cubierto el ticket deja de estar Pagado, y si lo que se cobró ya
|
||||
# alcanza, un ticket Parcial vuelve a Pagado. No se re-acumulan puntos
|
||||
# (eso lo hace _sync_wallet_after_edit, no action_mark_paid).
|
||||
if self.state in ('paid', 'partial'):
|
||||
cubierto = self.amount_paid >= self.total - 0.005
|
||||
if not cubierto and self.state == 'paid':
|
||||
self.write({'state': 'partial'})
|
||||
warnings.append(_(
|
||||
'El nuevo total (%s) es mayor a lo pagado (%s): el ticket pasó '
|
||||
'a Parcial y quedó un saldo de %s.'
|
||||
) % (_fmt_mxn(self.total), _fmt_mxn(self.amount_paid),
|
||||
_fmt_mxn(self.amount_due)))
|
||||
elif cubierto and self.state == 'partial':
|
||||
self.write({'state': 'paid'})
|
||||
warnings.append(_(
|
||||
'Lo cobrado (%s) alcanza para el nuevo total (%s): el ticket '
|
||||
'volvió a Pagado.'
|
||||
) % (_fmt_mxn(self.amount_paid), _fmt_mxn(self.total)))
|
||||
if self.amount_paid - self.total > 0.005 and self.amount_paid:
|
||||
warnings.append(_(
|
||||
'El ticket quedó pagado de más por %s. Ese dinero sigue en caja '
|
||||
'y hay que devolverlo o dejarlo como saldo a favor del paciente.'
|
||||
) % _fmt_mxn(self.amount_paid - self.total))
|
||||
if prev_accrued and puntos < 0:
|
||||
warnings.append(_(
|
||||
'Se quitaron %d puntos del monedero del paciente. Si ya los '
|
||||
'gastó, el ajuste se limitó al saldo disponible.'
|
||||
) % -puntos)
|
||||
if paquetes_afectados:
|
||||
warnings.append(_(
|
||||
'Se anularon %d paquete(s) del saldo del paciente.',
|
||||
len(paquetes_afectados)))
|
||||
|
||||
# ---------- auditoría ----------
|
||||
detalle = self._edit_detalle(prev_lines)
|
||||
author = self.env.ref('base.user_admin', raise_if_not_found=False)
|
||||
if not author:
|
||||
author = self.env['res.users'].sudo().search(
|
||||
[('login', '=', 'admin')], limit=1)
|
||||
auditor = self.with_user(author) if author else self
|
||||
auditor.message_post(body=Markup(
|
||||
'<b>Ticket corregido</b><br/>'
|
||||
'Motivo: {}<br/>'
|
||||
'Corrigió: {}<br/>'
|
||||
'Total anterior: {} → nuevo total: {}<br/>'
|
||||
'Puntos del monedero ajustados: {}<br/>'
|
||||
'Movimientos de inventario compensatorios: {}<br/>'
|
||||
'{}'
|
||||
).format(
|
||||
reason, editor or 'sistema',
|
||||
_fmt_mxn(prev_total), _fmt_mxn(self.total),
|
||||
puntos, len(movimientos), Markup('<br/>').join(detalle),
|
||||
))
|
||||
|
||||
return {
|
||||
'prev_total': prev_total,
|
||||
'total': self.total,
|
||||
'diff': self.total - prev_total,
|
||||
'points_adjusted': puntos,
|
||||
'stock_moves': len(movimientos),
|
||||
'packages_cancelled': len(paquetes_afectados),
|
||||
'warnings': warnings,
|
||||
}
|
||||
|
||||
def _edit_detalle(self, prev_lines):
|
||||
"""Líneas 'antes -> después' para el chatter (solo lo que cambió)."""
|
||||
self.ensure_one()
|
||||
out = []
|
||||
for line in self.line_ids:
|
||||
p = prev_lines.get(line.id)
|
||||
if not p:
|
||||
out.append(Markup('+ {} · {} × {} = {}').format(
|
||||
_etiqueta_line(line), line.quantity,
|
||||
_fmt_mxn(line.price_unit), _fmt_mxn(line.subtotal)))
|
||||
elif (abs(p['quantity'] - line.quantity) > 0.0001
|
||||
or abs(p['price_unit'] - line.price_unit) > 0.005):
|
||||
out.append(Markup('~ {} → {} × {} = {}').format(
|
||||
p['description'], line.quantity,
|
||||
_fmt_mxn(line.price_unit), _fmt_mxn(line.subtotal)))
|
||||
for lid, p in prev_lines.items():
|
||||
if lid not in self.line_ids.ids:
|
||||
out.append(Markup('- {}').format(p['description']))
|
||||
return out
|
||||
|
||||
def action_refund(self, amount=None, reason=''):
|
||||
"""Devolución total o parcial de una venta. Reversible: no borra nada.
|
||||
|
||||
|
||||
@@ -4360,6 +4360,61 @@ class SkeenFrontendController(http.Controller):
|
||||
except Exception as e:
|
||||
return json_response({'status': 'error', 'message': str(e)}, 500)
|
||||
|
||||
@http.route('/skeen/frontend/v1/sales/<int:sale_id>', type='http',
|
||||
auth='none', methods=['PUT', 'OPTIONS'], csrf=False)
|
||||
@require_role('recepcion')
|
||||
@forbid_roles('marketing')
|
||||
def update_sale(self, sale_id, **kw):
|
||||
"""Corrige un ticket ya emitido (gerencia / administrador).
|
||||
|
||||
Recepción a veces cobra mal — un servicio equivocado, una cantidad o un
|
||||
precio alzado, falta un concepto. Antes la única salida era cancelar y
|
||||
rehacer, que revierte el cobro completo. Aquí se modifica el ticket
|
||||
conservando el pago registrado.
|
||||
|
||||
`lines` reemplaza la lista de conceptos (con `id` para editar, sin `id`
|
||||
para alta; las que no aparecen se eliminan). Se requiere `motivo` y
|
||||
queda en el chatter del ticket y en la bitácora de auditoría.
|
||||
"""
|
||||
try:
|
||||
if request.frontend_user.role not in ('admin', 'gerencia'):
|
||||
return json_response({'status': 'error', 'message': 'Sin permiso'}, 403)
|
||||
sale = request.env['skeen.venta'].sudo().browse(sale_id)
|
||||
if not sale.exists():
|
||||
return json_response({'status': 'error', 'message': 'Venta no encontrada'}, 404)
|
||||
data = _parse_json_body()
|
||||
motivo = (data.get('motivo') or data.get('reason') or '').strip()
|
||||
if not motivo:
|
||||
return json_response({'status': 'error',
|
||||
'message': 'Indica el motivo de la corrección'}, 400)
|
||||
antes = {'total': sale.total, 'conceptos': len(sale.line_ids),
|
||||
'state': sale.state}
|
||||
result = sale.action_edit(
|
||||
reason=motivo,
|
||||
lines=data.get('lines'),
|
||||
discount=data.get('discount'),
|
||||
tax=data.get('tax'),
|
||||
notes=data.get('notes'),
|
||||
date=data.get('date'),
|
||||
editor=request.frontend_user.login,
|
||||
)
|
||||
self._auditar('ventas', f'venta:{sale.id}', 'edicion_ticket',
|
||||
old_value='%.2f · %d conceptos · %s' % (
|
||||
antes['total'], antes['conceptos'], antes['state']),
|
||||
new_value='%.2f · %d conceptos · %s' % (
|
||||
sale.total, len(sale.line_ids), sale.state),
|
||||
entity_label=f'{sale.name} · {sale.partner_id.name}',
|
||||
notes=motivo)
|
||||
return json_response({'status': 'success',
|
||||
'sale': self._sale_con_pagos(sale),
|
||||
'result': result})
|
||||
except ValidationError as e:
|
||||
return json_response({'status': 'error',
|
||||
'message': e.args[0] if e.args else str(e)}, 400)
|
||||
except Exception as e:
|
||||
request.env.cr.rollback()
|
||||
return json_response({'status': 'error', 'message': str(e)}, 500)
|
||||
|
||||
@http.route('/skeen/frontend/v1/sales/<int:sale_id>/pay', type='http', auth='none', methods=['POST'], csrf=False)
|
||||
@require_role('recepcion')
|
||||
@forbid_roles('marketing')
|
||||
@@ -4410,7 +4465,7 @@ class SkeenFrontendController(http.Controller):
|
||||
amount = data.get('amount') # None o <=0 => devolución total de lo pagado
|
||||
reason = data.get('reason', '')
|
||||
result = sale.action_refund(amount=amount, reason=reason)
|
||||
return json_response({'status': 'success', 'sale': self._sale_to_dict(sale), 'result': result})
|
||||
return json_response({'status': 'success', 'sale': self._sale_con_pagos(sale), 'result': result})
|
||||
except Exception as e:
|
||||
return json_response({'status': 'error', 'message': str(e)}, 500)
|
||||
|
||||
@@ -4438,7 +4493,7 @@ class SkeenFrontendController(http.Controller):
|
||||
cancelled_by=request.frontend_user.login,
|
||||
restore_stock=bool(data.get('restore_stock', True)),
|
||||
)
|
||||
return json_response({'status': 'success', 'sale': self._sale_to_dict(sale), 'result': result})
|
||||
return json_response({'status': 'success', 'sale': self._sale_con_pagos(sale), 'result': result})
|
||||
except Exception as e:
|
||||
request.env.cr.rollback()
|
||||
return json_response({'status': 'error', 'message': str(e)}, 500)
|
||||
@@ -4518,6 +4573,14 @@ class SkeenFrontendController(http.Controller):
|
||||
except Exception as e:
|
||||
return json_response({'status': 'error', 'message': str(e)}, 500)
|
||||
|
||||
def _sale_con_pagos(self, v):
|
||||
# _sale_to_dict + los pagos ligados: el modal de Tickets los muestra
|
||||
# y tras una corrección hay que devolverlos o parecería que el cobro
|
||||
# se perdió.
|
||||
pagos = [self._payment_to_dict(p)
|
||||
for p in request.env['skeen.pago'].sudo().search([('venta_id', '=', v.id)])]
|
||||
return {**self._sale_to_dict(v), 'pagos': pagos}
|
||||
|
||||
def _sale_to_dict(self, v):
|
||||
return {
|
||||
'id': v.id,
|
||||
@@ -4557,6 +4620,9 @@ class SkeenFrontendController(http.Controller):
|
||||
'is_prescribed': line.is_prescribed,
|
||||
'prescribed_by_id': line.prescribed_by_id.id if line.prescribed_by_id else None,
|
||||
'prescribed_by': line.prescribed_by_id.name if line.prescribed_by_id else None,
|
||||
'paquete_id': line.paquete_id.id if line.paquete_id else None,
|
||||
'paquete': line.paquete_id.name if line.paquete_id else '',
|
||||
'sesiones': line.paquete_id.sessions if line.paquete_id else 0,
|
||||
} for line in v.line_ids],
|
||||
}
|
||||
|
||||
@@ -6081,6 +6147,10 @@ class SkeenFrontendController(http.Controller):
|
||||
'total': total,
|
||||
'page': page,
|
||||
'page_size': page_size,
|
||||
# Los módulos disponibles salen de la selección del modelo para
|
||||
# que el filtro del frontend nunca se desincronice.
|
||||
'modulos': [{'value': k, 'label': v} for k, v in
|
||||
request.env['skeen.config.audit']._fields['module'].selection],
|
||||
'total_pages': (total + page_size - 1) // page_size,
|
||||
})
|
||||
except Exception as e:
|
||||
|
||||
@@ -35,6 +35,7 @@ class SkeenConfigAudit(models.Model):
|
||||
('agenda', 'Agenda'),
|
||||
('paquetes', 'Paquetes'),
|
||||
('monedero', 'Monedero'),
|
||||
('ventas', 'Tickets y cobros'),
|
||||
], string='Módulo', required=True, index=True)
|
||||
|
||||
entity = fields.Char(string='Entidad', index=True,
|
||||
|
||||
Reference in New Issue
Block a user