fase 3.2: compras y proveedores (modulo skeen_compras: proveedores, ordenes de compra con recepcion total/parcial, costo promedio ponderado con lote y caducidad, pagos a proveedor, cuentas por pagar con antiguedad, reportes de compras y margen por articulo); apagado por defecto

This commit is contained in:
2026-10-11 12:17:23 +00:00
parent ee8a844d96
commit a916753ebe
38 changed files with 4201 additions and 30 deletions

View File

@@ -31,6 +31,7 @@ import Reportes from './pages/Reportes';
import Configuracion from './pages/Configuracion'; import Configuracion from './pages/Configuracion';
import Cumpleanos from './pages/Cumpleanos'; import Cumpleanos from './pages/Cumpleanos';
import Inventario from './pages/Inventario'; import Inventario from './pages/Inventario';
import Compras from './pages/Compras';
import WacrmMessages from './pages/WacrmMessages'; import WacrmMessages from './pages/WacrmMessages';
import WacrmLeads from './pages/WacrmLeads'; import WacrmLeads from './pages/WacrmLeads';
import Chat from './pages/Chat'; import Chat from './pages/Chat';
@@ -108,7 +109,7 @@ const LoginRoute: FC = () => {
const LANDING_CANDIDATOS = [ const LANDING_CANDIDATOS = [
'/agenda', '/mis-ventas', '/pacientes', '/pos', '/visitas', '/consultas', '/agenda', '/mis-ventas', '/pacientes', '/pos', '/visitas', '/consultas',
'/medicos', '/servicios', '/paquetes', '/ventas', '/pagos', '/monedero', '/medicos', '/servicios', '/paquetes', '/ventas', '/pagos', '/monedero',
'/inventario', '/cortes', '/reportes', '/cumpleanos', '/wacrm/messages', '/inventario', '/compras', '/cortes', '/reportes', '/cumpleanos', '/wacrm/messages',
'/wacrm/leads', '/configuracion', '/wacrm/leads', '/configuracion',
]; ];
@@ -121,6 +122,15 @@ const MembresiasRoute: FC = () => {
return <Membresias />; return <Membresias />;
}; };
// Compras vive detrás de `compras.activo`. Apagado, la ruta rebota a "/" aunque
// el rol tenga permiso de menú (así apagarlo vuelve todo al estado anterior,
// incluso entrando por URL).
const ComprasRoute: FC = () => {
const { compras } = useClinicSettings();
if (!compras.activo) return <Navigate to="/" replace />;
return <Compras />;
};
const HomeRoute: FC = () => { const HomeRoute: FC = () => {
const { user, canSee } = useAuth(); const { user, canSee } = useAuth();
if (user?.role === 'medico') return <Navigate to="/agenda" replace />; if (user?.role === 'medico') return <Navigate to="/agenda" replace />;
@@ -181,6 +191,8 @@ const App: FC = () => {
<Route path="/usuarios" element={<Navigate to="/configuracion?tab=usuarios" replace />} /> <Route path="/usuarios" element={<Navigate to="/configuracion?tab=usuarios" replace />} />
<Route path="/cumpleanos" element={<RequireAuth href="/cumpleanos"><Cumpleanos /></RequireAuth>} /> <Route path="/cumpleanos" element={<RequireAuth href="/cumpleanos"><Cumpleanos /></RequireAuth>} />
<Route path="/inventario" element={<RequireAuth href="/inventario"><Inventario /></RequireAuth>} /> <Route path="/inventario" element={<RequireAuth href="/inventario"><Inventario /></RequireAuth>} />
{/* Compras solo cuando la clínica la activa (compras.activo). */}
<Route path="/compras" element={<RequireAuth href="/compras"><ComprasRoute /></RequireAuth>} />
<Route path="/chat" element={<RequireAuth href="/chat"><Chat /></RequireAuth>} /> <Route path="/chat" element={<RequireAuth href="/chat"><Chat /></RequireAuth>} />
<Route path="/wacrm/messages" element={<RequireAuth href="/wacrm/messages"><WacrmMessages /></RequireAuth>} /> <Route path="/wacrm/messages" element={<RequireAuth href="/wacrm/messages"><WacrmMessages /></RequireAuth>} />
<Route path="/wacrm/leads" element={<RequireAuth href="/wacrm/leads"><WacrmLeads /></RequireAuth>} /> <Route path="/wacrm/leads" element={<RequireAuth href="/wacrm/leads"><WacrmLeads /></RequireAuth>} />

View File

@@ -146,17 +146,23 @@ export const ClasicoLayout: FC<LayoutProps> = ({ children, title: _title, subtit
const location = useLocation(); const location = useLocation();
const navigate = useNavigate(); const navigate = useNavigate();
const { user, canSee, hasRole, logout, brand } = useAuth(); const { user, canSee, hasRole, logout, brand } = useAuth();
const { membresias: membresiasCfg } = useClinicSettings(); const { membresias: membresiasCfg, compras: comprasCfg } = useClinicSettings();
const [sidebarOpen, setSidebarOpen] = useState(false); const [sidebarOpen, setSidebarOpen] = useState(false);
const chatUnread = useChatUnread(); const chatUnread = useChatUnread();
// Membresías solo cuando la clínica enciende `membresias.activo`. // Membresías y Compras solo cuando la clínica enciende su bandera.
const menuTreeT = useMemo<MenuEntry[]>( const menuTreeT = useMemo<MenuEntry[]>(() => {
() => (membresiasCfg.activo let tree = menuTree;
? [...menuTree, { label: 'Membresías', icon: Repeat, href: '/membresias' }] if (comprasCfg.activo) {
: menuTree), tree = tree.map((e) => (e.label === 'Consumibles' && e.children
[membresiasCfg.activo] ? { ...e, children: [...e.children, { label: 'Órdenes de compra', href: '/compras' }] }
); : e));
}
if (membresiasCfg.activo) {
tree = [...tree, { label: 'Membresías', icon: Repeat, href: '/membresias' }];
}
return tree;
}, [membresiasCfg.activo, comprasCfg.activo]);
const menuLeavesT = useMemo( const menuLeavesT = useMemo(
() => menuTreeT.flatMap((e) => () => menuTreeT.flatMap((e) =>
e.children e.children

View File

@@ -0,0 +1,149 @@
import type { FC } from 'react';
import { useEffect, useState } from 'react';
import { Save, Truck } from 'lucide-react';
import { Card, Button, Input, toast } from './ui';
import { odooApi, COMPRAS_DEFAULT, type ClinicSettings, type ComprasSettings } from '../services/odoo';
import ProveedoresPanel from './compras/ProveedoresPanel';
interface Props {
settings: ClinicSettings;
onSaved: (settings: ClinicSettings) => void;
}
const Toggle: FC<{ label: string; checked: boolean; onChange: (v: boolean) => void; ayuda?: string }> = ({ label, checked, onChange, ayuda }) => (
<label className="flex items-start gap-3 cursor-pointer">
<button
type="button"
role="switch"
aria-checked={checked}
onClick={() => onChange(!checked)}
className={`mt-0.5 relative h-6 w-11 shrink-0 rounded-full transition-colors ${checked ? 'bg-theme-accent' : 'bg-theme-border-strong'}`}
>
<span className={`absolute top-0.5 h-5 w-5 rounded-full bg-white transition-transform ${checked ? 'translate-x-5' : 'translate-x-0.5'}`} />
</button>
<span>
<span className="block text-sm font-medium text-theme-heading">{label}</span>
{ayuda && <span className="block text-xs text-theme-muted">{ayuda}</span>}
</span>
</label>
);
// Configuración → Compras: ajustes del bloque `compras` + acceso a proveedores.
// Mientras `activo` esté apagado no aparece la sección en el menú y el
// inventario y el POS se ven idénticos; este panel es la llave para encenderlo.
const ComprasPanel: FC<Props> = ({ settings, onSaved }) => {
const [form, setForm] = useState<ComprasSettings>(settings.compras || COMPRAS_DEFAULT);
const [saving, setSaving] = useState(false);
useEffect(() => {
setForm(settings.compras || COMPRAS_DEFAULT);
}, [settings.compras]);
const set = <K extends keyof ComprasSettings>(k: K, v: ComprasSettings[K]) =>
setForm((prev) => ({ ...prev, [k]: v }));
const guardar = async () => {
setSaving(true);
try {
const res = await odooApi.putClinicSettings({
compras: {
...form,
dias_credito_default: Number(form.dias_credito_default) || 0,
margen_minimo_alerta: Number(form.margen_minimo_alerta) || 0,
},
motivo: 'Configuración de compras',
});
if (res.status === 'success') {
onSaved(res.settings);
toast.success('Configuración de compras guardada');
}
} catch (err) {
const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message;
toast.error(msg || 'No se pudo guardar la configuración');
} finally {
setSaving(false);
}
};
return (
<div className="space-y-6">
<div className="flex items-start justify-between gap-3">
<div>
<h3 className="font-heading text-lg text-theme-heading flex items-center gap-2">
<Truck size={18} /> Compras y proveedores
</h3>
<p className="text-sm text-theme-muted mt-1">
Con el módulo apagado no aparece la sección Compras, el inventario y el POS se ven
igual que siempre y no se recalcula ningún costo.
</p>
</div>
<Button onClick={guardar} loading={saving}><Save size={16} className="mr-1.5" />Guardar</Button>
</div>
<div className="space-y-4">
<Toggle
label="Activar compras"
checked={form.activo}
onChange={(v) => set('activo', v)}
ayuda="Enciende la sección Compras, las recepciones, el costo promedio y los reportes."
/>
<Toggle
label="Actualizar costo promedio al recibir"
checked={form.actualizar_costo_promedio}
onChange={(v) => set('actualizar_costo_promedio', v)}
ayuda="Recalcula el costo promedio ponderado del artículo en cada recepción."
/>
<Toggle
label="Permitir recepción parcial"
checked={form.permitir_recepcion_parcial}
onChange={(v) => set('permitir_recepcion_parcial', v)}
ayuda="Permite recibir menos de lo pedido y completar después."
/>
<Toggle
label="Requiere autorización (enviar antes de recibir)"
checked={form.requiere_autorizacion}
onChange={(v) => set('requiere_autorizacion', v)}
ayuda="Las órdenes nacen en borrador y deben enviarse antes de poder recibirse."
/>
<Toggle
label="Permitir compras sin orden"
checked={form.permitir_compras_sin_orden}
onChange={(v) => set('permitir_compras_sin_orden', v)}
ayuda="Reservado para registrar recepciones directas sin orden de compra."
/>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<Input
label="Días de crédito por defecto"
type="number"
value={String(form.dias_credito_default)}
onChange={(e) => set('dias_credito_default', Number(e.target.value) as unknown as number)}
/>
<Input
label="Margen mínimo de alerta (%)"
type="number"
value={String(form.margen_minimo_alerta)}
onChange={(e) => set('margen_minimo_alerta', Number(e.target.value) as unknown as number)}
/>
</div>
</div>
<Card>
<Card.Header>
<p className="font-medium text-theme-heading">Proveedores</p>
</Card.Header>
<Card.Body>
{form.activo ? (
<ProveedoresPanel />
) : (
<p className="text-sm text-theme-muted">
Activa Compras y guarda para administrar proveedores desde aquí. También puedes
hacerlo desde Compras › Proveedores.
</p>
)}
</Card.Body>
</Card>
</div>
);
};
export default ComprasPanel;

View File

@@ -37,6 +37,7 @@ import {
Layers, Layers,
Download, Download,
Repeat, Repeat,
Truck,
} from 'lucide-react'; } from 'lucide-react';
import { Button, Modal, Input } from './ui'; import { Button, Modal, Input } from './ui';
import QuickLinksDock, { QuickLinksModePicker } from './QuickLinksDock'; import QuickLinksDock, { QuickLinksModePicker } from './QuickLinksDock';
@@ -113,25 +114,32 @@ const MENU_SECTIONS = [
]; ];
const HOME_MEMBRESIA_ITEM = { label: 'Membresías', icon: Repeat, href: '/membresias' }; const HOME_MEMBRESIA_ITEM = { label: 'Membresías', icon: Repeat, href: '/membresias' };
const HOME_COMPRAS_ITEM = { label: 'Compras', icon: Truck, href: '/compras' };
export const HomeNestLayout: FC<LayoutProps> = ({ children, title: _title, subtitle: _subtitle }) => { export const HomeNestLayout: FC<LayoutProps> = ({ children, title: _title, subtitle: _subtitle }) => {
const location = useLocation(); const location = useLocation();
const navigate = useNavigate(); const navigate = useNavigate();
const { user, canSee, hasRole, logout, brand } = useAuth(); const { user, canSee, hasRole, logout, brand } = useAuth();
const { term } = useGlosario(); const { term } = useGlosario();
const { membresias: membresiasCfg } = useClinicSettings(); const { membresias: membresiasCfg, compras: comprasCfg } = useClinicSettings();
const topNavItems = useMemo( const topNavItems = useMemo(
() => TOP_NAV_ITEMS.map((it) => ({ ...it, label: traducirEtiqueta(it.label, term) })), () => TOP_NAV_ITEMS.map((it) => ({ ...it, label: traducirEtiqueta(it.label, term) })),
[term] [term]
); );
const menuSections = useMemo(() => { const menuSections = useMemo(() => {
const base = membresiasCfg.activo let base = MENU_SECTIONS;
? MENU_SECTIONS.map((s) => (s.label === 'Clínica' if (membresiasCfg.activo) {
base = base.map((s) => (s.label === 'Clínica'
? { ...s, items: [...s.items, HOME_MEMBRESIA_ITEM] } ? { ...s, items: [...s.items, HOME_MEMBRESIA_ITEM] }
: s)) : s));
: MENU_SECTIONS; }
if (comprasCfg.activo) {
base = base.map((s) => (s.label === 'Operaciones'
? { ...s, items: [...s.items, HOME_COMPRAS_ITEM] }
: s));
}
return traducirMenu(base, term); return traducirMenu(base, term);
}, [term, membresiasCfg.activo]); }, [term, membresiasCfg.activo, comprasCfg.activo]);
const [menuOpen, setMenuOpen] = useState(false); const [menuOpen, setMenuOpen] = useState(false);
const [expanded, setExpanded] = useState<string[]>(['Clínica']); const [expanded, setExpanded] = useState<string[]>(['Clínica']);
const chatUnread = useChatUnread(); const chatUnread = useChatUnread();

View File

@@ -39,6 +39,7 @@ import {
Clock, Clock,
Download, Download,
Repeat, Repeat,
Truck,
} from 'lucide-react'; } from 'lucide-react';
import { Button, Modal, Input } from './ui'; import { Button, Modal, Input } from './ui';
import QuickLinksDock, { QuickLinksModePicker } from './QuickLinksDock'; import QuickLinksDock, { QuickLinksModePicker } from './QuickLinksDock';
@@ -124,21 +125,29 @@ const menuGroups = [
// `membresias.activo`. Con el módulo apagado (default) el menú queda idéntico // `membresias.activo`. Con el módulo apagado (default) el menú queda idéntico
// al de siempre. // al de siempre.
const MEMBRESIA_ITEM = { label: 'Membresías', icon: Repeat, href: '/membresias' }; const MEMBRESIA_ITEM = { label: 'Membresías', icon: Repeat, href: '/membresias' };
// La sección Compras solo existe con `compras.activo=true`.
const COMPRAS_ITEM = { label: 'Compras', icon: Truck, href: '/compras' };
export const SkeenLayout: FC<LayoutProps> = ({ children, title, subtitle }) => { export const SkeenLayout: FC<LayoutProps> = ({ children, title, subtitle }) => {
const location = useLocation(); const location = useLocation();
const navigate = useNavigate(); const navigate = useNavigate();
const { user, canSee, hasRole, logout, brand } = useAuth(); const { user, canSee, hasRole, logout, brand } = useAuth();
const { term } = useGlosario(); const { term } = useGlosario();
const { membresias: membresiasCfg } = useClinicSettings(); const { membresias: membresiasCfg, compras: comprasCfg } = useClinicSettings();
const menuGroupsT = useMemo(() => { const menuGroupsT = useMemo(() => {
const base = membresiasCfg.activo let base = menuGroups;
? menuGroups.map((g) => (g.label === 'Clínica' if (membresiasCfg.activo) {
base = base.map((g) => (g.label === 'Clínica'
? { ...g, items: [...g.items, MEMBRESIA_ITEM] } ? { ...g, items: [...g.items, MEMBRESIA_ITEM] }
: g)) : g));
: menuGroups; }
if (comprasCfg.activo) {
base = base.map((g) => (g.label === 'Operaciones'
? { ...g, items: [...g.items, COMPRAS_ITEM] }
: g));
}
return traducirMenu(base, term); return traducirMenu(base, term);
}, [term, membresiasCfg.activo]); }, [term, membresiasCfg.activo, comprasCfg.activo]);
const [sidebarOpen, setSidebarOpen] = useState(false); const [sidebarOpen, setSidebarOpen] = useState(false);
const chatUnread = useChatUnread(); const chatUnread = useChatUnread();
// Sidebar colapsable (modo iconos). Preferencia persistente; en pantallas // Sidebar colapsable (modo iconos). Preferencia persistente; en pantallas

View File

@@ -0,0 +1,179 @@
import type { FC } from 'react';
import { Send, PackageCheck, CreditCard, Ban, Pencil } from 'lucide-react';
import { Modal, Button, Badge } from '../ui';
import type { Compra } from '../../services/odoo';
import { ESTADO_META, fmtMoney, fmtNum } from './shared';
interface Props {
isOpen: boolean;
onClose: () => void;
compra: Compra | null;
puedeEditar: boolean;
puedeRecibir: boolean;
puedePagar: boolean;
puedeCancelar: boolean;
puedeCrear: boolean;
onEdit: (c: Compra) => void;
onEnviar: (c: Compra) => void;
onRecibir: (c: Compra) => void;
onPagar: (c: Compra) => void;
onCancelar: (c: Compra) => void;
}
const CompraDetalleModal: FC<Props> = ({
isOpen, onClose, compra, puedeEditar, puedeRecibir, puedePagar, puedeCancelar, puedeCrear,
onEdit, onEnviar, onRecibir, onPagar, onCancelar,
}) => {
if (!compra) return null;
const meta = ESTADO_META[compra.estado];
return (
<Modal
isOpen={isOpen}
onClose={onClose}
title={`Compra ${compra.folio}`}
maxWidth="full"
footer={(
<div className="flex flex-wrap justify-end gap-2 w-full">
{puedeEditar && ['borrador', 'enviada'].includes(compra.estado) && (
<Button variant="outline" onClick={() => onEdit(compra)}><Pencil size={14} className="mr-1.5" />Editar</Button>
)}
{puedeCrear && compra.estado === 'borrador' && (
<Button variant="outline" onClick={() => onEnviar(compra)}><Send size={14} className="mr-1.5" />Enviar</Button>
)}
{puedeRecibir && compra.puede_recibir && (
<Button onClick={() => onRecibir(compra)}><PackageCheck size={14} className="mr-1.5" />Recibir</Button>
)}
{puedePagar && compra.estado !== 'cancelada' && compra.saldo > 0 && (
<Button onClick={() => onPagar(compra)}><CreditCard size={14} className="mr-1.5" />Pagar</Button>
)}
{puedeCancelar && compra.puede_cancelar && (
<Button variant="danger" onClick={() => onCancelar(compra)}><Ban size={14} className="mr-1.5" />Cancelar</Button>
)}
</div>
)}
>
<div className="space-y-5">
<div className="flex flex-wrap items-center gap-3">
<span className={`inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium ${meta.cls}`}>{meta.label}</span>
<span className="text-sm text-theme-heading">{compra.proveedor}</span>
<span className="text-xs text-theme-muted">Fecha {compra.fecha}</span>
{compra.fecha_entrega_esperada && <span className="text-xs text-theme-muted">Entrega esperada {compra.fecha_entrega_esperada}</span>}
<span className="text-xs text-theme-muted">Moneda {compra.moneda}</span>
{compra.recibida_at && <span className="text-xs text-theme-muted">Recibida {compra.recibida_at}</span>}
</div>
{compra.cancel_reason && (
<p className="text-xs text-rose-700">Cancelada: {compra.cancel_reason}</p>
)}
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
<div className="rounded-xl bg-theme-bg p-3">
<p className="text-[11px] text-theme-muted">Subtotal</p>
<p className="text-theme-heading font-medium tabular-nums">{fmtMoney(compra.subtotal, compra.moneda)}</p>
</div>
<div className="rounded-xl bg-theme-bg p-3">
<p className="text-[11px] text-theme-muted">Impuestos</p>
<p className="text-theme-heading font-medium tabular-nums">{fmtMoney(compra.impuestos, compra.moneda)}</p>
</div>
<div className="rounded-xl bg-theme-bg p-3">
<p className="text-[11px] text-theme-muted">Total</p>
<p className="text-theme-heading font-medium tabular-nums">{fmtMoney(compra.total, compra.moneda)}</p>
</div>
<div className="rounded-xl bg-theme-bg p-3">
<p className="text-[11px] text-theme-muted">Pagado / Saldo</p>
<p className="text-theme-heading font-medium tabular-nums">
{fmtMoney(compra.pagado, compra.moneda)} <span className="text-theme-muted">/ {fmtMoney(compra.saldo, compra.moneda)}</span>
</p>
</div>
</div>
<div>
<p className="text-sm font-medium text-theme-heading mb-2">Líneas</p>
<div className="overflow-x-auto border border-theme-border rounded-xl">
<table className="w-full text-sm">
<thead className="bg-theme-bg text-theme-muted text-[11px] uppercase">
<tr>
<th className="text-left px-3 py-2">Artículo</th>
<th className="text-right px-3 py-2">Pedida</th>
<th className="text-right px-3 py-2">Recibida</th>
<th className="text-right px-3 py-2">Pendiente</th>
<th className="text-right px-3 py-2">Costo</th>
<th className="text-right px-3 py-2">Subtotal</th>
<th className="text-left px-3 py-2">Lote / Cad.</th>
</tr>
</thead>
<tbody className="divide-y divide-theme-border">
{(compra.lineas || []).map((l) => (
<tr key={l.id}>
<td className="px-3 py-2 text-theme-heading">{l.item}</td>
<td className="px-3 py-2 text-right tabular-nums">{fmtNum(l.cantidad_pedida)}</td>
<td className="px-3 py-2 text-right tabular-nums">{fmtNum(l.cantidad_recibida)}</td>
<td className="px-3 py-2 text-right tabular-nums">{fmtNum(l.cantidad_pendiente)}</td>
<td className="px-3 py-2 text-right tabular-nums">{fmtMoney(l.costo_unitario, compra.moneda)}</td>
<td className="px-3 py-2 text-right tabular-nums">{fmtMoney(l.subtotal, compra.moneda)}</td>
<td className="px-3 py-2 text-[11px] text-theme-muted">
{l.lote || '—'}{l.fecha_caducidad ? ` · ${l.fecha_caducidad}` : ''}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
<div>
<p className="text-sm font-medium text-theme-heading mb-2">Recepciones</p>
{(compra.recepciones || []).length === 0 ? (
<p className="text-sm text-theme-muted">Sin recepciones todavía.</p>
) : (
<ul className="divide-y divide-theme-border border border-theme-border rounded-xl">
{(compra.recepciones || []).map((r) => (
<li key={r.id} className="px-3 py-2 text-sm">
<div className="flex justify-between">
<span className="text-theme-heading">{r.fecha}</span>
<span className="text-theme-muted tabular-nums">{fmtNum(r.total_unidades)} u · {fmtMoney(r.total_costo, compra.moneda)}</span>
</div>
<p className="text-[11px] text-theme-muted">{r.usuario || '—'}{r.notas ? ` · ${r.notas}` : ''}</p>
<ul className="mt-1 space-y-0.5">
{r.lineas.map((ln) => (
<li key={ln.id} className="text-[11px] text-theme-muted">
{ln.item}: {fmtNum(ln.cantidad)} u{ln.lote ? ` · lote ${ln.lote}` : ''}{ln.fecha_caducidad ? ` · cad. ${ln.fecha_caducidad}` : ''}
</li>
))}
</ul>
</li>
))}
</ul>
)}
</div>
<div>
<p className="text-sm font-medium text-theme-heading mb-2">Pagos</p>
{(compra.pagos || []).length === 0 ? (
<p className="text-sm text-theme-muted">Sin pagos registrados.</p>
) : (
<ul className="divide-y divide-theme-border border border-theme-border rounded-xl">
{(compra.pagos || []).map((p) => (
<li key={p.id} className="px-3 py-2 text-sm flex justify-between">
<div>
<p className="text-theme-heading">{fmtMoney(p.monto, p.moneda)}</p>
<p className="text-[11px] text-theme-muted">{p.fecha} · {p.metodo_label}{p.referencia ? ` · ${p.referencia}` : ''}</p>
</div>
{p.moneda !== compra.moneda && (
<span className="text-[11px] text-theme-muted tabular-nums">= {fmtMoney(p.monto_en_compra, compra.moneda)}</span>
)}
</li>
))}
</ul>
)}
</div>
</div>
{compra.notas && <p className="text-sm text-theme-muted">Observaciones: {compra.notas}</p>}
<Badge variant="info">Los pagos a proveedores son egresos y no se mezclan con los cobros a pacientes.</Badge>
</div>
</Modal>
);
};
export default CompraDetalleModal;

View File

@@ -0,0 +1,240 @@
import type { FC } from 'react';
import { useEffect, useMemo, useState } from 'react';
import { Plus, Trash2 } from 'lucide-react';
import { Modal, Button, Input, TextArea, Select, toast } from '../ui';
import { odooApi, type Compra, type InventoryItem, type MonedaCompra, type Proveedor } from '../../services/odoo';
import { MONEDA_OPTIONS, fmtMoney } from './shared';
interface LineaForm {
item_id: string;
cantidad_pedida: string;
costo_unitario: string;
descuento: string;
lote: string;
fecha_caducidad: string;
}
const emptyLinea: LineaForm = {
item_id: '', cantidad_pedida: '1', costo_unitario: '0', descuento: '0',
lote: '', fecha_caducidad: '',
};
interface Props {
isOpen: boolean;
onClose: () => void;
compra: Compra | null;
onDone: (compraId?: number) => void;
}
const hoy = () => new Date().toISOString().slice(0, 10);
const CompraFormModal: FC<Props> = ({ isOpen, onClose, compra, onDone }) => {
const [proveedores, setProveedores] = useState<Proveedor[]>([]);
const [items, setItems] = useState<InventoryItem[]>([]);
const [partnerId, setPartnerId] = useState('');
const [fecha, setFecha] = useState(hoy());
const [entrega, setEntrega] = useState('');
const [moneda, setMoneda] = useState<MonedaCompra>('MXN');
const [descuento, setDescuento] = useState('0');
const [referencia, setReferencia] = useState('');
const [notas, setNotas] = useState('');
const [lineas, setLineas] = useState<LineaForm[]>([{ ...emptyLinea }]);
const [saving, setSaving] = useState(false);
useEffect(() => {
if (!isOpen) return;
odooApi.getProveedores({ page_size: 500 })
.then((res) => { if (res.status === 'success') setProveedores(res.proveedores || []); })
.catch(() => {});
odooApi.getInventory()
.then((res) => setItems(res.items || []))
.catch(() => {});
}, [isOpen]);
useEffect(() => {
if (!isOpen) return;
if (compra) {
setPartnerId(String(compra.partner_id));
setFecha(compra.fecha || hoy());
setEntrega(compra.fecha_entrega_esperada || '');
setMoneda(compra.moneda);
setDescuento(String(compra.descuento || 0));
setReferencia(compra.referencia_factura || '');
setNotas(compra.notas || '');
setLineas((compra.lineas || []).map((l) => ({
item_id: String(l.item_id),
cantidad_pedida: String(l.cantidad_pedida),
costo_unitario: String(l.costo_unitario),
descuento: String(l.descuento || 0),
lote: l.lote || '',
fecha_caducidad: l.fecha_caducidad || '',
})));
} else {
setPartnerId(''); setFecha(hoy()); setEntrega(''); setMoneda('MXN');
setDescuento('0'); setReferencia(''); setNotas('');
setLineas([{ ...emptyLinea }]);
}
}, [isOpen, compra]);
const itemMap = useMemo(() => new Map(items.map((i) => [i.id, i])), [items]);
const setLinea = (idx: number, patch: Partial<LineaForm>) =>
setLineas((prev) => prev.map((l, i) => (i === idx ? { ...l, ...patch } : l)));
const onPickItem = (idx: number, itemId: string) => {
const it = itemMap.get(Number(itemId));
const costo = it ? (it.ultimo_costo || it.costo_promedio || it.cost || 0) : 0;
setLinea(idx, { item_id: itemId, costo_unitario: String(costo) });
};
const subtotalLinea = (l: LineaForm) =>
Math.max(0, (parseFloat(l.cantidad_pedida) || 0) * (parseFloat(l.costo_unitario) || 0) - (parseFloat(l.descuento) || 0));
const subtotal = lineas.reduce((s, l) => s + subtotalLinea(l), 0);
const desc = parseFloat(descuento) || 0;
const guardar = async () => {
if (!partnerId) { toast.error('Selecciona un proveedor'); return; }
const payload = lineas
.filter((l) => l.item_id && (parseFloat(l.cantidad_pedida) || 0) > 0)
.map((l) => ({
item_id: Number(l.item_id),
cantidad_pedida: parseFloat(l.cantidad_pedida) || 0,
costo_unitario: parseFloat(l.costo_unitario) || 0,
descuento: parseFloat(l.descuento) || 0,
lote: l.lote,
fecha_caducidad: l.fecha_caducidad || null,
}));
if (payload.length === 0) { toast.error('Agrega al menos una línea con artículo y cantidad'); return; }
setSaving(true);
try {
const body = {
partner_id: Number(partnerId),
fecha,
fecha_entrega_esperada: entrega || null,
moneda,
descuento: desc,
notas,
referencia_factura: referencia,
lineas: payload,
};
if (compra) {
await odooApi.updateCompra(compra.id, body);
} else {
await odooApi.crearOrdenCompra(body);
}
toast.success(compra ? 'Compra actualizada' : 'Compra creada');
onDone(compra?.id);
onClose();
} catch (err) {
const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message;
toast.error(msg || 'No se pudo guardar la compra');
} finally {
setSaving(false);
}
};
return (
<Modal
isOpen={isOpen}
onClose={onClose}
title={compra ? `Editar compra ${compra.folio}` : 'Nueva orden de compra'}
maxWidth="full"
footer={(
<div className="flex items-center justify-between gap-2 w-full">
<span className="text-sm text-theme-muted">
Subtotal <strong className="text-theme-heading">{fmtMoney(subtotal, moneda)}</strong>
{desc > 0 && <> · Descuento <strong className="text-theme-heading">{fmtMoney(desc, moneda)}</strong></>}
</span>
<div className="flex gap-2">
<Button variant="outline" onClick={onClose}>Cancelar</Button>
<Button onClick={guardar} loading={saving}>Guardar</Button>
</div>
</div>
)}
>
<div className="space-y-4">
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
<Select
label="Proveedor"
options={[{ value: '', label: 'Selecciona…' }, ...proveedores.map((p) => ({ value: String(p.id), label: p.name }))]}
value={partnerId}
onChange={(e) => setPartnerId(e.target.value)}
/>
<Input label="Fecha" type="date" value={fecha} onChange={(e) => setFecha(e.target.value)} />
<Input label="Entrega esperada" type="date" value={entrega} onChange={(e) => setEntrega(e.target.value)} />
<Select label="Moneda" options={MONEDA_OPTIONS} value={moneda} onChange={(e) => setMoneda(e.target.value as MonedaCompra)} />
<Input label="Descuento global" type="number" value={descuento} onChange={(e) => setDescuento(e.target.value)} />
<Input label="Referencia / factura" value={referencia} onChange={(e) => setReferencia(e.target.value)} />
</div>
<div className="overflow-x-auto border border-theme-border rounded-xl">
<table className="w-full text-sm">
<thead className="bg-theme-bg text-theme-muted text-[11px] uppercase">
<tr>
<th className="text-left px-3 py-2 min-w-[200px]">Artículo</th>
<th className="text-right px-3 py-2 w-24">Cantidad</th>
<th className="text-right px-3 py-2 w-28">Costo unit.</th>
<th className="text-right px-3 py-2 w-24">Desc.</th>
<th className="text-left px-3 py-2 w-32">Lote</th>
<th className="text-left px-3 py-2 w-40">Caducidad</th>
<th className="text-right px-3 py-2 w-24">Subtotal</th>
<th className="px-3 py-2 w-10"></th>
</tr>
</thead>
<tbody className="divide-y divide-theme-border">
{lineas.map((l, idx) => (
<tr key={idx}>
<td className="px-3 py-2">
<Select
options={[{ value: '', label: 'Selecciona…' }, ...items.map((it) => ({ value: String(it.id), label: it.name }))]}
value={l.item_id}
onChange={(e) => onPickItem(idx, e.target.value)}
/>
</td>
<td className="px-3 py-2">
<Input type="number" value={l.cantidad_pedida} onChange={(e) => setLinea(idx, { cantidad_pedida: e.target.value })} />
</td>
<td className="px-3 py-2">
<Input type="number" value={l.costo_unitario} onChange={(e) => setLinea(idx, { costo_unitario: e.target.value })} />
</td>
<td className="px-3 py-2">
<Input type="number" value={l.descuento} onChange={(e) => setLinea(idx, { descuento: e.target.value })} />
</td>
<td className="px-3 py-2">
<Input value={l.lote} onChange={(e) => setLinea(idx, { lote: e.target.value })} />
</td>
<td className="px-3 py-2">
<Input type="date" value={l.fecha_caducidad} onChange={(e) => setLinea(idx, { fecha_caducidad: e.target.value })} />
</td>
<td className="px-3 py-2 text-right tabular-nums">{fmtMoney(subtotalLinea(l), moneda)}</td>
<td className="px-3 py-2">
<button
type="button"
className="p-1.5 rounded-lg hover:bg-rose-50 text-rose-600"
onClick={() => setLineas((prev) => prev.length > 1 ? prev.filter((_, i) => i !== idx) : prev)}
title="Quitar línea"
>
<Trash2 size={15} />
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
<Button variant="outline" size="sm" onClick={() => setLineas((prev) => [...prev, { ...emptyLinea }])}>
<Plus size={14} className="mr-1.5" />Agregar línea
</Button>
<TextArea label="Observaciones" rows={2} value={notas} onChange={(e) => setNotas(e.target.value)} />
<p className="text-[11px] text-theme-muted">
Los impuestos se calculan según la configuración de impuestos de la clínica. Si no están
activos, la compra queda sin impuesto.
</p>
</div>
</Modal>
);
};
export default CompraFormModal;

View File

@@ -0,0 +1,148 @@
import type { FC } from 'react';
import { useCallback, useEffect, useState } from 'react';
import { CreditCard, RefreshCw } from 'lucide-react';
import { Card, Button, EmptyState, toast } from '../ui';
import { odooApi, type CuentaPorPagar } from '../../services/odoo';
import { fmtMoney } from './shared';
import PagoCompraModal from './PagoCompraModal';
import { useAuth } from '../../lib/auth';
const CuentasPorPagarPanel: FC = () => {
const { can } = useAuth();
const [proveedores, setProveedores] = useState<CuentaPorPagar[]>([]);
const [buckets, setBuckets] = useState<{ key: string; label: string }[]>([]);
const [totales, setTotales] = useState<{
saldo: number; por_bucket: Record<string, number>; proveedores: number; compras: number;
} | null>(null);
const [loading, setLoading] = useState(true);
const [abierto, setAbierto] = useState<number | null>(null);
const [pago, setPago] = useState<{ id: number; folio: string; saldo: number; moneda: 'MXN' | 'USD' } | null>(null);
const load = useCallback(async () => {
setLoading(true);
try {
const res = await odooApi.getCuentasPorPagar();
if (res.status === 'success') {
setProveedores(res.proveedores || []);
setBuckets(res.buckets || []);
setTotales(res.totales || null);
}
} catch (err) {
const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message;
toast.error(msg || 'No se pudieron cargar las cuentas por pagar');
} finally {
setLoading(false);
}
}, []);
useEffect(() => { load(); }, [load]);
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<p className="text-sm text-theme-muted">
{totales ? `${totales.proveedores} proveedor(es) · ${totales.compras} compra(s) con saldo` : ''}
</p>
<Button variant="outline" size="sm" onClick={load}><RefreshCw size={14} className="mr-1.5" />Actualizar</Button>
</div>
<div className="grid grid-cols-2 md:grid-cols-5 gap-3">
<Card><Card.Body>
<p className="text-xs text-theme-muted">Total por pagar</p>
<p className="text-xl font-heading text-theme-heading tabular-nums">{fmtMoney(totales?.saldo || 0)}</p>
</Card.Body></Card>
{buckets.map((b) => (
<Card key={b.key}><Card.Body>
<p className="text-xs text-theme-muted">{b.label}</p>
<p className="text-xl font-heading text-theme-heading tabular-nums">{fmtMoney(totales?.por_bucket?.[b.key] || 0)}</p>
</Card.Body></Card>
))}
</div>
{loading ? (
<Card><Card.Body><p className="text-sm text-theme-muted">Cargando…</p></Card.Body></Card>
) : proveedores.length === 0 ? (
<Card><Card.Body>
<EmptyState title="Sin cuentas por pagar" subtitle="Todas las compras están pagadas o no hay compras con saldo." />
</Card.Body></Card>
) : (
<div className="space-y-3">
{proveedores.map((p) => (
<Card key={p.partner_id}>
<Card.Body noPadding>
<button
type="button"
className="w-full flex items-center justify-between px-4 py-3 hover:bg-theme-bg"
onClick={() => setAbierto(abierto === p.partner_id ? null : p.partner_id)}
>
<div className="text-left">
<p className="text-theme-heading font-medium">{p.proveedor}</p>
<p className="text-[11px] text-theme-muted">{p.compras} compra(s) · crédito {p.dias_credito} días</p>
</div>
<div className="flex items-center gap-4">
{buckets.map((b) => (p.buckets[b.key] ? (
<span key={b.key} className="hidden md:inline text-[11px] text-theme-muted">
{b.label}: <strong className="text-theme-heading tabular-nums">{fmtMoney(p.buckets[b.key])}</strong>
</span>
) : null))}
<span className="text-theme-heading font-semibold tabular-nums">{fmtMoney(p.saldo)}</span>
</div>
</button>
{abierto === p.partner_id && (
<div className="border-t border-theme-border overflow-x-auto">
<table className="w-full text-sm">
<thead className="bg-theme-bg text-theme-muted text-[11px] uppercase">
<tr>
<th className="text-left px-4 py-2">Folio</th>
<th className="text-left px-4 py-2">Fecha</th>
<th className="text-left px-4 py-2">Vence</th>
<th className="text-right px-4 py-2">Días</th>
<th className="text-right px-4 py-2">Total</th>
<th className="text-right px-4 py-2">Pagado</th>
<th className="text-right px-4 py-2">Saldo</th>
<th className="px-4 py-2"></th>
</tr>
</thead>
<tbody className="divide-y divide-theme-border">
{p.detalle.map((d) => (
<tr key={d.id}>
<td className="px-4 py-2 text-theme-heading">{d.folio}</td>
<td className="px-4 py-2 text-theme-muted">{d.fecha}</td>
<td className="px-4 py-2 text-theme-muted">{d.fecha_vencimiento}</td>
<td className="px-4 py-2 text-right tabular-nums">{d.dias}</td>
<td className="px-4 py-2 text-right tabular-nums">{fmtMoney(d.total, d.moneda)}</td>
<td className="px-4 py-2 text-right tabular-nums">{fmtMoney(d.pagado, d.moneda)}</td>
<td className="px-4 py-2 text-right tabular-nums font-medium">{fmtMoney(d.saldo, d.moneda)}</td>
<td className="px-4 py-2 text-right">
{can('compras.pagar') && (
<Button size="sm" variant="outline" onClick={() => setPago({ id: d.id, folio: d.folio, saldo: d.saldo, moneda: d.moneda })}>
<CreditCard size={13} className="mr-1.5" />Pagar
</Button>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</Card.Body>
</Card>
))}
</div>
)}
<PagoCompraModal
isOpen={!!pago}
onClose={() => setPago(null)}
compraId={pago?.id ?? null}
folio={pago?.folio || ''}
saldo={pago?.saldo || 0}
moneda={pago?.moneda || 'MXN'}
onDone={() => load()}
/>
</div>
);
};
export default CuentasPorPagarPanel;

View File

@@ -0,0 +1,88 @@
import type { FC } from 'react';
import { useEffect, useState } from 'react';
import { Modal, Button, Input, TextArea, Select, toast } from '../ui';
import { odooApi, type MonedaCompra, type MetodoPagoCompra } from '../../services/odoo';
import { METODO_OPTIONS, MONEDA_OPTIONS, fmtMoney } from './shared';
interface Props {
isOpen: boolean;
onClose: () => void;
compraId: number | null;
folio: string;
saldo: number;
moneda: MonedaCompra;
onDone: (compraId: number) => void;
}
const PagoCompraModal: FC<Props> = ({ isOpen, onClose, compraId, folio, saldo, moneda, onDone }) => {
const [monto, setMonto] = useState('');
const [monedaPago, setMonedaPago] = useState<MonedaCompra>(moneda);
const [metodo, setMetodo] = useState<MetodoPagoCompra>('transferencia');
const [referencia, setReferencia] = useState('');
const [notas, setNotas] = useState('');
const [saving, setSaving] = useState(false);
useEffect(() => {
if (isOpen) {
setMonto(saldo > 0 ? String(saldo) : '');
setMonedaPago(moneda);
setMetodo('transferencia');
setReferencia('');
setNotas('');
}
}, [isOpen, saldo, moneda]);
const guardar = async () => {
if (!compraId) return;
const m = parseFloat(monto);
if (!m || m <= 0) { toast.error('Ingresa un monto válido'); return; }
setSaving(true);
try {
await odooApi.pagarCompra(compraId, {
monto: m, moneda: monedaPago, metodo, referencia, notas,
});
toast.success('Pago registrado');
onDone(compraId);
onClose();
} catch (err) {
const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message;
toast.error(msg || 'No se pudo registrar el pago');
} finally {
setSaving(false);
}
};
return (
<Modal
isOpen={isOpen}
onClose={onClose}
title={`Pagar compra ${folio}`}
footer={(
<div className="flex justify-end gap-2">
<Button variant="outline" onClick={onClose}>Cancelar</Button>
<Button onClick={guardar} loading={saving}>Registrar pago</Button>
</div>
)}
>
<div className="space-y-4">
<div className="rounded-xl bg-theme-bg p-3 text-sm">
<span className="text-theme-muted">Saldo pendiente: </span>
<span className="text-theme-heading font-semibold tabular-nums">{fmtMoney(saldo, moneda)}</span>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<Input label="Monto" type="number" value={monto} onChange={(e) => setMonto(e.target.value)} />
<Select label="Moneda" options={MONEDA_OPTIONS} value={monedaPago} onChange={(e) => setMonedaPago(e.target.value as MonedaCompra)} />
<Select label="Método" options={METODO_OPTIONS} value={metodo} onChange={(e) => setMetodo(e.target.value as MetodoPagoCompra)} />
<Input label="Referencia" value={referencia} onChange={(e) => setReferencia(e.target.value)} placeholder="Folio, transferencia…" />
</div>
<TextArea label="Notas" rows={2} value={notas} onChange={(e) => setNotas(e.target.value)} />
<p className="text-[11px] text-theme-muted">
Este pago es un EGRESO a proveedor: no se mezcla con los cobros a pacientes
(`skeen.pago`), por lo que no aparece en cortes de caja ni en estados de cuenta.
</p>
</div>
</Modal>
);
};
export default PagoCompraModal;

View File

@@ -0,0 +1,257 @@
import type { FC } from 'react';
import { useCallback, useEffect, useState } from 'react';
import { Plus, Pencil, Trash2, Power, Search, RefreshCw } from 'lucide-react';
import { Card, Button, Input, TextArea, Modal, toast, EmptyState } from '../ui';
import { odooApi, type Proveedor } from '../../services/odoo';
import { useAuth } from '../../lib/auth';
import { useClinicSettings } from '../../lib/clinic-settings';
import { fmtMoney } from './shared';
interface ProveedorForm {
id?: number;
name: string;
contacto: string;
phone: string;
email: string;
vat: string;
dias_credito: string;
banco: string;
cuenta_pago: string;
notas: string;
activo: boolean;
}
const empty: ProveedorForm = {
name: '', contacto: '', phone: '', email: '', vat: '', dias_credito: '0',
banco: '', cuenta_pago: '', notas: '', activo: true,
};
const ProveedoresPanel: FC = () => {
const { can } = useAuth();
const { compras } = useClinicSettings();
const puedeEditar = can('proveedores.editar');
const [rows, setRows] = useState<Proveedor[]>([]);
const [loading, setLoading] = useState(true);
const [q, setQ] = useState('');
const [incluirInactivos, setIncluirInactivos] = useState(false);
const [formOpen, setFormOpen] = useState(false);
const [form, setForm] = useState<ProveedorForm>(empty);
const [saving, setSaving] = useState(false);
const [baja, setBaja] = useState<Proveedor | null>(null);
const load = useCallback(async () => {
setLoading(true);
try {
const res = await odooApi.getProveedores({ q: q || undefined, includeInactive: incluirInactivos });
if (res.status === 'success') setRows(res.proveedores || []);
} catch (err) {
const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message;
toast.error(msg || 'No se pudieron cargar los proveedores');
} finally {
setLoading(false);
}
}, [q, incluirInactivos]);
useEffect(() => { load(); }, [load]);
const abrirNuevo = () => {
setForm({ ...empty, dias_credito: String(compras?.dias_credito_default ?? 0) });
setFormOpen(true);
};
const abrirEditar = (p: Proveedor) => {
setForm({
id: p.id, name: p.name, contacto: p.contacto || '', phone: p.phone || '',
email: p.email || '', vat: p.vat || '', dias_credito: String(p.dias_credito || 0),
banco: p.banco || '', cuenta_pago: p.cuenta_pago || '', notas: p.notas || '',
activo: p.activo,
});
setFormOpen(true);
};
const guardar = async () => {
if (!form.name.trim()) { toast.error('El nombre es obligatorio'); return; }
setSaving(true);
try {
const payload = {
name: form.name.trim(),
skeen_contacto: form.contacto, phone: form.phone, email: form.email, vat: form.vat,
dias_credito: parseInt(form.dias_credito || '0', 10) || 0,
skeen_banco: form.banco, skeen_cuenta_pago: form.cuenta_pago,
skeen_notas_proveedor: form.notas, activo: form.activo,
};
if (form.id) await odooApi.updateProveedor(form.id, payload);
else await odooApi.createProveedor(payload);
toast.success(form.id ? 'Proveedor actualizado' : 'Proveedor creado');
setFormOpen(false);
load();
} catch (err) {
const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message;
toast.error(msg || 'No se pudo guardar el proveedor');
} finally {
setSaving(false);
}
};
const toggleActivo = async (p: Proveedor) => {
try {
await odooApi.updateProveedor(p.id, { activo: !p.activo });
toast.success(p.activo ? 'Proveedor desactivado' : 'Proveedor reactivado');
load();
} catch (err) {
const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message;
toast.error(msg || 'No se pudo cambiar el estado');
}
};
const eliminar = async () => {
if (!baja) return;
try {
const res = await odooApi.deleteProveedor(baja.id);
if (res.status === 'success') toast.success('Proveedor eliminado');
else toast.error(res.message || 'No se pudo eliminar');
setBaja(null);
load();
} catch (err) {
const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message;
toast.error(msg || 'No se pudo eliminar el proveedor');
setBaja(null);
}
};
return (
<div className="space-y-4">
<div className="flex flex-wrap items-center gap-2">
<div className="relative flex-1 min-w-[200px]">
<Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-theme-muted" />
<Input
className="pl-9"
placeholder="Buscar por nombre, correo, teléfono o RFC"
value={q}
onChange={(e) => setQ(e.target.value)}
/>
</div>
<label className="flex items-center gap-2 text-sm text-theme-muted whitespace-nowrap">
<input type="checkbox" checked={incluirInactivos} onChange={(e) => setIncluirInactivos(e.target.checked)} />
Incluir inactivos
</label>
<Button variant="outline" size="sm" onClick={load}><RefreshCw size={14} className="mr-1.5" />Actualizar</Button>
{puedeEditar && (
<Button onClick={abrirNuevo}><Plus size={16} className="mr-1.5" />Nuevo proveedor</Button>
)}
</div>
<Card>
<Card.Body noPadding>
{loading ? (
<div className="p-6 text-sm text-theme-muted">Cargando proveedores…</div>
) : rows.length === 0 ? (
<div className="p-6">
<EmptyState title="Sin proveedores" subtitle="Da de alta tu primer proveedor para comenzar a comprar." />
</div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="bg-theme-bg text-theme-muted text-[11px] uppercase">
<tr>
<th className="text-left px-4 py-3">Proveedor</th>
<th className="text-left px-4 py-3 hidden md:table-cell">Contacto</th>
<th className="text-left px-4 py-3 hidden lg:table-cell">Teléfono / correo</th>
<th className="text-right px-4 py-3">Crédito</th>
<th className="text-right px-4 py-3">Compras</th>
<th className="text-right px-4 py-3">Saldo</th>
<th className="text-right px-4 py-3">Acciones</th>
</tr>
</thead>
<tbody className="divide-y divide-theme-border">
{rows.map((p) => (
<tr key={p.id} className={p.activo ? '' : 'opacity-60'}>
<td className="px-4 py-3">
<p className="text-theme-heading font-medium">{p.name}</p>
{p.vat && <p className="text-[11px] text-theme-muted">{p.vat}</p>}
{!p.activo && <span className="text-[11px] text-rose-600">Inactivo</span>}
</td>
<td className="px-4 py-3 text-theme-muted hidden md:table-cell">{p.contacto || '—'}</td>
<td className="px-4 py-3 text-theme-muted hidden lg:table-cell">
<span className="block">{p.phone || '—'}</span>
<span className="block text-[11px]">{p.email || ''}</span>
</td>
<td className="px-4 py-3 text-right tabular-nums">{p.dias_credito} días</td>
<td className="px-4 py-3 text-right tabular-nums">{p.compras_count}</td>
<td className="px-4 py-3 text-right tabular-nums">{fmtMoney(p.saldo)}</td>
<td className="px-4 py-3">
<div className="flex items-center justify-end gap-1">
{puedeEditar ? (
<>
<button type="button" title="Editar" onClick={() => abrirEditar(p)} className="p-1.5 rounded-lg hover:bg-theme-bg text-theme-muted">
<Pencil size={15} />
</button>
<button type="button" title={p.activo ? 'Desactivar' : 'Reactivar'} onClick={() => toggleActivo(p)} className="p-1.5 rounded-lg hover:bg-theme-bg text-theme-muted">
<Power size={15} />
</button>
{p.compras_count === 0 && (
<button type="button" title="Eliminar" onClick={() => setBaja(p)} className="p-1.5 rounded-lg hover:bg-rose-50 text-rose-600">
<Trash2 size={15} />
</button>
)}
</>
) : <span className="text-[11px] text-theme-muted">—</span>}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</Card.Body>
</Card>
<Modal
isOpen={formOpen}
onClose={() => setFormOpen(false)}
title={form.id ? 'Editar proveedor' : 'Nuevo proveedor'}
maxWidth="lg"
footer={(
<div className="flex justify-end gap-2">
<Button variant="outline" onClick={() => setFormOpen(false)}>Cancelar</Button>
<Button onClick={guardar} loading={saving}>Guardar</Button>
</div>
)}
>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<Input label="Nombre / razón social" value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} />
<Input label="Contacto de compras" value={form.contacto} onChange={(e) => setForm({ ...form, contacto: e.target.value })} />
<Input label="Teléfono" value={form.phone} onChange={(e) => setForm({ ...form, phone: e.target.value })} />
<Input label="Correo" value={form.email} onChange={(e) => setForm({ ...form, email: e.target.value })} />
<Input label="RFC / identificación" value={form.vat} onChange={(e) => setForm({ ...form, vat: e.target.value })} />
<Input label="Días de crédito" type="number" value={form.dias_credito} onChange={(e) => setForm({ ...form, dias_credito: e.target.value })} />
<Input label="Banco" value={form.banco} onChange={(e) => setForm({ ...form, banco: e.target.value })} />
<Input label="Cuenta / CLABE" value={form.cuenta_pago} onChange={(e) => setForm({ ...form, cuenta_pago: e.target.value })} />
<div className="sm:col-span-2">
<TextArea label="Notas" rows={3} value={form.notas} onChange={(e) => setForm({ ...form, notas: e.target.value })} />
</div>
</div>
</Modal>
<Modal
isOpen={!!baja}
onClose={() => setBaja(null)}
title="Eliminar proveedor"
footer={(
<div className="flex justify-end gap-2">
<Button variant="outline" onClick={() => setBaja(null)}>Cancelar</Button>
<Button variant="danger" onClick={eliminar}>Eliminar</Button>
</div>
)}
>
<p className="text-sm text-theme-muted">
¿Eliminar a <strong className="text-theme-heading">{baja?.name}</strong>? Solo se permite
si no tiene compras registradas.
</p>
</Modal>
</div>
);
};
export default ProveedoresPanel;

View File

@@ -0,0 +1,191 @@
import type { FC } from 'react';
import { useEffect, useState } from 'react';
import { AlertTriangle } from 'lucide-react';
import { Modal, Button, Input, TextArea, toast } from '../ui';
import { odooApi, type Compra } from '../../services/odoo';
import { useClinicSettings } from '../../lib/clinic-settings';
import { fmtNum, fmtMoney } from './shared';
interface LineaForm {
linea_id: number;
item: string;
unit: string;
kind: string;
pedida: number;
recibida: number;
pendiente: number;
costo_unitario: number;
cantidad: string;
lote: string;
fecha_caducidad: string;
}
interface Props {
isOpen: boolean;
onClose: () => void;
compra: Compra | null;
onDone: (compraId: number) => void;
}
const RecepcionModal: FC<Props> = ({ isOpen, onClose, compra, onDone }) => {
const { compras } = useClinicSettings();
const [lineas, setLineas] = useState<LineaForm[]>([]);
const [notas, setNotas] = useState('');
const [saving, setSaving] = useState(false);
useEffect(() => {
if (isOpen && compra) {
setLineas((compra.lineas || []).map((l) => ({
linea_id: l.id,
item: l.item,
unit: l.unit,
kind: l.kind,
pedida: l.cantidad_pedida,
recibida: l.cantidad_recibida,
pendiente: l.cantidad_pendiente,
costo_unitario: l.costo_unitario,
cantidad: String(l.cantidad_pendiente),
lote: l.lote || '',
fecha_caducidad: l.fecha_caducidad || '',
})));
setNotas('');
}
}, [isOpen, compra]);
const setLinea = (idx: number, patch: Partial<LineaForm>) =>
setLineas((prev) => prev.map((l, i) => (i === idx ? { ...l, ...patch } : l)));
const diferencias = lineas.filter((l) => {
const c = parseFloat(l.cantidad) || 0;
return c > 0 && Math.abs(c - l.pendiente) > 1e-6;
});
const excesos = lineas.filter((l) => (parseFloat(l.cantidad) || 0) > l.pendiente + 1e-6);
const totalRecibir = lineas.reduce((s, l) => s + (parseFloat(l.cantidad) || 0), 0);
const confirmar = async () => {
if (!compra) return;
const payload = lineas
.filter((l) => (parseFloat(l.cantidad) || 0) > 0)
.map((l) => ({
linea_id: l.linea_id,
cantidad: parseFloat(l.cantidad) || 0,
lote: l.lote,
fecha_caducidad: l.fecha_caducidad || null,
costo_unitario: l.costo_unitario,
}));
if (payload.length === 0) { toast.error('Indica al menos una cantidad a recibir'); return; }
setSaving(true);
try {
await odooApi.recibirCompra(compra.id, { lineas: payload, notas });
toast.success('Recepción registrada: stock y costo actualizados');
onDone(compra.id);
onClose();
} catch (err) {
const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message;
toast.error(msg || 'No se pudo registrar la recepción');
} finally {
setSaving(false);
}
};
return (
<Modal
isOpen={isOpen}
onClose={onClose}
title={`Recibir mercancía · ${compra?.folio || ''}`}
maxWidth="full"
footer={(
<div className="flex justify-between items-center gap-2 w-full">
<span className="text-xs text-theme-muted">
{fmtNum(totalRecibir)} unidades por recibir
</span>
<div className="flex gap-2">
<Button variant="outline" onClick={onClose}>Cancelar</Button>
<Button onClick={confirmar} loading={saving}>Confirmar recepción</Button>
</div>
</div>
)}
>
<div className="space-y-4">
<div className="rounded-xl bg-amber-50 border border-amber-200 p-3 text-xs text-amber-800 flex gap-2">
<AlertTriangle size={16} className="shrink-0 mt-0.5" />
<span>
Esta acción afecta el <strong>stock</strong> y, si la clínica lo tiene activado, el
<strong> costo promedio</strong> del artículo. Se permiten recepciones parciales:
lo que no recibas quedará pendiente para una recepción posterior.
</span>
</div>
<div className="overflow-x-auto border border-theme-border rounded-xl">
<table className="w-full text-sm">
<thead className="bg-theme-bg text-theme-muted text-[11px] uppercase">
<tr>
<th className="text-left px-3 py-2">Artículo</th>
<th className="text-right px-3 py-2">Pedida</th>
<th className="text-right px-3 py-2">Recibida</th>
<th className="text-right px-3 py-2">Pendiente</th>
<th className="text-right px-3 py-2 w-28">Recibir</th>
<th className="text-left px-3 py-2 w-32">Lote</th>
<th className="text-left px-3 py-2 w-40">Caducidad</th>
<th className="text-right px-3 py-2">Costo</th>
</tr>
</thead>
<tbody className="divide-y divide-theme-border">
{lineas.map((l, idx) => {
const c = parseFloat(l.cantidad) || 0;
const dif = c > 0 && Math.abs(c - l.pendiente) > 1e-6;
const exceso = c > l.pendiente + 1e-6;
return (
<tr key={l.linea_id}>
<td className="px-3 py-2">
<p className="text-theme-heading">{l.item}</p>
<p className="text-[11px] text-theme-muted">{l.unit}{l.kind === 'consumible' ? ' · consumible' : ''}</p>
</td>
<td className="px-3 py-2 text-right tabular-nums">{fmtNum(l.pedida)}</td>
<td className="px-3 py-2 text-right tabular-nums text-theme-muted">{fmtNum(l.recibida)}</td>
<td className="px-3 py-2 text-right tabular-nums">{fmtNum(l.pendiente)}</td>
<td className="px-3 py-2">
<Input
type="number"
value={l.cantidad}
onChange={(e) => setLinea(idx, { cantidad: e.target.value })}
inputClassName={exceso ? 'border-rose-300' : dif ? 'border-amber-300' : undefined}
/>
</td>
<td className="px-3 py-2">
<Input value={l.lote} onChange={(e) => setLinea(idx, { lote: e.target.value })} />
</td>
<td className="px-3 py-2">
<Input type="date" value={l.fecha_caducidad} onChange={(e) => setLinea(idx, { fecha_caducidad: e.target.value })} />
</td>
<td className="px-3 py-2 text-right tabular-nums">{fmtMoney(l.costo_unitario)}</td>
</tr>
);
})}
</tbody>
</table>
</div>
{excesos.length > 0 && (
<p className="text-xs text-rose-600">
Hay líneas con cantidad mayor a lo pendiente; se ajustarán al pendiente.
</p>
)}
{diferencias.length > 0 && excesos.length === 0 && (
<p className="text-xs text-amber-700">
Diferencias contra lo pedido en {diferencias.length} línea(s): se registrará una recepción parcial.
</p>
)}
<TextArea label="Notas de la recepción" rows={2} value={notas} onChange={(e) => setNotas(e.target.value)} />
{compras?.permitir_recepcion_parcial === false && (
<p className="text-[11px] text-theme-muted">
Esta clínica no permite recepciones parciales: cada línea debe recibirse completa.
</p>
)}
</div>
</Modal>
);
};
export default RecepcionModal;

View File

@@ -0,0 +1,29 @@
import type { EstadoCompra } from '../../services/odoo';
export const fmtMoney = (value: number, currency = 'MXN') =>
new Intl.NumberFormat('es-MX', {
style: 'currency', currency, maximumFractionDigits: 2,
}).format(value || 0);
export const fmtNum = (value: number) =>
new Intl.NumberFormat('es-MX', { maximumFractionDigits: 2 }).format(value || 0);
export const ESTADO_META: Record<EstadoCompra, { label: string; cls: string }> = {
borrador: { label: 'Borrador', cls: 'bg-slate-200 text-slate-700' },
enviada: { label: 'Enviada', cls: 'bg-sky-200 text-sky-900' },
recibida_parcial: { label: 'Recibida parcial', cls: 'bg-amber-200 text-amber-900' },
recibida: { label: 'Recibida', cls: 'bg-theme-success text-theme-heading' },
cancelada: { label: 'Cancelada', cls: 'bg-rose-200 text-rose-900' },
};
export const METODO_OPTIONS = [
{ value: 'transferencia', label: 'Transferencia' },
{ value: 'efectivo', label: 'Efectivo' },
{ value: 'tarjeta', label: 'Tarjeta' },
{ value: 'otro', label: 'Otro' },
];
export const MONEDA_OPTIONS = [
{ value: 'MXN', label: 'MXN (pesos)' },
{ value: 'USD', label: 'USD (dólares)' },
];

View File

@@ -0,0 +1,185 @@
import type { FC } from 'react';
import { useCallback, useEffect, useState } from 'react';
import { RefreshCw, Download } from 'lucide-react';
import { Card, Button, EmptyState, Skeleton, toast } from '../ui';
import { odooApi, type ComprasReportResponse } from '../../services/odoo';
import { useReportRange } from '../../lib/reportRange';
import { useAuth } from '../../lib/auth';
import { exportToExcel } from '../../lib/exporter';
import { fmtMoney } from '../compras/shared';
const ComprasReport: FC = () => {
const { start, end } = useReportRange('compras');
const { brand } = useAuth();
const [data, setData] = useState<ComprasReportResponse | null>(null);
const [loading, setLoading] = useState(true);
const load = useCallback(async () => {
try {
setLoading(true);
const res = await odooApi.getComprasReport(start, end);
if (res.status === 'success') setData(res);
else toast.error(res.message || 'No se pudo cargar el reporte');
} catch (err) {
const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message;
toast.error(msg || 'Error al cargar el reporte de compras');
} finally {
setLoading(false);
}
}, [start, end]);
useEffect(() => { load(); }, [load]);
const exportar = async () => {
if (!data) return;
try {
await exportToExcel({
filename: `reporte-compras-${data.start}_${data.end}`,
sheetName: 'Compras',
title: `Reporte de compras · ${brand.nombre}`,
subtitle: `Periodo ${data.start} a ${data.end}`,
columns: [
{ header: 'Concepto', key: 'concepto' },
{ header: 'Compras', key: 'compras', format: 'number' },
{ header: 'Total', key: 'total', format: 'currency' },
{ header: 'Pagado', key: 'pagado', format: 'currency' },
{ header: 'Saldo', key: 'saldo', format: 'currency' },
],
rows: [
{ concepto: 'Totales', compras: data.totales.compras, total: data.totales.total, pagado: data.totales.pagado, saldo: data.totales.saldo },
...data.por_proveedor.map((p) => ({ concepto: p.proveedor, compras: p.compras, total: p.total, pagado: p.pagado, saldo: p.saldo })),
],
});
toast.success('Excel descargado');
} catch {
toast.error('No se pudo exportar');
}
};
if (loading) return <Card><Card.Body><Skeleton count={6} /></Card.Body></Card>;
if (!data) return <Card><Card.Body><EmptyState title="Sin datos" subtitle="No se pudo cargar el reporte de compras." /></Card.Body></Card>;
return (
<div className="space-y-6">
<div className="flex items-center justify-between gap-3">
<p className="text-xs text-theme-muted">Periodo {data.start} a {data.end}</p>
<div className="flex gap-2">
<Button variant="outline" size="sm" onClick={load}><RefreshCw size={14} className="mr-1.5" />Actualizar</Button>
<Button variant="outline" size="sm" onClick={exportar}><Download size={14} className="mr-1.5" />Exportar</Button>
</div>
</div>
<div className="grid grid-cols-2 md:grid-cols-5 gap-3">
<Card><Card.Body><p className="text-xs text-theme-muted">Compras</p><p className="text-2xl font-heading text-theme-heading">{data.totales.compras}</p></Card.Body></Card>
<Card><Card.Body><p className="text-xs text-theme-muted">Subtotal</p><p className="text-lg font-heading text-theme-heading">{fmtMoney(data.totales.subtotal)}</p></Card.Body></Card>
<Card><Card.Body><p className="text-xs text-theme-muted">Impuestos</p><p className="text-lg font-heading text-theme-heading">{fmtMoney(data.totales.impuestos)}</p></Card.Body></Card>
<Card><Card.Body><p className="text-xs text-theme-muted">Total</p><p className="text-lg font-heading text-theme-heading">{fmtMoney(data.totales.total)}</p></Card.Body></Card>
<Card><Card.Body><p className="text-xs text-theme-muted">Saldo</p><p className="text-lg font-heading text-theme-heading">{fmtMoney(data.totales.saldo)}</p></Card.Body></Card>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
<Card>
<Card.Header><p className="font-medium text-theme-heading">Gasto por proveedor</p></Card.Header>
<Card.Body noPadding>
{data.por_proveedor.length === 0 ? (
<div className="p-6"><p className="text-sm text-theme-muted">Sin compras en el periodo.</p></div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="bg-theme-bg text-theme-muted text-[11px] uppercase">
<tr>
<th className="text-left px-4 py-3">Proveedor</th>
<th className="text-right px-4 py-3">Compras</th>
<th className="text-right px-4 py-3">Total</th>
<th className="text-right px-4 py-3">Saldo</th>
</tr>
</thead>
<tbody className="divide-y divide-theme-border">
{data.por_proveedor.map((p) => (
<tr key={p.partner_id}>
<td className="px-4 py-3 text-theme-heading">{p.proveedor}</td>
<td className="px-4 py-3 text-right tabular-nums">{p.compras}</td>
<td className="px-4 py-3 text-right tabular-nums">{fmtMoney(p.total)}</td>
<td className="px-4 py-3 text-right tabular-nums">{fmtMoney(p.saldo)}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</Card.Body>
</Card>
<Card>
<Card.Header><p className="font-medium text-theme-heading">Gasto por artículo</p></Card.Header>
<Card.Body noPadding>
{data.por_articulo.length === 0 ? (
<div className="p-6"><p className="text-sm text-theme-muted">Sin artículos comprados en el periodo.</p></div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="bg-theme-bg text-theme-muted text-[11px] uppercase">
<tr>
<th className="text-left px-4 py-3">Artículo</th>
<th className="text-right px-4 py-3">Pedido</th>
<th className="text-right px-4 py-3">Recibido</th>
<th className="text-right px-4 py-3">Subtotal</th>
</tr>
</thead>
<tbody className="divide-y divide-theme-border">
{data.por_articulo.map((a) => (
<tr key={a.item_id}>
<td className="px-4 py-3 text-theme-heading">{a.item}</td>
<td className="px-4 py-3 text-right tabular-nums">{a.cantidad_pedida}</td>
<td className="px-4 py-3 text-right tabular-nums">{a.cantidad_recibida}</td>
<td className="px-4 py-3 text-right tabular-nums">{fmtMoney(a.subtotal)}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</Card.Body>
</Card>
</div>
<Card>
<Card.Header><p className="font-medium text-theme-heading">Cuentas por pagar</p></Card.Header>
<Card.Body noPadding>
{data.cuentas_por_pagar.proveedores.length === 0 ? (
<div className="p-6"><p className="text-sm text-theme-muted">Sin saldos pendientes.</p></div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="bg-theme-bg text-theme-muted text-[11px] uppercase">
<tr>
<th className="text-left px-4 py-3">Proveedor</th>
<th className="text-right px-4 py-3">Compras</th>
<th className="text-right px-4 py-3">Crédito</th>
<th className="text-right px-4 py-3">Saldo</th>
</tr>
</thead>
<tbody className="divide-y divide-theme-border">
{data.cuentas_por_pagar.proveedores.map((p) => (
<tr key={p.partner_id}>
<td className="px-4 py-3 text-theme-heading">{p.proveedor}</td>
<td className="px-4 py-3 text-right tabular-nums">{p.compras}</td>
<td className="px-4 py-3 text-right tabular-nums">{p.dias_credito} días</td>
<td className="px-4 py-3 text-right tabular-nums">{fmtMoney(p.saldo)}</td>
</tr>
))}
<tr className="bg-theme-bg font-medium">
<td className="px-4 py-3 text-theme-heading" colSpan={3}>Total por pagar</td>
<td className="px-4 py-3 text-right tabular-nums">{fmtMoney(data.cuentas_por_pagar.total)}</td>
</tr>
</tbody>
</table>
</div>
)}
</Card.Body>
</Card>
</div>
);
};
export default ComprasReport;

View File

@@ -0,0 +1,144 @@
import type { FC } from 'react';
import { useCallback, useEffect, useState } from 'react';
import { RefreshCw, Download, AlertTriangle } from 'lucide-react';
import { Card, Button, EmptyState, Skeleton, toast } from '../ui';
import { odooApi, type MargenReportResponse } from '../../services/odoo';
import { useReportRange } from '../../lib/reportRange';
import { useAuth } from '../../lib/auth';
import { exportToExcel } from '../../lib/exporter';
import { fmtMoney, fmtNum } from '../compras/shared';
const MargenReport: FC = () => {
const { start, end } = useReportRange('margen');
const { brand } = useAuth();
const [data, setData] = useState<MargenReportResponse | null>(null);
const [loading, setLoading] = useState(true);
const [soloAlertas, setSoloAlertas] = useState(false);
const load = useCallback(async () => {
try {
setLoading(true);
const res = await odooApi.getMargenReport(start, end);
if (res.status === 'success') setData(res);
else toast.error(res.message || 'No se pudo cargar el reporte');
} catch (err) {
const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message;
toast.error(msg || 'Error al cargar el reporte de margen');
} finally {
setLoading(false);
}
}, [start, end]);
useEffect(() => { load(); }, [load]);
const exportar = async () => {
if (!data) return;
try {
await exportToExcel({
filename: `reporte-margen-${data.start}_${data.end}`,
sheetName: 'Margen',
title: `Reporte de margen · ${brand.nombre}`,
subtitle: `Periodo ${data.start} a ${data.end}`,
columns: [
{ header: 'Artículo', key: 'item' },
{ header: 'SKU', key: 'sku' },
{ header: 'Precio', key: 'precio', format: 'currency' },
{ header: 'Costo promedio', key: 'costo_promedio', format: 'currency' },
{ header: 'Margen unitario', key: 'margen_unitario', format: 'currency' },
{ header: 'Margen %', key: 'margen_pct', format: 'number' },
{ header: 'Unidades vendidas', key: 'unidades_vendidas', format: 'number' },
{ header: 'Ingresos', key: 'ingresos', format: 'currency' },
{ header: 'Costo de ventas', key: 'costo_ventas', format: 'currency' },
{ header: 'Margen total', key: 'margen_total', format: 'currency' },
],
rows: data.articulos as unknown as Record<string, unknown>[],
totals: {
item: 'Totales', sku: '', precio: '', costo_promedio: '', margen_unitario: '',
margen_pct: data.totales.margen_pct, unidades_vendidas: '',
ingresos: data.totales.ingresos, costo_ventas: data.totales.costo_ventas,
margen_total: data.totales.margen,
},
});
toast.success('Excel descargado');
} catch {
toast.error('No se pudo exportar');
}
};
if (loading) return <Card><Card.Body><Skeleton count={6} /></Card.Body></Card>;
if (!data) return <Card><Card.Body><EmptyState title="Sin datos" subtitle="No se pudo cargar el reporte de margen." /></Card.Body></Card>;
const filas = soloAlertas ? data.articulos.filter((a) => a.alerta) : data.articulos;
return (
<div className="space-y-6">
<div className="flex flex-wrap items-center justify-between gap-3">
<p className="text-xs text-theme-muted">Periodo {data.start} a {data.end}</p>
<div className="flex items-center gap-2">
<label className="flex items-center gap-2 text-sm text-theme-muted">
<input type="checkbox" checked={soloAlertas} onChange={(e) => setSoloAlertas(e.target.checked)} />
Solo alertas (&lt; {data.margen_minimo_alerta}%)
</label>
<Button variant="outline" size="sm" onClick={load}><RefreshCw size={14} className="mr-1.5" />Actualizar</Button>
<Button variant="outline" size="sm" onClick={exportar}><Download size={14} className="mr-1.5" />Exportar</Button>
</div>
</div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
<Card><Card.Body><p className="text-xs text-theme-muted">Ingresos por artículos</p><p className="text-xl font-heading text-theme-heading">{fmtMoney(data.totales.ingresos)}</p></Card.Body></Card>
<Card><Card.Body><p className="text-xs text-theme-muted">Costo de ventas</p><p className="text-xl font-heading text-theme-heading">{fmtMoney(data.totales.costo_ventas)}</p></Card.Body></Card>
<Card><Card.Body><p className="text-xs text-theme-muted">Margen</p><p className="text-xl font-heading text-theme-heading">{fmtMoney(data.totales.margen)}</p><p className="text-[11px] text-theme-muted">{data.totales.margen_pct}%</p></Card.Body></Card>
<Card><Card.Body><p className="text-xs text-theme-muted flex items-center gap-1"><AlertTriangle size={13} />En alerta</p><p className="text-xl font-heading text-theme-heading">{data.totales.alertas}</p></Card.Body></Card>
</div>
<Card>
<Card.Header><p className="font-medium text-theme-heading">Margen por artículo</p></Card.Header>
<Card.Body noPadding>
{filas.length === 0 ? (
<div className="p-6"><p className="text-sm text-theme-muted">Sin artículos en el periodo.</p></div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="bg-theme-bg text-theme-muted text-[11px] uppercase">
<tr>
<th className="text-left px-4 py-3">Artículo</th>
<th className="text-right px-4 py-3">Precio</th>
<th className="text-right px-4 py-3">Costo prom.</th>
<th className="text-right px-4 py-3">Margen unit.</th>
<th className="text-right px-4 py-3">Margen %</th>
<th className="text-right px-4 py-3">Vendidas</th>
<th className="text-right px-4 py-3">Ingresos</th>
<th className="text-right px-4 py-3">Margen total</th>
</tr>
</thead>
<tbody className="divide-y divide-theme-border">
{filas.map((a) => (
<tr key={a.item_id} className={a.alerta ? 'bg-amber-50' : ''}>
<td className="px-4 py-3">
<p className="text-theme-heading">{a.item}</p>
{a.sku && <p className="text-[11px] text-theme-muted">{a.sku}</p>}
</td>
<td className="px-4 py-3 text-right tabular-nums">{fmtMoney(a.precio)}</td>
<td className="px-4 py-3 text-right tabular-nums">{fmtMoney(a.costo_promedio)}</td>
<td className="px-4 py-3 text-right tabular-nums">{fmtMoney(a.margen_unitario)}</td>
<td className="px-4 py-3 text-right tabular-nums">{a.margen_pct.toFixed(1)}%</td>
<td className="px-4 py-3 text-right tabular-nums">{fmtNum(a.unidades_vendidas)}</td>
<td className="px-4 py-3 text-right tabular-nums">{fmtMoney(a.ingresos)}</td>
<td className="px-4 py-3 text-right tabular-nums font-medium">{fmtMoney(a.margen_total)}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</Card.Body>
</Card>
<p className="text-[11px] text-theme-muted">
El margen usa el precio de venta sin impuesto menos el costo promedio del artículo. Si un
artículo no tiene costo promedio cargado, se usa su costo unitario.
</p>
</div>
);
};
export default MargenReport;

View File

@@ -46,6 +46,9 @@ const MENU_ROLES: Record<string, FrontendRole[]> = {
'/pagos': ['admin', 'recepcion', 'gerencia'], '/pagos': ['admin', 'recepcion', 'gerencia'],
'/monedero': ['admin', 'recepcion', 'lectura', 'gerencia'], '/monedero': ['admin', 'recepcion', 'lectura', 'gerencia'],
'/inventario': ['admin', 'recepcion', 'lectura', 'gerencia'], '/inventario': ['admin', 'recepcion', 'lectura', 'gerencia'],
// Compras (apagado por defecto: la sección solo se pinta con
// compras.activo=true). Se lista aquí para que canSee la reconozca.
'/compras': ['admin', 'recepcion', 'lectura', 'gerencia'],
'/cortes': ['admin', 'recepcion', 'gerencia'], '/cortes': ['admin', 'recepcion', 'gerencia'],
'/reportes': ['admin', 'recepcion', 'lectura', 'gerencia'], '/reportes': ['admin', 'recepcion', 'lectura', 'gerencia'],
'/reportes?tab=adeudos': ['admin', 'recepcion', 'lectura', 'gerencia'], '/reportes?tab=adeudos': ['admin', 'recepcion', 'lectura', 'gerencia'],
@@ -56,6 +59,8 @@ const MENU_ROLES: Record<string, FrontendRole[]> = {
'/reportes?tab=vendedores': ['admin', 'recepcion', 'lectura', 'gerencia'], '/reportes?tab=vendedores': ['admin', 'recepcion', 'lectura', 'gerencia'],
'/reportes?tab=concentrado': ['admin', 'recepcion', 'lectura', 'gerencia'], '/reportes?tab=concentrado': ['admin', 'recepcion', 'lectura', 'gerencia'],
'/reportes?tab=recomendaciones': ['admin', 'recepcion', 'lectura', 'gerencia'], '/reportes?tab=recomendaciones': ['admin', 'recepcion', 'lectura', 'gerencia'],
'/reportes?tab=compras': ['admin', 'recepcion', 'lectura', 'gerencia'],
'/reportes?tab=margen': ['admin', 'recepcion', 'lectura', 'gerencia'],
'/reportes?tab=exportar': ['admin', 'recepcion', 'lectura', 'gerencia'], '/reportes?tab=exportar': ['admin', 'recepcion', 'lectura', 'gerencia'],
'/configuracion': ['admin', 'gerencia'], '/configuracion': ['admin', 'gerencia'],
'/cumpleanos': ['admin', 'recepcion', 'gerencia', 'marketing'], '/cumpleanos': ['admin', 'recepcion', 'gerencia', 'marketing'],

View File

@@ -1,5 +1,5 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { odooApi, type ClinicSettings, CLINIC_SETTINGS_DEFAULT, IMPUESTOS_DEFAULT, NOTIFICACIONES_DEFAULT, MEMBRESIAS_DEFAULT, RECURSOS_DEFAULT } from '../services/odoo'; import { odooApi, type ClinicSettings, CLINIC_SETTINGS_DEFAULT, IMPUESTOS_DEFAULT, NOTIFICACIONES_DEFAULT, MEMBRESIAS_DEFAULT, RECURSOS_DEFAULT, COMPRAS_DEFAULT } from '../services/odoo';
// Ajustes de la clínica compartidos por la sesión (nombre, moneda e impuestos). // 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. // Se leen una sola vez del servidor y se avisa a quien los use cuando cambian.
@@ -12,6 +12,7 @@ const normalizar = (settings?: Partial<ClinicSettings> | null): ClinicSettings =
notificaciones: { ...NOTIFICACIONES_DEFAULT, ...(settings?.notificaciones || {}) }, notificaciones: { ...NOTIFICACIONES_DEFAULT, ...(settings?.notificaciones || {}) },
membresias: { ...MEMBRESIAS_DEFAULT, ...(settings?.membresias || {}) }, membresias: { ...MEMBRESIAS_DEFAULT, ...(settings?.membresias || {}) },
recursos: { ...RECURSOS_DEFAULT, ...(settings?.recursos || {}) }, recursos: { ...RECURSOS_DEFAULT, ...(settings?.recursos || {}) },
compras: { ...COMPRAS_DEFAULT, ...(settings?.compras || {}) },
}); });
let actual: ClinicSettings = normalizar(null); let actual: ClinicSettings = normalizar(null);

View File

@@ -23,6 +23,7 @@ export const MENU_ITEMS: MenuItem[] = [
{ label: 'Pagos', href: '/pagos' }, { label: 'Pagos', href: '/pagos' },
{ label: 'Monedero', href: '/monedero' }, { label: 'Monedero', href: '/monedero' },
{ label: 'Inventario', href: '/inventario' }, { label: 'Inventario', href: '/inventario' },
{ label: 'Compras', href: '/compras' },
{ label: 'Cortes de Caja', href: '/cortes' }, { label: 'Cortes de Caja', href: '/cortes' },
{ label: 'Reportes', href: '/reportes' }, { label: 'Reportes', href: '/reportes' },
{ label: 'Comisiones', href: '/reportes?tab=comisiones' }, { label: 'Comisiones', href: '/reportes?tab=comisiones' },

View File

@@ -20,6 +20,8 @@ export const PERM_KEYS: string[] = [
'formularios.ver', 'formularios.crear', 'formularios.editar', 'formularios.enviar', 'formularios.ver', 'formularios.crear', 'formularios.editar', 'formularios.enviar',
'membresias.ver', 'membresias.crear', 'membresias.cobrar', 'membresias.editar', 'membresias.ver', 'membresias.crear', 'membresias.cobrar', 'membresias.editar',
'membresias.cancelar', 'membresias.cancelar',
'compras.ver', 'compras.crear', 'compras.recibir', 'compras.pagar',
'compras.editar', 'compras.cancelar', 'proveedores.ver', 'proveedores.editar',
'config.usuarios', 'config.roles', 'config.editar', 'config.auditoria', 'config.usuarios', 'config.roles', 'config.editar', 'config.auditoria',
]; ];
@@ -46,6 +48,8 @@ const READ_PERM_KEYS = new Set([
'notificaciones.ver', 'notificaciones.ver',
'formularios.ver', 'formularios.ver',
'membresias.ver', 'membresias.ver',
'compras.ver',
'proveedores.ver',
]); ]);
const DEFAULT_PERM_ROLES: Record<string, string[]> = { const DEFAULT_PERM_ROLES: Record<string, string[]> = {
@@ -71,6 +75,11 @@ const DEFAULT_PERM_ROLES: Record<string, string[]> = {
'membresias.ver': ALL, 'membresias.crear': ['admin', 'recepcion', 'gerencia'], 'membresias.ver': ALL, 'membresias.crear': ['admin', 'recepcion', 'gerencia'],
'membresias.cobrar': ['admin', 'recepcion', 'gerencia'], 'membresias.editar': AG, 'membresias.cobrar': ['admin', 'recepcion', 'gerencia'], 'membresias.editar': AG,
'membresias.cancelar': AG, 'membresias.cancelar': AG,
'compras.ver': ALL, 'compras.crear': ['admin', 'recepcion', 'gerencia'],
'compras.recibir': ['admin', 'recepcion', 'gerencia'],
'compras.pagar': ['admin', 'recepcion', 'gerencia'],
'compras.editar': AG, 'compras.cancelar': AG,
'proveedores.ver': ALL, 'proveedores.editar': AG,
'config.usuarios': AG, 'config.roles': AG, 'config.editar': AG, 'config.auditoria': AD, 'config.usuarios': AG, 'config.roles': AG, 'config.editar': AG, 'config.auditoria': AD,
}; };

View File

@@ -0,0 +1,359 @@
import type { FC } from 'react';
import { useCallback, useEffect, useState } from 'react';
import { useSearchParams } from 'react-router-dom';
import { Plus, Search, RefreshCw, Eye, Pencil, Send, PackageCheck, CreditCard, Ban, Download, Truck } from 'lucide-react';
import Layout from '../components/Layout';
import { Card, Button, Input, Modal, PageHeader, EmptyState, toast } from '../components/ui';
import { odooApi, type Compra, type CompraResumen } from '../services/odoo';
import { useAuth } from '../lib/auth';
import { exportToExcel } from '../lib/exporter';
import { ESTADO_META, fmtMoney } from '../components/compras/shared';
import CompraFormModal from '../components/compras/CompraFormModal';
import CompraDetalleModal from '../components/compras/CompraDetalleModal';
import RecepcionModal from '../components/compras/RecepcionModal';
import PagoCompraModal from '../components/compras/PagoCompraModal';
import CuentasPorPagarPanel from '../components/compras/CuentasPorPagarPanel';
import ProveedoresPanel from '../components/compras/ProveedoresPanel';
const TABS = [
{ key: 'ordenes', label: 'Órdenes de compra' },
{ key: 'cuentas', label: 'Cuentas por pagar' },
{ key: 'proveedores', label: 'Proveedores' },
] as const;
const CHIPS = [
{ key: '', label: 'Todas' },
{ key: 'borrador', label: 'Borradores' },
{ key: 'enviada', label: 'Enviadas' },
{ key: 'por_recibir', label: 'Por recibir' },
{ key: 'por_pagar', label: 'Cuentas por pagar' },
];
const Compras: FC = () => {
const { can, brand } = useAuth();
const [searchParams, setSearchParams] = useSearchParams();
const tab = (searchParams.get('tab') || 'ordenes') as typeof TABS[number]['key'];
const setTab = (t: string) => setSearchParams({ tab: t });
const [rows, setRows] = useState<Compra[]>([]);
const [resumen, setResumen] = useState<CompraResumen | null>(null);
const [loading, setLoading] = useState(true);
const [estado, setEstado] = useState('');
const [q, setQ] = useState('');
const [formOpen, setFormOpen] = useState(false);
const [formCompra, setFormCompra] = useState<Compra | null>(null);
const [detalle, setDetalle] = useState<Compra | null>(null);
const [detalleOpen, setDetalleOpen] = useState(false);
const [recepcion, setRecepcion] = useState<Compra | null>(null);
const [pago, setPago] = useState<Compra | null>(null);
const [cancelar, setCancelar] = useState<Compra | null>(null);
const [cancelMotivo, setCancelMotivo] = useState('');
const [busy, setBusy] = useState(false);
const load = useCallback(async () => {
if (tab !== 'ordenes') return;
setLoading(true);
try {
const res = await odooApi.getCompras({
...(estado ? { estado } : {}),
...(q ? { q } : {}),
page_size: 100,
});
setRows(res.compras || []);
setResumen(res.resumen || null);
} catch (err) {
const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message;
toast.error(msg || 'No se pudieron cargar las compras');
} finally {
setLoading(false);
}
}, [tab, estado, q]);
useEffect(() => { load(); }, [load]);
const abrirDetalle = async (c: Compra) => {
try {
const res = await odooApi.getCompra(c.id);
if (res.status === 'success') { setDetalle(res.compra); setDetalleOpen(true); }
} catch (err) {
const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message;
toast.error(msg || 'No se pudo abrir la compra');
}
};
const enviar = async (c: Compra) => {
try {
await odooApi.enviarCompra(c.id);
toast.success('Compra enviada');
load();
if (detalle?.id === c.id) setDetalleOpen(false);
} catch (err) {
const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message;
toast.error(msg || 'No se pudo enviar');
}
};
const confirmarCancelar = async () => {
if (!cancelar) return;
setBusy(true);
try {
await odooApi.cancelarCompra(cancelar.id, cancelMotivo);
toast.success('Compra cancelada');
setCancelar(null);
setCancelMotivo('');
load();
if (detalle?.id === cancelar.id) setDetalleOpen(false);
} catch (err) {
const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message;
toast.error(msg || 'No se pudo cancelar');
} finally {
setBusy(false);
}
};
const exportar = async () => {
try {
await exportToExcel({
filename: `compras-${new Date().toISOString().slice(0, 10)}`,
sheetName: 'Compras',
title: `Compras · ${brand.nombre}`,
subtitle: `Filtro: ${CHIPS.find((c) => c.key === estado)?.label || 'Todas'}`,
columns: [
{ header: 'Folio', key: 'folio' },
{ header: 'Proveedor', key: 'proveedor' },
{ header: 'Fecha', key: 'fecha', format: 'date' },
{ header: 'Estado', key: 'estado_label' },
{ header: 'Moneda', key: 'moneda' },
{ header: 'Total', key: 'total', format: 'currency' },
{ header: 'Pagado', key: 'pagado', format: 'currency' },
{ header: 'Saldo', key: 'saldo', format: 'currency' },
],
rows: rows as unknown as Record<string, unknown>[],
totals: {
folio: '', proveedor: 'Totales', fecha: '', estado_label: '', moneda: '',
total: rows.reduce((s, r) => s + r.total, 0),
pagado: rows.reduce((s, r) => s + r.pagado, 0),
saldo: rows.reduce((s, r) => s + r.saldo, 0),
},
});
toast.success('Excel descargado');
} catch {
toast.error('No se pudo exportar');
}
};
return (
<Layout title="Compras" subtitle="Proveedores, órdenes, recepciones y cuentas por pagar">
<PageHeader title="Compras" subtitle="Órdenes de compra, recepción de mercancía y cuentas por pagar">
{tab === 'ordenes' && can('compras.crear') && (
<Button onClick={() => { setFormCompra(null); setFormOpen(true); }}>
<Plus size={16} className="mr-1.5" />Nueva compra
</Button>
)}
{tab === 'ordenes' && can('reportes.exportar') && (
<Button variant="outline" onClick={exportar} disabled={rows.length === 0}>
<Download size={16} className="mr-1.5" />Exportar
</Button>
)}
</PageHeader>
<div className="flex flex-wrap gap-1.5 mb-6">
{TABS.map((t) => (
<button
key={t.key}
type="button"
onClick={() => setTab(t.key)}
className={`px-4 py-2 text-sm font-medium rounded-full transition ${
tab === t.key ? 'bg-theme-accent text-theme-inverse' : 'bg-theme-accent-bg text-theme-heading hover:bg-theme-bg'
}`}
>
{t.label}
</button>
))}
</div>
{tab === 'cuentas' ? (
<CuentasPorPagarPanel />
) : tab === 'proveedores' ? (
<ProveedoresPanel />
) : (
<div className="space-y-4">
{resumen && (
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
<Card><Card.Body>
<p className="text-xs text-theme-muted">Gasto del mes</p>
<p className="text-xl font-heading text-theme-heading tabular-nums">{fmtMoney(resumen.gasto_mes)}</p>
<p className="text-[11px] text-theme-muted">{resumen.compras_mes} compra(s)</p>
</Card.Body></Card>
<Card><Card.Body>
<p className="text-xs text-theme-muted">Abiertas (por recibir)</p>
<p className="text-xl font-heading text-theme-heading tabular-nums">{resumen.abiertas}</p>
</Card.Body></Card>
<Card><Card.Body>
<p className="text-xs text-theme-muted">Compras con saldo</p>
<p className="text-xl font-heading text-theme-heading tabular-nums">{resumen.por_pagar}</p>
</Card.Body></Card>
<Card><Card.Body>
<p className="text-xs text-theme-muted">Saldo por pagar</p>
<p className="text-xl font-heading text-theme-heading tabular-nums">{fmtMoney(resumen.saldo_total)}</p>
</Card.Body></Card>
</div>
)}
<div className="flex flex-wrap items-center gap-2">
<div className="relative flex-1 min-w-[200px]">
<Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-theme-muted" />
<Input className="pl-9" placeholder="Buscar por folio, proveedor o referencia" value={q} onChange={(e) => setQ(e.target.value)} />
</div>
<Button variant="outline" size="sm" onClick={load}><RefreshCw size={14} className="mr-1.5" />Actualizar</Button>
</div>
<div className="flex flex-wrap gap-1.5">
{CHIPS.map((c) => (
<button
key={c.key || 'todas'}
type="button"
onClick={() => setEstado(c.key)}
className={`px-3 py-1.5 text-xs font-medium rounded-full transition ${
estado === c.key ? 'bg-theme-accent text-theme-inverse' : 'bg-theme-bg text-theme-muted hover:text-theme-heading'
}`}
>
{c.label}
</button>
))}
</div>
<Card>
<Card.Body noPadding>
{loading ? (
<div className="p-6 text-sm text-theme-muted">Cargando compras…</div>
) : rows.length === 0 ? (
<div className="p-6">
<EmptyState title="Sin compras" subtitle="Crea una orden de compra para comenzar." icon={<Truck size={28} />} />
</div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="bg-theme-bg text-theme-muted text-[11px] uppercase">
<tr>
<th className="text-left px-4 py-3">Folio</th>
<th className="text-left px-4 py-3">Proveedor</th>
<th className="text-left px-4 py-3 hidden md:table-cell">Fecha</th>
<th className="text-right px-4 py-3">Total</th>
<th className="text-right px-4 py-3 hidden lg:table-cell">Pagado</th>
<th className="text-right px-4 py-3">Saldo</th>
<th className="text-left px-4 py-3">Estado</th>
<th className="text-right px-4 py-3">Acciones</th>
</tr>
</thead>
<tbody className="divide-y divide-theme-border">
{rows.map((c) => {
const meta = ESTADO_META[c.estado];
return (
<tr key={c.id}>
<td className="px-4 py-3 text-theme-heading font-medium">{c.folio}</td>
<td className="px-4 py-3 text-theme-heading">{c.proveedor}</td>
<td className="px-4 py-3 text-theme-muted hidden md:table-cell">{c.fecha}</td>
<td className="px-4 py-3 text-right tabular-nums">{fmtMoney(c.total, c.moneda)}</td>
<td className="px-4 py-3 text-right tabular-nums hidden lg:table-cell text-theme-muted">{fmtMoney(c.pagado, c.moneda)}</td>
<td className="px-4 py-3 text-right tabular-nums font-medium">{fmtMoney(c.saldo, c.moneda)}</td>
<td className="px-4 py-3">
<span className={`inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium ${meta.cls}`}>{meta.label}</span>
</td>
<td className="px-4 py-3">
<div className="flex items-center justify-end gap-1">
<button type="button" title="Ver detalle" onClick={() => abrirDetalle(c)} className="p-1.5 rounded-lg hover:bg-theme-bg text-theme-muted"><Eye size={15} /></button>
{can('compras.editar') && ['borrador', 'enviada'].includes(c.estado) && (
<button type="button" title="Editar" onClick={() => { setFormCompra(c); setFormOpen(true); }} className="p-1.5 rounded-lg hover:bg-theme-bg text-theme-muted"><Pencil size={15} /></button>
)}
{can('compras.crear') && c.estado === 'borrador' && (
<button type="button" title="Enviar" onClick={() => enviar(c)} className="p-1.5 rounded-lg hover:bg-theme-bg text-theme-muted"><Send size={15} /></button>
)}
{can('compras.recibir') && c.puede_recibir && (
<button type="button" title="Recibir" onClick={() => setRecepcion(c)} className="p-1.5 rounded-lg hover:bg-theme-bg text-theme-muted"><PackageCheck size={15} /></button>
)}
{can('compras.pagar') && c.estado !== 'cancelada' && c.saldo > 0 && (
<button type="button" title="Pagar" onClick={() => setPago(c)} className="p-1.5 rounded-lg hover:bg-theme-bg text-theme-muted"><CreditCard size={15} /></button>
)}
{can('compras.cancelar') && c.puede_cancelar && (
<button type="button" title="Cancelar" onClick={() => { setCancelar(c); setCancelMotivo(''); }} className="p-1.5 rounded-lg hover:bg-rose-50 text-rose-600"><Ban size={15} /></button>
)}
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</Card.Body>
</Card>
</div>
)}
<CompraFormModal
isOpen={formOpen}
onClose={() => setFormOpen(false)}
compra={formCompra}
onDone={() => { load(); if (detalleOpen) setDetalleOpen(false); }}
/>
<CompraDetalleModal
isOpen={detalleOpen}
onClose={() => setDetalleOpen(false)}
compra={detalle}
puedeEditar={can('compras.editar')}
puedeRecibir={can('compras.recibir')}
puedePagar={can('compras.pagar')}
puedeCancelar={can('compras.cancelar')}
puedeCrear={can('compras.crear')}
onEdit={(c) => { setDetalleOpen(false); setFormCompra(c); setFormOpen(true); }}
onEnviar={(c) => enviar(c)}
onRecibir={(c) => setRecepcion(c)}
onPagar={(c) => setPago(c)}
onCancelar={(c) => { setCancelar(c); setCancelMotivo(''); }}
/>
<RecepcionModal
isOpen={!!recepcion}
onClose={() => setRecepcion(null)}
compra={recepcion}
onDone={() => { load(); }}
/>
<PagoCompraModal
isOpen={!!pago}
onClose={() => setPago(null)}
compraId={pago?.id ?? null}
folio={pago?.folio || ''}
saldo={pago?.saldo || 0}
moneda={pago?.moneda || 'MXN'}
onDone={() => load()}
/>
<Modal
isOpen={!!cancelar}
onClose={() => setCancelar(null)}
title={`Cancelar compra ${cancelar?.folio || ''}`}
footer={(
<div className="flex justify-end gap-2">
<Button variant="outline" onClick={() => setCancelar(null)}>Volver</Button>
<Button variant="danger" onClick={confirmarCancelar} loading={busy}>Cancelar compra</Button>
</div>
)}
>
<p className="text-sm text-theme-muted">
Solo se pueden cancelar compras <strong>sin recepciones</strong>. Si la mercancía ya
entró, registra una baja o un ajuste de inventario.
</p>
<div className="mt-3">
<Input label="Motivo (opcional)" value={cancelMotivo} onChange={(e) => setCancelMotivo(e.target.value)} />
</div>
</Modal>
</Layout>
);
};
export default Compras;

View File

@@ -19,6 +19,7 @@ import NotificacionesPanel from '../components/NotificacionesPanel';
import NotificacionesEnviadasPanel from '../components/NotificacionesEnviadasPanel'; import NotificacionesEnviadasPanel from '../components/NotificacionesEnviadasPanel';
import FormulariosPanel from '../components/FormulariosPanel'; import FormulariosPanel from '../components/FormulariosPanel';
import MembresiasPanel from '../components/MembresiasPanel'; import MembresiasPanel from '../components/MembresiasPanel';
import ComprasPanel from '../components/ComprasPanel';
import { Card, Button, Input, PageHeader, toast } from '../components/ui'; import { Card, Button, Input, PageHeader, toast } from '../components/ui';
import { odooApi, type Doctor, type ClinicSettings, type ImpuestosSettings, BRAND_DEFAULT, CLINIC_SETTINGS_DEFAULT, IMPUESTOS_DEFAULT, RESERVAS_ONLINE_DEFAULT, NOTIFICACIONES_DEFAULT } from '../services/odoo'; import { odooApi, type Doctor, type ClinicSettings, type ImpuestosSettings, BRAND_DEFAULT, CLINIC_SETTINGS_DEFAULT, IMPUESTOS_DEFAULT, RESERVAS_ONLINE_DEFAULT, NOTIFICACIONES_DEFAULT } from '../services/odoo';
import { useAuth } from '../lib/auth'; import { useAuth } from '../lib/auth';
@@ -34,6 +35,7 @@ const TABS = [
{ key: 'categorias', label: 'Categorías' }, { key: 'categorias', label: 'Categorías' },
{ key: 'recursos', label: 'Recursos' }, { key: 'recursos', label: 'Recursos' },
{ key: 'membresias', label: 'Membresías' }, { key: 'membresias', label: 'Membresías' },
{ key: 'compras', label: 'Compras' },
{ key: 'terminologia', label: 'Terminología' }, { key: 'terminologia', label: 'Terminología' },
{ key: 'usuarios', label: 'Usuarios' }, { key: 'usuarios', label: 'Usuarios' },
{ key: 'roles', label: 'Roles' }, { key: 'roles', label: 'Roles' },
@@ -44,7 +46,7 @@ const TABS = [
] as const; ] as const;
// Secciones que ve gerencia (admin del sistema ve todas). // Secciones que ve gerencia (admin del sistema ve todas).
const TABS_GERENCIA = ['reservas', 'notificaciones', 'notif_enviadas', 'formularios', 'sucursales', 'categorias', 'recursos', 'membresias', 'terminologia', 'usuarios', 'roles', 'tickets']; const TABS_GERENCIA = ['reservas', 'notificaciones', 'notif_enviadas', 'formularios', 'sucursales', 'categorias', 'recursos', 'membresias', 'compras', 'terminologia', 'usuarios', 'roles', 'tickets'];
const SUBCATALOGOS = [ const SUBCATALOGOS = [
{ key: 'diagnosticos', label: 'Diagnósticos' }, { key: 'diagnosticos', label: 'Diagnósticos' },
@@ -69,6 +71,7 @@ const Configuracion: FC = () => {
notif_enviadas: 'notificaciones.ver', notif_enviadas: 'notificaciones.ver',
formularios: 'formularios.ver', formularios: 'formularios.ver',
membresias: 'membresias.editar', membresias: 'membresias.editar',
compras: 'compras.editar',
recursos: 'recursos.editar', recursos: 'recursos.editar',
}; };
const tabsVisibles = useMemo( const tabsVisibles = useMemo(
@@ -397,6 +400,12 @@ const Configuracion: FC = () => {
<MembresiasPanel settings={settings} onSaved={setSettings} /> <MembresiasPanel settings={settings} onSaved={setSettings} />
</Card.Body> </Card.Body>
</Card> </Card>
) : tab === 'compras' ? (
<Card>
<Card.Body>
<ComprasPanel settings={settings} onSaved={setSettings} />
</Card.Body>
</Card>
) : tab === 'terminologia' ? ( ) : tab === 'terminologia' ? (
<Card> <Card>
<Card.Body> <Card.Body>

View File

@@ -11,6 +11,7 @@ import { Card, Button, Input, Select, TextArea, Modal, PageHeader, EmptyState, S
import { odooApi } from '../services/odoo'; import { odooApi } from '../services/odoo';
import type { InventoryItem, InventoryMove, InventoryKind, InventoryLevel, InventoryMoveType, InventorySummary, InventoryConteo } from '../services/odoo'; import type { InventoryItem, InventoryMove, InventoryKind, InventoryLevel, InventoryMoveType, InventorySummary, InventoryConteo } from '../services/odoo';
import { useClinicSettings } from '../lib/clinic-settings'; import { useClinicSettings } from '../lib/clinic-settings';
import { useAuth } from '../lib/auth';
const TABS = [ const TABS = [
{ key: 'items', label: 'Items' }, { key: 'items', label: 'Items' },
@@ -70,6 +71,9 @@ const Inventario: FC = () => {
const tab = searchParams.get('tab') || 'items'; const tab = searchParams.get('tab') || 'items';
const setTab = (t: string) => setSearchParams({ tab: t }); const setTab = (t: string) => setSearchParams({ tab: t });
const impuestos = useClinicSettings().impuestos; const impuestos = useClinicSettings().impuestos;
const comprasCfg = useClinicSettings().compras;
const { can } = useAuth();
const puedeEditarCosto = comprasCfg.activo && can('inventario.costos');
const [items, setItems] = useState<InventoryItem[]>([]); 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 [summary, setSummary] = useState<InventorySummary>({ levels: { out: 0, critical: 0, low: 0, optimal: 0 }, total_value: 0, count: 0 });
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@@ -182,6 +186,7 @@ const Inventario: FC = () => {
setEditItem(it); setEditItem(it);
setEditForm({ name: it.name, sku: it.sku, category: it.category, unit: it.unit, 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,
costo_promedio: it.costo_promedio ?? 0, ultimo_costo: it.ultimo_costo ?? 0,
tax_use_default: it.tax_use_default !== false, tax_rate: it.tax_rate ?? 0 }); tax_use_default: it.tax_use_default !== false, tax_rate: it.tax_rate ?? 0 });
}; };
@@ -414,7 +419,14 @@ const Inventario: FC = () => {
<th className="text-right px-4 py-3">Existencia</th> <th className="text-right px-4 py-3">Existencia</th>
<th className="text-left px-4 py-3">Nivel</th> <th className="text-left px-4 py-3">Nivel</th>
<th className="text-right px-4 py-3">Costo</th> <th className="text-right px-4 py-3">Costo</th>
{comprasCfg.activo && (
<>
<th className="text-right px-4 py-3">Costo prom.</th>
<th className="text-right px-4 py-3">Último</th>
</>
)}
<th className="text-right px-4 py-3">Precio venta</th> <th className="text-right px-4 py-3">Precio venta</th>
{comprasCfg.activo && <th className="text-right px-4 py-3">Margen</th>}
<th className="text-right px-4 py-3">Valor</th> <th className="text-right px-4 py-3">Valor</th>
<th className="text-left px-4 py-3">Caducidad</th> <th className="text-left px-4 py-3">Caducidad</th>
<th className="px-4 py-3"></th> <th className="px-4 py-3"></th>
@@ -435,7 +447,21 @@ const Inventario: FC = () => {
<td className="px-4 py-3 text-right font-medium text-theme-heading">{it.qty} <span className="text-xs text-theme-muted">{it.unit}</span></td> <td className="px-4 py-3 text-right font-medium text-theme-heading">{it.qty} <span className="text-xs text-theme-muted">{it.unit}</span></td>
<td className="px-4 py-3"><span className={`text-xs px-2 py-1 rounded-full ${LEVEL_META[it.stock_level].cls}`}>{LEVEL_META[it.stock_level].label}</span></td> <td className="px-4 py-3"><span className={`text-xs px-2 py-1 rounded-full ${LEVEL_META[it.stock_level].cls}`}>{LEVEL_META[it.stock_level].label}</span></td>
<td className="px-4 py-3 text-right text-theme-muted">{fmtMoney(it.cost)}</td> <td className="px-4 py-3 text-right text-theme-muted">{fmtMoney(it.cost)}</td>
{comprasCfg.activo && (
<>
<td className="px-4 py-3 text-right text-theme-muted">{fmtMoney(it.costo_promedio ?? 0)}</td>
<td className="px-4 py-3 text-right text-theme-muted">{fmtMoney(it.ultimo_costo ?? 0)}</td>
</>
)}
<td className="px-4 py-3 text-right font-medium text-theme-heading">{fmtMoney(it.price ?? 0)}</td> <td className="px-4 py-3 text-right font-medium text-theme-heading">{fmtMoney(it.price ?? 0)}</td>
{comprasCfg.activo && (
<td className="px-4 py-3 text-right">
<span className={(it.margen ?? 0) < 0 ? 'text-rose-600' : 'text-theme-muted'}>
{fmtMoney(it.margen ?? 0)}
<span className="text-[11px] text-theme-muted"> ({(it.margen_pct ?? 0).toFixed(0)}%)</span>
</span>
</td>
)}
<td className="px-4 py-3 text-right text-theme-muted">{fmtMoney(it.inventory_value)}</td> <td className="px-4 py-3 text-right text-theme-muted">{fmtMoney(it.inventory_value)}</td>
<td className="px-4 py-3 text-theme-muted">{it.expiry_date || '—'}</td> <td className="px-4 py-3 text-theme-muted">{it.expiry_date || '—'}</td>
<td className="px-4 py-3"> <td className="px-4 py-3">
@@ -537,6 +563,28 @@ const Inventario: FC = () => {
/> />
)} )}
<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="Costo unitario" type="number" min="0" step="0.01" value={String(editForm.cost ?? 0)} onChange={(e) => setEditForm({ ...editForm, cost: parseFloat(e.target.value) || 0 })} />
{puedeEditarCosto && (
<>
<Input
label="Costo promedio (ajuste manual)"
type="number"
step="0.01"
value={String(editForm.costo_promedio ?? 0)}
onChange={(e) => setEditForm({ ...editForm, costo_promedio: parseFloat(e.target.value) || 0 })}
/>
<Input
label="Último costo"
type="number"
step="0.01"
value={String(editForm.ultimo_costo ?? 0)}
onChange={(e) => setEditForm({ ...editForm, ultimo_costo: parseFloat(e.target.value) || 0 })}
/>
<p className="sm:col-span-2 text-xs text-sky-700 bg-sky-50 rounded-lg p-2">
El costo promedio se recalcula solo con cada recepción de compra. Editarlo aquí es
un ajuste manual de inventario.
</p>
</>
)}
<Input label="SKU / Código" value={editForm.sku || ''} onChange={(e) => setEditForm({ ...editForm, sku: e.target.value })} /> <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 })} /> <Input label="Línea / Categoría" value={editForm.category || ''} onChange={(e) => setEditForm({ ...editForm, category: e.target.value })} />
<Input label="Unidad" value={editForm.unit || ''} onChange={(e) => setEditForm({ ...editForm, unit: e.target.value })} /> <Input label="Unidad" value={editForm.unit || ''} onChange={(e) => setEditForm({ ...editForm, unit: e.target.value })} />

View File

@@ -35,6 +35,8 @@ import ConcentradoReport from '../components/reportes/ConcentradoReport';
import RecomendacionesReport from '../components/reportes/RecomendacionesReport'; import RecomendacionesReport from '../components/reportes/RecomendacionesReport';
import KpisReport from '../components/reportes/KpisReport'; import KpisReport from '../components/reportes/KpisReport';
import RecursosReport from '../components/reportes/RecursosReport'; import RecursosReport from '../components/reportes/RecursosReport';
import ComprasReport from '../components/reportes/ComprasReport';
import MargenReport from '../components/reportes/MargenReport';
import { Card, Button, Input, PageHeader, Skeleton, EmptyState, toast } from '../components/ui'; import { Card, Button, Input, PageHeader, Skeleton, EmptyState, toast } from '../components/ui';
import { odooApi } from '../services/odoo'; import { odooApi } from '../services/odoo';
import type { CommissionRow } from '../services/odoo'; import type { CommissionRow } from '../services/odoo';
@@ -104,6 +106,8 @@ const FAMILIAS: ReportFamily[] = [
{ key: 'horas-agenda', label: 'Horas agenda', desc: 'Citas atendidas por hora y médico' }, { key: 'horas-agenda', label: 'Horas agenda', desc: 'Citas atendidas por hora y médico' },
{ key: 'recursos', label: 'Recursos', desc: 'Ocupación de cabinas, salas y equipos' }, { key: 'recursos', label: 'Recursos', desc: 'Ocupación de cabinas, salas y equipos' },
{ key: 'inventario', label: 'Inventario', desc: 'Existencias, mínimos y caducidades' }, { key: 'inventario', label: 'Inventario', desc: 'Existencias, mínimos y caducidades' },
{ key: 'compras', label: 'Compras', desc: 'Gasto por periodo, proveedor y artículo' },
{ key: 'margen', label: 'Margen', desc: 'Margen por artículo: precio menos costo promedio' },
{ key: 'pagos-servicios', label: 'Pagos · Servicios', desc: 'Cobros agrupados por servicio' }, { key: 'pagos-servicios', label: 'Pagos · Servicios', desc: 'Cobros agrupados por servicio' },
{ key: 'concentrado', label: 'Concentrado', desc: 'Todo el periodo en una tabla' }, { key: 'concentrado', label: 'Concentrado', desc: 'Todo el periodo en una tabla' },
], ],
@@ -150,7 +154,7 @@ interface CashReport {
const ReportesContent: FC = () => { const ReportesContent: FC = () => {
const [searchParams, setSearchParams] = useSearchParams(); const [searchParams, setSearchParams] = useSearchParams();
const { hasRole, canSee, can, brand } = useAuth(); const { hasRole, canSee, can, brand } = useAuth();
const { membresias: membresiasCfg, recursos: recursosCfg } = useClinicSettings(); const { membresias: membresiasCfg, recursos: recursosCfg, compras: comprasCfg } = useClinicSettings();
const esAdmin = hasRole('admin'); const esAdmin = hasRole('admin');
// PuedeVer una pestana = canSee('/reportes?tab=x'): la matriz guarda claves // PuedeVer una pestana = canSee('/reportes?tab=x'): la matriz guarda claves
// por pestana y, cuando no existe la del tab, canSee cae al prefijo // por pestana y, cuando no existe la del tab, canSee cae al prefijo
@@ -160,8 +164,9 @@ const ReportesContent: FC = () => {
const puedeTab = useCallback((k: string) => { const puedeTab = useCallback((k: string) => {
if (k === 'membresias' && !membresiasCfg.activo) return false; if (k === 'membresias' && !membresiasCfg.activo) return false;
if (k === 'recursos' && !recursosCfg.activo) return false; if (k === 'recursos' && !recursosCfg.activo) return false;
if ((k === 'compras' || k === 'margen') && !comprasCfg.activo) return false;
return canSee(`/reportes?tab=${k}`); return canSee(`/reportes?tab=${k}`);
}, [canSee, membresiasCfg.activo, recursosCfg.activo]); }, [canSee, membresiasCfg.activo, recursosCfg.activo, comprasCfg.activo]);
const familiasVisibles = useMemo( const familiasVisibles = useMemo(
() => () =>
FAMILIAS FAMILIAS
@@ -465,6 +470,10 @@ const ReportesContent: FC = () => {
<ImpuestosReport /> <ImpuestosReport />
) : tab === 'inventario' ? ( ) : tab === 'inventario' ? (
<InventarioReport /> <InventarioReport />
) : tab === 'compras' ? (
<ComprasReport />
) : tab === 'margen' ? (
<MargenReport />
) : tab === 'adeudos' ? ( ) : tab === 'adeudos' ? (
<AdeudosReport /> <AdeudosReport />
) : tab === 'comisiones' ? ( ) : tab === 'comisiones' ? (

View File

@@ -816,6 +816,11 @@ export interface InventoryItem {
qty_min: number; qty_min: number;
cost: number; cost: number;
price?: number; price?: number;
/** Costo promedio ponderado de compras y último costo recibido (módulo Compras). */
costo_promedio?: number;
ultimo_costo?: number;
margen?: number;
margen_pct?: number;
inventory_value: number; inventory_value: number;
expiry_date: string | null; expiry_date: string | null;
last_count_date: string | null; last_count_date: string | null;
@@ -1069,6 +1074,7 @@ export interface ClinicSettings {
notificaciones: NotificacionesSettings; notificaciones: NotificacionesSettings;
membresias: MembresiasSettings; membresias: MembresiasSettings;
recursos: RecursosSettings; recursos: RecursosSettings;
compras: ComprasSettings;
} }
// ---- Reservas en línea (portal público de auto-agendado) ---- // ---- Reservas en línea (portal público de auto-agendado) ----
@@ -1234,6 +1240,238 @@ export const MEMBRESIAS_DEFAULT: MembresiasSettings = {
dias_gracia: 5, dias_gracia: 5,
}; };
// ---- Compras y proveedores ----
export interface ComprasSettings {
/** Enciende TODO el módulo: menú, recepciones, costo promedio y reportes. */
activo: boolean;
/** Al recibir, recalcula el costo promedio ponderado del artículo. */
actualizar_costo_promedio: boolean;
/** Permite recibir menos de lo pedido y completar en recepciones posteriores. */
permitir_recepcion_parcial: boolean;
/** Exige enviar (autorizar) la orden antes de poder recibirla. */
requiere_autorizacion: boolean;
/** Días de crédito por defecto de un proveedor nuevo. */
dias_credito_default: number;
/** Margen (%) bajo el cual el reporte de margen avisa. */
margen_minimo_alerta: number;
/** Permite registrar una recepción sin orden de compra. */
permitir_compras_sin_orden: boolean;
}
export const COMPRAS_DEFAULT: ComprasSettings = {
activo: false,
actualizar_costo_promedio: true,
permitir_recepcion_parcial: true,
requiere_autorizacion: false,
dias_credito_default: 0,
margen_minimo_alerta: 0,
permitir_compras_sin_orden: false,
};
export type EstadoCompra = 'borrador' | 'enviada' | 'recibida_parcial' | 'recibida' | 'cancelada';
export type MonedaCompra = 'MXN' | 'USD';
export type MetodoPagoCompra = 'efectivo' | 'transferencia' | 'tarjeta' | 'otro';
export interface Proveedor {
id: number;
name: string;
email: string;
phone: string;
mobile: string;
vat: string;
street: string;
city: string;
supplier_rank: number;
activo: boolean;
dias_credito: number;
contacto: string;
banco: string;
cuenta_pago: string;
notas: string;
compras_count: number;
saldo: number;
}
export interface CompraLinea {
id: number;
item_id: number;
item: string;
sku: string;
unit: string;
kind: 'producto' | 'consumible';
cantidad_pedida: number;
cantidad_recibida: number;
cantidad_pendiente: number;
costo_unitario: number;
descuento: number;
subtotal: number;
impuesto: number;
total: number;
lote: string;
fecha_caducidad: string | null;
notas: string;
}
export interface CompraRecepcionLinea {
id: number;
item_id: number;
item: string;
cantidad: number;
costo_unitario: number;
lote: string;
fecha_caducidad: string | null;
move_id: number | null;
}
export interface CompraRecepcion {
id: number;
fecha: string | null;
usuario: string;
notas: string;
total_unidades: number;
total_costo: number;
lineas: CompraRecepcionLinea[];
}
export interface CompraPago {
id: number;
fecha: string | null;
monto: number;
moneda: MonedaCompra;
metodo: MetodoPagoCompra;
metodo_label: string;
referencia: string;
notas: string;
usuario: string;
monto_en_compra: number;
}
export interface Compra {
id: number;
folio: string;
partner_id: number;
proveedor: string;
fecha: string | null;
fecha_entrega_esperada: string | null;
estado: EstadoCompra;
estado_label: string;
moneda: MonedaCompra;
subtotal: number;
descuento: number;
impuestos: number;
total: number;
pagado: number;
saldo: number;
sucursal_id: number | null;
sucursal: string;
referencia_factura: string;
notas: string;
creado_por_login: string;
recibida_at: string | null;
cancel_reason: string;
lineas_count: number;
recepciones_count: number;
tiene_recepciones: boolean;
total_pedido: number;
total_recibido: number;
puede_recibir: boolean;
puede_cancelar: boolean;
lineas?: CompraLinea[];
recepciones?: CompraRecepcion[];
pagos?: CompraPago[];
}
export interface CompraInput {
partner_id?: number;
fecha?: string;
fecha_entrega_esperada?: string | null;
moneda?: MonedaCompra;
descuento?: number;
notas?: string;
referencia_factura?: string;
sucursal_id?: number | null;
lineas?: {
item_id: number;
cantidad_pedida: number;
costo_unitario?: number;
descuento?: number;
lote?: string;
fecha_caducidad?: string | null;
notas?: string;
}[];
}
export interface CompraResumen {
abiertas: number;
por_pagar: number;
saldo_total: number;
compras_mes: number;
gasto_mes: number;
borradores: number;
enviadas: number;
}
export interface CuentaPorPagar {
partner_id: number;
proveedor: string;
dias_credito: number;
saldo: number;
compras: number;
buckets: Record<string, number>;
detalle: {
id: number; folio: string; fecha: string | null; fecha_vencimiento: string | null;
dias: number; bucket: string; total: number; pagado: number; saldo: number;
moneda: MonedaCompra;
}[];
}
export interface CuentasPorPagarResponse {
status: string;
proveedores: CuentaPorPagar[];
buckets: { key: string; label: string }[];
totales: { saldo: number; por_bucket: Record<string, number>; proveedores: number; compras: number };
}
export interface ComprasReportResponse {
status: string;
start: string;
end: string;
totales: { compras: number; subtotal: number; impuestos: number; total: number; pagado: number; saldo: number };
por_proveedor: { partner_id: number; proveedor: string; compras: number; total: number; pagado: number; saldo: number }[];
por_articulo: { item_id: number; item: string; sku: string; category: string; unidad: string;
cantidad_pedida: number; cantidad_recibida: number; subtotal: number }[];
por_mes: { mes: string; total: number }[];
cuentas_por_pagar: { total: number; proveedores: { partner_id: number; proveedor: string; saldo: number; compras: number; dias_credito: number }[] };
message?: string;
}
export interface MargenReportFila {
item_id: number;
item: string;
sku: string;
category: string;
unidad: string;
precio: number;
costo_promedio: number;
margen_unitario: number;
margen_pct: number;
unidades_vendidas: number;
ingresos: number;
costo_ventas: number;
margen_total: number;
alerta: boolean;
}
export interface MargenReportResponse {
status: string;
start: string;
end: string;
margen_minimo_alerta: number;
articulos: MargenReportFila[];
totales: { ingresos: number; costo_ventas: number; margen: number; margen_pct: number; alertas: number };
message?: string;
}
export type PeriodicidadMembresia = 'semanal' | 'mensual' | 'trimestral' | 'semestral' | 'anual'; export type PeriodicidadMembresia = 'semanal' | 'mensual' | 'trimestral' | 'semestral' | 'anual';
export type EstadoMembresia = 'activa' | 'congelada' | 'vencida' | 'pausada' | 'cancelada'; export type EstadoMembresia = 'activa' | 'congelada' | 'vencida' | 'pausada' | 'cancelada';
export type EstadoPeriodoMembresia = 'pendiente' | 'pagado' | 'vencido' | 'condonado'; export type EstadoPeriodoMembresia = 'pendiente' | 'pagado' | 'vencido' | 'condonado';
@@ -1636,6 +1874,7 @@ export const CLINIC_SETTINGS_DEFAULT: ClinicSettings = {
notificaciones: NOTIFICACIONES_DEFAULT, notificaciones: NOTIFICACIONES_DEFAULT,
membresias: MEMBRESIAS_DEFAULT, membresias: MEMBRESIAS_DEFAULT,
recursos: RECURSOS_DEFAULT, recursos: RECURSOS_DEFAULT,
compras: COMPRAS_DEFAULT,
}; };
export interface FrontendUser { export interface FrontendUser {
@@ -2330,6 +2569,96 @@ export const odooApi = {
return data; return data;
}, },
// ---- Compras y proveedores ----
// Con `compras.activo=false` (default) el backend responde 403 y estas
// llamadas nunca se hacen desde la UI (la sección no se monta).
async getProveedores(opts?: { includeInactive?: boolean; q?: string; page?: number; page_size?: number }): Promise<{
status: string; proveedores: Proveedor[]; total: number; page: number; page_size: number; message?: string;
}> {
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/proveedores`, {
params: {
...(opts?.includeInactive ? { include_inactive: 1 } : {}),
...(opts?.q ? { q: opts.q } : {}),
...(opts?.page ? { page: opts.page } : {}),
...(opts?.page_size ? { page_size: opts.page_size } : {}),
},
});
return data;
},
async createProveedor(payload: Partial<Proveedor> & { name: string }): Promise<{ status: string; proveedor: Proveedor; message?: string }> {
const { data } = await axios.post(`${ODOO_BASE}/skeen/frontend/v1/proveedores`, payload);
return data;
},
async updateProveedor(id: number, payload: Partial<Proveedor>): Promise<{ status: string; proveedor: Proveedor; message?: string }> {
const { data } = await axios.put(`${ODOO_BASE}/skeen/frontend/v1/proveedores/${id}`, payload);
return data;
},
async deleteProveedor(id: number): Promise<{ status: string; proveedor?: Proveedor; message?: string; desactivar?: boolean }> {
const { data } = await axios.delete(`${ODOO_BASE}/skeen/frontend/v1/proveedores/${id}`);
return data;
},
async getCompras(params?: { estado?: string; partner_id?: number; start?: string; end?: string; q?: string; page?: number; page_size?: number }): Promise<{
status: string; compras: Compra[]; total: number; page: number; page_size: number; total_pages: number;
resumen: CompraResumen; message?: string;
}> {
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/compras`, { params });
return data;
},
async getCompra(id: number): Promise<{ status: string; compra: Compra; message?: string }> {
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/compras/${id}`);
return data;
},
async crearOrdenCompra(payload: CompraInput): Promise<{ status: string; compra: Compra; message?: string }> {
const { data } = await axios.post(`${ODOO_BASE}/skeen/frontend/v1/compras`, payload);
return data;
},
async updateCompra(id: number, payload: CompraInput): Promise<{ status: string; compra: Compra; message?: string }> {
const { data } = await axios.put(`${ODOO_BASE}/skeen/frontend/v1/compras/${id}`, payload);
return data;
},
async enviarCompra(id: number): Promise<{ status: string; compra: Compra; message?: string }> {
const { data } = await axios.post(`${ODOO_BASE}/skeen/frontend/v1/compras/${id}/enviar`, {});
return data;
},
async recibirCompra(id: number, payload: { notas?: string; lineas: { linea_id: number; cantidad: number; lote?: string; fecha_caducidad?: string | null; costo_unitario?: number }[] }): Promise<{ status: string; compra: Compra; message?: string }> {
const { data } = await axios.post(`${ODOO_BASE}/skeen/frontend/v1/compras/${id}/recibir`, payload);
return data;
},
async cancelarCompra(id: number, motivo?: string): Promise<{ status: string; compra: Compra; message?: string }> {
const { data } = await axios.post(`${ODOO_BASE}/skeen/frontend/v1/compras/${id}/cancelar`, { motivo });
return data;
},
async pagarCompra(id: number, payload: { monto: number; moneda?: MonedaCompra; metodo?: MetodoPagoCompra; referencia?: string; notas?: string }): Promise<{ status: string; compra: Compra; pago: CompraPago; message?: string }> {
const { data } = await axios.post(`${ODOO_BASE}/skeen/frontend/v1/compras/${id}/pagar`, payload);
return data;
},
async getCuentasPorPagar(params?: { partner_id?: number }): Promise<CuentasPorPagarResponse> {
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/compras/cuentas_por_pagar`, { params });
return data;
},
async getComprasReport(start: string, end: string): Promise<ComprasReportResponse> {
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/reports/compras`, { params: { start, end } });
return data;
},
async getMargenReport(start: string, end: string): Promise<MargenReportResponse> {
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/reports/margen`, { params: { start, end } });
return data;
},
// ---- Membresías y suscripciones ---- // ---- Membresías y suscripciones ----
// Con `membresias.activo=false` (default) el backend responde 403 y estas // Con `membresias.activo=false` (default) el backend responde 403 y estas
// llamadas nunca se hacen desde la UI (la sección no se monta). // llamadas nunca se hacen desde la UI (la sección no se monta).

View File

@@ -666,6 +666,91 @@ def get_recursos(env):
"""Bloque de recursos vigente (copia). Fuente única para modelos y API.""" """Bloque de recursos vigente (copia). Fuente única para modelos y API."""
return normalizar_recursos(get_settings(env).get('recursos')) return normalizar_recursos(get_settings(env).get('recursos'))
# ---------------------------------------------------------------------------
# Compras y proveedores.
#
# Bloque anidado del mismo JSON de ajustes, igual que `impuestos`. La regla de
# oro: con `activo=False` (default) NADA cambia en la instancia — la sección
# Compras no aparece en el menú, el inventario y el POS se ven idénticos, no
# corre ningún cron y las rutas del módulo responden 403. Por eso el default es
# apagado.
#
# activo: enciende TODO el módulo de compras
# actualizar_costo_promedio: al recibir, recalcula el costo promedio ponderado
# permitir_recepcion_parcial: permite recibir menos de lo pedido y recibir después
# requiere_autorizacion: las órdenes nacen en borrador y exigen enviarse
# dias_credito_default: días de crédito por defecto de un proveedor nuevo
# margen_minimo_alerta: margen (%) bajo el cual el reporte avisa
# permitir_compras_sin_orden: permite registrar una recepción sin orden de compra
# ---------------------------------------------------------------------------
COMPRAS_DEFAULT = {
'activo': False,
'actualizar_costo_promedio': True,
'permitir_recepcion_parcial': True,
'requiere_autorizacion': False,
'dias_credito_default': 0,
'margen_minimo_alerta': 0,
'permitir_compras_sin_orden': False,
}
COMPRAS_DIAS_CREDITO_MAX = 365
COMPRAS_MARGEN_MAX = 1000 # % (permite margen negativo / muy alto sin tope real)
def normalizar_compras(raw):
"""Bloque `compras` completo, con defaults. Tolerante: nunca lanza."""
out = dict(COMPRAS_DEFAULT)
if not isinstance(raw, dict):
return out
for k in ('activo', 'actualizar_costo_promedio', 'permitir_recepcion_parcial',
'requiere_autorizacion', 'permitir_compras_sin_orden'):
out[k] = _bool(raw.get(k), COMPRAS_DEFAULT[k])
out['dias_credito_default'] = min(max(_int(
raw.get('dias_credito_default'), COMPRAS_DEFAULT['dias_credito_default']),
0), COMPRAS_DIAS_CREDITO_MAX)
out['margen_minimo_alerta'] = min(max(_num(
raw.get('margen_minimo_alerta'), COMPRAS_DEFAULT['margen_minimo_alerta']),
-COMPRAS_MARGEN_MAX), COMPRAS_MARGEN_MAX)
return out
def validar_compras(raw):
"""Valida el bloque `compras` de un PUT. Lanza ValueError si algo no cuadra
(se traduce a 400). Devuelve SOLO las claves presentes y normalizadas para
que el merge anidado no reinicie lo que el PUT no tocó."""
if raw is None:
return None
if not isinstance(raw, dict):
raise ValueError('El bloque de compras debe ser un objeto')
out = {}
for k in ('activo', 'actualizar_costo_promedio', 'permitir_recepcion_parcial',
'requiere_autorizacion', 'permitir_compras_sin_orden'):
if k in raw:
if not isinstance(raw[k], bool):
raise ValueError('El campo %s debe ser verdadero o falso' % k)
out[k] = raw[k]
if 'dias_credito_default' in raw:
if isinstance(raw['dias_credito_default'], bool) or \
not isinstance(raw['dias_credito_default'], int):
raise ValueError('Los días de crédito deben ser un número entero')
if not (0 <= raw['dias_credito_default'] <= COMPRAS_DIAS_CREDITO_MAX):
raise ValueError('Los días de crédito deben estar entre 0 y %d'
% COMPRAS_DIAS_CREDITO_MAX)
out['dias_credito_default'] = raw['dias_credito_default']
if 'margen_minimo_alerta' in raw:
n = _num(raw['margen_minimo_alerta'], None)
if n is None:
raise ValueError('El margen mínimo de alerta debe ser un número')
if not (-COMPRAS_MARGEN_MAX <= n <= COMPRAS_MARGEN_MAX):
raise ValueError('El margen mínimo de alerta está fuera de rango')
out['margen_minimo_alerta'] = n
return out
def get_compras(env):
"""Bloque de compras vigente (copia). Fuente única para modelos y API."""
return normalizar_compras(get_settings(env).get('compras'))
# Horas de agenda de respaldo: el comportamiento de siempre (8:00-19:00). # Horas de agenda de respaldo: el comportamiento de siempre (8:00-19:00).
AGENDA_START_DEFAULT = 8.0 AGENDA_START_DEFAULT = 8.0
AGENDA_END_DEFAULT = 19.0 AGENDA_END_DEFAULT = 19.0
@@ -710,6 +795,7 @@ def get_settings(env):
'notificaciones': normalizar_notificaciones(None), 'notificaciones': normalizar_notificaciones(None),
'membresias': normalizar_membresias(None), 'membresias': normalizar_membresias(None),
'recursos': dict(RECURSOS_DEFAULT), 'recursos': dict(RECURSOS_DEFAULT),
'compras': dict(COMPRAS_DEFAULT),
} }
raw = env['ir.config_parameter'].sudo().get_param(SETTINGS_KEY) raw = env['ir.config_parameter'].sudo().get_param(SETTINGS_KEY)
if raw: if raw:
@@ -726,6 +812,7 @@ def get_settings(env):
out['notificaciones'] = normalizar_notificaciones(guardado.get('notificaciones')) out['notificaciones'] = normalizar_notificaciones(guardado.get('notificaciones'))
out['membresias'] = normalizar_membresias(guardado.get('membresias')) out['membresias'] = normalizar_membresias(guardado.get('membresias'))
out['recursos'] = normalizar_recursos(guardado.get('recursos')) out['recursos'] = normalizar_recursos(guardado.get('recursos'))
out['compras'] = normalizar_compras(guardado.get('compras'))
return out return out
@@ -758,6 +845,11 @@ def set_settings(env, vals):
bloque = validar_recursos(vals['recursos']) bloque = validar_recursos(vals['recursos'])
if bloque is not None: if bloque is not None:
actual['recursos'] = {**actual['recursos'], **bloque} actual['recursos'] = {**actual['recursos'], **bloque}
if 'compras' in vals:
bloque = validar_compras(vals['compras'])
if bloque is not None:
# Merge anidado: un PUT parcial (p. ej. solo activo) no borra el resto.
actual['compras'] = {**actual['compras'], **bloque}
env['ir.config_parameter'].sudo().set_param( env['ir.config_parameter'].sudo().set_param(
SETTINGS_KEY, json.dumps(actual, ensure_ascii=False)) SETTINGS_KEY, json.dumps(actual, ensure_ascii=False))
return get_settings(env) return get_settings(env)

View File

@@ -0,0 +1,4 @@
# -*- coding: utf-8 -*-
from . import models
from . import controllers
from .hooks import post_init_hook

View File

@@ -0,0 +1,36 @@
# -*- coding: utf-8 -*-
{
'name': 'SKEEN Compras y Proveedores',
'version': '1.0.0',
'category': 'Healthcare',
'summary': 'Órdenes de compra, recepciones, costo promedio y cuentas por pagar',
'description': """
Compras y proveedores con costo promedio ponderado para cualquier giro.
Da de alta proveedores (sobre `res.partner`), genera órdenes de compra,
recibe mercancía total o parcialmente actualizando el stock y el costo
promedio del artículo (con lote/caducidad para consumibles), controla las
cuentas por pagar y mide el margen por artículo y del periodo.
El módulo está APAGADO por defecto (`compras.activo=false`): no aparece
en el menú, el inventario y el POS se ven idénticos, no corre ningún cron
y las rutas del módulo responden 403.
Los pagos a proveedores viven en `skeen.compra.pago` (son EGRESOS) y NO
se mezclan con `skeen.pago`, que registra los cobros a pacientes. Esta
separación evita que un egreso aparezca en los cortes de caja o en el
estado de cuenta de un paciente.
""",
'author': 'Consultoria Alcaraz Salazar, S.A.S.',
'website': 'https://skeen.mx',
'depends': ['base', 'skeen_citas', 'skeen_inventario', 'skeen_ventas', 'skeen_whatsapp'],
'data': [
'security/ir.model.access.csv',
'data/sequences.xml',
],
'installable': True,
'application': False,
'auto_install': False,
'post_init_hook': 'post_init_hook',
'license': 'LGPL-3',
}

View File

@@ -0,0 +1,2 @@
# -*- coding: utf-8 -*-
from . import compras

View File

@@ -0,0 +1,950 @@
# -*- coding: utf-8 -*-
"""API del frontend React para Compras y proveedores.
Vive en su propio controlador (no en `skeen_whatsapp/controllers/frontend.py`)
para no chocar con otros trabajos. Las rutas cuelgan de
`/skeen/frontend/v1/{proveedores,compras,reports/compras,reports/margen}` y
exigen token (`@require_role`) más el permiso por acción (`@require_perm`).
Regla de oro: con `compras.activo=False` TODAS las rutas responden 403 y no
tocan nada, así el despliegue en una instancia apagada no cambia ningún
comportamiento.
"""
import functools
import json
from collections import defaultdict
from datetime import date, datetime, timedelta
from odoo import fields, http
from odoo.exceptions import ValidationError, UserError
from odoo.http import request, Response
from odoo.addons.skeen_whatsapp.controllers.auth import require_perm, require_role
from odoo.addons.skeen_citas.clinic_settings import get_compras
BASE = '/skeen/frontend/v1'
COMPRAS = BASE + '/compras'
PROVEEDORES = BASE + '/proveedores'
METODOS_PAGO = ('efectivo', 'transferencia', 'tarjeta', 'otro')
METODOS_LABEL = {'efectivo': 'Efectivo', 'transferencia': 'Transferencia',
'tarjeta': 'Tarjeta', 'otro': 'Otro'}
ESTADO_LABEL = {
'borrador': 'Borrador', 'enviada': 'Enviada',
'recibida_parcial': 'Recibida parcial', 'recibida': 'Recibida',
'cancelada': 'Cancelada',
}
def _json(data, status=200):
return Response(
json.dumps(data, default=str),
status=status,
mimetype='application/json',
headers={
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
},
)
def _body():
try:
raw = request.httprequest.get_data(as_text=True)
return json.loads(raw) if raw else {}
except Exception:
return {}
def _cfg():
return get_compras(request.env)
def _activo():
return bool(_cfg().get('activo'))
def compras_activas(func):
"""Niega la operación si la instancia no tiene el módulo encendido."""
@functools.wraps(func)
def wrapper(self, *args, **kw):
if request.httprequest.method == 'OPTIONS':
return _json({}, 200)
if not _activo():
return _json({'status': 'error',
'message': 'Compras no está activado en esta clínica.'}, 403)
return func(self, *args, **kw)
return wrapper
def _login():
return getattr(request.frontend_user, 'login', '') if hasattr(request, 'frontend_user') else ''
def _num(valor, por_defecto=0.0):
try:
return float(valor)
except (TypeError, ValueError):
return por_defecto
def _fecha(valor, por_defecto=None):
if not valor:
return por_defecto
return fields.Date.to_date(valor) or por_defecto
class SkeenComprasController(http.Controller):
# ==================================================================
# Serializadores
# ==================================================================
def _proveedor_to_dict(self, p):
return {
'id': p.id,
'name': p.name or '',
'email': p.email or '',
'phone': p.phone or '',
'mobile': p.mobile or '',
'vat': p.vat or '',
'street': p.street or '',
'city': p.city or '',
'supplier_rank': getattr(p, 'supplier_rank', 0) or 0,
'es_proveedor': p.skeen_es_proveedor,
'activo': p.skeen_proveedor_activo,
'dias_credito': p.skeen_dias_credito or 0,
'contacto': p.skeen_contacto or '',
'banco': p.skeen_banco or '',
'cuenta_pago': p.skeen_cuenta_pago or '',
'notas': p.skeen_notas_proveedor or '',
'compras_count': p.skeen_compras_count,
'saldo': p.skeen_saldo,
}
def _linea_to_dict(self, l):
return {
'id': l.id,
'item_id': l.item_id.id,
'item': l.item_id.name,
'sku': l.item_sku or '',
'unit': l.item_unit or 'pieza',
'kind': l.item_kind or 'producto',
'cantidad_pedida': l.cantidad_pedida,
'cantidad_recibida': l.cantidad_recibida,
'cantidad_pendiente': l.cantidad_pendiente,
'costo_unitario': l.costo_unitario,
'descuento': l.descuento,
'subtotal': l.subtotal,
'impuesto': l.impuesto,
'total': l.total,
'lote': l.lote or '',
'fecha_caducidad': l.fecha_caducidad.strftime('%Y-%m-%d')
if l.fecha_caducidad else None,
'notas': l.notas or '',
}
def _recepcion_to_dict(self, r):
return {
'id': r.id,
'fecha': r.fecha.strftime('%Y-%m-%d %H:%M') if r.fecha else None,
'usuario': r.usuario or '',
'notas': r.notas or '',
'total_unidades': r.total_unidades,
'total_costo': r.total_costo,
'lineas': [{
'id': ln.id,
'item_id': ln.item_id.id,
'item': ln.item_id.name,
'cantidad': ln.cantidad,
'costo_unitario': ln.costo_unitario,
'lote': ln.lote or '',
'fecha_caducidad': ln.fecha_caducidad.strftime('%Y-%m-%d')
if ln.fecha_caducidad else None,
'move_id': ln.move_id.id if ln.move_id else None,
} for ln in r.line_ids],
}
def _pago_to_dict(self, p):
return {
'id': p.id,
'fecha': p.fecha.strftime('%Y-%m-%d') if p.fecha else None,
'monto': p.monto,
'moneda': p.moneda,
'metodo': p.metodo,
'metodo_label': METODOS_LABEL.get(p.metodo, p.metodo or ''),
'referencia': p.referencia or '',
'notas': p.notas or '',
'usuario': p.usuario or '',
'monto_en_compra': p.monto_en_compra,
}
def _compra_to_dict(self, c, with_detail=False):
d = {
'id': c.id,
'folio': c.name or '',
'partner_id': c.partner_id.id,
'proveedor': c.partner_id.name or '',
'fecha': c.fecha.strftime('%Y-%m-%d') if c.fecha else None,
'fecha_entrega_esperada': c.fecha_entrega_esperada.strftime('%Y-%m-%d')
if c.fecha_entrega_esperada else None,
'estado': c.estado,
'estado_label': ESTADO_LABEL.get(c.estado, c.estado),
'moneda': c.moneda,
'subtotal': c.subtotal,
'descuento': c.descuento,
'impuestos': c.impuestos,
'total': c.total,
'pagado': c.pagado,
'saldo': c.saldo,
'sucursal_id': c.sucursal_id.id if c.sucursal_id else None,
'sucursal': c.sucursal_id.name if c.sucursal_id else '',
'referencia_factura': c.referencia_factura or '',
'notas': c.notas or '',
'creado_por_login': c.creado_por_login or '',
'recibida_at': c.recibida_at.strftime('%Y-%m-%d %H:%M') if c.recibida_at else None,
'cancel_reason': c.cancel_reason or '',
'lineas_count': len(c.linea_ids),
'recepciones_count': c.recepciones_count,
'tiene_recepciones': c.tiene_recepciones,
'total_pedido': c.total_pedido,
'total_recibido': c.total_recibido,
'puede_recibir': c.estado in ('borrador', 'enviada', 'recibida_parcial'),
'puede_cancelar': c.estado != 'cancelada' and not c.tiene_recepciones,
}
if with_detail:
d['lineas'] = [self._linea_to_dict(l) for l in c.linea_ids]
d['recepciones'] = [self._recepcion_to_dict(r) for r in c.recepcion_ids]
d['pagos'] = [self._pago_to_dict(p) for p in c.pago_ids]
return d
def _compra(self, cid):
c = request.env['skeen.compra'].sudo().browse(int(cid))
return c if c.exists() else None
# ==================================================================
# Proveedores
# ==================================================================
@http.route(PROVEEDORES, type='http', auth='none',
methods=['GET', 'OPTIONS'], csrf=False)
@require_role('lectura')
@require_perm('proveedores.ver')
@compras_activas
def list_proveedores(self, **kw):
try:
domain = [('skeen_es_proveedor', '=', True)]
if not (kw.get('include_inactive') or '').strip():
domain.append(('skeen_proveedor_activo', '=', True))
if kw.get('q'):
q = kw['q']
domain += ['|', '|', '|', ('name', 'ilike', q),
('email', 'ilike', q), ('phone', 'ilike', q),
('vat', 'ilike', q)]
Partner = request.env['res.partner'].sudo()
total = Partner.search_count(domain)
page = max(int(kw.get('page', 1) or 1), 1)
page_size = min(max(int(kw.get('page_size', 100) or 100), 1), 500)
rows = Partner.search(domain, order='name asc',
limit=page_size, offset=(page - 1) * page_size)
return _json({'status': 'success',
'proveedores': [self._proveedor_to_dict(p) for p in rows],
'total': total, 'page': page, 'page_size': page_size})
except Exception as e:
request.env.cr.rollback()
return _json({'status': 'error', 'message': str(e)}, 500)
def _proveedor_vals(self, data, crear=False):
vals = {}
if crear or 'name' in data:
nombre = (data.get('name') or '').strip()
if not nombre:
raise ValidationError('El nombre del proveedor es obligatorio')
vals['name'] = nombre
for key in ('email', 'phone', 'mobile', 'vat', 'street', 'city',
'skeen_contacto', 'skeen_banco', 'skeen_cuenta_pago',
'skeen_notas_proveedor'):
if key in data:
vals[key] = (data.get(key) or '').strip()
if 'dias_credito' in data or 'skeen_dias_credito' in data:
raw = data.get('dias_credito', data.get('skeen_dias_credito'))
try:
n = int(raw or 0)
except (TypeError, ValueError):
raise ValidationError('Los días de crédito deben ser un número entero')
if n < 0:
raise ValidationError('Los días de crédito no pueden ser negativos')
vals['skeen_dias_credito'] = n
if 'activo' in data:
vals['skeen_proveedor_activo'] = bool(data['activo'])
if crear:
vals['skeen_es_proveedor'] = True
if 'skeen_dias_credito' not in vals:
vals['skeen_dias_credito'] = int(_cfg().get('dias_credito_default') or 0)
vals['skeen_proveedor_activo'] = True
return vals
@http.route(PROVEEDORES, type='http', auth='none',
methods=['POST', 'OPTIONS'], csrf=False)
@require_role('gerencia')
@require_perm('proveedores.editar')
@compras_activas
def create_proveedor(self, **kw):
try:
data = _body()
try:
vals = self._proveedor_vals(data, crear=True)
partner = request.env['res.partner'].sudo().create(vals)
partner.action_marcar_proveedor()
except ValidationError as e:
request.env.cr.rollback()
return _json({'status': 'error',
'message': str(e.args[0] if e.args else e)}, 400)
return _json({'status': 'success',
'proveedor': self._proveedor_to_dict(partner)}, 201)
except Exception as e:
request.env.cr.rollback()
return _json({'status': 'error', 'message': str(e)}, 500)
@http.route(PROVEEDORES + '/<int:pid>', type='http', auth='none',
methods=['GET', 'OPTIONS'], csrf=False)
@require_role('lectura')
@require_perm('proveedores.ver')
@compras_activas
def get_proveedor(self, pid, **kw):
try:
p = request.env['res.partner'].sudo().browse(pid)
if not p.exists() or not p.skeen_es_proveedor:
return _json({'status': 'error', 'message': 'Proveedor no encontrado'}, 404)
return _json({'status': 'success',
'proveedor': self._proveedor_to_dict(p)})
except Exception as e:
request.env.cr.rollback()
return _json({'status': 'error', 'message': str(e)}, 500)
@http.route(PROVEEDORES + '/<int:pid>', type='http', auth='none',
methods=['PUT', 'PATCH'], csrf=False)
@require_role('gerencia')
@require_perm('proveedores.editar')
@compras_activas
def update_proveedor(self, pid, **kw):
try:
p = request.env['res.partner'].sudo().browse(pid)
if not p.exists() or not p.skeen_es_proveedor:
return _json({'status': 'error', 'message': 'Proveedor no encontrado'}, 404)
try:
p.write(self._proveedor_vals(_body(), crear=False))
except ValidationError as e:
request.env.cr.rollback()
return _json({'status': 'error',
'message': str(e.args[0] if e.args else e)}, 400)
return _json({'status': 'success',
'proveedor': self._proveedor_to_dict(p)})
except Exception as e:
request.env.cr.rollback()
return _json({'status': 'error', 'message': str(e)}, 500)
@http.route(PROVEEDORES + '/<int:pid>', type='http', auth='none',
methods=['DELETE'], csrf=False)
@require_role('gerencia')
@require_perm('proveedores.editar')
@compras_activas
def delete_proveedor(self, pid, **kw):
try:
p = request.env['res.partner'].sudo().browse(pid)
if not p.exists() or not p.skeen_es_proveedor:
return _json({'status': 'error', 'message': 'Proveedor no encontrado'}, 404)
if p.skeen_compra_ids:
return _json({
'status': 'error',
'message': 'El proveedor tiene compras registradas: '
'desactívalo en lugar de borrarlo.',
'desactivar': True,
}, 409)
p.unlink()
return _json({'status': 'success', 'message': 'Proveedor eliminado'})
except Exception as e:
request.env.cr.rollback()
return _json({'status': 'error', 'message': str(e)}, 500)
# ==================================================================
# Compras
# ==================================================================
def _rango_kw(self, kw):
inicio = _fecha(kw.get('start'), fields.Date.today() - timedelta(days=30))
fin = _fecha(kw.get('end'), fields.Date.today())
return inicio, fin
@http.route(COMPRAS, type='http', auth='none',
methods=['GET', 'OPTIONS'], csrf=False)
@require_role('lectura')
@require_perm('compras.ver')
@compras_activas
def list_compras(self, **kw):
try:
domain = []
if kw.get('estado'):
estado = kw['estado']
if estado == 'por_recibir':
domain.append(('estado', 'in', ('borrador', 'enviada',
'recibida_parcial')))
elif estado == 'por_pagar':
domain.append(('estado', '!=', 'cancelada'))
domain.append(('saldo', '>', 0))
elif estado in ESTADO_LABEL:
domain.append(('estado', '=', estado))
else:
return _json({'status': 'error', 'message': 'Estado inválido'}, 400)
if kw.get('partner_id'):
domain.append(('partner_id', '=', int(kw['partner_id'])))
inicio, fin = self._rango_kw(kw)
if kw.get('start'):
domain.append(('fecha', '>=', inicio))
if kw.get('end'):
domain.append(('fecha', '<=', fin))
if kw.get('q'):
q = kw['q']
domain += ['|', '|', ('name', 'ilike', q),
('partner_id.name', 'ilike', q),
('referencia_factura', 'ilike', q)]
Compra = request.env['skeen.compra'].sudo()
total = Compra.search_count(domain)
page = max(int(kw.get('page', 1) or 1), 1)
page_size = min(max(int(kw.get('page_size', 50) or 50), 1), 200)
rows = Compra.search(domain, order='fecha desc, id desc',
limit=page_size, offset=(page - 1) * page_size)
return _json({
'status': 'success',
'compras': [self._compra_to_dict(c) for c in rows],
'total': total, 'page': page, 'page_size': page_size,
'total_pages': (total + page_size - 1) // page_size,
'resumen': self._resumen_compras(),
})
except Exception as e:
request.env.cr.rollback()
return _json({'status': 'error', 'message': str(e)}, 500)
def _resumen_compras(self):
Compra = request.env['skeen.compra'].sudo()
hoy = fields.Date.today()
mes_ini = hoy.replace(day=1)
abiertas = Compra.search([('estado', 'in', ('borrador', 'enviada',
'recibida_parcial'))])
por_pagar = Compra.search([('estado', '!=', 'cancelada'), ('saldo', '>', 0)])
del_mes = Compra.search([('estado', '!=', 'cancelada'),
('fecha', '>=', mes_ini), ('fecha', '<=', hoy)])
return {
'abiertas': len(abiertas),
'por_pagar': len(por_pagar),
'saldo_total': round(sum(c.saldo for c in por_pagar), 2),
'compras_mes': len(del_mes),
'gasto_mes': round(sum(c.total for c in del_mes), 2),
'borradores': Compra.search_count([('estado', '=', 'borrador')]),
'enviadas': Compra.search_count([('estado', '=', 'enviada')]),
}
def _compra_vals(self, data, crear=False):
CompraLinea = request.env['skeen.compra.linea'].sudo()
vals = {}
if 'partner_id' in data:
partner = request.env['res.partner'].sudo().browse(int(data['partner_id'] or 0))
if not partner.exists():
raise ValidationError('Proveedor no encontrado')
vals['partner_id'] = partner.id
if crear and not vals.get('partner_id'):
raise ValidationError('El proveedor es obligatorio')
if 'fecha' in data:
vals['fecha'] = _fecha(data.get('fecha'), fields.Date.today())
if 'fecha_entrega_esperada' in data:
vals['fecha_entrega_esperada'] = _fecha(data.get('fecha_entrega_esperada'), False)
if 'moneda' in data:
moneda = data['moneda'] or 'MXN'
if moneda not in ('MXN', 'USD'):
raise ValidationError('La moneda debe ser MXN o USD')
vals['moneda'] = moneda
if 'descuento' in data:
vals['descuento'] = max(0.0, _num(data.get('descuento')))
for key in ('notas', 'referencia_factura'):
if key in data:
vals[key] = data.get(key) or ''
if 'sucursal_id' in data:
vals['sucursal_id'] = int(data['sucursal_id']) if data['sucursal_id'] else False
if 'lineas' in data and data.get('lineas') is not None:
lineas = []
for ld in (data.get('lineas') or []):
item = request.env['skeen.inventario.item'].sudo().browse(
int(ld.get('item_id') or 0))
if not item.exists():
raise ValidationError('Artículo de la línea no encontrado')
cantidad = _num(ld.get('cantidad_pedida'), 1.0)
if cantidad <= 0:
raise ValidationError('La cantidad pedida debe ser mayor a 0')
costo = ld.get('costo_unitario')
if costo in (None, ''):
costo = (item.ultimo_costo or item.costo_promedio
or item.cost or 0.0)
lineas.append((0, 0, {
'item_id': item.id,
'cantidad_pedida': cantidad,
'costo_unitario': max(0.0, _num(costo)),
'descuento': max(0.0, _num(ld.get('descuento'))),
'lote': (ld.get('lote') or '').strip(),
'fecha_caducidad': _fecha(ld.get('fecha_caducidad'), False),
'notas': (ld.get('notas') or '').strip(),
}))
vals['linea_ids'] = lineas
return vals
@http.route(COMPRAS, type='http', auth='none',
methods=['POST', 'OPTIONS'], csrf=False)
@require_role('recepcion')
@require_perm('compras.crear')
@compras_activas
def create_compra(self, **kw):
try:
data = _body()
try:
vals = self._compra_vals(data, crear=True)
if not vals.get('linea_ids'):
raise ValidationError('Agrega al menos una línea')
vals['creado_por_login'] = _login()
compra = request.env['skeen.compra'].sudo().create(vals)
except ValidationError as e:
request.env.cr.rollback()
return _json({'status': 'error',
'message': str(e.args[0] if e.args else e)}, 400)
return _json({'status': 'success',
'compra': self._compra_to_dict(compra, with_detail=True)}, 201)
except Exception as e:
request.env.cr.rollback()
return _json({'status': 'error', 'message': str(e)}, 500)
@http.route(COMPRAS + '/<int:cid>', type='http', auth='none',
methods=['GET', 'OPTIONS'], csrf=False)
@require_role('lectura')
@require_perm('compras.ver')
@compras_activas
def get_compra(self, cid, **kw):
try:
c = self._compra(cid)
if not c:
return _json({'status': 'error', 'message': 'Compra no encontrada'}, 404)
return _json({'status': 'success',
'compra': self._compra_to_dict(c, with_detail=True)})
except Exception as e:
request.env.cr.rollback()
return _json({'status': 'error', 'message': str(e)}, 500)
@http.route(COMPRAS + '/<int:cid>', type='http', auth='none',
methods=['PUT', 'PATCH'], csrf=False)
@require_role('recepcion')
@require_perm('compras.editar')
@compras_activas
def update_compra(self, cid, **kw):
try:
c = self._compra(cid)
if not c:
return _json({'status': 'error', 'message': 'Compra no encontrada'}, 404)
if c.estado in ('cancelada', 'recibida', 'recibida_parcial'):
return _json({'status': 'error',
'message': 'La compra ya no se puede editar en su estado actual.'}, 409)
data = _body()
try:
if data.get('lineas') is not None and c.estado != 'borrador':
raise ValidationError('Solo se editan las líneas de una compra en borrador')
vals = self._compra_vals(data, crear=False)
if 'linea_ids' in vals:
c.linea_ids.unlink()
c.write(vals)
except ValidationError as e:
request.env.cr.rollback()
return _json({'status': 'error',
'message': str(e.args[0] if e.args else e)}, 400)
return _json({'status': 'success',
'compra': self._compra_to_dict(c, with_detail=True)})
except Exception as e:
request.env.cr.rollback()
return _json({'status': 'error', 'message': str(e)}, 500)
@http.route(COMPRAS + '/<int:cid>/enviar', type='http', auth='none',
methods=['POST', 'OPTIONS'], csrf=False)
@require_role('recepcion')
@require_perm('compras.crear')
@compras_activas
def enviar_compra(self, cid, **kw):
return self._accion_compra(cid, 'action_enviar')
@http.route(COMPRAS + '/<int:cid>/recibir', type='http', auth='none',
methods=['POST', 'OPTIONS'], csrf=False)
@require_role('recepcion')
@require_perm('compras.recibir')
@compras_activas
def recibir_compra(self, cid, **kw):
try:
c = self._compra(cid)
if not c:
return _json({'status': 'error', 'message': 'Compra no encontrada'}, 404)
data = _body()
try:
c.registrar_recepcion(
lineas=data.get('lineas') or [],
usuario=_login(),
notas=data.get('notas') or '')
except (UserError, ValidationError) as e:
request.env.cr.rollback()
return _json({'status': 'error',
'message': str(e.args[0] if e.args else e)}, 400)
c.invalidate_recordset()
return _json({'status': 'success',
'compra': self._compra_to_dict(c, with_detail=True)}, 201)
except Exception as e:
request.env.cr.rollback()
return _json({'status': 'error', 'message': str(e)}, 500)
@http.route(COMPRAS + '/directa', type='http', auth='none',
methods=['POST', 'OPTIONS'], csrf=False)
@require_role('recepcion')
@require_perm('compras.recibir')
@compras_activas
def compra_directa(self, **kw):
"""Recepción sin orden de compra (si `permitir_compras_sin_orden`).
Crea una compra ya recibida en un solo paso: es el documento que respalda
la entrada de mercancía y el pago. Requiere el flag encendido.
"""
try:
if not _cfg().get('permitir_compras_sin_orden'):
return _json({'status': 'error',
'message': 'Esta clínica no permite compras sin orden.'}, 403)
data = _body()
try:
vals = self._compra_vals(data, crear=True)
if not vals.get('linea_ids'):
raise ValidationError('Agrega al menos una línea')
vals['creado_por_login'] = _login()
compra = request.env['skeen.compra'].sudo().create(vals)
lineas = [{
'linea_id': l.id, 'cantidad': l.cantidad_pedida,
'costo_unitario': l.costo_unitario, 'lote': l.lote,
'fecha_caducidad': l.fecha_caducidad,
} for l in compra.linea_ids]
compra.registrar_recepcion(lineas=lineas, usuario=_login(),
notas='Compra directa sin orden')
except (UserError, ValidationError) as e:
request.env.cr.rollback()
return _json({'status': 'error',
'message': str(e.args[0] if e.args else e)}, 400)
compra.invalidate_recordset()
return _json({'status': 'success',
'compra': self._compra_to_dict(compra, with_detail=True)}, 201)
except Exception as e:
request.env.cr.rollback()
return _json({'status': 'error', 'message': str(e)}, 500)
@http.route(COMPRAS + '/<int:cid>/cancelar', type='http', auth='none',
methods=['POST', 'OPTIONS'], csrf=False)
@require_role('gerencia')
@require_perm('compras.cancelar')
@compras_activas
def cancelar_compra(self, cid, **kw):
try:
c = self._compra(cid)
if not c:
return _json({'status': 'error', 'message': 'Compra no encontrada'}, 404)
data = _body()
try:
c.action_cancelar(data.get('motivo') or '')
except UserError as e:
request.env.cr.rollback()
return _json({'status': 'error',
'message': str(e.args[0] if e.args else e)}, 409)
return _json({'status': 'success',
'compra': self._compra_to_dict(c, with_detail=True)})
except Exception as e:
request.env.cr.rollback()
return _json({'status': 'error', 'message': str(e)}, 500)
@http.route(COMPRAS + '/<int:cid>/pagar', type='http', auth='none',
methods=['POST', 'OPTIONS'], csrf=False)
@require_role('recepcion')
@require_perm('compras.pagar')
@compras_activas
def pagar_compra(self, cid, **kw):
try:
c = self._compra(cid)
if not c:
return _json({'status': 'error', 'message': 'Compra no encontrada'}, 404)
data = _body()
try:
pago = c.registrar_pago(
monto=data.get('monto'),
moneda=data.get('moneda') or c.moneda or 'MXN',
metodo=data.get('metodo') or 'transferencia',
referencia=data.get('referencia') or '',
notas=data.get('notas') or '',
usuario=_login())
except (UserError, ValidationError) as e:
request.env.cr.rollback()
return _json({'status': 'error',
'message': str(e.args[0] if e.args else e)}, 400)
c.invalidate_recordset()
return _json({'status': 'success',
'pago': self._pago_to_dict(pago),
'compra': self._compra_to_dict(c, with_detail=True)}, 201)
except Exception as e:
request.env.cr.rollback()
return _json({'status': 'error', 'message': str(e)}, 500)
def _accion_compra(self, cid, metodo):
try:
c = self._compra(cid)
if not c:
return _json({'status': 'error', 'message': 'Compra no encontrada'}, 404)
try:
getattr(c, metodo)()
except (UserError, ValidationError) as e:
request.env.cr.rollback()
return _json({'status': 'error',
'message': str(e.args[0] if e.args else e)}, 400)
return _json({'status': 'success',
'compra': self._compra_to_dict(c, with_detail=True)})
except Exception as e:
request.env.cr.rollback()
return _json({'status': 'error', 'message': str(e)}, 500)
# ==================================================================
# Cuentas por pagar
# ==================================================================
def _buckets_antiguedad(self):
return [
{'key': '0-30', 'label': '0 a 30 días'},
{'key': '31-60', 'label': '31 a 60 días'},
{'key': '61-90', 'label': '61 a 90 días'},
{'key': '+90', 'label': 'Más de 90 días'},
]
def _bucket_de(self, dias):
if dias <= 30:
return '0-30'
if dias <= 60:
return '31-60'
if dias <= 90:
return '61-90'
return '+90'
@http.route(COMPRAS + '/cuentas_por_pagar', type='http', auth='none',
methods=['GET', 'OPTIONS'], csrf=False)
@require_role('lectura')
@require_perm('compras.ver')
@compras_activas
def cuentas_por_pagar(self, **kw):
try:
Compra = request.env['skeen.compra'].sudo()
domain = [('estado', '!=', 'cancelada'), ('saldo', '>', 0)]
if kw.get('partner_id'):
domain.append(('partner_id', '=', int(kw['partner_id'])))
compras = Compra.search(domain, order='fecha asc, id asc')
hoy = fields.Date.today()
por_proveedor = {}
total = 0.0
buckets_tot = {b['key']: 0.0 for b in self._buckets_antiguedad()}
for c in compras:
dias = (hoy - c.fecha).days if c.fecha else 0
bucket = self._bucket_de(dias)
total += c.saldo
buckets_tot[bucket] += c.saldo
key = c.partner_id.id
if key not in por_proveedor:
por_proveedor[key] = {
'partner_id': key,
'proveedor': c.partner_id.name or '',
'dias_credito': c.partner_id.skeen_dias_credito or 0,
'saldo': 0.0, 'compras': 0,
'buckets': {b['key']: 0.0 for b in self._buckets_antiguedad()},
'detalle': [],
}
p = por_proveedor[key]
p['saldo'] += c.saldo
p['compras'] += 1
p['buckets'][bucket] += c.saldo
p['detalle'].append({
'id': c.id, 'folio': c.name,
'fecha': c.fecha.strftime('%Y-%m-%d') if c.fecha else None,
'fecha_vencimiento': (c.fecha + timedelta(
days=c.partner_id.skeen_dias_credito or 0)).strftime('%Y-%m-%d')
if c.fecha else None,
'dias': dias, 'bucket': bucket,
'total': c.total, 'pagado': c.pagado, 'saldo': c.saldo,
'moneda': c.moneda,
})
proveedores = sorted(por_proveedor.values(),
key=lambda x: x['saldo'], reverse=True)
return _json({
'status': 'success',
'proveedores': proveedores,
'buckets': self._buckets_antiguedad(),
'totales': {'saldo': round(total, 2),
'por_bucket': {k: round(v, 2) for k, v in buckets_tot.items()},
'proveedores': len(proveedores),
'compras': len(compras)},
})
except Exception as e:
request.env.cr.rollback()
return _json({'status': 'error', 'message': str(e)}, 500)
# ==================================================================
# Reportes
# ==================================================================
@http.route(BASE + '/reports/compras', type='http', auth='none',
methods=['GET', 'OPTIONS'], csrf=False)
@require_role('lectura')
@require_perm('compras.ver')
@compras_activas
def report_compras(self, **kw):
try:
inicio, fin = self._rango_kw(kw)
Compra = request.env['skeen.compra'].sudo()
domain = [('estado', '!=', 'cancelada'),
('fecha', '>=', inicio), ('fecha', '<=', fin)]
compras = Compra.search(domain, order='fecha asc, id asc')
total = sum(c.total for c in compras)
subtotal = sum(c.subtotal for c in compras)
impuestos = sum(c.impuestos for c in compras)
pagado = sum(c.pagado for c in compras)
por_proveedor = {}
por_mes = defaultdict(float)
lineas_por_item = {}
for c in compras:
key = c.partner_id.id
pp = por_proveedor.setdefault(key, {
'partner_id': key, 'proveedor': c.partner_id.name or '',
'compras': 0, 'total': 0.0, 'pagado': 0.0, 'saldo': 0.0})
pp['compras'] += 1
pp['total'] += c.total
pp['pagado'] += c.pagado
pp['saldo'] += c.saldo
por_mes[c.fecha.strftime('%Y-%m') if c.fecha else ''] += c.total
for l in c.linea_ids:
k = l.item_id.id
li = lineas_por_item.setdefault(k, {
'item_id': k, 'item': l.item_id.name,
'sku': l.item_sku or '', 'category': l.item_id.category or '',
'unidad': l.item_unit or 'pieza',
'cantidad_pedida': 0.0, 'cantidad_recibida': 0.0,
'subtotal': 0.0})
li['cantidad_pedida'] += l.cantidad_pedida
li['cantidad_recibida'] += l.cantidad_recibida
li['subtotal'] += l.subtotal
return _json({
'status': 'success',
'start': inicio.strftime('%Y-%m-%d'),
'end': fin.strftime('%Y-%m-%d'),
'totales': {
'compras': len(compras), 'subtotal': round(subtotal, 2),
'impuestos': round(impuestos, 2), 'total': round(total, 2),
'pagado': round(pagado, 2), 'saldo': round(total - pagado, 2),
},
'por_proveedor': sorted(por_proveedor.values(),
key=lambda x: x['total'], reverse=True),
'por_articulo': sorted(lineas_por_item.values(),
key=lambda x: x['subtotal'], reverse=True),
'por_mes': [{'mes': k, 'total': round(v, 2)}
for k, v in sorted(por_mes.items())],
'cuentas_por_pagar': self._json_cuentas(),
})
except Exception as e:
request.env.cr.rollback()
return _json({'status': 'error', 'message': str(e)}, 500)
def _json_cuentas(self):
"""Cuentas por pagar resumidas para incrustar en el reporte."""
Compra = request.env['skeen.compra'].sudo()
compras = Compra.search([('estado', '!=', 'cancelada'), ('saldo', '>', 0)])
hoy = fields.Date.today()
por_proveedor = {}
total = 0.0
for c in compras:
total += c.saldo
key = c.partner_id.id
pp = por_proveedor.setdefault(key, {
'partner_id': key, 'proveedor': c.partner_id.name or '',
'saldo': 0.0, 'compras': 0, 'dias_credito':
c.partner_id.skeen_dias_credito or 0})
pp['saldo'] += c.saldo
pp['compras'] += 1
return {'total': round(total, 2),
'proveedores': sorted(por_proveedor.values(),
key=lambda x: x['saldo'], reverse=True)}
@http.route(BASE + '/reports/margen', type='http', auth='none',
methods=['GET', 'OPTIONS'], csrf=False)
@require_role('lectura')
@require_perm('compras.ver')
@compras_activas
def report_margen(self, **kw):
try:
inicio, fin = self._rango_kw(kw)
Line = request.env['skeen.venta.line'].sudo()
lineas = Line.search([
('item_id', '!=', False),
('venta_id.state', 'in', ('confirmed', 'partial', 'paid')),
('venta_id.date', '>=', inicio),
('venta_id.date', '<=', fin),
])
ventas_por_item = defaultdict(lambda: {'unidades': 0.0, 'ingresos': 0.0})
for l in lineas:
agg = ventas_por_item[l.item_id.id]
agg['unidades'] += l.quantity or 0.0
agg['ingresos'] += l.subtotal or 0.0
Item = request.env['skeen.inventario.item'].sudo()
items = Item.search([('active', '=', True)], order='name asc')
margen_min = _num(_cfg().get('margen_minimo_alerta'), 0.0)
filas = []
tot_ingresos = tot_costo = 0.0
for it in items:
venta = ventas_por_item.get(it.id)
costo_unit = it.costo_promedio or it.cost or 0.0
margen_unit = (it.margen or 0.0) # precio sin impuesto - costo
if not venta and not it.price and not costo_unit:
continue
unidades = venta['unidades'] if venta else 0.0
ingresos = venta['ingresos'] if venta else 0.0
costo = unidades * costo_unit
filas.append({
'item_id': it.id, 'item': it.name, 'sku': it.sku or '',
'category': it.category or '', 'unidad': it.unit or 'pieza',
'precio': it.price or 0.0,
'costo_promedio': costo_unit,
'margen_unitario': margen_unit,
'margen_pct': it.margen_pct or 0.0,
'unidades_vendidas': unidades,
'ingresos': round(ingresos, 2),
'costo_ventas': round(costo, 2),
'margen_total': round(ingresos - costo, 2),
'alerta': bool(venta and (it.margen_pct or 0.0) < margen_min),
})
tot_ingresos += ingresos
tot_costo += costo
filas.sort(key=lambda x: x['margen_total'], reverse=True)
margen_agg = tot_ingresos - tot_costo
return _json({
'status': 'success',
'start': inicio.strftime('%Y-%m-%d'),
'end': fin.strftime('%Y-%m-%d'),
'margen_minimo_alerta': margen_min,
'articulos': filas,
'totales': {
'ingresos': round(tot_ingresos, 2),
'costo_ventas': round(tot_costo, 2),
'margen': round(margen_agg, 2),
'margen_pct': round((margen_agg / tot_ingresos * 100.0)
if tot_ingresos else 0.0, 2),
'alertas': len([f for f in filas if f['alerta']]),
},
})
except Exception as e:
request.env.cr.rollback()
return _json({'status': 'error', 'message': str(e)}, 500)

View File

@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data noupdate="1">
<!-- Folio de orden de compra: OC-0001, OC-0002, ... -->
<record id="seq_skeen_compra" model="ir.sequence">
<field name="name">Orden de compra SKEEN</field>
<field name="code">skeen.compra</field>
<field name="prefix">OC-</field>
<field name="padding">4</field>
<field name="number_next">1</field>
<field name="number_increment">1</field>
</record>
</data>
</odoo>

View File

@@ -0,0 +1,19 @@
# -*- coding: utf-8 -*-
"""Hook de instalación de skeen_compras.
Inicializa `costo_promedio` y `ultimo_costo` con el costo unitario que ya tenía
cada artículo, para que el margen sea significativo desde el primer día. Solo
escribe las columnas NUEVAS: no toca `cost`, `qty` ni ningún valor que usen los
reportes existentes, así que una instancia con `compras.activo=false` no cambia
ninguna cifra.
"""
def post_init_hook(env):
env.cr.execute("""
UPDATE skeen_inventario_item
SET costo_promedio = COALESCE(cost, 0.0),
ultimo_costo = COALESCE(cost, 0.0)
WHERE COALESCE(costo_promedio, 0.0) = 0.0
AND COALESCE(cost, 0.0) <> 0.0
""")

View File

@@ -0,0 +1,4 @@
# -*- coding: utf-8 -*-
from . import partner
from . import inventario
from . import compra

View File

@@ -0,0 +1,424 @@
# -*- coding: utf-8 -*-
"""Órdenes de compra, recepciones (parciales), pagos y costo promedio.
Reglas de negocio de este módulo:
* Una compra nace en `borrador`, se `enviar` y se recibe (total o parcial).
Recibir genera movimientos `skeen.inventario.move` tipo `compra` (que suben el
stock) y recalcula el costo promedio del artículo si `actualizar_costo_promedio`
está encendido en `compras` (clinic-settings).
* Las recepciones son ACUMULATIVAS: `skeen.compra.linea.cantidad_recibida` suma
cada recepción y el estado pasa a `recibida_parcial` o `recibida`.
* Los pagos viven en `skeen.compra.pago` (EGRESOS) y NO se mezclan con
`skeen.pago` (cobros a pacientes). Un egreso no debe aparecer en un corte de
caja ni en el estado de cuenta de un paciente.
* Cancelar solo se permite si NO hubo recepciones. Se decide bloquear en vez de
revertir movimientos: un ajuste de reversa dejaría el costo promedio ya
recalculado y el kardex sin trazabilidad de la compra cancelada. Si la
mercancía ya entró, el camino correcto es una baja/ajuste de inventario.
"""
from odoo import models, fields, api
from odoo.exceptions import ValidationError, UserError
from odoo.addons.skeen_citas.clinic_settings import get_compras, get_impuestos
MONEDAS = ('MXN', 'USD')
METODOS_PAGO = ('efectivo', 'transferencia', 'tarjeta', 'otro')
ESTADOS = ('borrador', 'enviada', 'recibida_parcial', 'recibida', 'cancelada')
ESTADO_LABEL = {
'borrador': 'Borrador',
'enviada': 'Enviada',
'recibida_parcial': 'Recibida parcial',
'recibida': 'Recibida',
'cancelada': 'Cancelada',
}
def _num(valor, por_defecto=0.0):
try:
return float(valor)
except (TypeError, ValueError):
return por_defecto
class SkeenCompra(models.Model):
_name = 'skeen.compra'
_description = 'Orden de compra SKEEN'
_order = 'fecha desc, id desc'
name = fields.Char(string='Folio', readonly=True, copy=False, index=True)
partner_id = fields.Many2one('res.partner', string='Proveedor',
required=True, index=True, ondelete='restrict')
partner_contacto = fields.Char(related='partner_id.skeen_contacto',
string='Contacto', readonly=True)
fecha = fields.Date(string='Fecha', default=fields.Date.today, required=True)
fecha_entrega_esperada = fields.Date(string='Entrega esperada')
estado = fields.Selection(
[(e, ESTADO_LABEL[e]) for e in ESTADOS], string='Estado',
default='borrador', required=True, index=True)
linea_ids = fields.One2many('skeen.compra.linea', 'compra_id', string='Líneas')
moneda = fields.Selection(
[(m, m) for m in MONEDAS], string='Moneda', default='MXN', required=True)
subtotal = fields.Float(string='Subtotal', compute='_compute_totales',
store=True)
descuento = fields.Float(string='Descuento', default=0.0)
impuestos = fields.Float(string='Impuestos', compute='_compute_totales',
store=True)
total = fields.Float(string='Total', compute='_compute_totales', store=True)
pagado = fields.Float(string='Pagado', compute='_compute_pagado', store=True)
saldo = fields.Float(string='Saldo', compute='_compute_pagado', store=True)
sucursal_id = fields.Many2one('skeen.sucursal', string='Sucursal')
referencia_factura = fields.Char(string='Referencia de factura')
notas = fields.Text(string='Notas')
creado_por_login = fields.Char(string='Creado por')
recibida_at = fields.Datetime(string='Recibida el', readonly=True)
cancel_reason = fields.Char(string='Motivo de cancelación', readonly=True)
recepcion_ids = fields.One2many('skeen.compra.recepcion', 'compra_id',
string='Recepciones')
pago_ids = fields.One2many('skeen.compra.pago', 'compra_id', string='Pagos')
total_pedido = fields.Float(string='Unidades pedidas',
compute='_compute_totales', store=True)
total_recibido = fields.Float(string='Unidades recibidas',
compute='_compute_recepcion')
recepciones_count = fields.Integer(compute='_compute_recepcion')
tiene_recepciones = fields.Boolean(compute='_compute_recepcion')
@api.model_create_multi
def create(self, vals_list):
for vals in vals_list:
if not vals.get('name'):
# Se busca la secuencia por código y se usa next_by_id (no
# next_by_code): en peticiones auth='none' `env.company` puede
# no ser la compañía de la secuencia y next_by_code devolvería
# False (folio repetido).
seq = self.env['ir.sequence'].sudo().search(
[('code', '=', 'skeen.compra')], limit=1)
folio = seq.next_by_id() if seq else False
vals['name'] = folio or 'OC-0001'
return super().create(vals_list)
@api.depends('linea_ids.subtotal', 'linea_ids.impuesto', 'linea_ids.cantidad_pedida',
'descuento')
def _compute_totales(self):
for c in self:
c.subtotal = sum(l.subtotal for l in c.linea_ids)
c.impuestos = sum(l.impuesto for l in c.linea_ids)
c.total = max(0.0, c.subtotal - (c.descuento or 0.0) + c.impuestos)
c.total_pedido = sum(l.cantidad_pedida for l in c.linea_ids)
@api.depends('pago_ids.monto_en_compra', 'total')
def _compute_pagado(self):
for c in self:
c.pagado = sum(p.monto_en_compra for p in c.pago_ids)
c.saldo = c.total - c.pagado
@api.depends('linea_ids.cantidad_recibida', 'recepcion_ids')
def _compute_recepcion(self):
for c in self:
recibido = sum(l.cantidad_recibida for l in c.linea_ids)
c.total_recibido = recibido
c.recepciones_count = len(c.recepcion_ids)
c.tiene_recepciones = recibido > 0
# ------------------------------------------------------------------
# Acciones
# ------------------------------------------------------------------
def action_enviar(self):
for c in self:
if c.estado == 'cancelada':
raise UserError('Una compra cancelada no se puede enviar.')
if c.estado != 'borrador':
raise UserError('Solo una compra en borrador se puede enviar.')
if not c.linea_ids:
raise UserError('Agrega al menos una línea antes de enviar.')
c.estado = 'enviada'
return True
def action_cancelar(self, motivo=''):
for c in self:
if c.estado == 'cancelada':
raise UserError('La compra ya está cancelada.')
if c.tiene_recepciones:
raise UserError(
'No se puede cancelar: la compra ya tiene mercancía '
'recibida. Registra una baja o un ajuste de inventario.')
c.write({'estado': 'cancelada', 'cancel_reason': (motivo or '').strip()})
return True
def registrar_recepcion(self, lineas, usuario='', notas=''):
"""Recibe cantidades (posiblemente parciales) y actualiza stock y costo.
`lineas` es una lista de dicts: `{'linea_id'|'item_id', 'cantidad',
'lote', 'fecha_caducidad', 'costo_unitario'}`. Devuelve el registro
`skeen.compra.recepcion` creado.
"""
self.ensure_one()
if self.estado == 'cancelada':
raise UserError('No se puede recibir una compra cancelada.')
if self.estado == 'recibida':
raise UserError('La compra ya está totalmente recibida.')
cfg = get_compras(self.env)
if cfg.get('requiere_autorizacion') and self.estado == 'borrador':
raise UserError('Esta clínica exige autorizar la compra: envíala '
'antes de recibirla.')
Recepcion = self.env['skeen.compra.recepcion']
RecepcionLinea = self.env['skeen.compra.recepcion.linea']
Move = self.env['skeen.inventario.move']
recepcion = Recepcion.create({
'compra_id': self.id,
'fecha': fields.Datetime.now(),
'usuario': usuario or '',
'notas': notas or '',
})
recibidas_total = 0
for data in (lineas or []):
linea = None
if data.get('linea_id'):
linea = self.linea_ids.filtered(
lambda l: l.id == int(data['linea_id']))
if not linea and data.get('item_id'):
linea = self.linea_ids.filtered(
lambda l: l.item_id.id == int(data['item_id']))
if not linea:
raise UserError('Línea de compra no encontrada para la recepción.')
linea = linea[0]
cantidad = _num(data.get('cantidad'))
if cantidad <= 0:
continue
pendiente = linea.cantidad_pendiente
if cantidad - pendiente > 1e-6:
if not cfg.get('permitir_recepcion_parcial', True):
raise UserError('No se permite recibir más de lo pendiente.')
cantidad = pendiente
if not cfg.get('permitir_recepcion_parcial', True):
if abs(cantidad - pendiente) > 1e-6:
raise UserError('Esta clínica no permite recepciones '
'parciales: recibe la cantidad completa.')
costo = _num(data.get('costo_unitario'), linea.costo_unitario)
lote = (data.get('lote') or '').strip()
caducidad = data.get('fecha_caducidad') or False
item = linea.item_id
# El costo promedio usa la existencia ANTES del movimiento: se
# actualiza aquí y el movimiento (que sube el stock) va después.
item._actualizar_costo_recepcion(
cantidad, costo,
actualizar_promedio=cfg.get('actualizar_costo_promedio', True))
if caducidad:
item.expiry_date = caducidad
move = Move.create({
'item_id': item.id,
'type': 'compra',
'qty': cantidad,
'reference': self.name,
'notes': ('Recepción %s%s' % (self.name, (' · lote ' + lote) if lote else '')),
'lote': lote,
'fecha_caducidad': caducidad or False,
})
RecepcionLinea.create({
'recepcion_id': recepcion.id,
'compra_linea_id': linea.id,
'item_id': item.id,
'cantidad': cantidad,
'costo_unitario': costo,
'lote': lote,
'fecha_caducidad': caducidad or False,
'move_id': move.id,
})
linea.cantidad_recibida = (linea.cantidad_recibida or 0.0) + cantidad
recibidas_total += cantidad
if recibidas_total <= 0:
raise UserError('No se indicó ninguna cantidad a recibir.')
completa = all(
(l.cantidad_recibida or 0.0) + 1e-6 >= (l.cantidad_pedida or 0.0)
for l in self.linea_ids)
vals = {'estado': 'recibida' if completa else 'recibida_parcial'}
if completa:
vals['recibida_at'] = fields.Datetime.now()
self.write(vals)
return recepcion
def registrar_pago(self, monto, moneda='MXN', metodo='transferencia',
referencia='', notas='', usuario=''):
self.ensure_one()
if self.estado == 'cancelada':
raise UserError('No se puede pagar una compra cancelada.')
monto = _num(monto)
if monto <= 0:
raise UserError('El monto del pago debe ser mayor a 0.')
if moneda not in MONEDAS:
moneda = self.moneda or 'MXN'
if metodo not in METODOS_PAGO:
metodo = 'otro'
return self.env['skeen.compra.pago'].create({
'compra_id': self.id,
'fecha': fields.Date.today(),
'monto': monto,
'moneda': moneda,
'metodo': metodo,
'referencia': referencia or '',
'notas': notas or '',
'usuario': usuario or '',
})
class SkeenCompraLinea(models.Model):
_name = 'skeen.compra.linea'
_description = 'Línea de orden de compra SKEEN'
_order = 'id'
compra_id = fields.Many2one('skeen.compra', string='Compra', required=True,
ondelete='cascade', index=True)
item_id = fields.Many2one('skeen.inventario.item', string='Artículo',
required=True, ondelete='restrict')
item_sku = fields.Char(related='item_id.sku', string='SKU', readonly=True)
item_unit = fields.Char(related='item_id.unit', string='Unidad', readonly=True)
item_kind = fields.Selection(related='item_id.kind', string='Tipo', readonly=True)
cantidad_pedida = fields.Float(string='Cantidad pedida', default=1.0)
cantidad_recibida = fields.Float(string='Cantidad recibida', default=0.0)
cantidad_pendiente = fields.Float(string='Pendiente',
compute='_compute_pendiente')
costo_unitario = fields.Float(string='Costo unitario', default=0.0)
descuento = fields.Float(string='Descuento', default=0.0)
subtotal = fields.Float(string='Subtotal', compute='_compute_subtotal',
store=True)
impuesto = fields.Float(string='Impuesto', compute='_compute_impuesto',
store=True)
total = fields.Float(string='Total', compute='_compute_total')
lote = fields.Char(string='Lote')
fecha_caducidad = fields.Date(string='Caducidad')
notas = fields.Char(string='Notas')
@api.depends('cantidad_pedida', 'cantidad_recibida')
def _compute_pendiente(self):
for l in self:
l.cantidad_pendiente = max(
0.0, (l.cantidad_pedida or 0.0) - (l.cantidad_recibida or 0.0))
@api.depends('cantidad_pedida', 'costo_unitario', 'descuento')
def _compute_subtotal(self):
for l in self:
bruto = (l.cantidad_pedida or 0.0) * (l.costo_unitario or 0.0)
l.subtotal = max(0.0, bruto - (l.descuento or 0.0))
@api.depends('subtotal', 'item_id')
def _compute_impuesto(self):
imp = get_impuestos(self.env)
for l in self:
tasa = 0.0
if imp.get('activo') and l.item_id:
tasa = (imp.get('tasa_default') or 0.0) if l.item_id.tax_use_default \
else (l.item_id.tax_rate or 0.0)
if tasa <= 0:
l.impuesto = 0.0
elif imp.get('precios_incluyen_impuesto'):
l.impuesto = l.subtotal - l.subtotal / (1.0 + tasa / 100.0)
else:
l.impuesto = l.subtotal * tasa / 100.0
@api.depends('subtotal', 'impuesto')
def _compute_total(self):
for l in self:
l.total = l.subtotal + l.impuesto
# Sugerencia de costo: la UI la pide, pero deja el dato listo también aquí.
def _sugerir_costo(self):
self.ensure_one()
return (self.item_id.ultimo_costo or self.item_id.costo_promedio
or self.item_id.cost or 0.0)
class SkeenCompraRecepcion(models.Model):
_name = 'skeen.compra.recepcion'
_description = 'Recepción de mercancía de una compra'
_order = 'fecha desc, id desc'
compra_id = fields.Many2one('skeen.compra', string='Compra', required=True,
ondelete='cascade', index=True)
fecha = fields.Datetime(string='Fecha', default=fields.Datetime.now)
usuario = fields.Char(string='Usuario')
notas = fields.Text(string='Notas')
line_ids = fields.One2many('skeen.compra.recepcion.linea', 'recepcion_id',
string='Detalle')
total_unidades = fields.Float(string='Unidades', compute='_compute_total')
total_costo = fields.Float(string='Costo recibido', compute='_compute_total')
@api.depends('line_ids.cantidad', 'line_ids.costo_unitario')
def _compute_total(self):
for r in self:
r.total_unidades = sum(l.cantidad for l in r.line_ids)
r.total_costo = sum(l.cantidad * l.costo_unitario for l in r.line_ids)
class SkeenCompraRecepcionLinea(models.Model):
_name = 'skeen.compra.recepcion.linea'
_description = 'Línea de recepción de compra'
_order = 'id'
recepcion_id = fields.Many2one('skeen.compra.recepcion', string='Recepción',
required=True, ondelete='cascade', index=True)
compra_linea_id = fields.Many2one('skeen.compra.linea', string='Línea de compra',
required=True, ondelete='cascade')
item_id = fields.Many2one('skeen.inventario.item', string='Artículo',
required=True, ondelete='restrict')
cantidad = fields.Float(string='Cantidad', default=0.0)
costo_unitario = fields.Float(string='Costo unitario', default=0.0)
lote = fields.Char(string='Lote')
fecha_caducidad = fields.Date(string='Caducidad')
move_id = fields.Many2one('skeen.inventario.move', string='Movimiento',
readonly=True)
class SkeenCompraPago(models.Model):
"""Pago (EGRESO) a un proveedor. Separado de `skeen.pago` (cobros)."""
_name = 'skeen.compra.pago'
_description = 'Pago a proveedor SKEEN'
_order = 'fecha desc, id desc'
compra_id = fields.Many2one('skeen.compra', string='Compra', required=True,
ondelete='cascade', index=True)
partner_id = fields.Many2one(related='compra_id.partner_id', string='Proveedor',
store=True, readonly=True)
fecha = fields.Date(string='Fecha', default=fields.Date.today, required=True)
monto = fields.Float(string='Monto', default=0.0)
moneda = fields.Selection([(m, m) for m in MONEDAS], string='Moneda',
default='MXN', required=True)
metodo = fields.Selection([(m, m) for m in METODOS_PAGO], string='Método',
default='transferencia')
referencia = fields.Char(string='Referencia')
notas = fields.Text(string='Notas')
usuario = fields.Char(string='Usuario')
monto_en_compra = fields.Float(
string='Equivalente', compute='_compute_monto_en_compra', store=True,
help='Monto convertido a la moneda de la compra (tipo de cambio vigente).')
@api.depends('monto', 'moneda', 'compra_id.moneda')
def _compute_monto_en_compra(self):
rate = self._tipo_cambio()
for p in self:
objetivo = p.compra_id.moneda or 'MXN'
if p.moneda == objetivo:
p.monto_en_compra = p.monto or 0.0
elif p.moneda == 'USD' and objetivo == 'MXN':
p.monto_en_compra = (p.monto or 0.0) * rate
elif p.moneda == 'MXN' and objetivo == 'USD':
p.monto_en_compra = (p.monto or 0.0) / rate if rate else 0.0
else:
p.monto_en_compra = p.monto or 0.0
def _tipo_cambio(self):
"""Último tipo de cambio MXN por USD registrado en `skeen.tipo.cambio`."""
try:
tc = self.env['skeen.tipo.cambio'].sudo().search(
[], order='date desc, id desc', limit=1)
return (tc.rate or 1.0) if tc else 1.0
except Exception:
return 1.0

View File

@@ -0,0 +1,92 @@
# -*- coding: utf-8 -*-
"""Campos de costo/margen que Compras agrega al artículo de inventario.
`costo_promedio` y `ultimo_costo` son NUEVOS: `cost` (Costo unitario) se queda
como estaba porque lo usan el valor de inventario y los reportes existentes, de
modo que una instancia con `compras.activo=false` conserva sus cifras idénticas.
El margen se calcula contra `costo_promedio` (con respaldo en `cost`) y el
precio de venta ya sin impuesto.
"""
from odoo import models, fields, api
class SkeenInventarioItem(models.Model):
_inherit = 'skeen.inventario.item'
costo_promedio = fields.Float(
string='Costo promedio', default=0.0,
help='Costo promedio ponderado de las compras recibidas. Editable por '
'administración como ajuste de inventario.')
ultimo_costo = fields.Float(
string='Último costo', default=0.0,
help='Costo unitario de la última recepción de compra.')
margen = fields.Float(
string='Margen', compute='_compute_margen',
help='Precio de venta sin impuesto menos costo promedio.')
margen_pct = fields.Float(
string='Margen (%)', compute='_compute_margen')
@api.depends('price', 'cost', 'costo_promedio', 'tax_rate',
'tax_use_default')
def _compute_margen(self):
from odoo.addons.skeen_citas.clinic_settings import get_impuestos
try:
imp = get_impuestos(self.env)
except Exception:
imp = {'activo': False, 'tasa_default': 0.0,
'precios_incluyen_impuesto': False}
for it in self:
precio = it.price or 0.0
if imp.get('activo'):
tasa = (imp.get('tasa_default') or 0.0) if it.tax_use_default \
else (it.tax_rate or 0.0)
if imp.get('precios_incluyen_impuesto') and tasa:
precio = precio / (1.0 + tasa / 100.0)
costo = it.costo_promedio or it.cost or 0.0
it.margen = precio - costo
it.margen_pct = (it.margen / precio * 100.0) if precio else 0.0
def _actualizar_costo_recepcion(self, cantidad, costo_unitario,
actualizar_promedio=True):
"""Aplica el costo de una recepción sobre el artículo.
Fórmula ponderada: (stock_actual*costo_actual + cantidad*costo_recibido)
/ (stock_actual + cantidad). Si el stock actual es <= 0 (cero o negativo)
el promedio se reinicia al costo recibido, porque mezclar un costo viejo
con existencias negativas daría un número sin sentido.
Debe llamarse ANTES de crear el movimiento de entrada, para que
`stock_actual` sea la existencia previa a la recepción.
"""
self.ensure_one()
stock_actual = self.qty or 0.0
cantidad = float(cantidad or 0.0)
costo_unitario = float(costo_unitario or 0.0)
vals = {'ultimo_costo': costo_unitario}
if actualizar_promedio and cantidad > 0:
if stock_actual > 0:
# Sin historial de compras el promedio arranca del costo unitario
# ya cargado (`cost`); si no, mezclar contra 0 daría un promedio
# artificialmente bajo.
costo_actual = self.costo_promedio or self.cost or 0.0
nuevo = ((stock_actual * costo_actual)
+ (cantidad * costo_unitario)) / (stock_actual + cantidad)
else:
nuevo = costo_unitario
vals['costo_promedio'] = nuevo
self.write(vals)
return self.costo_promedio
class SkeenInventarioMove(models.Model):
"""Lote y caducidad de la mercancía recibida.
Columnas nuevas y opcionales: el kardex actual no cambia. Las recepciones de
compra las llenan; los demás movimientos las dejan vacías.
"""
_inherit = 'skeen.inventario.move'
lote = fields.Char(string='Lote')
fecha_caducidad = fields.Date(string='Caducidad')

View File

@@ -0,0 +1,69 @@
# -*- coding: utf-8 -*-
"""Proveedores sobre `res.partner`.
No se duplica el modelo de persona: un proveedor es un `res.partner` marcado con
`skeen_es_proveedor` más un puñado de campos propios de negocio (días de crédito,
contacto, datos de pago y notas).
Nota de compatibilidad: el `supplier_rank` estándar de Odoo lo define el módulo
`account`, que NO está instalado en todas las instancias (p. ej. la de pruebas).
Por eso la fuente de verdad es el flag propio `skeen_es_proveedor`; si la
instancia sí tiene `supplier_rank`, se sincroniza para no perder la integración
con el estándar. El flag `skeen_proveedor_activo` permite "desactivar" un
proveedor con historial sin borrar los datos ni romper las compras pasadas.
"""
from odoo import models, fields, api
from odoo.exceptions import ValidationError
class ResPartner(models.Model):
_inherit = 'res.partner'
skeen_es_proveedor = fields.Boolean(
string='Es proveedor', default=False, index=True,
help='Marca propia de proveedor. Se sincroniza con supplier_rank si la '
'instancia tiene el módulo account instalado.')
skeen_proveedor_activo = fields.Boolean(
string='Proveedor activo', default=True,
help='Se apaga al "eliminar" un proveedor con compras registradas.')
skeen_dias_credito = fields.Integer(
string='Días de crédito', default=0)
skeen_contacto = fields.Char(
string='Contacto de compras')
skeen_banco = fields.Char(string='Banco')
skeen_cuenta_pago = fields.Char(string='Cuenta / CLABE de pago')
skeen_notas_proveedor = fields.Text(string='Notas de proveedor')
skeen_compra_ids = fields.One2many(
'skeen.compra', 'partner_id', string='Compras')
skeen_compras_count = fields.Integer(
string='Número de compras', compute='_compute_skeen_compras_count')
skeen_saldo = fields.Float(
string='Saldo por pagar', compute='_compute_skeen_saldo',
help='Suma de los saldos de las compras no canceladas.')
@api.depends('skeen_compra_ids')
def _compute_skeen_compras_count(self):
for p in self:
p.skeen_compras_count = len(p.skeen_compra_ids)
@api.depends('skeen_compra_ids.saldo', 'skeen_compra_ids.estado')
def _compute_skeen_saldo(self):
for p in self:
p.skeen_saldo = sum(
c.saldo for c in p.skeen_compra_ids
if c.estado not in ('cancelada',))
@api.constrains('skeen_dias_credito')
def _check_dias_credito(self):
for p in self:
if p.skeen_dias_credito is not None and p.skeen_dias_credito < 0:
raise ValidationError('Los días de crédito no pueden ser negativos.')
def action_marcar_proveedor(self):
"""Marca al partner como proveedor (flag propio + supplier_rank si existe)."""
for p in self:
p.skeen_es_proveedor = True
if 'supplier_rank' in p._fields and (p.supplier_rank or 0) <= 0:
p.supplier_rank = 1
p.skeen_proveedor_activo = True

View File

@@ -0,0 +1,6 @@
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_skeen_compra_user,skeen.compra.user,model_skeen_compra,base.group_user,1,1,1,1
access_skeen_compra_linea_user,skeen.compra.linea.user,model_skeen_compra_linea,base.group_user,1,1,1,1
access_skeen_compra_recepcion_user,skeen.compra.recepcion.user,model_skeen_compra_recepcion,base.group_user,1,1,1,1
access_skeen_compra_recepcion_linea_user,skeen.compra.recepcion.linea.user,model_skeen_compra_recepcion_linea,base.group_user,1,1,1,1
access_skeen_compra_pago_user,skeen.compra.pago.user,model_skeen_compra_pago,base.group_user,1,1,1,1
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_skeen_compra_user skeen.compra.user model_skeen_compra base.group_user 1 1 1 1
3 access_skeen_compra_linea_user skeen.compra.linea.user model_skeen_compra_linea base.group_user 1 1 1 1
4 access_skeen_compra_recepcion_user skeen.compra.recepcion.user model_skeen_compra_recepcion base.group_user 1 1 1 1
5 access_skeen_compra_recepcion_linea_user skeen.compra.recepcion.linea.user model_skeen_compra_recepcion_linea base.group_user 1 1 1 1
6 access_skeen_compra_pago_user skeen.compra.pago.user model_skeen_compra_pago base.group_user 1 1 1 1

View File

@@ -21,6 +21,7 @@ from odoo.addons.skeen_citas.clinic_settings import (
set_settings as set_clinic_settings, set_settings as set_clinic_settings,
get_impuestos, get_impuestos,
get_recursos, get_recursos,
get_compras,
) )
from odoo.addons.skeen_citas.glosario import ( from odoo.addons.skeen_citas.glosario import (
GLOSARIO_KEYS as GLOSARIO_KEY_SET, GLOSARIO_KEYS as GLOSARIO_KEY_SET,
@@ -660,9 +661,16 @@ class SkeenFrontendController(http.Controller):
# Permisos por acción (catálogo + por rol) # Permisos por acción (catálogo + por rol)
# ============================================================ # ============================================================
def _perm_catalogo(self): def _perm_catalogo(self):
# Con `compras.activo=false` el grupo de permisos de Compras no se
# muestra en el editor de roles: apagar el módulo no debe cambiar
# ninguna pantalla existente.
try:
ocultar = set() if get_compras(request.env).get('activo') else {'compras'}
except Exception:
ocultar = set()
return [{'grupo': g, 'label': label, return [{'grupo': g, 'label': label,
'permisos': [{'key': k, 'label': l} for k, l in perms]} 'permisos': [{'key': k, 'label': l} for k, l in perms]}
for g, label, perms in PERMISSION_GROUPS] for g, label, perms in PERMISSION_GROUPS if g not in ocultar]
@http.route('/skeen/frontend/v1/permisos', type='http', auth='none', @http.route('/skeen/frontend/v1/permisos', type='http', auth='none',
methods=['GET', 'OPTIONS'], csrf=False) methods=['GET', 'OPTIONS'], csrf=False)
@@ -8014,6 +8022,10 @@ class SkeenFrontendController(http.Controller):
'qty_min': it.qty_min, 'qty_min': it.qty_min,
'cost': it.cost, 'cost': it.cost,
'price': it.price, 'price': it.price,
'costo_promedio': getattr(it, 'costo_promedio', 0.0),
'ultimo_costo': getattr(it, 'ultimo_costo', 0.0),
'margen': getattr(it, 'margen', 0.0),
'margen_pct': getattr(it, 'margen_pct', 0.0),
'inventory_value': it.inventory_value, 'inventory_value': it.inventory_value,
'expiry_date': it.expiry_date.strftime('%Y-%m-%d') if it.expiry_date else None, 'expiry_date': it.expiry_date.strftime('%Y-%m-%d') if it.expiry_date else None,
'last_count_date': it.last_count_date.strftime('%Y-%m-%d') if it.last_count_date else None, 'last_count_date': it.last_count_date.strftime('%Y-%m-%d') if it.last_count_date else None,
@@ -8036,6 +8048,9 @@ class SkeenFrontendController(http.Controller):
'date': m.date.strftime('%Y-%m-%d %H:%M') if m.date else None, 'date': m.date.strftime('%Y-%m-%d %H:%M') if m.date else None,
'reference': m.reference or '', 'reference': m.reference or '',
'notes': m.notes or '', 'notes': m.notes or '',
'lote': getattr(m, 'lote', '') or '',
'fecha_caducidad': m.fecha_caducidad.strftime('%Y-%m-%d')
if getattr(m, 'fecha_caducidad', None) else None,
} }
@http.route('/skeen/frontend/v1/inventory/items', type='http', auth='none', methods=['GET', 'OPTIONS'], csrf=False) @http.route('/skeen/frontend/v1/inventory/items', type='http', auth='none', methods=['GET', 'OPTIONS'], csrf=False)
@@ -8114,12 +8129,13 @@ class SkeenFrontendController(http.Controller):
data = _parse_json_body() data = _parse_json_body()
allowed = ['name', 'kind', 'sku', 'category', 'unit', 'qty_optimal', 'qty_min', 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'] 'tax_rate', 'tax_use_default', 'costo_promedio', 'ultimo_costo']
vals = {} vals = {}
for k in allowed: for k in allowed:
if k in data: if k in data:
vals[k] = data[k] vals[k] = data[k]
for f in ('qty_optimal', 'qty_min', 'cost', 'price', 'tax_rate'): for f in ('qty_optimal', 'qty_min', 'cost', 'price', 'tax_rate',
'costo_promedio', 'ultimo_costo'):
if f in vals: if f in vals:
vals[f] = float(vals[f] or 0) vals[f] = float(vals[f] or 0)
if 'tax_use_default' in vals: if 'tax_use_default' in vals:

View File

@@ -106,6 +106,16 @@ PERMISSION_GROUPS = [
('membresias.editar', 'Editar planes y membresías'), ('membresias.editar', 'Editar planes y membresías'),
('membresias.cancelar', 'Congelar y cancelar membresías'), ('membresias.cancelar', 'Congelar y cancelar membresías'),
]), ]),
('compras', 'Compras y proveedores', [
('compras.ver', 'Ver compras, cuentas por pagar y margen'),
('compras.crear', 'Crear órdenes de compra'),
('compras.recibir', 'Recibir mercancía (afecta stock y costo)'),
('compras.pagar', 'Registrar pagos a proveedores'),
('compras.editar', 'Editar órdenes de compra'),
('compras.cancelar', 'Cancelar órdenes de compra'),
('proveedores.ver', 'Ver proveedores'),
('proveedores.editar', 'Administrar proveedores'),
]),
('config', 'Usuarios y configuración', [ ('config', 'Usuarios y configuración', [
('config.usuarios', 'Administrar usuarios'), ('config.usuarios', 'Administrar usuarios'),
('config.roles', 'Administrar roles y menús'), ('config.roles', 'Administrar roles y menús'),
@@ -136,6 +146,8 @@ READ_PERM_KEYS = (
'notificaciones.ver', 'notificaciones.ver',
'formularios.ver', 'formularios.ver',
'membresias.ver', 'membresias.ver',
'compras.ver',
'proveedores.ver',
) )
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -234,6 +246,17 @@ DEFAULT_PERM_ROLES = {
'membresias.cobrar': ('admin', 'recepcion', 'gerencia'), 'membresias.cobrar': ('admin', 'recepcion', 'gerencia'),
'membresias.editar': AG, 'membresias.editar': AG,
'membresias.cancelar': AG, 'membresias.cancelar': AG,
# Compras y proveedores. Ver es de todos; crear/recibir/pagar es de
# recepción/gerencia/admin (igual que inventario y membresías); editar,
# cancelar y administrar proveedores es de admin/gerencia (tocan dinero).
'compras.ver': ALL,
'compras.crear': ('admin', 'recepcion', 'gerencia'),
'compras.recibir': ('admin', 'recepcion', 'gerencia'),
'compras.pagar': ('admin', 'recepcion', 'gerencia'),
'compras.editar': AG,
'compras.cancelar': AG,
'proveedores.ver': ALL,
'proveedores.editar': AG,
# Usuarios y configuración # Usuarios y configuración
'config.usuarios': AG, 'config.usuarios': AG,
'config.roles': AG, 'config.roles': AG,

View File

@@ -169,6 +169,9 @@ DEFAULT_MENU_ROLES = {
'/pagos': ['admin', 'recepcion', 'gerencia'], '/pagos': ['admin', 'recepcion', 'gerencia'],
'/monedero': ['admin', 'recepcion', 'lectura', 'gerencia'], '/monedero': ['admin', 'recepcion', 'lectura', 'gerencia'],
'/inventario': ['admin', 'recepcion', 'lectura', 'gerencia'], '/inventario': ['admin', 'recepcion', 'lectura', 'gerencia'],
# Compras: apagado por defecto (compras.activo=false). La sección solo se
# pinta cuando la instancia la enciende; aquí queda su visibilidad por rol.
'/compras': ['admin', 'recepcion', 'lectura', 'gerencia'],
'/cortes': ['admin', 'recepcion', 'gerencia'], '/cortes': ['admin', 'recepcion', 'gerencia'],
'/reportes': ['admin', 'recepcion', 'lectura', 'gerencia'], '/reportes': ['admin', 'recepcion', 'lectura', 'gerencia'],
'/reportes?tab=adeudos': ['admin', 'recepcion', 'lectura', 'gerencia'], '/reportes?tab=adeudos': ['admin', 'recepcion', 'lectura', 'gerencia'],
@@ -179,6 +182,8 @@ DEFAULT_MENU_ROLES = {
'/reportes?tab=vendedores': ['admin', 'recepcion', 'lectura', 'gerencia'], '/reportes?tab=vendedores': ['admin', 'recepcion', 'lectura', 'gerencia'],
'/reportes?tab=concentrado': ['admin', 'recepcion', 'lectura', 'gerencia'], '/reportes?tab=concentrado': ['admin', 'recepcion', 'lectura', 'gerencia'],
'/reportes?tab=recomendaciones': ['admin', 'recepcion', 'lectura', 'gerencia'], '/reportes?tab=recomendaciones': ['admin', 'recepcion', 'lectura', 'gerencia'],
'/reportes?tab=compras': ['admin', 'recepcion', 'lectura', 'gerencia'],
'/reportes?tab=margen': ['admin', 'recepcion', 'lectura', 'gerencia'],
'/reportes?tab=exportar': ['admin', 'recepcion', 'lectura', 'gerencia'], '/reportes?tab=exportar': ['admin', 'recepcion', 'lectura', 'gerencia'],
'/configuracion': ['admin', 'gerencia'], '/configuracion': ['admin', 'gerencia'],
'/cumpleanos': ['admin', 'recepcion', 'gerencia', 'marketing'], '/cumpleanos': ['admin', 'recepcion', 'gerencia', 'marketing'],