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

@@ -4,6 +4,7 @@ import { Printer, Wallet, AlertTriangle } from 'lucide-react';
import { Button, Modal, Skeleton, toast } from './ui';
import { odooApi, type Patient, type Appointment, type Sale, type Visita, type EstadoCuenta } from '../services/odoo';
import { useClinicaPrint } from '../lib/ticketConfig';
import { useClinicSettings } from '../lib/clinic-settings';
const fmtMoney = (n: number) =>
@@ -78,12 +79,14 @@ export const EstadoCuentaPrint: FC<EstadoCuentaPrintProps> = ({ isOpen, onClose,
// El hook va antes de cualquier return condicional (reglas de React).
const clinic = useClinicaPrint();
const impuestos = useClinicSettings().impuestos;
if (!paciente) return null;
const fechaEmision = new Date().toLocaleDateString('es-MX', { day: '2-digit', month: 'long', year: 'numeric' });
const totalVentas = ventas.reduce((a, v) => a + v.total, 0);
const totalPagado = ventas.reduce((a, v) => a + v.amount_paid, 0);
const totalPorCobrar = ventas.reduce((a, v) => a + v.amount_due, 0);
const totalImpuesto = ventas.reduce((a, v) => a + (v.tax_total ?? 0), 0);
// Cronología unificada de citas y visitas
const cronologia = [
@@ -164,6 +167,13 @@ export const EstadoCuentaPrint: FC<EstadoCuentaPrintProps> = ({ isOpen, onClose,
</div>
</div>
{impuestos.activo && totalImpuesto > 0 && (
<p className="text-xs text-[#555] mb-4 text-right">
{impuestos.nombre} del periodo: <span className="font-semibold text-[#222]">{fmtMoney(totalImpuesto)}</span>
{impuestos.precios_incluyen_impuesto ? ' (incluido en los precios)' : ''}
</p>
)}
{/* Detalle de ventas */}
<h3 className="text-sm font-bold border-b border-[#ddd] pb-1 mb-2">Detalle de ventas</h3>
{ventas.length === 0 ? (

View File

@@ -13,6 +13,7 @@ import {
import { odooApi } from '../services/odoo';
import { useAuth } from '../lib/auth';
import { useSucursales } from '../lib/sucursales';
import { useClinicSettings } from '../lib/clinic-settings';
// Editor del diseño del ticket (Configuración › Tickets).
//
@@ -61,6 +62,7 @@ const SECCIONES: { bloque: string; label: string; claves: { key: string; label:
{ key: 'subtotal', label: 'Subtotal' },
{ key: 'descuento', label: 'Descuento' },
{ key: 'puntos_canjeados', label: 'Puntos canjeados' },
{ key: 'impuesto', label: 'Impuesto (si está activo)' },
{ key: 'total', label: 'Total' },
{ key: 'total_usd', label: 'Equivalente en dólares' },
],
@@ -138,6 +140,11 @@ export const TicketDesigner: FC = () => {
const config = useTicketConfig();
const { user } = useAuth();
const { sucursales } = useSucursales();
const impuestos = useClinicSettings().impuestos;
// Muestra con impuesto cuando la clínica lo tiene activo (si no, igual que hoy).
const muestra: TicketData = impuestos.activo
? { ...MUESTRA, tax: 480, tax_name: impuestos.nombre }
: MUESTRA;
// Nombre de la sucursal predeterminada para la vista previa (antes fijo).
const sucursalMuestra = (sucursales.find((s) => s.is_default) || sucursales[0])?.name || 'Rosarito Centro';
// Restaurar el formato original queda solo para administración del sistema;
@@ -508,7 +515,7 @@ export const TicketDesigner: FC = () => {
<div className="xl:sticky xl:top-4">
<p className="text-xs font-medium text-theme-muted uppercase mb-2">Vista previa</p>
<div className="rounded-2xl bg-theme-bg border border-theme-border p-4 overflow-auto max-h-[calc(100vh-8rem)]">
<TicketDoc data={{ ...MUESTRA, sucursal: sucursalMuestra }} config={previewConfig} preview />
<TicketDoc data={{ ...muestra, sucursal: sucursalMuestra }} config={previewConfig} preview />
</div>
<p className="text-[11px] text-theme-muted mt-2">
La vista previa usa una venta de ejemplo con todos los rubros. Los datos reales se toman

View File

@@ -19,6 +19,9 @@ export interface TicketData {
lines: TicketLinea[];
subtotal: number;
discount: number;
/** Impuesto del ticket y su etiqueta (vacío cuando los impuestos están apagados) */
tax?: number;
tax_name?: string;
total: number;
metodo?: string | null;
puntos_usados?: number;
@@ -173,6 +176,9 @@ export const TicketDoc: FC<TicketDocProps> = ({ data, config, preview }) => {
{on('puntos_canjeados') && (data.puntos_usados ?? 0) > 0 && (
<Fila izquierda="Puntos canjeados" derecha={`-${data.puntos_usados} pts`} />
)}
{on('impuesto') && (data.tax ?? 0) > 0 && (
<Fila izquierda={data.tax_name || 'Impuesto'} derecha={fmtMXN(data.tax!)} />
)}
{on('total') && <Fila izquierda="Total" derecha={fmtMXN(data.total)} fuerte />}
{on('total_usd') && data.rate_used ? (
<Fila

View File

@@ -0,0 +1,215 @@
import type { FC } from 'react';
import { useEffect, useState, useCallback } from 'react';
import { Percent, Receipt } from 'lucide-react';
import {
BarChart,
Bar,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
} from 'recharts';
import { Card, Input, EmptyState, Skeleton, toast } from '../ui';
import { ExportButton } from '../ui/ExportButton';
import { exportToExcel } from '../../lib/exporter';
import { odooApi, type TaxReportResult } from '../../services/odoo';
import { useReportRange } from '../../lib/reportRange';
// Reporte de impuestos: base, impuesto y total del periodo, agrupado por mes,
// servicio y sucursal. Con los impuestos apagados sale en ceros (no cambia
// ninguna cifra existente).
const formatCurrency = (value: number) =>
new Intl.NumberFormat('es-MX', { style: 'currency', currency: 'MXN', minimumFractionDigits: 2, maximumFractionDigits: 2 }).format(value);
const mesLabel = (mes: string) => {
const [y, m] = mes.split('-');
if (!y || !m) return mes;
return new Date(Number(y), Number(m) - 1, 1).toLocaleDateString('es-MX', { month: 'long', year: 'numeric' });
};
const ImpuestosReport: FC = () => {
const { start, end, setStart, setEnd } = useReportRange('impuestos');
const [data, setData] = useState<TaxReportResult | null>(null);
const [loading, setLoading] = useState(true);
const load = useCallback(async () => {
try {
setLoading(true);
const res = await odooApi.getTaxReport(start, end);
if (res.status === 'success') setData(res);
} catch (err) {
toast.error('Error al cargar impuestos');
console.error(err);
} finally {
setLoading(false);
}
}, [start, end]);
useEffect(() => { load(); }, [load]);
const [exporting, setExporting] = useState(false);
const exportar = async () => {
if (!data) return;
try {
setExporting(true);
const rows: Record<string, unknown>[] = [];
data.por_mes.forEach((m) =>
rows.push({ grupo: 'Por mes', concepto: mesLabel(m.mes), ventas: m.ventas, base: m.base, impuesto: m.impuesto }));
data.por_servicio.forEach((s) =>
rows.push({ grupo: 'Por servicio', concepto: s.servicio, ventas: s.cantidad, base: s.base, impuesto: s.impuesto }));
data.por_sucursal.forEach((s) =>
rows.push({ grupo: 'Por sucursal', concepto: s.sucursal, ventas: null, base: s.base, impuesto: s.impuesto }));
await exportToExcel({
filename: `impuestos-${start}_${end}`,
sheetName: 'Impuestos',
title: `Impuestos ${data.nombre}`,
subtitle: `Del ${start} al ${end}${data.incluido ? ' · precios con impuesto incluido' : ''}`,
columns: [
{ header: 'Grupo', key: 'grupo' },
{ header: 'Concepto', key: 'concepto' },
{ header: 'Cantidad / ventas', key: 'ventas', format: 'number' },
{ header: 'Base', key: 'base', format: 'currency' },
{ header: 'Impuesto', key: 'impuesto', format: 'currency' },
],
rows,
totals: { grupo: '', concepto: 'Impuesto total', base: data.total_base, impuesto: data.total_impuesto },
});
toast.success('Excel descargado');
} catch (err) {
toast.error('Error al exportar');
console.error(err);
} finally {
setExporting(false);
}
};
const chartData = (data?.por_mes || []).map((m) => ({ name: mesLabel(m.mes), impuesto: m.impuesto, base: m.base }));
return (
<div className="space-y-4 sm:space-y-6">
<Card>
<Card.Body>
<div className="flex flex-col sm:flex-row gap-3 sm:gap-4 items-end">
<Input label="Inicio" type="date" value={start} onChange={(e) => setStart(e.target.value)} className="sm:max-w-[180px]" />
<Input label="Fin" type="date" value={end} onChange={(e) => setEnd(e.target.value)} className="sm:max-w-[180px]" />
<div className="sm:ml-auto">
<ExportButton onClick={exportar} loading={exporting} />
</div>
</div>
{data && !data.activo && (
<p className="text-xs text-theme-muted mt-3">
Los impuestos están apagados en la configuración de la clínica; este reporte sale en ceros.
</p>
)}
</Card.Body>
</Card>
{loading ? (
<div className="grid grid-cols-2 md:grid-cols-3 gap-4">
{Array.from({ length: 3 }).map((_, i) => <Skeleton key={i} className="h-24 w-full" />)}
</div>
) : data ? (
<div className="grid grid-cols-2 md:grid-cols-3 gap-4">
<Card className="p-4 bg-theme-bg border-0">
<p className="text-xs text-theme-muted">Base (sin impuesto)</p>
<p className="text-xl font-heading font-semibold text-theme-heading">{formatCurrency(data.total_base)}</p>
</Card>
<Card className="p-4 bg-theme-bg border-0">
<p className="text-xs text-theme-muted">{data.nombre} del periodo</p>
<p className="text-xl font-heading font-semibold text-theme-heading">{formatCurrency(data.total_impuesto)}</p>
</Card>
<Card className="p-4 bg-theme-bg border-0">
<p className="text-xs text-theme-muted">Total con impuesto</p>
<p className="text-xl font-heading font-semibold text-theme-heading">{formatCurrency(data.total)}</p>
</Card>
</div>
) : null}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4 sm:gap-6">
<Card className="lg:col-span-2">
<Card.Body>
<h3 className="font-heading text-xl text-theme-heading mb-4">{data?.nombre || 'Impuesto'} por mes</h3>
{loading ? (
<Skeleton className="h-[280px] w-full" />
) : chartData.length === 0 ? (
<EmptyState title="Sin datos" subtitle="No hay ventas en el periodo." icon={<Percent size={28} />} />
) : (
<ResponsiveContainer width="100%" height={280}>
<BarChart data={chartData} margin={{ top: 8, right: 8, left: -12, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="#e7e5e4" />
<XAxis dataKey="name" axisLine={false} tickLine={false} tick={{ fill: '#78716c', fontSize: 12 }} />
<YAxis axisLine={false} tickLine={false} tick={{ fill: '#78716c', fontSize: 12 }} tickFormatter={(v: number) => formatCurrency(v)} />
<Tooltip
contentStyle={{ borderRadius: '12px', border: 'none', boxShadow: '0 4px 12px rgba(0,0,0,0.08)' }}
formatter={(v) => formatCurrency(Number(v))}
/>
<Bar dataKey="impuesto" name="Impuesto" fill="#57534e" radius={[6, 6, 0, 0]} />
</BarChart>
</ResponsiveContainer>
)}
</Card.Body>
</Card>
<Card>
<Card.Body>
<h3 className="font-heading text-xl text-theme-heading mb-4">Por sucursal</h3>
{loading ? (
<Skeleton className="h-32 w-full" />
) : !data || data.por_sucursal.length === 0 ? (
<p className="text-sm text-theme-muted">Sin datos en el periodo.</p>
) : (
<div className="space-y-2">
{data.por_sucursal.map((s) => (
<div key={s.sucursal} className="flex items-center justify-between p-2.5 bg-theme-bg rounded-lg text-sm">
<span className="text-theme-heading">{s.sucursal}</span>
<span className="font-medium text-theme-heading">{formatCurrency(s.impuesto)}</span>
</div>
))}
</div>
)}
</Card.Body>
</Card>
</div>
<Card>
<Card.Body>
<h3 className="font-heading text-xl text-theme-heading mb-4 flex items-center gap-2">
<Receipt size={18} /> Impuesto por servicio
</h3>
{loading ? (
<Skeleton className="h-40 w-full" />
) : !data || data.por_servicio.length === 0 ? (
<EmptyState title="Sin datos" subtitle="No hay ventas en el periodo." icon={<Receipt size={28} />} />
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="text-left text-theme-muted border-b border-theme-border">
<th className="py-2 pr-4 font-medium">Servicio / artículo</th>
<th className="py-2 pr-4 font-medium text-right">Cantidad</th>
<th className="py-2 pr-4 font-medium text-right">Base</th>
<th className="py-2 font-medium text-right">Impuesto</th>
</tr>
</thead>
<tbody>
{data.por_servicio.map((s) => (
<tr key={s.servicio} className="border-b border-theme-border last:border-0">
<td className="py-2 pr-4 text-theme-heading">{s.servicio}</td>
<td className="py-2 pr-4 text-right text-theme-muted">{s.cantidad.toLocaleString('es-MX')}</td>
<td className="py-2 pr-4 text-right text-theme-heading">{formatCurrency(s.base)}</td>
<td className="py-2 text-right font-medium text-theme-heading">{formatCurrency(s.impuesto)}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</Card.Body>
</Card>
</div>
);
};
export default ImpuestosReport;

View File

@@ -0,0 +1,54 @@
import { useEffect, useState } from 'react';
import { odooApi, type ClinicSettings, CLINIC_SETTINGS_DEFAULT, IMPUESTOS_DEFAULT } from '../services/odoo';
// Ajustes de la clínica compartidos por la sesión (nombre, moneda e impuestos).
// Se leen una sola vez del servidor y se avisa a quien los use cuando cambian.
// Es la fuente que decide si los impuestos están activos en cada pantalla.
const normalizar = (settings?: Partial<ClinicSettings> | null): ClinicSettings => ({
...CLINIC_SETTINGS_DEFAULT,
...(settings || {}),
impuestos: { ...IMPUESTOS_DEFAULT, ...(settings?.impuestos || {}) },
});
let actual: ClinicSettings = normalizar(null);
let peticion: Promise<ClinicSettings> | null = null;
const oyentes = new Set<(s: ClinicSettings) => void>();
const publicar = (s: ClinicSettings) => {
actual = s;
oyentes.forEach((f) => f(s));
};
export const cargarClinicSettings = (): Promise<ClinicSettings> => {
if (peticion) return peticion;
peticion = odooApi.getClinicSettings()
.then((res) => {
if (res.status === 'success' && res.settings) publicar(normalizar(res.settings));
return actual;
})
.catch(() => actual)
.finally(() => { peticion = null; });
return peticion;
};
/** Fuerza la recarga (tras guardar en Configuración). */
export const recargarClinicSettings = (): Promise<ClinicSettings> => {
peticion = null;
return cargarClinicSettings();
};
/** Registra los ajustes ya cargados/guardados sin volver a pedirlos. */
export const publicarClinicSettings = (settings: Partial<ClinicSettings>) =>
publicar(normalizar(settings));
export const useClinicSettings = (): ClinicSettings => {
const [s, setS] = useState(actual);
useEffect(() => {
const f = (v: ClinicSettings) => setS(v);
oyentes.add(f);
cargarClinicSettings().then(setS);
return () => { oyentes.delete(f); };
}, []);
return s;
};

View File

@@ -9,7 +9,7 @@ export const PERM_KEYS: string[] = [
'pacientes.ver', 'pacientes.expediente', 'pacientes.crear', 'pacientes.editar',
'pacientes.eliminar', 'pacientes.exportar', 'pacientes.saldos',
'visitas.ver', 'visitas.crear', 'visitas.editar', 'visitas.eliminar', 'visitas.archivos',
'ventas.vender', 'ventas.cobrar', 'ventas.descuentos', 'ventas.cancelar',
'ventas.vender', 'ventas.cobrar', 'ventas.descuentos', 'ventas.impuestos', 'ventas.cancelar',
'ventas.devolver', 'ventas.reimprimir',
'inventario.ver', 'inventario.ajustar', 'inventario.mover', 'inventario.costos',
'servicios.ver', 'servicios.editar',
@@ -46,6 +46,7 @@ const DEFAULT_PERM_ROLES: Record<string, string[]> = {
'visitas.ver': ALL, 'visitas.crear': W, 'visitas.editar': W,
'visitas.eliminar': W, 'visitas.archivos': W,
'ventas.vender': MONEY, 'ventas.cobrar': WN, 'ventas.descuentos': MONEY,
'ventas.impuestos': MONEY,
'ventas.cancelar': AG, 'ventas.devolver': MONEY, 'ventas.reimprimir': ALL,
'inventario.ver': ALL, 'inventario.ajustar': WN, 'inventario.mover': WN,
'inventario.costos': WN,

View File

@@ -68,6 +68,7 @@ export const FALLBACK_CONFIG: TicketConfig = {
subtotal: true,
descuento: true,
puntos_canjeados: true,
impuesto: true,
total: true,
total_usd: true,
metodo_pago: true,

View File

@@ -14,8 +14,9 @@ import SucursalesPanel from '../components/SucursalesPanel';
import CategoriasPanel from '../components/CategoriasPanel';
import TerminologiaPanel from '../components/TerminologiaPanel';
import { Card, Button, Input, PageHeader, toast } from '../components/ui';
import { odooApi, type Doctor, type ClinicSettings, BRAND_DEFAULT, CLINIC_SETTINGS_DEFAULT } from '../services/odoo';
import { odooApi, type Doctor, type ClinicSettings, type ImpuestosSettings, BRAND_DEFAULT, CLINIC_SETTINGS_DEFAULT, IMPUESTOS_DEFAULT } from '../services/odoo';
import { useAuth } from '../lib/auth';
import { publicarClinicSettings } from '../lib/clinic-settings';
const TABS = [
{ key: 'clinica', label: 'Clínica' },
@@ -101,7 +102,11 @@ const Configuracion: FC = () => {
} catch {
localStorage.removeItem(LEGACY_STORAGE_KEY);
}
if (alive) setSettings(next);
if (alive) setSettings({
...CLINIC_SETTINGS_DEFAULT,
...next,
impuestos: { ...IMPUESTOS_DEFAULT, ...(next.impuestos || {}) },
});
})
.catch(() => {
toast.error('No se pudieron cargar los ajustes de la clínica');
@@ -115,7 +120,10 @@ const Configuracion: FC = () => {
const save = async () => {
try {
const res = await odooApi.putClinicSettings({ ...settings, motivo: 'Ajustes desde Configuración' });
if (res.status === 'success') setSettings(res.settings);
if (res.status === 'success') {
setSettings(res.settings);
publicarClinicSettings(res.settings);
}
toast.success('Configuración guardada');
} catch (err) {
const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message;
@@ -127,6 +135,13 @@ const Configuracion: FC = () => {
setSettings((prev) => ({ ...prev, [field]: value }));
};
const updateImpuesto = (patch: Partial<ImpuestosSettings>) => {
setSettings((prev) => ({
...prev,
impuestos: { ...IMPUESTOS_DEFAULT, ...prev.impuestos, ...patch },
}));
};
// ---- Tipo de cambio USD/MXN ----
const [tcCurrent, setTcCurrent] = useState<{ rate: number; date: string; source: string } | null>(null);
const [tcInput, setTcInput] = useState('');
@@ -506,6 +521,74 @@ const Configuracion: FC = () => {
<p className="text-xs text-theme-muted">
Moneda base: <span className="font-medium text-theme-heading">{settings.moneda_base || 'MXN'}</span>
</p>
{/* Impuestos (por instancia). Apagados = comportamiento de
siempre: sin cálculo, sin desglose y sin renglón en tickets. */}
<div className="border border-theme-border rounded-xl p-3 space-y-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Percent size={16} className="text-theme-muted" />
<span className="text-sm font-medium text-theme-heading">Impuestos</span>
</div>
<label className="flex items-center gap-2 text-sm text-theme-muted cursor-pointer">
<input
type="checkbox"
checked={settings.impuestos.activo}
onChange={(e) => updateImpuesto({ activo: e.target.checked })}
className="h-4 w-4 rounded border-theme-border-strong"
/>
Calcular impuestos
</label>
</div>
<p className="text-xs text-theme-muted">
{settings.impuestos.activo
? 'El impuesto se muestra y se cobra en POS, tickets y reportes.'
: 'Apagado: los totales, tickets y reportes salen exactamente como hoy.'}
</p>
{settings.impuestos.activo && (
<>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<Input
label="Nombre del impuesto"
value={settings.impuestos.nombre}
onChange={(e) => updateImpuesto({ nombre: e.target.value })}
placeholder="IVA"
/>
<Input
label="Tasa por defecto (%)"
type="number"
min={0}
max={100}
step={0.01}
value={String(settings.impuestos.tasa_default)}
onChange={(e) => updateImpuesto({ tasa_default: parseFloat(e.target.value) || 0 })}
/>
</div>
<Input
label="Opciones de tasa (%)"
value={settings.impuestos.tasa_opciones.join(', ')}
onChange={(e) => {
const opciones = e.target.value
.split(',')
.map((v) => parseFloat(v.trim()))
.filter((v) => !Number.isNaN(v) && v >= 0 && v <= 100);
updateImpuesto({ tasa_opciones: opciones.length ? opciones : [0] });
}}
placeholder="0, 8, 16"
/>
<label className="flex items-center gap-2 text-sm text-theme-muted cursor-pointer">
<input
type="checkbox"
checked={settings.impuestos.precios_incluyen_impuesto}
onChange={(e) => updateImpuesto({ precios_incluyen_impuesto: e.target.checked })}
className="h-4 w-4 rounded border-theme-border-strong"
/>
Los precios capturados ya incluyen impuesto
</label>
</>
)}
</div>
<div className="pt-2">
<Button onClick={save}>
<Save size={16} className="mr-2" />

View File

@@ -10,6 +10,7 @@ import ConteoPrint from '../components/inventario/ConteoPrint';
import { Card, Button, Input, Select, TextArea, Modal, PageHeader, EmptyState, Skeleton, toast } from '../components/ui';
import { odooApi } from '../services/odoo';
import type { InventoryItem, InventoryMove, InventoryKind, InventoryLevel, InventoryMoveType, InventorySummary, InventoryConteo } from '../services/odoo';
import { useClinicSettings } from '../lib/clinic-settings';
const TABS = [
{ key: 'items', label: 'Items' },
@@ -61,12 +62,14 @@ const MOVE_OPTIONS = [
const emptyItem: Partial<InventoryItem> = {
name: '', kind: 'producto', sku: '', category: '', unit: 'pieza',
qty: 0, qty_optimal: 0, qty_min: 0, cost: 0, price: 0, expiry_date: null, notes: '',
tax_use_default: true, tax_rate: 0,
};
const Inventario: FC = () => {
const [searchParams, setSearchParams] = useSearchParams();
const tab = searchParams.get('tab') || 'items';
const setTab = (t: string) => setSearchParams({ tab: t });
const impuestos = useClinicSettings().impuestos;
const [items, setItems] = useState<InventoryItem[]>([]);
const [summary, setSummary] = useState<InventorySummary>({ levels: { out: 0, critical: 0, low: 0, optimal: 0 }, total_value: 0, count: 0 });
const [loading, setLoading] = useState(true);
@@ -178,7 +181,8 @@ const Inventario: FC = () => {
const openEdit = (it: InventoryItem) => {
setEditItem(it);
setEditForm({ name: it.name, sku: it.sku, category: it.category, unit: it.unit,
cost: it.cost, price: it.price, qty_min: it.qty_min, qty_optimal: it.qty_optimal, notes: it.notes });
cost: it.cost, price: it.price, qty_min: it.qty_min, qty_optimal: it.qty_optimal, notes: it.notes,
tax_use_default: it.tax_use_default !== false, tax_rate: it.tax_rate ?? 0 });
};
const saveEdit = async () => {
@@ -482,6 +486,22 @@ const Inventario: FC = () => {
<Input label="Existencia inicial" type="number" min="0" step="0.01" value={String(newForm.qty ?? 0)} onChange={(e) => setNewForm({ ...newForm, qty: parseFloat(e.target.value) || 0 })} />
<Input label="Costo unitario" type="number" min="0" step="0.01" value={String(newForm.cost ?? 0)} onChange={(e) => setNewForm({ ...newForm, cost: parseFloat(e.target.value) || 0 })} />
<Input label="Precio venta" type="number" min="0" step="0.01" value={String(newForm.price ?? 0)} onChange={(e) => setNewForm({ ...newForm, price: parseFloat(e.target.value) || 0 })} />
{impuestos.activo && (
<Select
label={`Impuesto (${impuestos.nombre})`}
options={[
{ value: 'default', label: `Usar tasa por defecto (${impuestos.tasa_default}%)` },
...[...new Set([...(impuestos.tasa_opciones || []), newForm.tax_rate ?? 0])]
.sort((a, b) => a - b)
.map((o) => ({ value: String(o), label: `${o}%` })),
]}
value={newForm.tax_use_default !== false ? 'default' : String(newForm.tax_rate ?? 0)}
onChange={(e) => {
const v = e.target.value;
setNewForm({ ...newForm, tax_use_default: v === 'default', tax_rate: v === 'default' ? 0 : parseFloat(v) || 0 });
}}
/>
)}
<Input label="Stock mínimo (crítico)" type="number" min="0" step="0.01" value={String(newForm.qty_min ?? 0)} onChange={(e) => setNewForm({ ...newForm, qty_min: parseFloat(e.target.value) || 0 })} />
<Input label="Nivel óptimo" type="number" min="0" step="0.01" value={String(newForm.qty_optimal ?? 0)} onChange={(e) => setNewForm({ ...newForm, qty_optimal: parseFloat(e.target.value) || 0 })} />
{newForm.kind === 'consumible' && (
@@ -500,6 +520,22 @@ const Inventario: FC = () => {
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="sm:col-span-2"><Input label="Nombre" value={editForm.name || ''} onChange={(e) => setEditForm({ ...editForm, name: e.target.value })} /></div>
<Input label="Precio venta" type="number" min="0" step="0.01" value={String(editForm.price ?? 0)} onChange={(e) => setEditForm({ ...editForm, price: parseFloat(e.target.value) || 0 })} />
{impuestos.activo && (
<Select
label={`Impuesto (${impuestos.nombre})`}
options={[
{ value: 'default', label: `Usar tasa por defecto (${impuestos.tasa_default}%)` },
...[...new Set([...(impuestos.tasa_opciones || []), editForm.tax_rate ?? 0])]
.sort((a, b) => a - b)
.map((o) => ({ value: String(o), label: `${o}%` })),
]}
value={editForm.tax_use_default !== false ? 'default' : String(editForm.tax_rate ?? 0)}
onChange={(e) => {
const v = e.target.value;
setEditForm({ ...editForm, tax_use_default: v === 'default', tax_rate: v === 'default' ? 0 : parseFloat(v) || 0 });
}}
/>
)}
<Input label="Costo unitario" type="number" min="0" step="0.01" value={String(editForm.cost ?? 0)} onChange={(e) => setEditForm({ ...editForm, cost: parseFloat(e.target.value) || 0 })} />
<Input label="SKU / Código" value={editForm.sku || ''} onChange={(e) => setEditForm({ ...editForm, sku: e.target.value })} />
<Input label="Línea / Categoría" value={editForm.category || ''} onChange={(e) => setEditForm({ ...editForm, category: e.target.value })} />

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,

View File

@@ -18,6 +18,7 @@ import Layout from '../components/Layout';
import DailyReport from '../components/reportes/DailyReport';
import CortesReport from '../components/reportes/CortesReport';
import IngresosReport from '../components/reportes/IngresosReport';
import ImpuestosReport from '../components/reportes/ImpuestosReport';
import InventarioReport from '../components/reportes/InventarioReport';
import AdeudosReport from '../components/reportes/AdeudosReport';
import ComisionesReport from '../components/reportes/ComisionesReport';
@@ -72,6 +73,7 @@ const FAMILIAS: ReportFamily[] = [
{ key: 'general', label: 'General', desc: 'Resumen del periodo, citas y comisiones' },
{ key: 'diario', label: 'Movimientos diarios', desc: 'Citas, ventas, cobros e inventario del día' },
{ key: 'ingresos', label: 'Ingresos', desc: 'Ventas y formas de pago' },
{ key: 'impuestos', label: 'Impuestos', desc: 'Base e impuesto del periodo por mes, servicio y sucursal' },
{ key: 'cortes', label: 'Cortes de caja', desc: 'Arqueos y cierres por día' },
{ key: 'adeudos', label: 'Adeudos', desc: 'Cartera por cobrar y planes de pago' },
{ key: 'devoluciones', label: 'Devoluciones', desc: 'Ventas devueltas y montos' },
@@ -448,6 +450,8 @@ const ReportesContent: FC = () => {
<CortesReport />
) : tab === 'ingresos' ? (
<IngresosReport />
) : tab === 'impuestos' ? (
<ImpuestosReport />
) : tab === 'inventario' ? (
<InventarioReport />
) : tab === 'adeudos' ? (

View File

@@ -19,6 +19,7 @@ import { odooApi, type Service } from '../services/odoo';
import { useGlosario } from '../lib/glosario';
import { useAuth } from '../lib/auth';
import { useServiceCategories } from '../lib/service-categories';
import { useClinicSettings } from '../lib/clinic-settings';
interface ServiceForm {
name: string;
@@ -32,6 +33,8 @@ interface ServiceForm {
description: string;
color: string;
service_group: string;
tax_use_default: boolean;
tax_rate: string;
}
const emptyForm: ServiceForm = {
@@ -46,12 +49,15 @@ const emptyForm: ServiceForm = {
description: '',
color: '#EDA588',
service_group: '',
tax_use_default: true,
tax_rate: '0',
};
const Servicios: FC = () => {
const { term } = useGlosario();
const { can } = useAuth();
const puedeEditar = can('servicios.editar');
const impuestos = useClinicSettings().impuestos;
const { categories, labelFor } = useServiceCategories();
const categoryOptions = useMemo(
() => [{ value: '', label: 'Todas las categorías' }, ...categories.map((c) => ({ value: c.code, label: c.name }))],
@@ -79,6 +85,12 @@ const Servicios: FC = () => {
const [reordenando, setReordenando] = useState(false);
const [form, setForm] = useState<ServiceForm>(emptyForm);
// Opciones de tasa del selector: las configuradas + la del servicio en edición.
const opcionesImpuesto = useMemo(() => {
const set = new Set<number>([...(impuestos.tasa_opciones || []), parseFloat(form.tax_rate) || 0]);
return [...set].sort((a, b) => a - b);
}, [impuestos.tasa_opciones, form.tax_rate]);
const load = useCallback(async () => {
try {
setLoading(true);
@@ -156,6 +168,8 @@ const Servicios: FC = () => {
description: s.description || '',
color: s.color || '#EDA588',
service_group: s.service_group || '',
tax_use_default: s.tax_use_default !== false,
tax_rate: String(s.tax_rate ?? 0),
});
setModalOpen(true);
};
@@ -177,6 +191,8 @@ const Servicios: FC = () => {
description: s.description || '',
color: s.color || '#EDA588',
service_group: s.service_group || '',
tax_use_default: s.tax_use_default !== false,
tax_rate: String(s.tax_rate ?? 0),
});
setModalOpen(true);
};
@@ -261,6 +277,10 @@ const Servicios: FC = () => {
color: form.color,
service_group: form.service_group,
};
if (impuestos.activo) {
payload.tax_use_default = form.tax_use_default;
payload.tax_rate = form.tax_use_default ? 0 : (parseFloat(form.tax_rate) || 0);
}
try {
setSubmitting(true);
if (editing) {
@@ -566,6 +586,20 @@ const Servicios: FC = () => {
onChange={(e) => setForm({ ...form, color: e.target.value })}
/>
</div>
{impuestos.activo && (
<Select
label={`Impuesto (${impuestos.nombre})`}
options={[
{ value: 'default', label: `Usar tasa por defecto (${impuestos.tasa_default}%)` },
...opcionesImpuesto.map((o) => ({ value: String(o), label: `${o}%` })),
]}
value={form.tax_use_default ? 'default' : form.tax_rate}
onChange={(e) => {
const v = e.target.value;
setForm({ ...form, tax_use_default: v === 'default', tax_rate: v === 'default' ? '0' : v });
}}
/>
)}
<TextArea
label="Descripción"
value={form.description}

View File

@@ -8,6 +8,7 @@ import TicketPrint from '../components/TicketPrint';
import TicketEditor from '../components/TicketEditor';
import { odooApi, type Sale } from '../services/odoo';
import { useAuth } from '../lib/auth';
import { useClinicSettings } from '../lib/clinic-settings';
import { readDateFilterPrefs, writeDateFilterPrefs, DATE_PRESETS, rangeForPreset, type DatePreset } from '../lib/dateRange';
const PREF_KEY = 'skeen_tickets_filtros';
@@ -48,6 +49,7 @@ const PAGE_SIZE = 50;
*/
const Tickets: FC = () => {
const { user, can } = useAuth();
const impuestos = useClinicSettings().impuestos;
const puedeCorregir = user?.role === 'admin' || user?.role === 'gerencia';
const puedeCancelar = can('ventas.cancelar');
const prefs = readDateFilterPrefs(PREF_KEY);
@@ -511,6 +513,8 @@ const Tickets: FC = () => {
})),
subtotal: reimprimir.subtotal,
discount: reimprimir.discount,
tax: reimprimir.tax_total ?? reimprimir.tax ?? 0,
tax_name: impuestos.nombre,
total: reimprimir.total,
amount_paid: reimprimir.amount_paid,
amount_due: reimprimir.amount_due,

View File

@@ -23,6 +23,7 @@ import {
import { odooApi, type Sale, type SaleLine, type Service, type Doctor } from '../services/odoo';
import { exportToExcel } from '../lib/exporter';
import { useAuth } from '../lib/auth';
import { useClinicSettings } from '../lib/clinic-settings';
import { DATE_PRESETS, rangeForPreset, readDateFilterPrefs, writeDateFilterPrefs, type DatePreset } from '../lib/dateRange';
// Rango de fechas recordado entre visitas (igual que los presets de la agenda)
@@ -47,6 +48,7 @@ const paymentMethods = ['Efectivo', 'Tarjeta', 'Transferencia', 'MercadoPago'];
const Ventas: FC = () => {
const { openPatient } = usePatientDrawer();
const { brand, can } = useAuth();
const impuestos = useClinicSettings().impuestos;
const puedeCancelar = can('ventas.cancelar');
const puedeCobrar = can('ventas.cobrar');
const puedeDevolver = can('ventas.devolver');
@@ -904,6 +906,8 @@ const Ventas: FC = () => {
})),
subtotal: printSale.subtotal,
discount: printSale.discount,
tax: printSale.tax_total ?? printSale.tax ?? 0,
tax_name: impuestos.nombre,
total: printSale.total,
amount_paid: printSale.amount_paid,
amount_due: printSale.amount_due,

View File

@@ -316,6 +316,9 @@ export interface Service {
sequence?: number;
sold_90d?: number | null;
genera_monedero?: boolean;
/** Impuesto propio del servicio (%) y si usa la tasa por defecto de la config */
tax_rate?: number;
tax_use_default?: boolean;
}
export interface ImpactSimulationDoctor {
@@ -476,6 +479,9 @@ export interface SaleLine {
quantity: number;
price_unit: number;
subtotal: number;
/** Tasa (%) e importe del impuesto de la línea (0 con impuestos apagados) */
tax_rate?: number;
tax_amount?: number;
is_prescribed?: boolean;
prescribed_by_id?: number | null;
prescribed_by?: string | null;
@@ -531,6 +537,9 @@ export interface Sale {
subtotal: number;
discount: number;
tax: number;
/** Impuesto sumado de las líneas y base sin impuesto (0 = sin impuestos) */
tax_total?: number;
subtotal_sin_impuesto?: number;
total: number;
amount_paid: number;
amount_due: number;
@@ -757,6 +766,9 @@ export interface InventoryItem {
last_count_date: string | null;
active: boolean;
genera_monedero?: boolean;
/** Impuesto propio del artículo (%) y si usa la tasa por defecto */
tax_rate?: number;
tax_use_default?: boolean;
notes: string;
stock_level: InventoryLevel;
}
@@ -963,6 +975,27 @@ export interface ServiceCategory {
}
// ---- Ajustes de la clínica ----
export interface ImpuestosSettings {
/** Enciende el cálculo del impuesto en POS, tickets y reportes */
activo: boolean;
/** Etiqueta del impuesto (p. ej. 'IVA') */
nombre: string;
/** % aplicado cuando el catálogo no fija uno */
tasa_default: number;
/** El precio capturado ya trae impuesto: el subtotal se desglosa hacia atrás */
precios_incluyen_impuesto: boolean;
/** Tasas ofrecidas en los formularios (%) */
tasa_opciones: number[];
}
export const IMPUESTOS_DEFAULT: ImpuestosSettings = {
activo: false,
nombre: 'IVA',
tasa_default: 0,
precios_incluyen_impuesto: false,
tasa_opciones: [0, 8, 16],
};
export interface ClinicSettings {
nombre: string;
telefono: string;
@@ -976,6 +1009,7 @@ export interface ClinicSettings {
url_publica: string;
moneda_base: string;
nota_ticket: string;
impuestos: ImpuestosSettings;
}
export const CLINIC_SETTINGS_DEFAULT: ClinicSettings = {
@@ -991,6 +1025,7 @@ export const CLINIC_SETTINGS_DEFAULT: ClinicSettings = {
url_publica: '',
moneda_base: 'MXN',
nota_ticket: '',
impuestos: IMPUESTOS_DEFAULT,
};
export interface FrontendUser {
@@ -1407,18 +1442,39 @@ export interface AppNotification {
export interface PosCheckoutPayload {
partner_id: number;
lines: { service_id?: number; item_id?: number; paquete_id?: number; quantity: number; price_unit: number; description?: string; prescribed_by_id?: number }[];
lines: { service_id?: number; item_id?: number; paquete_id?: number; quantity: number; price_unit: number; description?: string; prescribed_by_id?: number; tax_rate?: number }[];
discount?: number;
/** Tasa de impuesto a nivel venta (opcional); cada línea puede traer la suya */
tax_rate?: number;
payment_method?: string;
amount_received?: number;
pay_with_points?: boolean;
currency?: 'MXN' | 'USD';
}
export interface ImpuestosDesglose {
activo: boolean;
nombre: string;
incluido: boolean;
subtotal: number;
descuento: number;
impuesto: number;
total: number;
subtotal_usd?: number;
impuesto_usd?: number;
total_usd?: number;
}
export interface PosCheckoutResult {
status: string;
sale: Sale;
payment: Payment | null;
/** Desglose de la venta (subtotal, descuento, impuesto, total) */
subtotal?: number;
descuento?: number;
impuesto?: number;
total?: number;
impuestos?: ImpuestosDesglose;
cambio: number;
/** Efectivo recibido en caja (MXN), para imprimir "Recibido" en el ticket */
recibido?: number;
@@ -1446,6 +1502,42 @@ export interface InventoryReportSummary {
critical: number;
}
// ---- Reporte de impuestos ----
export interface TaxMonthRow {
mes: string;
base: number;
impuesto: number;
ventas: number;
}
export interface TaxServiceRow {
servicio: string;
base: number;
impuesto: number;
cantidad: number;
}
export interface TaxBranchRow {
sucursal: string;
base: number;
impuesto: number;
}
export interface TaxReportResult {
status: string;
start: string;
end: string;
activo: boolean;
nombre: string;
incluido: boolean;
total_base: number;
total_impuesto: number;
total: number;
por_mes: TaxMonthRow[];
por_servicio: TaxServiceRow[];
por_sucursal: TaxBranchRow[];
}
export interface ApiResponse<T> {
status: string;
message?: string;
@@ -2517,6 +2609,11 @@ export const odooApi = {
return data;
},
async getTaxReport(start?: string, end?: string): Promise<TaxReportResult> {
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/reports/taxes`, { params: { start, end } });
return data;
},
// Tipo de cambio
async getExchangeRate(): Promise<{ status: string; current: { rate: number; date: string; source: string } | null; history: { id: number; date: string; rate: number; source: string }[] }> {
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/exchange_rate`);

View File

@@ -29,6 +29,135 @@ CAMPOS = (
'moneda_base', 'nota_ticket',
)
# ---------------------------------------------------------------------------
# Impuestos (bloque anidado dentro del mismo JSON de ajustes).
#
# El impuesto es propio y simple: `account`/`l10n_mx` están desinstalados, así
# que no hay motor fiscal de Odoo. Con `activo=False` TODO el sistema se
# comporta como antes (sin cálculo, sin desglose, sin renglón en tickets); esa
# es la regla de oro y por eso los defaults son apagados y en cero.
#
# activo: enciende el cálculo en POS, tickets y reportes
# nombre: etiqueta del impuesto (p. ej. 'IVA')
# tasa_default: % que se aplica cuando el catálogo no fija una
# precios_incluyen_impuesto: el precio capturado YA trae impuesto; el
# subtotal se desglosa hacia atrás
# tasa_opciones: tasas ofrecidas en los formularios (%)
# ---------------------------------------------------------------------------
IMPUESTOS_DEFAULT = {
'activo': False,
'nombre': 'IVA',
'tasa_default': 0.0,
'precios_incluyen_impuesto': False,
'tasa_opciones': [0, 8, 16],
}
def _num(valor, por_defecto=0.0):
"""Número finito de un valor JSON, sin explotar con None/texto."""
try:
n = float(valor)
except (TypeError, ValueError):
return por_defecto
if n != n or n in (float('inf'), float('-inf')): # NaN / infinitos
return por_defecto
return n
def _bool(valor, por_defecto=False):
if isinstance(valor, bool):
return valor
if valor in ('1', 'true', 'True', 'si', 'sí', 1):
return True
if valor in ('0', 'false', 'False', 'no', 0):
return False
return por_defecto
def normalizar_impuestos(raw):
"""Bloque `impuestos` completo, con defaults, a partir de lo guardado.
Tolerante: nunca lanza. Lo inválido cae al default (una instancia sin
configurar debe verse EXACTAMENTE como hoy).
"""
out = dict(IMPUESTOS_DEFAULT)
if not isinstance(raw, dict):
return out
out['activo'] = _bool(raw.get('activo'), IMPUESTOS_DEFAULT['activo'])
out['precios_incluyen_impuesto'] = _bool(
raw.get('precios_incluyen_impuesto'),
IMPUESTOS_DEFAULT['precios_incluyen_impuesto'])
nombre = str(raw.get('nombre') or '').strip()
out['nombre'] = (nombre or IMPUESTOS_DEFAULT['nombre'])[:40]
out['tasa_default'] = min(max(_num(raw.get('tasa_default')), 0.0), 100.0)
opciones = raw.get('tasa_opciones')
if isinstance(opciones, list):
limpias = []
for o in opciones:
n = _num(o, None)
if n is None:
continue
n = min(max(n, 0.0), 100.0)
if n not in limpias:
limpias.append(n)
if limpias:
out['tasa_opciones'] = sorted(limpias)
return out
def validar_impuestos(raw):
"""Valida el bloque `impuestos` de un PUT. Lanza ValueError si algo no
cuadra (se traduce a 400 en el controlador).
Devuelve SOLO las claves presentes (ya normalizadas) para que el merge no
reinicie lo que el PUT no tocó."""
if raw is None:
return None
if not isinstance(raw, dict):
raise ValueError('El bloque de impuestos debe ser un objeto')
out = {}
if 'activo' in raw:
if not isinstance(raw['activo'], bool):
raise ValueError('El campo activo de impuestos debe ser verdadero o falso')
out['activo'] = raw['activo']
if 'precios_incluyen_impuesto' in raw:
if not isinstance(raw['precios_incluyen_impuesto'], bool):
raise ValueError('precios_incluyen_impuesto debe ser verdadero o falso')
out['precios_incluyen_impuesto'] = raw['precios_incluyen_impuesto']
if 'nombre' in raw:
if not isinstance(raw['nombre'], str) or not raw['nombre'].strip():
raise ValueError('El nombre del impuesto no puede estar vacío')
if len(raw['nombre'].strip()) > 40:
raise ValueError('El nombre del impuesto es demasiado largo (máx. 40)')
out['nombre'] = raw['nombre'].strip()[:40]
if 'tasa_default' in raw:
n = _num(raw['tasa_default'], None)
if n is None:
raise ValueError('La tasa por defecto debe ser un número')
if not (0 <= n <= 100):
raise ValueError('La tasa por defecto debe estar entre 0 y 100')
out['tasa_default'] = n
if 'tasa_opciones' in raw:
opciones = raw['tasa_opciones']
if not isinstance(opciones, list) or not opciones:
raise ValueError('tasa_opciones debe ser una lista de números')
limpias = []
for o in opciones:
n = _num(o, None)
if n is None:
raise ValueError('Cada opción de tasa debe ser un número')
if not (0 <= n <= 100):
raise ValueError('Las opciones de tasa deben estar entre 0 y 100')
if n not in limpias:
limpias.append(n)
out['tasa_opciones'] = sorted(limpias)
return out
def get_impuestos(env):
"""Bloque de impuestos vigente (copia). Fuente única para modelos y API."""
return normalizar_impuestos(get_settings(env).get('impuestos'))
# Horas de agenda de respaldo: el comportamiento de siempre (8:00-19:00).
AGENDA_START_DEFAULT = 8.0
AGENDA_END_DEFAULT = 19.0
@@ -68,6 +197,7 @@ def get_settings(env):
'url_publica': '',
'moneda_base': MONEDA_DEFAULT,
'nota_ticket': '',
'impuestos': dict(IMPUESTOS_DEFAULT),
}
raw = env['ir.config_parameter'].sudo().get_param(SETTINGS_KEY)
if raw:
@@ -79,6 +209,7 @@ def get_settings(env):
for k in CAMPOS:
if k in guardado and guardado[k] is not None:
out[k] = str(guardado[k])
out['impuestos'] = normalizar_impuestos(guardado.get('impuestos'))
return out
@@ -90,6 +221,11 @@ def set_settings(env, vals):
for k in CAMPOS:
if k in vals and vals[k] is not None:
actual[k] = str(vals[k]).strip()
if 'impuestos' in vals:
bloque = validar_impuestos(vals['impuestos'])
if bloque is not None:
# Merge anidado: un PUT parcial (p. ej. solo activo) no borra el resto.
actual['impuestos'] = {**actual['impuestos'], **bloque}
env['ir.config_parameter'].sudo().set_param(
SETTINGS_KEY, json.dumps(actual, ensure_ascii=False))
return get_settings(env)

View File

@@ -51,6 +51,15 @@ class SkeenServicio(models.Model):
help='Si está activo, la venta de este servicio acumula puntos monedero.')
tag_ids = fields.Many2many('skeen.service.tag', string='Etiquetas')
# Impuesto propio del servicio (%). Solo se usa cuando la configuración de
# la clínica tiene los impuestos activos. Con 'Usar tasa por defecto' el
# servicio NO congela una tasa: al vender se toma la de la configuración
# vigente en ese momento.
tax_rate = fields.Float(string='Impuesto (%)', default=0.0,
help='Tasa de impuesto del servicio. Se ignora si usa la tasa por defecto.')
tax_use_default = fields.Boolean(string='Usar tasa de impuesto por defecto', default=True,
help='Si está activo, se aplica la tasa por defecto de la configuración de la clínica.')
# Configuración
active = fields.Boolean(string='Activo', default=True)
sequence = fields.Integer(string='Secuencia', default=10)

View File

@@ -32,6 +32,14 @@ class SkeenInventarioItem(models.Model):
help='Si está activo, la venta de este artículo acumula puntos monedero.')
notes = fields.Text(string='Notas')
# Impuesto propio del artículo (%). Mismo criterio que el catálogo de
# servicios: con la bandera activa se toma la tasa por defecto de la
# configuración al momento de vender.
tax_rate = fields.Float(string='Impuesto (%)', default=0.0,
help='Tasa de impuesto del artículo. Se ignora si usa la tasa por defecto.')
tax_use_default = fields.Boolean(string='Usar tasa de impuesto por defecto', default=True,
help='Si está activo, se aplica la tasa por defecto de la configuración de la clínica.')
move_ids = fields.One2many('skeen.inventario.move', 'item_id', string='Movimientos')
stock_level = fields.Selection([
('out', 'Sin existencias'),

View File

@@ -5,12 +5,36 @@ from odoo.exceptions import ValidationError
from markupsafe import Markup
from odoo.addons.skeen_citas.clinic_settings import get_impuestos
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 impuestos_activos(env):
"""True si la instancia tiene el cálculo de impuestos encendido."""
return bool(get_impuestos(env).get('activo'))
def tasa_efectiva(record, env=None):
"""Tasa (%) que corresponde a un servicio/artículo al momento de vender.
- Con 'usar tasa por defecto' (o sin registro) se toma la tasa por defecto
de la configuración vigente: NO se congela al crear el catálogo.
- Con una tasa propia se respeta ese valor.
Devuelve (tasa, usa_default).
"""
env = env or record.env
cfg = get_impuestos(env)
if record is None or not getattr(record, 'exists', lambda: False)():
return float(cfg.get('tasa_default') or 0.0), True
if getattr(record, 'tax_use_default', False):
return float(cfg.get('tasa_default') or 0.0), True
return float(record.tax_rate or 0.0), False
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'
@@ -43,10 +67,20 @@ class SkeenVenta(models.Model):
# Totales
subtotal = fields.Float(string='Subtotal', compute='_compute_totals', store=True)
discount = fields.Float(string='Descuento', default=0.0)
tax = fields.Float(string='Impuesto', default=0.0)
tax = fields.Float(
string='Impuesto', default=0.0,
help='Con los impuestos de la clínica ACTIVOS, este campo refleja la '
'suma de los impuestos de las líneas (se recalcula al vender). '
'Con los impuestos apagados se conserva el monto capturado a mano.')
total = fields.Float(string='Total', compute='_compute_totals', store=True)
amount_paid = fields.Float(string='Pagado', default=0.0)
amount_due = fields.Float(string='Adeudo', compute='_compute_totals', store=True)
# Desglose consultable para reportes (base sin impuesto + impuesto de las
# líneas). Con los impuestos apagados quedan en 0/igual al subtotal, así
# que no alteran ninguna cifra existente.
tax_total = fields.Float(string='Impuesto (líneas)', compute='_compute_tax_breakdown', store=True)
subtotal_sin_impuesto = fields.Float(string='Base sin impuesto', compute='_compute_tax_breakdown', store=True)
card_amount = fields.Float(
string='Pagado con tarjeta', default=0.0,
help="Monto pagado con terminal física (payment_method='card'). "
@@ -105,14 +139,37 @@ class SkeenVenta(models.Model):
# Notas
notes = fields.Text(string='Notas')
@api.depends('line_ids.subtotal', 'discount', 'tax', 'amount_paid', 'points_discount')
@api.depends('line_ids.subtotal', 'line_ids.tax_amount', 'discount', 'tax',
'amount_paid', 'points_discount')
def _compute_totals(self):
for venta in self:
subtotal = sum(line.subtotal for line in venta.line_ids)
venta.subtotal = subtotal
# La fórmula de siempre: subtotal - descuento - puntos + impuesto.
# Con impuestos apagados `tax` conserva el valor manual, así que el
# total sale idéntico al de antes.
venta.total = max(0.0, subtotal - venta.discount - venta.points_discount + venta.tax)
venta.amount_due = max(0.0, venta.total - venta.amount_paid)
@api.depends('line_ids.subtotal', 'line_ids.tax_amount')
def _compute_tax_breakdown(self):
for venta in self:
venta.subtotal_sin_impuesto = sum(line.subtotal for line in venta.line_ids)
venta.tax_total = sum(line.tax_amount for line in venta.line_ids)
def _sync_tax_from_lines(self):
"""Impuestos ACTIVOS: `tax` = suma de los impuestos de las líneas.
Con los impuestos apagados NO se toca: el monto capturado a mano
(compatibilidad con clientes que lo mandaban por API) se conserva igual.
"""
if not impuestos_activos(self.env):
return
for venta in self:
impuesto = sum((line.tax_amount or 0.0) for line in venta.line_ids)
if abs((venta.tax or 0.0) - impuesto) > 0.000001:
venta.write({'tax': impuesto})
@api.model_create_multi
def create(self, vals_list):
for vals in vals_list:
@@ -497,7 +554,9 @@ class SkeenVenta(models.Model):
head = {}
if discount is not None:
head['discount'] = float(discount or 0.0)
if tax is not None:
# Con impuestos ACTIVOS el impuesto se deriva de las líneas: no se
# acepta un monto manual (el renglón de cada concepto manda).
if tax is not None and not impuestos_activos(self.env):
head['tax'] = float(tax or 0.0)
if notes is not None:
head['notes'] = notes or ''
@@ -698,7 +757,12 @@ class SkeenVentaLine(models.Model):
description = fields.Char(string='Descripción')
quantity = fields.Float(string='Cantidad', default=1.0, required=True)
price_unit = fields.Float(string='Precio Unitario', required=True)
subtotal = fields.Float(string='Subtotal', compute='_compute_subtotal', store=True)
subtotal = fields.Float(string='Subtotal', compute='_compute_amounts', store=True)
# Impuesto propio de la línea: tasa (%) e importe. Se resuelve al vender
# (tasa del catálogo o la por defecto de la configuración); con los
# impuestos apagados la tasa queda en 0 y el importe en 0.
tax_rate = fields.Float(string='Impuesto (%)', default=0.0)
tax_amount = fields.Float(string='Impuesto', compute='_compute_amounts', store=True)
# Artículo recetado por médico (base para comisiones)
is_prescribed = fields.Boolean(string='Recetado', default=False,
help='Marcar si este artículo fue recetado por un médico (entra al cálculo de comisiones).')
@@ -726,22 +790,82 @@ class SkeenVentaLine(models.Model):
'ventas.'
) % {'nombre': line.prescribed_by_id.name})
@api.depends('quantity', 'price_unit')
def _compute_subtotal(self):
@api.depends('quantity', 'price_unit', 'tax_rate')
def _compute_amounts(self):
"""Subtotal e impuesto de la línea.
Con los impuestos apagados (o tasa 0) el subtotal es cantidad × precio
y el impuesto 0: idéntico a como se calculaba antes. Con
'precios_incluyen_impuesto' el precio capturado ya trae impuesto, así
que el subtotal se desglosa hacia atrás.
"""
cfg = get_impuestos(self.env)
activo = bool(cfg.get('activo'))
incluye = bool(cfg.get('precios_incluyen_impuesto'))
for line in self:
line.subtotal = line.quantity * line.price_unit
bruto = (line.quantity or 0.0) * (line.price_unit or 0.0)
tasa = (line.tax_rate or 0.0) if activo else 0.0
if tasa <= 0:
line.subtotal = bruto
line.tax_amount = 0.0
elif incluye:
base = bruto / (1.0 + tasa / 100.0)
line.subtotal = base
line.tax_amount = bruto - base
else:
line.subtotal = bruto
line.tax_amount = bruto * tasa / 100.0
def _tasa_para_linea(self, vals):
"""Tasa (%) a aplicar a una línea nueva cuando el cliente no la manda."""
cfg = get_impuestos(self.env)
if not cfg.get('activo'):
return 0.0
if vals.get('service_id'):
servicio = self.env['skeen.servicio'].browse(vals['service_id'])
if servicio.exists() and not servicio.tax_use_default:
return float(servicio.tax_rate or 0.0)
if vals.get('item_id'):
item = self.env['skeen.inventario.item'].browse(vals['item_id'])
if item.exists() and not item.tax_use_default:
return float(item.tax_rate or 0.0)
return float(cfg.get('tasa_default') or 0.0)
@api.model_create_multi
def create(self, vals_list):
for vals in vals_list:
if vals.get('tax_rate') is None:
vals['tax_rate'] = self._tasa_para_linea(vals)
lines = super(SkeenVentaLine, self).create(vals_list)
lines.mapped('venta_id')._sync_tax_from_lines()
return lines
def write(self, vals):
res = super(SkeenVentaLine, self).write(vals)
self.mapped('venta_id')._sync_tax_from_lines()
return res
def unlink(self):
ventas = self.mapped('venta_id')
res = super(SkeenVentaLine, self).unlink()
ventas._sync_tax_from_lines()
return res
@api.onchange('service_id')
def _onchange_service(self):
if self.service_id:
self.price_unit = self.service_id.price
self.description = self.service_id.name
if impuestos_activos(self.env):
self.tax_rate = self._tasa_para_linea({'service_id': self.service_id.id})
@api.onchange('item_id')
def _onchange_item(self):
if self.item_id:
self.price_unit = self.item_id.price
self.description = self.item_id.name
if impuestos_activos(self.env):
self.tax_rate = self._tasa_para_linea({'item_id': self.item_id.id})
class SkeenCorteCaja(models.Model):

View File

@@ -19,6 +19,7 @@ from odoo.addons.skeen_citas.clinic_settings import (
agenda_bounds as clinic_agenda_bounds,
get_settings as get_clinic_settings,
set_settings as set_clinic_settings,
get_impuestos,
)
from odoo.addons.skeen_citas.glosario import (
GLOSARIO_KEYS as GLOSARIO_KEY_SET,
@@ -3763,6 +3764,10 @@ class SkeenFrontendController(http.Controller):
'service_group': data.get('service_group', ''),
'is_favorite': data.get('is_favorite', False),
}
if 'tax_use_default' in data:
vals['tax_use_default'] = bool(data.get('tax_use_default'))
if 'tax_rate' in data and data.get('tax_rate') is not None:
vals['tax_rate'] = float(data.get('tax_rate') or 0)
service = Servicio.create(vals)
return json_response({'status': 'success', 'service': self._service_to_dict(service)}, 201)
except Exception as e:
@@ -3819,9 +3824,13 @@ class SkeenFrontendController(http.Controller):
if not service.exists():
return json_response({'status': 'error', 'message': 'Servicio no encontrado'}, 404)
data = _parse_json_body()
allowed = ['name', 'code', 'category', 'price', 'duration_min', 'package_price', 'package_sessions', 'package_notes', 'description', 'color', 'service_group', 'is_favorite', 'genera_monedero', 'active', 'sequence']
allowed = ['name', 'code', 'category', 'price', 'duration_min', 'package_price', 'package_sessions', 'package_notes', 'description', 'color', 'service_group', 'is_favorite', 'genera_monedero', 'active', 'sequence', 'tax_rate', 'tax_use_default']
vals = {k: data[k] for k in allowed if k in data}
Servicio = request.env['skeen.servicio'].sudo()
if 'tax_rate' in vals:
vals['tax_rate'] = float(vals['tax_rate'] or 0)
if 'tax_use_default' in vals:
vals['tax_use_default'] = bool(vals['tax_use_default'])
if 'sequence' in vals:
try:
vals['sequence'] = int(vals['sequence'])
@@ -3858,6 +3867,8 @@ class SkeenFrontendController(http.Controller):
'sequence': s.sequence or 0,
'sold_90d': (ventas_90d or {}).get(s.id, 0.0) if ventas_90d is not None else None,
'genera_monedero': s.genera_monedero,
'tax_rate': s.tax_rate,
'tax_use_default': s.tax_use_default,
}
# ============================================================
@@ -4991,6 +5002,8 @@ class SkeenFrontendController(http.Controller):
'subtotal': v.subtotal,
'discount': v.discount,
'tax': v.tax,
'tax_total': v.tax_total,
'subtotal_sin_impuesto': v.subtotal_sin_impuesto,
'total': v.total,
'amount_paid': v.amount_paid,
'amount_due': v.amount_due,
@@ -5017,6 +5030,8 @@ class SkeenFrontendController(http.Controller):
'quantity': line.quantity,
'price_unit': line.price_unit,
'subtotal': line.subtotal,
'tax_rate': line.tax_rate,
'tax_amount': line.tax_amount,
'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,
@@ -5061,7 +5076,37 @@ class SkeenFrontendController(http.Controller):
item_lines = [] # líneas de artículo: para el move 'venta' tras confirmar
paquete_lines = [] # (paquete_id, qty, price): materializan el saldo del paciente
stock_warnings = []
subtotal = 0.0
# Impuestos (config de la instancia). Apagados => todo idéntico a hoy.
cfg_imp = get_impuestos(request.env)
imp_activo = bool(cfg_imp.get('activo'))
imp_incluye = bool(cfg_imp.get('precios_incluyen_impuesto'))
puede_tax = has_perm(request.frontend_user, 'ventas.impuestos')
sale_tax = data.get('tax_rate') # tasa por venta (opcional)
base_total = 0.0
tax_total = 0.0
bruto_total = 0.0
def _tasa_linea(servicio=None, item=None, pedida=None):
"""Tasa (%) efectiva de una línea: catálogo -> default -> pedida."""
if servicio is not None:
cat = float(cfg_imp.get('tasa_default') or 0.0) if servicio.tax_use_default \
else float(servicio.tax_rate or 0.0)
elif item is not None:
cat = float(cfg_imp.get('tasa_default') or 0.0) if item.tax_use_default \
else float(item.tax_rate or 0.0)
else:
cat = float(cfg_imp.get('tasa_default') or 0.0)
if not imp_activo:
return 0.0
if pedida is None:
return cat
pedida = float(pedida)
# Sin permiso de impuestos se ignora el ajuste y manda el
# catálogo (la UI ya lo deshabilita; el backend es la autoridad).
if abs(pedida - cat) > 0.000001 and not puede_tax:
return cat
return pedida
for l in lines:
qty = float(l.get('quantity', 0) or 0)
price = float(l.get('price_unit', 0) or 0)
@@ -5077,16 +5122,19 @@ class SkeenFrontendController(http.Controller):
paquete_id).service_id.id or 0)
if bool(service_id) == bool(item_id):
return json_response({'status': 'error', 'message': 'Cada línea debe tener un servicio o un artículo'}, 400)
subtotal += qty * price
pedida = l.get('tax_rate', sale_tax)
bruto = qty * price
if item_id:
item = request.env['skeen.inventario.item'].sudo().browse(item_id)
if not item.exists() or not item.active:
return json_response({'status': 'error', 'message': 'Artículo no encontrado o inactivo'}, 404)
tasa = _tasa_linea(item=item, pedida=pedida)
line_vals.append((0, 0, {
'item_id': item.id,
'description': l.get('description') or item.name,
'quantity': qty,
'price_unit': price,
'tax_rate': tasa,
'is_prescribed': bool(l.get('prescribed_by_id')),
'prescribed_by_id': l.get('prescribed_by_id') or False,
}))
@@ -5097,18 +5145,31 @@ class SkeenFrontendController(http.Controller):
servicio = request.env['skeen.servicio'].sudo().browse(service_id)
if not servicio.exists():
return json_response({'status': 'error', 'message': 'Servicio no encontrado'}, 404)
tasa = _tasa_linea(servicio=servicio, pedida=pedida)
line_vals.append((0, 0, {
'service_id': servicio.id,
'description': l.get('description') or servicio.name,
'quantity': qty,
'price_unit': price,
'tax_rate': tasa,
'is_prescribed': bool(l.get('prescribed_by_id')),
'prescribed_by_id': l.get('prescribed_by_id') or False,
'paquete_id': paquete_id or False,
}))
if paquete_id:
paquete_lines.append((paquete_id, qty, price))
total_est = max(0.0, subtotal - discount)
# Desglose por línea (mismo cálculo que el modelo).
bruto_total += bruto
if tasa <= 0:
base_total += bruto
elif imp_incluye:
base = bruto / (1.0 + tasa / 100.0)
base_total += base
tax_total += bruto - base
else:
base_total += bruto
tax_total += bruto * tasa / 100.0
total_est = max(0.0, base_total - discount + tax_total)
# Moneda y tipo de cambio: validar ANTES de crear nada
currency = data.get('currency', 'MXN')
@@ -5240,10 +5301,30 @@ class SkeenFrontendController(http.Controller):
venta.action_mark_paid()
partner.invalidate_recordset()
# Desglose de impuestos cobrados (con impuestos apagados: base =
# subtotal, impuesto = 0, total = el de siempre).
desglose = {
'activo': imp_activo,
'nombre': cfg_imp.get('nombre') or 'Impuesto',
'incluido': imp_incluye,
'subtotal': venta.subtotal_sin_impuesto,
'descuento': venta.discount,
'impuesto': venta.tax_total,
'total': venta.total,
}
if rate_used:
desglose['subtotal_usd'] = round(desglose['subtotal'] / rate_used, 2)
desglose['impuesto_usd'] = round(desglose['impuesto'] / rate_used, 2)
desglose['total_usd'] = round(desglose['total'] / rate_used, 2)
return json_response({
'status': 'success',
'sale': self._sale_to_dict(venta),
'payment': self._payment_to_dict(pago) if pago else None,
'subtotal': desglose['subtotal'],
'descuento': desglose['descuento'],
'impuesto': desglose['impuesto'],
'total': desglose['total'],
'impuestos': desglose,
'cambio': cambio,
'recibido': recibido,
'cambio_usd': cambio_usd,
@@ -5262,6 +5343,10 @@ class SkeenFrontendController(http.Controller):
'expiry_date': c.expiry_date.strftime('%Y-%m-%d') if c.expiry_date else None,
} for c in compras_creadas],
}, 201)
except ValidationError as e:
msg = e.args[0] if e.args else str(e)
status = 403 if str(msg).startswith('Sin permiso') else 400
return json_response({'status': 'error', 'message': msg}, status)
except Exception as e:
return json_response({'status': 'error', 'message': str(e)}, 500)
@@ -6317,6 +6402,76 @@ class SkeenFrontendController(http.Controller):
except Exception as e:
return json_response({'status': 'error', 'message': str(e)}, 500)
@http.route('/skeen/frontend/v1/reports/taxes', type='http', auth='none', methods=['GET', 'OPTIONS'], csrf=False)
@require_role('lectura')
@require_perm('reportes.ver')
def report_taxes(self, **kw):
"""Impuestos del periodo, agrupables por mes, servicio y sucursal.
Con los impuestos apagados las tasas de las líneas son 0, así que la
respuesta sale en ceros (no altera ninguna cifra existente). Solo cuenta
ventas no canceladas, igual que el resto de reportes de dinero.
"""
try:
start = kw.get('start', (date.today().replace(day=1)).strftime('%Y-%m-%d'))
end = kw.get('end', date.today().strftime('%Y-%m-%d'))
cfg = get_impuestos(request.env)
ventas = request.env['skeen.venta'].sudo().search([
('state', '!=', 'cancelled'),
('date', '>=', start), ('date', '<=', end),
])
por_mes = {}
por_servicio = {}
por_sucursal = {}
total_base = 0.0
total_impuesto = 0.0
for v in ventas:
mes = v.date.strftime('%Y-%m') if v.date else ''
sucursal = v.cita_id.branch if v.cita_id and v.cita_id.branch else (v.branch if hasattr(v, 'branch') else '') or 'sin_sucursal'
for line in v.line_ids:
base = line.subtotal or 0.0
impuesto = line.tax_amount or 0.0
total_base += base
total_impuesto += impuesto
m = por_mes.setdefault(mes, {'base': 0.0, 'impuesto': 0.0, 'ventas': 0})
m['base'] += base
m['impuesto'] += impuesto
nombre = (line.service_id.name if line.service_id
else line.item_id.name if line.item_id
else line.description or 'Sin concepto')
s = por_servicio.setdefault(nombre, {'base': 0.0, 'impuesto': 0.0, 'cantidad': 0.0})
s['base'] += base
s['impuesto'] += impuesto
s['cantidad'] += line.quantity or 0.0
su = por_sucursal.setdefault(sucursal, {'base': 0.0, 'impuesto': 0.0})
su['base'] += base
su['impuesto'] += impuesto
# 'ventas' por mes: cuántos tickets del mes tocaron impuestos
por_mes[mes]['ventas'] += 1
return json_response({
'status': 'success',
'start': start,
'end': end,
'activo': bool(cfg.get('activo')),
'nombre': cfg.get('nombre') or 'Impuesto',
'incluido': bool(cfg.get('precios_incluyen_impuesto')),
'total_base': total_base,
'total_impuesto': total_impuesto,
'total': total_base + total_impuesto,
'por_mes': [
{'mes': k, **v} for k, v in sorted(por_mes.items())
],
'por_servicio': [
{'servicio': k, **v}
for k, v in sorted(por_servicio.items(), key=lambda kv: -kv[1]['impuesto'])
],
'por_sucursal': [
{'sucursal': k, **v} for k, v in por_sucursal.items()
],
})
except Exception as e:
return json_response({'status': 'error', 'message': str(e)}, 500)
@http.route('/skeen/frontend/v1/reports/inventory', type='http', auth='none', methods=['GET', 'OPTIONS'], csrf=False)
@require_role('lectura')
def report_inventory(self, **kw):
@@ -6950,6 +7105,9 @@ class SkeenFrontendController(http.Controller):
notes=(data.get('motivo') or 'actualizado').strip())
return json_response({'status': 'success', 'settings': nuevo,
'message': 'Ajustes actualizados'})
except ValueError as e:
request.env.cr.rollback()
return json_response({'status': 'error', 'message': str(e)}, 400)
except Exception as e:
request.env.cr.rollback()
return json_response({'status': 'error', 'message': str(e)}, 500)
@@ -7501,6 +7659,8 @@ class SkeenFrontendController(http.Controller):
'last_count_date': it.last_count_date.strftime('%Y-%m-%d') if it.last_count_date else None,
'active': it.active,
'genera_monedero': it.genera_monedero,
'tax_rate': it.tax_rate,
'tax_use_default': it.tax_use_default,
'notes': it.notes or '',
'stock_level': it.stock_level,
}
@@ -7573,6 +7733,10 @@ class SkeenFrontendController(http.Controller):
'expiry_date': data.get('expiry_date') or False,
'notes': data.get('notes', ''),
}
if 'tax_use_default' in data:
vals['tax_use_default'] = bool(data.get('tax_use_default'))
if 'tax_rate' in data and data.get('tax_rate') is not None:
vals['tax_rate'] = float(data.get('tax_rate') or 0)
item = request.env['skeen.inventario.item'].sudo().create(vals)
return json_response({'status': 'success', 'item': self._item_to_dict(item)}, 201)
except Exception as e:
@@ -7589,14 +7753,17 @@ class SkeenFrontendController(http.Controller):
return json_response({'status': 'error', 'message': 'Item no encontrado'}, 404)
data = _parse_json_body()
allowed = ['name', 'kind', 'sku', 'category', 'unit', 'qty_optimal', 'qty_min',
'cost', 'price', 'expiry_date', 'notes', 'active', 'genera_monedero']
'cost', 'price', 'expiry_date', 'notes', 'active', 'genera_monedero',
'tax_rate', 'tax_use_default']
vals = {}
for k in allowed:
if k in data:
vals[k] = data[k]
for f in ('qty_optimal', 'qty_min', 'cost', 'price'):
for f in ('qty_optimal', 'qty_min', 'cost', 'price', 'tax_rate'):
if f in vals:
vals[f] = float(vals[f] or 0)
if 'tax_use_default' in vals:
vals['tax_use_default'] = bool(vals['tax_use_default'])
item.write(vals)
return json_response({'status': 'success', 'item': self._item_to_dict(item)})
except Exception as e:

View File

@@ -35,6 +35,7 @@ DEFAULT_TICKET_LAYOUT = {
'subtotal': True,
'descuento': True,
'puntos_canjeados': True,
'impuesto': True,
'total': True,
'total_usd': True,
'metodo_pago': True,

View File

@@ -58,6 +58,7 @@ PERMISSION_GROUPS = [
('ventas.vender', 'Vender (punto de venta)'),
('ventas.cobrar', 'Cobrar y registrar pagos'),
('ventas.descuentos', 'Aplicar descuentos'),
('ventas.impuestos', 'Ajustar el impuesto por línea en la venta'),
('ventas.cancelar', 'Cancelar tickets / ventas'),
('ventas.devolver', 'Hacer devoluciones'),
('ventas.reimprimir', 'Reimprimir tickets'),
@@ -164,6 +165,7 @@ DEFAULT_PERM_ROLES = {
'ventas.vender': MONEY,
'ventas.cobrar': WN, # medico también cobra depósitos de paciente hoy
'ventas.descuentos': MONEY,
'ventas.impuestos': MONEY,
'ventas.cancelar': AG, # hoy: admin/gerencia (chequeo explícito en el endpoint)
'ventas.devolver': MONEY,
'ventas.reimprimir': ALL,