feat(sat): factura global + fecha_efectiva, fallback tenant-contribuyente, fix anio_global typo
Factura Global & fecha_efectiva: - Migracion 045_factura_global.sql: periodicidad, meses_global, año_global, fecha_efectiva - sat-parser.service.ts: extrae InformacionGlobal del XML - sat.service.ts: calcFechaEfectiva con soporte bimestral (periodicidad 05) - metricas-compute, dashboard, impuestos, cfdi, export, conciliacion, alertas: reemplaza fecha_emision-1h por COALESCE(fecha_efectiva, fecha_emision-1h) - Script recalc-metricas.ts para recalculo manual Fallback datos fiscales tenant → contribuyente: - contribuyente.service.ts: fetchTenantFiscalData + mergeContribuyenteWithTenant rellena regimenFiscal, codigoPostal y domicilio cuando el contribuyente tiene el mismo RFC que el tenant y sus campos estan vacios - contribuyente.controller.ts y contribuyente-config.controller.ts: pasan req.user!.tenantId al servicio Fix critico SAT sync: - sat.service.ts: anio_global → año_global en INSERT/UPDATE de CFDIs (la migracion creo 'año_global' con tilde; el codigo usaba 'anio_global', causando fallo en 100% de inserciones de CFDI) - determineChunkMonths: salta sondeo si existe job previo con requestIds - MAX_POLL_ATTEMPTS: 45 → 500 (~8h) para syncs iniciales grandes Docs: - docs/sessions/2026-05-22-factura-global-contribuyente-fallback.md
This commit is contained in:
@@ -7,9 +7,9 @@ import { useRegimenesDelPeriodo } from '@/lib/hooks/use-dashboard';
|
||||
import { PeriodSelector, RegimenSelector } from '@horux/shared-ui';
|
||||
import { CfdiViewerModal } from '@/components/cfdi/cfdi-viewer-modal';
|
||||
import { Header } from '@/components/layouts/header';
|
||||
import { Card, CardContent, Button, Select, SelectContent, SelectItem, SelectTrigger, SelectValue, Input } from '@horux/shared-ui';
|
||||
import { Card, CardContent, Button, Select, SelectContent, SelectItem, SelectTrigger, SelectValue, Input, Label, Popover, PopoverTrigger, PopoverContent } from '@horux/shared-ui';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
import { formatCurrency } from '@/lib/utils';
|
||||
import { formatCurrency, toCfdiDate } from '@/lib/utils';
|
||||
|
||||
function formatCurrencyConciliacion(value: number): string {
|
||||
return new Intl.NumberFormat('es-MX', {
|
||||
@@ -20,7 +20,7 @@ function formatCurrencyConciliacion(value: number): string {
|
||||
}).format(value);
|
||||
}
|
||||
import { exportToExcel } from '@/lib/export-excel';
|
||||
import { Eye, Download, X, CheckCircle } from 'lucide-react';
|
||||
import { Eye, Download, X, CheckCircle, Search, ArrowUpDown, Filter } from 'lucide-react';
|
||||
|
||||
function getMonthRange(year: number, month: number) {
|
||||
const start = `${year}-${String(month).padStart(2, '0')}-01`;
|
||||
@@ -42,6 +42,20 @@ export default function ConciliacionPage() {
|
||||
const [bancoId, setBancoId] = useState<string>('');
|
||||
const [selectedCfdi, setSelectedCfdi] = useState<any>(null);
|
||||
|
||||
// Ordenación — Por conciliar
|
||||
const [sortPendientes, setSortPendientes] = useState<{ field: 'fecha' | 'total'; dir: 'asc' | 'desc' } | null>(null);
|
||||
|
||||
// Ordenación — Conciliadas
|
||||
const [sortConciliadas, setSortConciliadas] = useState<{ field: 'fecha' | 'total'; dir: 'asc' | 'desc' } | null>(null);
|
||||
|
||||
// Filtros por columna — Por conciliar
|
||||
const [filtersPendientes, setFiltersPendientes] = useState({ rfcEmisor: '', nombreEmisor: '', rfcReceptor: '', nombreReceptor: '' });
|
||||
const [openFilterPendientes, setOpenFilterPendientes] = useState<string | null>(null);
|
||||
|
||||
// Filtros por columna — Conciliadas
|
||||
const [filtersConciliadas, setFiltersConciliadas] = useState({ rfcEmisor: '', nombreEmisor: '', rfcReceptor: '', nombreReceptor: '' });
|
||||
const [openFilterConciliadas, setOpenFilterConciliadas] = useState<string | null>(null);
|
||||
|
||||
const { user } = useAuthStore();
|
||||
const isVisor = user?.role === 'visor';
|
||||
|
||||
@@ -66,9 +80,15 @@ export default function ConciliacionPage() {
|
||||
const montoConciliado = conciliadas.reduce((s, c) => s + getMonto(c), 0);
|
||||
const montoPendiente = pendientes.reduce((s, c) => s + getMonto(c), 0);
|
||||
|
||||
// Reset selection on tab/filter change
|
||||
// Reset selection + ordenación + filtros on tab/filter change
|
||||
useEffect(() => {
|
||||
setSelected(new Set());
|
||||
setSortPendientes(null);
|
||||
setSortConciliadas(null);
|
||||
setFiltersPendientes({ rfcEmisor: '', nombreEmisor: '', rfcReceptor: '', nombreReceptor: '' });
|
||||
setOpenFilterPendientes(null);
|
||||
setFiltersConciliadas({ rfcEmisor: '', nombreEmisor: '', rfcReceptor: '', nombreReceptor: '' });
|
||||
setOpenFilterConciliadas(null);
|
||||
}, [activeTab, fechaInicio, fechaFin, regimenSeleccionado]);
|
||||
|
||||
// Handlers
|
||||
@@ -85,10 +105,10 @@ export default function ConciliacionPage() {
|
||||
};
|
||||
|
||||
const toggleSelectAll = () => {
|
||||
if (selected.size === pendientes.length && pendientes.length > 0) {
|
||||
if (selected.size === pendientesOrdenados.length && pendientesOrdenados.length > 0) {
|
||||
setSelected(new Set());
|
||||
} else {
|
||||
setSelected(new Set(pendientes.map((c) => c.id)));
|
||||
setSelected(new Set(pendientesOrdenados.map((c) => c.id)));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -117,12 +137,100 @@ export default function ConciliacionPage() {
|
||||
}
|
||||
};
|
||||
|
||||
function matchesColumnFilters(c: any, filters: { rfcEmisor: string; nombreEmisor: string; rfcReceptor: string; nombreReceptor: string }) {
|
||||
const rfcEmisorMatch = !filters.rfcEmisor || (c.rfcEmisor || '').toLowerCase().includes(filters.rfcEmisor.toLowerCase());
|
||||
const nombreEmisorMatch = !filters.nombreEmisor || (c.nombreEmisor || '').toLowerCase().includes(filters.nombreEmisor.toLowerCase());
|
||||
const rfcReceptorMatch = !filters.rfcReceptor || (c.rfcReceptor || '').toLowerCase().includes(filters.rfcReceptor.toLowerCase());
|
||||
const nombreReceptorMatch = !filters.nombreReceptor || (c.nombreReceptor || '').toLowerCase().includes(filters.nombreReceptor.toLowerCase());
|
||||
return rfcEmisorMatch && nombreEmisorMatch && rfcReceptorMatch && nombreReceptorMatch;
|
||||
}
|
||||
|
||||
function sortCfdis(list: any[], sort: { field: 'fecha' | 'total'; dir: 'asc' | 'desc' } | null) {
|
||||
if (!sort) return list;
|
||||
const sorted = [...list].sort((a, b) => {
|
||||
if (sort.field === 'fecha') {
|
||||
const da = toCfdiDate(a.fechaPagoP || a.fechaEmision).getTime();
|
||||
const db = toCfdiDate(b.fechaPagoP || b.fechaEmision).getTime();
|
||||
return sort.dir === 'asc' ? da - db : db - da;
|
||||
}
|
||||
if (sort.field === 'total') {
|
||||
const ta = getMonto(a);
|
||||
const tb = getMonto(b);
|
||||
return sort.dir === 'asc' ? ta - tb : tb - ta;
|
||||
}
|
||||
return 0;
|
||||
});
|
||||
return sorted;
|
||||
}
|
||||
|
||||
function FilterHeader({
|
||||
label,
|
||||
filterKey,
|
||||
filters,
|
||||
setFilters,
|
||||
openFilter,
|
||||
setOpenFilter,
|
||||
}: {
|
||||
label: string;
|
||||
filterKey: string;
|
||||
filters: { rfcEmisor: string; nombreEmisor: string; rfcReceptor: string; nombreReceptor: string };
|
||||
setFilters: React.Dispatch<React.SetStateAction<{ rfcEmisor: string; nombreEmisor: string; rfcReceptor: string; nombreReceptor: string }>>;
|
||||
openFilter: string | null;
|
||||
setOpenFilter: (v: string | null) => void;
|
||||
}) {
|
||||
const hasFilter = !!(filters as any)[filterKey];
|
||||
return (
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
{label}
|
||||
<Popover open={openFilter === filterKey} onOpenChange={(open) => setOpenFilter(open ? filterKey : null)}>
|
||||
<PopoverTrigger asChild>
|
||||
<button className={`p-1 rounded hover:bg-muted ${hasFilter ? 'text-primary' : ''}`}>
|
||||
<Filter className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-64" align="start">
|
||||
<div className="space-y-3">
|
||||
<h4 className="font-medium text-sm">Filtrar por {label}</h4>
|
||||
<div>
|
||||
<Label className="text-xs">Contiene</Label>
|
||||
<Input
|
||||
placeholder={`Buscar ${label.toLowerCase()}...`}
|
||||
className="h-8 text-sm"
|
||||
value={(filters as any)[filterKey]}
|
||||
onChange={(e) => setFilters((prev: any) => ({ ...prev, [filterKey]: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button size="sm" onClick={() => setOpenFilter(null)}>Aplicar</Button>
|
||||
{hasFilter && (
|
||||
<Button size="sm" variant="outline" onClick={() => { setFilters((prev: any) => ({ ...prev, [filterKey]: '' })); setOpenFilter(null); }}>
|
||||
Limpiar
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const pendientesOrdenados = sortCfdis(
|
||||
pendientes.filter((c) => matchesColumnFilters(c, filtersPendientes)),
|
||||
sortPendientes
|
||||
);
|
||||
const conciliadasOrdenadas = sortCfdis(
|
||||
conciliadas.filter((c) => matchesColumnFilters(c, filtersConciliadas)),
|
||||
sortConciliadas
|
||||
);
|
||||
|
||||
const handleExport = () => {
|
||||
if (!cfdis?.length) return;
|
||||
const allVisible = [...pendientesOrdenados, ...conciliadasOrdenadas];
|
||||
if (!allVisible.length) return;
|
||||
exportToExcel(
|
||||
cfdis.map((c) => ({
|
||||
allVisible.map((c) => ({
|
||||
...c,
|
||||
_fecha: new Date(c.fechaPagoP || c.fechaEmision).toLocaleDateString('es-MX'),
|
||||
_fecha: toCfdiDate(c.fechaPagoP || c.fechaEmision).toLocaleDateString('es-MX'),
|
||||
_totalMxn: getMonto(c),
|
||||
_estado: c.conciliado === 'true' ? 'Conciliado' : 'Pendiente',
|
||||
_fechaPago: c.conciliacion?.fechaDePago || '',
|
||||
@@ -212,8 +320,8 @@ export default function ConciliacionPage() {
|
||||
{/* Por conciliar */}
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<h3 className="font-medium mb-4">Por conciliar ({pendientes.length})</h3>
|
||||
{pendientes.length === 0 ? (
|
||||
<h3 className="font-medium mb-4">Por conciliar ({pendientesOrdenados.length})</h3>
|
||||
{pendientesOrdenados.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
No hay CFDIs pendientes de conciliar
|
||||
</p>
|
||||
@@ -221,31 +329,35 @@ export default function ConciliacionPage() {
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b text-left text-muted-foreground">
|
||||
<tr className="border-b text-center text-muted-foreground">
|
||||
{!isVisor && (
|
||||
<th className="pb-3 w-8">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={
|
||||
selected.size === pendientes.length && pendientes.length > 0
|
||||
selected.size === pendientesOrdenados.length && pendientesOrdenados.length > 0
|
||||
}
|
||||
onChange={toggleSelectAll}
|
||||
/>
|
||||
</th>
|
||||
)}
|
||||
<th className="pb-3 font-medium">UUID</th>
|
||||
<th className="pb-3 font-medium">Fecha</th>
|
||||
<th className="pb-3 font-medium">RFC Emisor</th>
|
||||
<th className="pb-3 font-medium">Nombre Emisor</th>
|
||||
<th className="pb-3 font-medium">RFC Receptor</th>
|
||||
<th className="pb-3 font-medium">Nombre Receptor</th>
|
||||
<th className="pb-3 font-medium text-right">Total MXN</th>
|
||||
<th className="pb-3 font-medium cursor-pointer select-none" onClick={() => setSortPendientes(prev => prev?.field === 'fecha' ? { field: 'fecha', dir: prev.dir === 'asc' ? 'desc' : 'asc' } : { field: 'fecha', dir: 'asc' })}>
|
||||
<span className="flex items-center justify-center gap-1">Fecha <ArrowUpDown className="h-3 w-3" /></span>
|
||||
</th>
|
||||
<th className="pb-3 font-medium"><FilterHeader label="RFC Emisor" filterKey="rfcEmisor" filters={filtersPendientes} setFilters={setFiltersPendientes} openFilter={openFilterPendientes} setOpenFilter={setOpenFilterPendientes} /></th>
|
||||
<th className="pb-3 font-medium"><FilterHeader label="Nombre Emisor" filterKey="nombreEmisor" filters={filtersPendientes} setFilters={setFiltersPendientes} openFilter={openFilterPendientes} setOpenFilter={setOpenFilterPendientes} /></th>
|
||||
<th className="pb-3 font-medium"><FilterHeader label="RFC Receptor" filterKey="rfcReceptor" filters={filtersPendientes} setFilters={setFiltersPendientes} openFilter={openFilterPendientes} setOpenFilter={setOpenFilterPendientes} /></th>
|
||||
<th className="pb-3 font-medium"><FilterHeader label="Nombre Receptor" filterKey="nombreReceptor" filters={filtersPendientes} setFilters={setFiltersPendientes} openFilter={openFilterPendientes} setOpenFilter={setOpenFilterPendientes} /></th>
|
||||
<th className="pb-3 font-medium cursor-pointer select-none" onClick={() => setSortPendientes(prev => prev?.field === 'total' ? { field: 'total', dir: prev.dir === 'asc' ? 'desc' : 'asc' } : { field: 'total', dir: 'asc' })}>
|
||||
<span className="flex items-center justify-center gap-1">Total MXN <ArrowUpDown className="h-3 w-3" /></span>
|
||||
</th>
|
||||
<th className="pb-3 font-medium">M. Pago</th>
|
||||
<th className="pb-3"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{pendientes.map((cfdi) => (
|
||||
{pendientesOrdenados.map((cfdi) => (
|
||||
<tr key={cfdi.id} className="border-b hover:bg-muted/50">
|
||||
{!isVisor && (
|
||||
<td className="py-2">
|
||||
@@ -256,25 +368,25 @@ export default function ConciliacionPage() {
|
||||
/>
|
||||
</td>
|
||||
)}
|
||||
<td className="py-2 font-mono text-xs" title={cfdi.uuid}>
|
||||
<td className="py-2 font-mono text-xs text-center" title={cfdi.uuid}>
|
||||
{cfdi.uuid?.substring(0, 8)}
|
||||
</td>
|
||||
<td className="py-2 text-xs">
|
||||
{new Date(cfdi.fechaPagoP || cfdi.fechaEmision).toLocaleDateString('es-MX')}
|
||||
<td className="py-2 text-xs text-center">
|
||||
{toCfdiDate(cfdi.fechaPagoP || cfdi.fechaEmision).toLocaleDateString('es-MX')}
|
||||
</td>
|
||||
<td className="py-2 font-mono text-xs">{cfdi.rfcEmisor}</td>
|
||||
<td className="py-2 text-xs truncate max-w-[120px]">
|
||||
<td className="py-2 font-mono text-xs text-center">{cfdi.rfcEmisor}</td>
|
||||
<td className="py-2 text-xs truncate max-w-[120px] text-center">
|
||||
{cfdi.nombreEmisor}
|
||||
</td>
|
||||
<td className="py-2 font-mono text-xs">{cfdi.rfcReceptor}</td>
|
||||
<td className="py-2 text-xs truncate max-w-[120px]">
|
||||
<td className="py-2 font-mono text-xs text-center">{cfdi.rfcReceptor}</td>
|
||||
<td className="py-2 text-xs truncate max-w-[120px] text-center">
|
||||
{cfdi.nombreReceptor}
|
||||
</td>
|
||||
<td className="py-2 text-right text-xs font-medium">
|
||||
<td className="py-2 text-xs font-medium text-center">
|
||||
{formatCurrencyConciliacion(getMonto(cfdi))}
|
||||
</td>
|
||||
<td className="py-2 text-xs">{cfdi.metodoPago || '-'}</td>
|
||||
<td className="py-2">
|
||||
<td className="py-2 text-xs text-center">{cfdi.metodoPago || '-'}</td>
|
||||
<td className="py-2 text-center">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
@@ -340,53 +452,57 @@ export default function ConciliacionPage() {
|
||||
{/* Conciliadas */}
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<h3 className="font-medium mb-4">Conciliadas ({conciliadas.length})</h3>
|
||||
{conciliadas.length === 0 ? (
|
||||
<h3 className="font-medium mb-4">Conciliadas ({conciliadasOrdenadas.length})</h3>
|
||||
{conciliadasOrdenadas.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">No hay CFDIs conciliados</p>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b text-left text-muted-foreground">
|
||||
<tr className="border-b text-center text-muted-foreground">
|
||||
<th className="pb-3 font-medium">UUID</th>
|
||||
<th className="pb-3 font-medium">Fecha Emisión</th>
|
||||
<th className="pb-3 font-medium">RFC Emisor</th>
|
||||
<th className="pb-3 font-medium">Nombre Emisor</th>
|
||||
<th className="pb-3 font-medium text-right">Total MXN</th>
|
||||
<th className="pb-3 font-medium cursor-pointer select-none" onClick={() => setSortConciliadas(prev => prev?.field === 'fecha' ? { field: 'fecha', dir: prev.dir === 'asc' ? 'desc' : 'asc' } : { field: 'fecha', dir: 'asc' })}>
|
||||
<span className="flex items-center justify-center gap-1">Fecha Emisión <ArrowUpDown className="h-3 w-3" /></span>
|
||||
</th>
|
||||
<th className="pb-3 font-medium"><FilterHeader label="RFC Emisor" filterKey="rfcEmisor" filters={filtersConciliadas} setFilters={setFiltersConciliadas} openFilter={openFilterConciliadas} setOpenFilter={setOpenFilterConciliadas} /></th>
|
||||
<th className="pb-3 font-medium"><FilterHeader label="Nombre Emisor" filterKey="nombreEmisor" filters={filtersConciliadas} setFilters={setFiltersConciliadas} openFilter={openFilterConciliadas} setOpenFilter={setOpenFilterConciliadas} /></th>
|
||||
<th className="pb-3 font-medium cursor-pointer select-none" onClick={() => setSortConciliadas(prev => prev?.field === 'total' ? { field: 'total', dir: prev.dir === 'asc' ? 'desc' : 'asc' } : { field: 'total', dir: 'asc' })}>
|
||||
<span className="flex items-center justify-center gap-1">Total MXN <ArrowUpDown className="h-3 w-3" /></span>
|
||||
</th>
|
||||
<th className="pb-3 font-medium">Fecha Pago</th>
|
||||
<th className="pb-3 font-medium">Banco</th>
|
||||
<th className="pb-3"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{conciliadas.map((cfdi) => (
|
||||
{conciliadasOrdenadas.map((cfdi) => (
|
||||
<tr key={cfdi.id} className="border-b hover:bg-muted/50">
|
||||
<td className="py-2 font-mono text-xs" title={cfdi.uuid}>
|
||||
<td className="py-2 font-mono text-xs text-center" title={cfdi.uuid}>
|
||||
{cfdi.uuid?.substring(0, 8)}
|
||||
</td>
|
||||
<td className="py-2 text-xs">
|
||||
{new Date(cfdi.fechaPagoP || cfdi.fechaEmision).toLocaleDateString('es-MX')}
|
||||
<td className="py-2 text-xs text-center">
|
||||
{toCfdiDate(cfdi.fechaPagoP || cfdi.fechaEmision).toLocaleDateString('es-MX')}
|
||||
</td>
|
||||
<td className="py-2 font-mono text-xs">{cfdi.rfcEmisor}</td>
|
||||
<td className="py-2 text-xs truncate max-w-[120px]">
|
||||
<td className="py-2 font-mono text-xs text-center">{cfdi.rfcEmisor}</td>
|
||||
<td className="py-2 text-xs truncate max-w-[120px] text-center">
|
||||
{cfdi.nombreEmisor}
|
||||
</td>
|
||||
<td className="py-2 text-right text-xs font-medium">
|
||||
<td className="py-2 text-xs font-medium text-center">
|
||||
{formatCurrencyConciliacion(getMonto(cfdi))}
|
||||
</td>
|
||||
<td className="py-2 text-xs">
|
||||
<td className="py-2 text-xs text-center">
|
||||
{cfdi.conciliacion?.fechaDePago
|
||||
? new Date(
|
||||
cfdi.conciliacion.fechaDePago + 'T12:00:00',
|
||||
(cfdi.conciliacion.fechaDePago.split('T')[0]) + 'T12:00:00',
|
||||
).toLocaleDateString('es-MX')
|
||||
: '-'}
|
||||
</td>
|
||||
<td className="py-2 text-xs">
|
||||
<td className="py-2 text-xs text-center">
|
||||
{cfdi.conciliacion
|
||||
? `${cfdi.conciliacion.banco} ****${cfdi.conciliacion.terminacionCuenta}`
|
||||
: '-'}
|
||||
</td>
|
||||
<td className="py-2 flex gap-1">
|
||||
<td className="py-2 flex gap-1 justify-center">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
|
||||
Reference in New Issue
Block a user