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:
2026-09-24 17:53:22 +00:00
parent dcf2c367f1
commit e89bcdca3a
7 changed files with 881 additions and 4 deletions

View File

@@ -17,6 +17,7 @@ const MODULOS = [
{ value: 'agenda', label: 'Agenda' },
{ value: 'paquetes', label: 'Paquetes' },
{ value: 'monedero', label: 'Monedero' },
{ value: 'ventas', label: 'Tickets y cobros' },
{ value: 'clinica', label: 'Clínica' },
];
@@ -40,6 +41,9 @@ const AuditoriaPanel: FC = () => {
const [loading, setLoading] = useState(true);
const [modulo, setModulo] = useState('');
// Se reemplaza con la lista que devuelve el API (viene de la selección del
// modelo); MODULOS queda como fallback mientras el backend no la mande.
const [modulos, setModulos] = useState<{ value: string; label: string }[]>(MODULOS);
const [usuario, setUsuario] = useState('');
const [entidad, setEntidad] = useState('');
const [desde, setDesde] = useState('');
@@ -59,6 +63,9 @@ const AuditoriaPanel: FC = () => {
});
if (res.status === 'success') {
setRows(res.registros);
if (res.modulos?.length) {
setModulos([{ value: '', label: 'Todos los módulos' }, ...res.modulos]);
}
setTotal(res.total);
setTotalPages(res.total_pages || 1);
}
@@ -92,7 +99,7 @@ const AuditoriaPanel: FC = () => {
<div className="flex flex-col sm:flex-row sm:items-end gap-3 flex-wrap">
<Select
label="Módulo"
options={MODULOS}
options={modulos}
value={modulo}
onChange={(e) => setModulo(e.target.value)}
className="sm:max-w-[190px]"

View File

@@ -0,0 +1,353 @@
import type { FC } from 'react';
import { useMemo, useState } from 'react';
import { Trash2, AlertTriangle, Search } from 'lucide-react';
import { Button, Input, Select, TextArea, Modal, toast } from './ui';
import { odooApi, type Sale, type SaleEditLine } from '../services/odoo';
interface Catalogo {
servicios: { id: number; name: string; price: number }[];
items: { id: number; name: string; price: number; qty: number }[];
}
interface Fila {
/** id de la línea en Odoo; las nuevas van sin id */
id?: number;
service_id?: number | null;
item_id?: number | null;
paquete_id?: number | null;
description: string;
quantity: number;
price_unit: number;
prescribed_by_id?: number | null;
/** nombre del paquete: solo informativo, bloquea cantidad/precio */
paquete?: string;
}
const money = (v: number) =>
'$' + (v ?? 0).toLocaleString('es-MX', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
interface TicketEditorProps {
ticket: Sale;
catalogo: Catalogo;
doctores: { id: number; name: string }[];
onClose: () => void;
onGuardado: (sale: Sale) => void;
}
/**
* Corrección de un ticket ya emitido (gerencia / administrador).
*
* Edita conceptos, cantidades, precios, el médico que cuenta en comisiones y
* el descuento SIN cancelar el ticket: el backend recalcula los puntos del
* monedero y el inventario para que queden coherentes con el nuevo importe.
*/
export const TicketEditor: FC<TicketEditorProps> = ({
ticket,
catalogo,
doctores,
onClose,
onGuardado,
}) => {
const [filas, setFilas] = useState<Fila[]>(() =>
ticket.lines.map((l) => ({
id: l.id,
service_id: l.service_id || null,
item_id: l.item_id || null,
paquete_id: l.paquete_id || null,
description: l.description || l.service || l.item || '',
quantity: l.quantity,
price_unit: l.price_unit,
prescribed_by_id: l.prescribed_by_id || null,
paquete: l.paquete || undefined,
})),
);
const [descuento, setDescuento] = useState(String(ticket.discount ?? 0));
const [notas, setNotas] = useState(ticket.notes || '');
const [motivo, setMotivo] = useState('');
const [buscando, setBuscando] = useState('');
const [guardando, setGuardando] = useState(false);
const [error, setError] = useState('');
const opciones = useMemo(() => {
const q = buscando.trim().toLowerCase();
if (!q) return [];
const svcs = catalogo.servicios
.filter((s) => s.name.toLowerCase().includes(q))
.slice(0, 8)
.map((s) => ({ kind: 'servicio' as const, id: s.id, name: s.name, price: s.price }));
const its = catalogo.items
.filter((i) => i.name.toLowerCase().includes(q))
.slice(0, 8)
.map((i) => ({ kind: 'item' as const, id: i.id, name: i.name, price: i.price }));
return [...svcs, ...its];
}, [buscando, catalogo]);
const subtotal = filas.reduce((a, f) => a + f.quantity * f.price_unit, 0);
const desc = parseFloat(descuento) || 0;
const nuevoTotal = Math.max(0, subtotal - desc);
const dif = nuevoTotal - ticket.total;
const hayPaquete = filas.some((f) => f.paquete_id);
const setFila = (i: number, parche: Partial<Fila>) =>
setFilas((prev) => prev.map((f, j) => (j === i ? { ...f, ...parche } : f)));
const agregar = (op: (typeof opciones)[number]) => {
setFilas((prev) => [
...prev,
{
...(op.kind === 'servicio'
? { service_id: op.id, item_id: null }
: { item_id: op.id, service_id: null }),
description: op.name,
quantity: 1,
price_unit: op.price || 0,
prescribed_by_id: null,
},
]);
setBuscando('');
};
const quitar = (i: number) => setFilas((prev) => prev.filter((_, j) => j !== i));
const guardar = async () => {
setError('');
if (!motivo.trim()) {
setError('Escribe el motivo de la corrección (queda en la bitácora).');
return;
}
if (filas.length === 0) {
setError('El ticket debe conservar al menos un concepto.');
return;
}
if (filas.some((f) => f.quantity <= 0)) {
setError('Revisa las cantidades: ninguna puede ser cero.');
return;
}
const lines: SaleEditLine[] = filas.map((f) => ({
id: f.id,
service_id: f.service_id ?? null,
item_id: f.item_id ?? null,
paquete_id: f.paquete_id ?? null,
description: f.description,
quantity: f.quantity,
price_unit: f.price_unit,
prescribed_by_id: f.prescribed_by_id ?? null,
}));
try {
setGuardando(true);
const res = await odooApi.updateSale(ticket.id, {
motivo: motivo.trim(),
lines,
discount: desc,
notes: notas,
});
const r = res.result;
const extras: string[] = [];
if (r?.points_adjusted) {
extras.push(`${r.points_adjusted > 0 ? '+' : ''}${r.points_adjusted} pts en el monedero`);
}
if (r?.stock_moves) extras.push(`${r.stock_moves} ajuste(s) de inventario`);
toast.success(
`Ticket ${ticket.name} corregido · ${money(r?.prev_total ?? ticket.total)} → ${money(r?.total ?? nuevoTotal)}`
+ (extras.length ? ` · ${extras.join(' · ')}` : ''),
);
(r?.warnings || []).forEach((w) => toast.info(w, { duration: 9000 }));
if (res.sale) onGuardado(res.sale);
onClose();
} catch (err) {
const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message;
setError(msg || 'No se pudo guardar la corrección');
} finally {
setGuardando(false);
}
};
return (
<Modal
isOpen
onClose={onClose}
title={`Corregir ticket ${ticket.name}`}
maxWidth="2xl"
footer={
<>
<Button variant="outline" onClick={onClose} disabled={guardando}>Descartar</Button>
<Button onClick={guardar} loading={guardando}>Guardar corrección</Button>
</>
}
>
<div className="space-y-4">
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 text-sm">
<span className="text-theme-muted">
Paciente: <span className="font-medium text-theme-heading">{ticket.patient}</span>
</span>
<span className="text-theme-muted">
Cobró: <span className="font-medium text-theme-heading">{ticket.user_login || '—'}</span>
</span>
<span className="text-theme-muted">
Pagado: <span className="font-medium text-theme-heading">{money(ticket.amount_paid)}</span>
</span>
</div>
<div>
<p className="text-xs font-medium text-theme-muted uppercase mb-2">Conceptos</p>
<div className="space-y-2">
{filas.map((f, i) => (
<div key={f.id ?? `nueva-${i}`} className="rounded-xl border border-theme-border p-2.5">
<div className="flex items-center gap-2">
<input
value={f.description}
onChange={(e) => setFila(i, { description: e.target.value })}
placeholder="Concepto"
className="flex-1 min-w-0 bg-transparent text-sm font-medium text-theme-heading focus:outline-none"
/>
{f.paquete && (
<span className="text-[11px] px-2 py-0.5 rounded-full bg-theme-accent text-theme-inverse shrink-0">
Paquete · {f.paquete}
</span>
)}
<button
type="button"
onClick={() => quitar(i)}
disabled={!!f.paquete_id}
title={f.paquete_id
? 'Los paquetes se ajustan desde el módulo Paquetes'
: 'Quitar concepto'}
className="text-theme-muted hover:text-rose-600 disabled:opacity-30 shrink-0"
>
<Trash2 size={15} />
</button>
</div>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2 mt-2">
<Input
label="Cant."
type="number"
min={0}
step="1"
value={String(f.quantity)}
disabled={!!f.paquete_id}
onChange={(e) => setFila(i, { quantity: parseFloat(e.target.value) || 0 })}
/>
<Input
label="Precio"
type="number"
min={0}
step="0.01"
value={String(f.price_unit)}
disabled={!!f.paquete_id}
onChange={(e) => setFila(i, { price_unit: parseFloat(e.target.value) || 0 })}
/>
<Select
label="Venta a nombre de"
value={String(f.prescribed_by_id ?? '')}
onChange={(e) => setFila(i, {
prescribed_by_id: e.target.value ? Number(e.target.value) : null,
})}
options={[
{ value: '', label: 'Sin asignar' },
...doctores.map((d) => ({ value: String(d.id), label: d.name })),
]}
/>
<div className="pt-6 text-right text-sm font-semibold text-theme-heading">
{money(f.quantity * f.price_unit)}
</div>
</div>
</div>
))}
</div>
<div className="relative mt-2">
<Search size={15} className="absolute left-3 top-1/2 -translate-y-1/2 text-theme-muted" />
<Input
placeholder="Agregar concepto: busca un servicio o un artículo..."
value={buscando}
onChange={(e) => setBuscando(e.target.value)}
className="pl-9"
/>
{opciones.length > 0 && (
<div className="absolute z-20 mt-1 w-full max-h-64 overflow-auto rounded-xl border border-theme-border bg-theme-surface shadow-lg">
{opciones.map((o) => (
<button
key={`${o.kind}-${o.id}`}
type="button"
onClick={() => agregar(o)}
className="w-full text-left px-3 py-2 text-sm hover:bg-theme-bg flex items-center justify-between gap-2"
>
<span className="text-theme-heading truncate">{o.name}</span>
<span className="text-theme-muted shrink-0 text-xs">
{o.kind === 'servicio' ? 'Servicio' : 'Artículo'} · {money(o.price)}
</span>
</button>
))}
</div>
)}
{buscando.trim() && opciones.length === 0 && (
<p className="mt-1 text-xs text-theme-muted">Sin coincidencias en el catálogo.</p>
)}
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<Input
label="Descuento"
type="number"
min={0}
step="0.01"
value={descuento}
onChange={(e) => setDescuento(e.target.value)}
/>
<Input label="Notas del ticket" value={notas} onChange={(e) => setNotas(e.target.value)} />
</div>
<div className="rounded-xl bg-theme-bg p-3 space-y-1">
<div className="flex items-center justify-between text-sm">
<span className="text-theme-muted">Total actual</span>
<span className="text-theme-heading">{money(ticket.total)}</span>
</div>
<div className="flex items-center justify-between text-sm">
<span className="text-theme-muted">Total corregido</span>
<span className="font-semibold text-theme-heading">{money(nuevoTotal)}</span>
</div>
{Math.abs(dif) > 0.005 && (
<div className="flex items-center justify-between text-xs">
<span className="text-theme-muted">Diferencia</span>
<span className={dif > 0 ? 'text-amber-700' : 'text-emerald-700'}>
{dif > 0 ? '+' : ''}{money(dif)}
</span>
</div>
)}
<p className="text-[11px] text-theme-muted pt-1 border-t border-theme-border">
Lo cobrado no cambia ({money(ticket.amount_paid)}). Si el nuevo total es mayor, el
ticket queda con saldo; si es menor, el dinero sobrante sigue en caja y hay que
devolverlo o dejarlo como saldo a favor. Los puntos del monedero y el inventario se
ajustan solos a la diferencia.
</p>
</div>
{hayPaquete && (
<div className="flex items-start gap-2 text-xs text-amber-800 bg-amber-50 border border-amber-200 rounded-lg px-3 py-2">
<AlertTriangle size={14} className="mt-0.5 shrink-0" />
<span>
Este ticket vendió paquetes: sus conceptos no se editan aquí. Si el paciente ya
consumió sesiones, primero ayústalo o anula el paquete en el módulo Paquetes.
</span>
</div>
)}
<TextArea
label="Motivo de la corrección *"
placeholder="Ej. recepción capturó mal el precio del tratamiento / faltó el cargo del material"
value={motivo}
onChange={(e) => setMotivo(e.target.value)}
/>
{error && (
<p className="text-sm text-rose-600 flex items-center gap-1.5">
<AlertTriangle size={14} /> {error}
</p>
)}
</div>
</Modal>
);
};
export default TicketEditor;

View File

@@ -1,11 +1,13 @@
import type { FC } from 'react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { Link } from 'react-router-dom';
import { Search, Ticket, XCircle, RefreshCw, AlertTriangle, Printer, Receipt, User } from 'lucide-react';
import { Search, Ticket, XCircle, RefreshCw, AlertTriangle, Printer, Receipt, User, Pencil } from 'lucide-react';
import Layout from '../components/Layout';
import { Card, Button, Input, Select, Modal, TextArea, Badge, EmptyState, Skeleton, PageHeader, toast } from '../components/ui';
import TicketPrint from '../components/TicketPrint';
import TicketEditor from '../components/TicketEditor';
import { odooApi, type Sale } from '../services/odoo';
import { useAuth } from '../lib/auth';
import { readDateFilterPrefs, writeDateFilterPrefs, DATE_PRESETS, rangeForPreset, type DatePreset } from '../lib/dateRange';
const PREF_KEY = 'skeen_tickets_filtros';
@@ -45,6 +47,8 @@ const PAGE_SIZE = 50;
* Ventas. Permiso de cancelación: admin y gerencia (el API lo valida).
*/
const Tickets: FC = () => {
const { user } = useAuth();
const puedeCorregir = user?.role === 'admin' || user?.role === 'gerencia';
const prefs = readDateFilterPrefs(PREF_KEY);
const [preset, setPreset] = useState<DatePreset>(prefs?.preset || 'hoy');
const [desde, setDesde] = useState(prefs?.start || rangeForPreset('hoy').start);
@@ -64,6 +68,32 @@ const Tickets: FC = () => {
const [motivo, setMotivo] = useState('');
const [enviando, setEnviando] = useState(false);
const [reimprimir, setReimprimir] = useState<Sale | null>(null);
const [editando, setEditando] = useState<Sale | null>(null);
const [catalogo, setCatalogo] = useState<{
servicios: { id: number; name: string; price: number }[];
items: { id: number; name: string; price: number; qty: number }[];
}>({ servicios: [], items: [] });
const [doctores, setDoctores] = useState<{ id: number; name: string }[]>([]);
// El catálogo y los doctores solo hacen falta para corregir un ticket: se
// cargan perezosamente la primera vez que se abre el editor.
const cargarCatalogos = useCallback(async () => {
if (catalogo.servicios.length) return;
try {
const [sv, iv, dc] = await Promise.all([
odooApi.getServices({ page_size: 200 }),
odooApi.getInventory(),
odooApi.getDoctors(),
]);
setCatalogo({
servicios: (sv.services || []).map((x) => ({ id: x.id, name: x.name, price: x.price })),
items: (iv.items || []).map((x) => ({ id: x.id, name: x.name, price: x.price ?? 0, qty: x.qty })),
});
setDoctores((dc.doctors || []).map((x) => ({ id: x.id, name: x.name })));
} catch {
toast.error('No se pudo cargar el catálogo para corregir el ticket');
}
}, [catalogo.servicios.length]);
const aplicarPreset = (p: DatePreset) => {
setPreset(p);
@@ -264,6 +294,16 @@ const Tickets: FC = () => {
<Button size="sm" variant="ghost" onClick={() => setDetalle(s)} title="Ver detalle del ticket">
<Receipt size={14} />
</Button>
{s.state !== 'cancelled' && puedeCorregir && (
<Button
size="sm"
variant="ghost"
title="Corregir ticket"
onClick={async () => { await cargarCatalogos(); setEditando(s); }}
>
<Pencil size={14} />
</Button>
)}
{s.state !== 'cancelled' && (
<Button size="sm" variant="ghost" onClick={() => abrirCancelar(s)} title="Cancelar ticket (nota de crédito)">
<XCircle size={14} className="text-rose-600" />
@@ -305,6 +345,11 @@ const Tickets: FC = () => {
</Button>
)}
<Button variant="outline" onClick={() => setDetalle(null)}>Cerrar</Button>
{detalle && detalle.state !== 'cancelled' && puedeCorregir && (
<Button variant="secondary" onClick={async () => { const d = detalle; setDetalle(null); await cargarCatalogos(); setEditando(d); }}>
<Pencil size={14} className="mr-1.5" /> Corregir
</Button>
)}
{detalle && detalle.state !== 'cancelled' && (
<Button variant="danger" onClick={() => { const d = detalle; setDetalle(null); abrirCancelar(d); }}>
<XCircle size={14} className="mr-1.5" /> Cancelar ticket
@@ -440,6 +485,16 @@ const Tickets: FC = () => {
)}
</Modal>
{editando && (
<TicketEditor
ticket={editando}
catalogo={{ servicios: catalogo.servicios, items: catalogo.items }}
doctores={doctores}
onClose={() => setEditando(null)}
onGuardado={(sale) => { setDetalle(sale); load(); }}
/>
)}
<TicketPrint
isOpen={!!reimprimir}
onClose={() => setReimprimir(null)}

View File

@@ -365,6 +365,8 @@ export interface ConfigAuditResponse {
page: number;
page_size: number;
total_pages: number;
/** Módulos posibles según la selección del modelo (fallback: MODULOS local) */
modulos?: { value: string; label: string }[];
message?: string;
}
@@ -474,6 +476,33 @@ export interface SaleLine {
is_prescribed?: boolean;
prescribed_by_id?: number | null;
prescribed_by?: string | null;
/** La línea vendió un paquete de sesiones: cantidad y precio no se editan
* libremente (el saldo del paciente depende de ellos). */
paquete_id?: number | null;
paquete?: string;
sesiones?: number;
}
/** Edición de un ticket ya emitido (gerencia): reemplaza líneas e importes. */
export interface SaleEditLine {
id?: number;
service_id?: number | null;
item_id?: number | null;
paquete_id?: number | null;
description: string;
quantity: number;
price_unit: number;
prescribed_by_id?: number | null;
}
export interface SaleEditResult {
prev_total: number;
total: number;
diff: number;
points_adjusted: number;
stock_moves: number;
packages_cancelled: number;
warnings: string[];
}
export interface CommissionRow {
@@ -1892,6 +1921,18 @@ export const odooApi = {
return data;
},
/** Corrige un ticket ya emitido sin cancelarlo (gerencia / admin). */
async updateSale(id: number, payload: {
motivo: string;
lines?: SaleEditLine[];
discount?: number;
notes?: string;
date?: string;
}): Promise<{ status: string; sale: Sale; result: SaleEditResult }> {
const { data } = await axios.put(`${ODOO_BASE}/skeen/frontend/v1/sales/${id}`, payload);
return data;
},
// Última venta del paciente con precios actuales del catálogo (POS: repetir venta)
async getLastSale(patientId: number): Promise<LastSaleResponse> {
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/sales/last`, { params: { patient_id: patientId } });

View File

@@ -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: {} &rarr; 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('~ {} &rarr; {} × {} = {}').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.

View File

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

View File

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