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: 'agenda', label: 'Agenda' },
|
||||||
{ value: 'paquetes', label: 'Paquetes' },
|
{ value: 'paquetes', label: 'Paquetes' },
|
||||||
{ value: 'monedero', label: 'Monedero' },
|
{ value: 'monedero', label: 'Monedero' },
|
||||||
|
{ value: 'ventas', label: 'Tickets y cobros' },
|
||||||
{ value: 'clinica', label: 'Clínica' },
|
{ value: 'clinica', label: 'Clínica' },
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -40,6 +41,9 @@ const AuditoriaPanel: FC = () => {
|
|||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
const [modulo, setModulo] = useState('');
|
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 [usuario, setUsuario] = useState('');
|
||||||
const [entidad, setEntidad] = useState('');
|
const [entidad, setEntidad] = useState('');
|
||||||
const [desde, setDesde] = useState('');
|
const [desde, setDesde] = useState('');
|
||||||
@@ -59,6 +63,9 @@ const AuditoriaPanel: FC = () => {
|
|||||||
});
|
});
|
||||||
if (res.status === 'success') {
|
if (res.status === 'success') {
|
||||||
setRows(res.registros);
|
setRows(res.registros);
|
||||||
|
if (res.modulos?.length) {
|
||||||
|
setModulos([{ value: '', label: 'Todos los módulos' }, ...res.modulos]);
|
||||||
|
}
|
||||||
setTotal(res.total);
|
setTotal(res.total);
|
||||||
setTotalPages(res.total_pages || 1);
|
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">
|
<div className="flex flex-col sm:flex-row sm:items-end gap-3 flex-wrap">
|
||||||
<Select
|
<Select
|
||||||
label="Módulo"
|
label="Módulo"
|
||||||
options={MODULOS}
|
options={modulos}
|
||||||
value={modulo}
|
value={modulo}
|
||||||
onChange={(e) => setModulo(e.target.value)}
|
onChange={(e) => setModulo(e.target.value)}
|
||||||
className="sm:max-w-[190px]"
|
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 type { FC } from 'react';
|
||||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
import { Link } from 'react-router-dom';
|
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 Layout from '../components/Layout';
|
||||||
import { Card, Button, Input, Select, Modal, TextArea, Badge, EmptyState, Skeleton, PageHeader, toast } from '../components/ui';
|
import { Card, Button, Input, Select, Modal, TextArea, Badge, EmptyState, Skeleton, PageHeader, toast } from '../components/ui';
|
||||||
import TicketPrint from '../components/TicketPrint';
|
import TicketPrint from '../components/TicketPrint';
|
||||||
|
import TicketEditor from '../components/TicketEditor';
|
||||||
import { odooApi, type Sale } from '../services/odoo';
|
import { odooApi, type Sale } from '../services/odoo';
|
||||||
|
import { useAuth } from '../lib/auth';
|
||||||
import { readDateFilterPrefs, writeDateFilterPrefs, DATE_PRESETS, rangeForPreset, type DatePreset } from '../lib/dateRange';
|
import { readDateFilterPrefs, writeDateFilterPrefs, DATE_PRESETS, rangeForPreset, type DatePreset } from '../lib/dateRange';
|
||||||
|
|
||||||
const PREF_KEY = 'skeen_tickets_filtros';
|
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).
|
* Ventas. Permiso de cancelación: admin y gerencia (el API lo valida).
|
||||||
*/
|
*/
|
||||||
const Tickets: FC = () => {
|
const Tickets: FC = () => {
|
||||||
|
const { user } = useAuth();
|
||||||
|
const puedeCorregir = user?.role === 'admin' || user?.role === 'gerencia';
|
||||||
const prefs = readDateFilterPrefs(PREF_KEY);
|
const prefs = readDateFilterPrefs(PREF_KEY);
|
||||||
const [preset, setPreset] = useState<DatePreset>(prefs?.preset || 'hoy');
|
const [preset, setPreset] = useState<DatePreset>(prefs?.preset || 'hoy');
|
||||||
const [desde, setDesde] = useState(prefs?.start || rangeForPreset('hoy').start);
|
const [desde, setDesde] = useState(prefs?.start || rangeForPreset('hoy').start);
|
||||||
@@ -64,6 +68,32 @@ const Tickets: FC = () => {
|
|||||||
const [motivo, setMotivo] = useState('');
|
const [motivo, setMotivo] = useState('');
|
||||||
const [enviando, setEnviando] = useState(false);
|
const [enviando, setEnviando] = useState(false);
|
||||||
const [reimprimir, setReimprimir] = useState<Sale | null>(null);
|
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) => {
|
const aplicarPreset = (p: DatePreset) => {
|
||||||
setPreset(p);
|
setPreset(p);
|
||||||
@@ -264,6 +294,16 @@ const Tickets: FC = () => {
|
|||||||
<Button size="sm" variant="ghost" onClick={() => setDetalle(s)} title="Ver detalle del ticket">
|
<Button size="sm" variant="ghost" onClick={() => setDetalle(s)} title="Ver detalle del ticket">
|
||||||
<Receipt size={14} />
|
<Receipt size={14} />
|
||||||
</Button>
|
</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' && (
|
{s.state !== 'cancelled' && (
|
||||||
<Button size="sm" variant="ghost" onClick={() => abrirCancelar(s)} title="Cancelar ticket (nota de crédito)">
|
<Button size="sm" variant="ghost" onClick={() => abrirCancelar(s)} title="Cancelar ticket (nota de crédito)">
|
||||||
<XCircle size={14} className="text-rose-600" />
|
<XCircle size={14} className="text-rose-600" />
|
||||||
@@ -305,6 +345,11 @@ const Tickets: FC = () => {
|
|||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
<Button variant="outline" onClick={() => setDetalle(null)}>Cerrar</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' && (
|
{detalle && detalle.state !== 'cancelled' && (
|
||||||
<Button variant="danger" onClick={() => { const d = detalle; setDetalle(null); abrirCancelar(d); }}>
|
<Button variant="danger" onClick={() => { const d = detalle; setDetalle(null); abrirCancelar(d); }}>
|
||||||
<XCircle size={14} className="mr-1.5" /> Cancelar ticket
|
<XCircle size={14} className="mr-1.5" /> Cancelar ticket
|
||||||
@@ -440,6 +485,16 @@ const Tickets: FC = () => {
|
|||||||
)}
|
)}
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
|
{editando && (
|
||||||
|
<TicketEditor
|
||||||
|
ticket={editando}
|
||||||
|
catalogo={{ servicios: catalogo.servicios, items: catalogo.items }}
|
||||||
|
doctores={doctores}
|
||||||
|
onClose={() => setEditando(null)}
|
||||||
|
onGuardado={(sale) => { setDetalle(sale); load(); }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<TicketPrint
|
<TicketPrint
|
||||||
isOpen={!!reimprimir}
|
isOpen={!!reimprimir}
|
||||||
onClose={() => setReimprimir(null)}
|
onClose={() => setReimprimir(null)}
|
||||||
|
|||||||
@@ -365,6 +365,8 @@ export interface ConfigAuditResponse {
|
|||||||
page: number;
|
page: number;
|
||||||
page_size: number;
|
page_size: number;
|
||||||
total_pages: number;
|
total_pages: number;
|
||||||
|
/** Módulos posibles según la selección del modelo (fallback: MODULOS local) */
|
||||||
|
modulos?: { value: string; label: string }[];
|
||||||
message?: string;
|
message?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -474,6 +476,33 @@ export interface SaleLine {
|
|||||||
is_prescribed?: boolean;
|
is_prescribed?: boolean;
|
||||||
prescribed_by_id?: number | null;
|
prescribed_by_id?: number | null;
|
||||||
prescribed_by?: string | 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 {
|
export interface CommissionRow {
|
||||||
@@ -1892,6 +1921,18 @@ export const odooApi = {
|
|||||||
return data;
|
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)
|
// Última venta del paciente con precios actuales del catálogo (POS: repetir venta)
|
||||||
async getLastSale(patientId: number): Promise<LastSaleResponse> {
|
async getLastSale(patientId: number): Promise<LastSaleResponse> {
|
||||||
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/sales/last`, { params: { patient_id: patientId } });
|
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
|
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):
|
class SkeenVenta(models.Model):
|
||||||
_name = 'skeen.venta'
|
_name = 'skeen.venta'
|
||||||
_description = 'Venta SKEEN'
|
_description = 'Venta SKEEN'
|
||||||
@@ -247,6 +257,346 @@ class SkeenVenta(models.Model):
|
|||||||
'stock_moves': len(movimientos),
|
'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=''):
|
def action_refund(self, amount=None, reason=''):
|
||||||
"""Devolución total o parcial de una venta. Reversible: no borra nada.
|
"""Devolución total o parcial de una venta. Reversible: no borra nada.
|
||||||
|
|
||||||
|
|||||||
@@ -4360,6 +4360,61 @@ class SkeenFrontendController(http.Controller):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
return json_response({'status': 'error', 'message': str(e)}, 500)
|
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)
|
@http.route('/skeen/frontend/v1/sales/<int:sale_id>/pay', type='http', auth='none', methods=['POST'], csrf=False)
|
||||||
@require_role('recepcion')
|
@require_role('recepcion')
|
||||||
@forbid_roles('marketing')
|
@forbid_roles('marketing')
|
||||||
@@ -4410,7 +4465,7 @@ class SkeenFrontendController(http.Controller):
|
|||||||
amount = data.get('amount') # None o <=0 => devolución total de lo pagado
|
amount = data.get('amount') # None o <=0 => devolución total de lo pagado
|
||||||
reason = data.get('reason', '')
|
reason = data.get('reason', '')
|
||||||
result = sale.action_refund(amount=amount, reason=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:
|
except Exception as e:
|
||||||
return json_response({'status': 'error', 'message': str(e)}, 500)
|
return json_response({'status': 'error', 'message': str(e)}, 500)
|
||||||
|
|
||||||
@@ -4438,7 +4493,7 @@ class SkeenFrontendController(http.Controller):
|
|||||||
cancelled_by=request.frontend_user.login,
|
cancelled_by=request.frontend_user.login,
|
||||||
restore_stock=bool(data.get('restore_stock', True)),
|
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:
|
except Exception as e:
|
||||||
request.env.cr.rollback()
|
request.env.cr.rollback()
|
||||||
return json_response({'status': 'error', 'message': str(e)}, 500)
|
return json_response({'status': 'error', 'message': str(e)}, 500)
|
||||||
@@ -4518,6 +4573,14 @@ class SkeenFrontendController(http.Controller):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
return json_response({'status': 'error', 'message': str(e)}, 500)
|
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):
|
def _sale_to_dict(self, v):
|
||||||
return {
|
return {
|
||||||
'id': v.id,
|
'id': v.id,
|
||||||
@@ -4557,6 +4620,9 @@ class SkeenFrontendController(http.Controller):
|
|||||||
'is_prescribed': line.is_prescribed,
|
'is_prescribed': line.is_prescribed,
|
||||||
'prescribed_by_id': line.prescribed_by_id.id if line.prescribed_by_id else None,
|
'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,
|
'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],
|
} for line in v.line_ids],
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -6081,6 +6147,10 @@ class SkeenFrontendController(http.Controller):
|
|||||||
'total': total,
|
'total': total,
|
||||||
'page': page,
|
'page': page,
|
||||||
'page_size': page_size,
|
'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,
|
'total_pages': (total + page_size - 1) // page_size,
|
||||||
})
|
})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ class SkeenConfigAudit(models.Model):
|
|||||||
('agenda', 'Agenda'),
|
('agenda', 'Agenda'),
|
||||||
('paquetes', 'Paquetes'),
|
('paquetes', 'Paquetes'),
|
||||||
('monedero', 'Monedero'),
|
('monedero', 'Monedero'),
|
||||||
|
('ventas', 'Tickets y cobros'),
|
||||||
], string='Módulo', required=True, index=True)
|
], string='Módulo', required=True, index=True)
|
||||||
|
|
||||||
entity = fields.Char(string='Entidad', index=True,
|
entity = fields.Char(string='Entidad', index=True,
|
||||||
|
|||||||
Reference in New Issue
Block a user