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:
@@ -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]"
|
||||
|
||||
353
frontend/src/components/TicketEditor.tsx
Normal file
353
frontend/src/components/TicketEditor.tsx
Normal 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;
|
||||
@@ -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)}
|
||||
|
||||
@@ -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 } });
|
||||
|
||||
@@ -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