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 } });
|
||||
|
||||
Reference in New Issue
Block a user