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:
@@ -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>} />
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
149
frontend/src/components/ComprasPanel.tsx
Normal file
149
frontend/src/components/ComprasPanel.tsx
Normal 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;
|
||||||
@@ -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();
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
179
frontend/src/components/compras/CompraDetalleModal.tsx
Normal file
179
frontend/src/components/compras/CompraDetalleModal.tsx
Normal 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;
|
||||||
240
frontend/src/components/compras/CompraFormModal.tsx
Normal file
240
frontend/src/components/compras/CompraFormModal.tsx
Normal 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;
|
||||||
148
frontend/src/components/compras/CuentasPorPagarPanel.tsx
Normal file
148
frontend/src/components/compras/CuentasPorPagarPanel.tsx
Normal 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;
|
||||||
88
frontend/src/components/compras/PagoCompraModal.tsx
Normal file
88
frontend/src/components/compras/PagoCompraModal.tsx
Normal 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;
|
||||||
257
frontend/src/components/compras/ProveedoresPanel.tsx
Normal file
257
frontend/src/components/compras/ProveedoresPanel.tsx
Normal 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;
|
||||||
191
frontend/src/components/compras/RecepcionModal.tsx
Normal file
191
frontend/src/components/compras/RecepcionModal.tsx
Normal 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;
|
||||||
29
frontend/src/components/compras/shared.ts
Normal file
29
frontend/src/components/compras/shared.ts
Normal 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)' },
|
||||||
|
];
|
||||||
185
frontend/src/components/reportes/ComprasReport.tsx
Normal file
185
frontend/src/components/reportes/ComprasReport.tsx
Normal 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;
|
||||||
144
frontend/src/components/reportes/MargenReport.tsx
Normal file
144
frontend/src/components/reportes/MargenReport.tsx
Normal 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 (< {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;
|
||||||
@@ -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'],
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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' },
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
359
frontend/src/pages/Compras.tsx
Normal file
359
frontend/src/pages/Compras.tsx
Normal 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;
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 })} />
|
||||||
|
|||||||
@@ -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' ? (
|
||||||
|
|||||||
@@ -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).
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
4
odoo-addons/skeen_compras/__init__.py
Normal file
4
odoo-addons/skeen_compras/__init__.py
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
from . import models
|
||||||
|
from . import controllers
|
||||||
|
from .hooks import post_init_hook
|
||||||
36
odoo-addons/skeen_compras/__manifest__.py
Normal file
36
odoo-addons/skeen_compras/__manifest__.py
Normal 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',
|
||||||
|
}
|
||||||
2
odoo-addons/skeen_compras/controllers/__init__.py
Normal file
2
odoo-addons/skeen_compras/controllers/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
from . import compras
|
||||||
950
odoo-addons/skeen_compras/controllers/compras.py
Normal file
950
odoo-addons/skeen_compras/controllers/compras.py
Normal 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)
|
||||||
14
odoo-addons/skeen_compras/data/sequences.xml
Normal file
14
odoo-addons/skeen_compras/data/sequences.xml
Normal 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>
|
||||||
19
odoo-addons/skeen_compras/hooks.py
Normal file
19
odoo-addons/skeen_compras/hooks.py
Normal 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
|
||||||
|
""")
|
||||||
4
odoo-addons/skeen_compras/models/__init__.py
Normal file
4
odoo-addons/skeen_compras/models/__init__.py
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
from . import partner
|
||||||
|
from . import inventario
|
||||||
|
from . import compra
|
||||||
424
odoo-addons/skeen_compras/models/compra.py
Normal file
424
odoo-addons/skeen_compras/models/compra.py
Normal 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
|
||||||
92
odoo-addons/skeen_compras/models/inventario.py
Normal file
92
odoo-addons/skeen_compras/models/inventario.py
Normal 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')
|
||||||
|
|
||||||
69
odoo-addons/skeen_compras/models/partner.py
Normal file
69
odoo-addons/skeen_compras/models/partner.py
Normal 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
|
||||||
6
odoo-addons/skeen_compras/security/ir.model.access.csv
Normal file
6
odoo-addons/skeen_compras/security/ir.model.access.csv
Normal 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
|
||||||
|
@@ -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:
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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'],
|
||||||
|
|||||||
Reference in New Issue
Block a user