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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user