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:
@@ -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 ? (
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
215
frontend/src/components/reportes/ImpuestosReport.tsx
Normal file
215
frontend/src/components/reportes/ImpuestosReport.tsx
Normal 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;
|
||||
54
frontend/src/lib/clinic-settings.ts
Normal file
54
frontend/src/lib/clinic-settings.ts
Normal 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;
|
||||
};
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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 })} />
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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' ? (
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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`);
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -50,6 +50,15 @@ class SkeenServicio(models.Model):
|
||||
genera_monedero = fields.Boolean(string='Genera monedero', default=True,
|
||||
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)
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -35,6 +35,7 @@ DEFAULT_TICKET_LAYOUT = {
|
||||
'subtotal': True,
|
||||
'descuento': True,
|
||||
'puntos_canjeados': True,
|
||||
'impuesto': True,
|
||||
'total': True,
|
||||
'total_usd': True,
|
||||
'metodo_pago': True,
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user