fase 2.2: impuestos configurables por instancia (tasa por servicio/articulo, calculo por linea, desglose en POS/tickets/estado de cuenta, reporte de impuestos, permiso ventas.impuestos) con defaults apagados

This commit is contained in:
2026-10-11 08:04:11 +00:00
parent 28a690aa0f
commit 358b85afcd
22 changed files with 1105 additions and 30 deletions

View File

@@ -21,6 +21,7 @@ import {
import { odooApi, type Service, type Patient, type InventoryItem, type PosCheckoutResult, type Doctor, type Paquete } from '../services/odoo';
import type { PatientPackage } from '../services/odoo';
import { useAuth } from '../lib/auth';
import { useClinicSettings } from '../lib/clinic-settings';
const fmtMoney = (n: number) =>
(n || 0).toLocaleString('es-MX', { style: 'currency', currency: 'MXN', maximumFractionDigits: 2 });
@@ -49,11 +50,15 @@ interface TicketLine {
sesiones?: number;
stock?: number;
unit?: string;
/** Tasa de impuesto (%) de la línea (0 con impuestos apagados) */
tax_rate?: number;
}
const Pos: FC = () => {
const { openPatient } = usePatientDrawer();
const { can } = useAuth();
const impuestos = useClinicSettings().impuestos;
const puedeImpuestos = can('ventas.impuestos');
const [searchParams, setSearchParams] = useSearchParams();
// Catálogo
const [catMode, setCatMode] = useState<'servicios' | 'paquetes' | 'articulos'>('servicios');
@@ -347,16 +352,28 @@ const Pos: FC = () => {
service_id: p.service_id ?? undefined,
paquete_id: p.id,
sesiones: p.sessions,
tax_rate: tasaDePaquete(p),
}];
});
};
// ---- Ticket ----
// Tasa de impuesto que corresponde a un platillo del catálogo (0 si apagado).
const tasaDeServicio = (s: Service) =>
impuestos.activo ? (s.tax_use_default !== false ? impuestos.tasa_default : (s.tax_rate ?? 0)) : 0;
const tasaDeItem = (it: InventoryItem) =>
impuestos.activo ? (it.tax_use_default !== false ? impuestos.tasa_default : (it.tax_rate ?? 0)) : 0;
const tasaDePaquete = (p: Paquete) => {
if (!impuestos.activo) return 0;
const servicio = services.find((s) => s.id === p.service_id);
return servicio ? tasaDeServicio(servicio) : impuestos.tasa_default;
};
const addLine = (service: Service) => { setLines((prev) => {
const key = `s-${service.id}`;
const found = prev.find((l) => l.key === key);
if (found) return prev.map((l) => (l.key === key ? { ...l, qty: l.qty + 1 } : l));
return [...prev, { key, name: service.name, qty: 1, price: service.price, service_id: service.id }];
return [...prev, { key, name: service.name, qty: 1, price: service.price, service_id: service.id, tax_rate: tasaDeServicio(service) }];
});
};
@@ -365,7 +382,7 @@ const Pos: FC = () => {
const key = `i-${item.id}`;
const found = prev.find((l) => l.key === key);
if (found) return prev.map((l) => (l.key === key ? { ...l, qty: l.qty + 1 } : l));
return [...prev, { key, name: item.name, qty: 1, price: item.price || 0, item_id: item.id, stock: item.qty, unit: item.unit }];
return [...prev, { key, name: item.name, qty: 1, price: item.price || 0, item_id: item.id, stock: item.qty, unit: item.unit, tax_rate: tasaDeItem(item) }];
});
};
@@ -380,9 +397,20 @@ const Pos: FC = () => {
if (window.confirm('¿Vaciar el ticket?')) setLines([]);
};
const subtotal = lines.reduce((a, l) => a + l.qty * l.price, 0);
// Desglose del ticket. Con los impuestos apagados: bruto === base, impuesto 0
// y el total sale exactamente igual que antes.
const bruto = lines.reduce((a, l) => a + l.qty * l.price, 0);
const impuestoLinea = (l: TicketLine): number => {
if (!impuestos.activo) return 0;
const t = l.tax_rate ?? 0;
if (t <= 0) return 0;
const b = l.qty * l.price;
return impuestos.precios_incluyen_impuesto ? b - b / (1 + t / 100) : (b * t) / 100;
};
const impuesto = impuestos.activo ? lines.reduce((a, l) => a + impuestoLinea(l), 0) : 0;
const subtotal = impuestos.activo && impuestos.precios_incluyen_impuesto ? bruto - impuesto : bruto;
const discountNum = Math.min(parseFloat(discount) || 0, subtotal);
const total = Math.max(0, subtotal - discountNum);
const total = Math.max(0, subtotal - discountNum + impuesto);
const numItems = lines.reduce((a, l) => a + l.qty, 0);
const puntosPaciente = paciente?.wallet_points ?? 0;
const puedePuntos = puntosPaciente >= total && total > 0;
@@ -443,6 +471,7 @@ const Pos: FC = () => {
...(l.paquete_id ? { paquete_id: l.paquete_id, description: l.name } : {}),
quantity: l.qty,
price_unit: l.price,
...(impuestos.activo ? { tax_rate: l.tax_rate ?? 0 } : {}),
// Asignación a nivel venta: el doctor seleccionado aplica a todas las líneas
...(doctorId ? { prescribed_by_id: parseInt(doctorId, 10) } : {}),
})),
@@ -521,11 +550,15 @@ const Pos: FC = () => {
const key = l.kind === 'service' ? `s-${l.service_id}` : `i-${l.item_id}`;
const found = nuevas.find((x) => x.key === key);
if (found) { found.qty += l.quantity; continue; }
const tasa = l.kind === 'service'
? (() => { const s = services.find((x) => x.id === l.service_id); return s ? tasaDeServicio(s) : impuestos.tasa_default; })()
: (() => { const it = invItems.find((x) => x.id === l.item_id); return it ? tasaDeItem(it) : impuestos.tasa_default; })();
nuevas.push({
key,
name: l.name,
qty: l.quantity,
price: l.price_current ?? l.price_last,
tax_rate: impuestos.activo ? tasa : 0,
...(l.kind === 'service' ? { service_id: l.service_id ?? undefined } : {}),
...(l.kind === 'item' ? { item_id: l.item_id ?? undefined, stock: l.stock ?? undefined, unit: l.unit ?? undefined } : {}),
});
@@ -769,13 +802,37 @@ const Pos: FC = () => {
>
<X size={14} />
</button>
<p className="flex-1 min-w-0 text-sm font-medium text-theme-heading truncate" title={l.name}>
{l.item_id && <Package size={12} className="inline mr-1 text-theme-muted" />}
{l.name}
{l.item_id && l.stock !== undefined && (
<span className="block text-[10px] font-normal text-theme-muted">{l.stock} {l.unit || 'pzas'} en existencia</span>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-theme-heading truncate" title={l.name}>
{l.item_id && <Package size={12} className="inline mr-1 text-theme-muted" />}
{l.name}
{l.item_id && l.stock !== undefined && (
<span className="block text-[10px] font-normal text-theme-muted">{l.stock} {l.unit || 'pzas'} en existencia</span>
)}
</p>
{impuestos.activo && (
<span className="flex items-center gap-1.5 text-[11px] text-theme-muted">
<span>{impuestos.nombre}</span>
{puedeImpuestos ? (
<select
value={String(l.tax_rate ?? 0)}
onChange={(e) => updateLine(l.key, { tax_rate: parseFloat(e.target.value) || 0 })}
className="border border-theme-border-strong rounded-md px-1 py-0.5 text-[11px]"
title={`Tasa de ${impuestos.nombre} de la línea`}
>
{[...new Set([...(impuestos.tasa_opciones || []), l.tax_rate ?? 0])]
.sort((a, b) => a - b)
.map((o) => <option key={o} value={String(o)}>{o}%</option>)}
</select>
) : (
<span>{l.tax_rate ?? 0}%</span>
)}
{impuestoLinea(l) > 0 && (
<span>{fmtMoney(impuestoLinea(l))}</span>
)}
</span>
)}
</p>
</div>
<div className="flex items-center gap-0.5 shrink-0">
<Button variant="outline" size="sm" className="!px-1.5" onClick={() => updateLine(l.key, { qty: Math.max(1, l.qty - 1) })}><Minus size={12} /></Button>
<span className="w-6 text-center text-sm font-medium text-theme-heading">{l.qty}</span>
@@ -807,6 +864,19 @@ const Pos: FC = () => {
</span>
</span>
</div>
{impuestos.activo && (
<div className="flex items-center justify-between text-sm">
<span className="text-theme-muted">
{impuestos.nombre}{impuestos.precios_incluyen_impuesto ? ' (incluido)' : ''}
</span>
<span className="text-theme-heading">
{fmtMoney(impuesto)}
<span className="text-xs text-theme-muted ml-1.5">
{toUsd(impuesto) !== null ? `≈ ${fmtUsd(toUsd(impuesto)!)}` : '—'}
</span>
</span>
</div>
)}
<div className="flex items-center justify-between text-sm gap-2">
<span className="text-theme-muted">Descuento</span>
<span className="flex items-center gap-1.5">
@@ -1407,6 +1477,8 @@ const Pos: FC = () => {
})),
subtotal: resultado.sale.subtotal,
discount: resultado.sale.discount,
tax: resultado.sale.tax_total ?? resultado.impuesto ?? 0,
tax_name: impuestos.nombre,
total: resultado.sale.total,
metodo: resultado.payment ? metodoLabel(resultado.payment.payment_method) : 'Puntos',
puntos_usados: resultado.puntos_usados,