Files
HoruxDespachosNuevo/apps/web/app/(dashboard)/drill-down/page.tsx
Horux Dev 46846200da 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
2026-05-22 15:52:10 +00:00

180 lines
9.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client';
import { useState } from 'react';
import { useSearchParams } from 'next/navigation';
import { useQuery } from '@tanstack/react-query';
import { DashboardShell } from '@/components/layouts/dashboard-shell';
import { Card, CardContent, Button, SortableHeader, cn } from '@horux/shared-ui';
import { apiClient } from '@/lib/api/client';
import { useContribuyenteStore } from '@/stores/contribuyente-store';
import { formatCurrency, toCfdiDate } from '@/lib/utils';
import { exportToExcel } from '@/lib/export-excel';
import { useTableSort } from '@horux/shared-ui';
import { CfdiViewerModal } from '@/components/cfdi/cfdi-viewer-modal';
import { Eye, Download } from 'lucide-react';
import type { Cfdi } from '@horux/shared';
const EXCEL_COLUMNS = [
{ header: 'UUID', key: 'uuid', width: 40 },
{ header: 'Comprobante', key: 'tipoComprobante', width: 12 },
{ header: 'Fecha Emision', key: '_fecha', width: 15 },
{ header: 'RFC Emisor', key: 'rfcEmisor', width: 15 },
{ header: 'Nombre Emisor', key: 'nombreEmisor', width: 30 },
{ header: 'RFC Receptor', key: 'rfcReceptor', width: 15 },
{ header: 'Nombre Receptor', key: 'nombreReceptor', width: 30 },
{ header: 'Total MXN', key: '_totalMxn', width: 15 },
{ header: 'Monto Pago MXN', key: '_montoPagoMxn', width: 15 },
{ header: 'IVA Trasladado MXN', key: '_ivaMxn', width: 18 },
{ header: 'Metodo Pago', key: 'metodoPago', width: 12 },
{ header: 'Regimen Emisor', key: 'regimenEmisor', width: 15 },
{ header: 'Regimen Receptor', key: 'regimenReceptor', width: 15 },
];
function prepareRows(data: any[]) {
return data.map((c) => ({
...c,
_fecha: toCfdiDate(c.fechaEmision).toLocaleDateString('es-MX'),
_totalMxn: Number(c.totalMxn || 0),
_montoPagoMxn: Number(c.montoPagoMxn || 0),
_ivaMxn: Number(c.ivaTrasladoMxn || 0),
}));
}
export default function DrillDownPage() {
const searchParams = useSearchParams();
const titulo = searchParams.get('titulo') || 'Detalle de CFDIs';
const [selectedCfdi, setSelectedCfdi] = useState<Cfdi | null>(null);
const { selectedContribuyenteId } = useContribuyenteStore();
const params = new URLSearchParams();
for (const [key, value] of searchParams.entries()) {
if (key !== 'titulo') params.set(key, value);
}
// Respetar contribuyente seleccionado globalmente — así cualquier drillUrl
// construido desde dashboard/impuestos/etc queda automáticamente filtrado
// sin tener que acordarse de pasarlo en cada call-site. El URLSearchParams
// de entrada gana si el caller sí lo pasó explícitamente.
if (selectedContribuyenteId && !params.has('contribuyenteId')) {
params.set('contribuyenteId', selectedContribuyenteId);
}
const { data, isLoading } = useQuery({
queryKey: ['drill-down', params.toString()],
queryFn: async () => {
const res = await apiClient.get<Cfdi[]>(`/cfdi/drill-down?${params}`);
return res.data;
},
});
const { sortedData, toggleSort, getSortIndicator } = useTableSort<Cfdi, 'fecha' | 'total' | 'pago' | 'iva'>(
data,
{
fecha: (c) => toCfdiDate(c.fechaEmision).getTime(),
total: (c) => Number(c.totalMxn || 0),
pago: (c) => Number(c.montoPagoMxn || 0),
iva: (c) => Number(c.ivaTrasladoMxn || 0),
},
'fecha',
);
// Total con signo: tipo E resta (es una nota de crédito que reduce el bucket).
// Tipo I/N suman total_mxn; tipo P suma monto_pago_mxn (su total es 0 por convención
// del complemento). Así el total del header coincide con los KPIs del dashboard.
const totalMxn = data?.reduce((s, r) => {
const sign = r.tipoComprobante === 'E' ? -1 : 1;
const amount = r.tipoComprobante === 'P'
? Number(r.montoPagoMxn || 0)
: Number(r.totalMxn || 0);
return s + sign * amount;
}, 0) || 0;
const totalPagos = data?.reduce((s, r) => s + Number(r.montoPagoMxn || 0), 0) || 0;
const handleExport = () => {
if (!sortedData || sortedData.length === 0) return;
exportToExcel(prepareRows(sortedData), EXCEL_COLUMNS, 'drill-down-cfdis');
};
return (
<DashboardShell title={titulo}>
<Card>
<CardContent className="pt-6">
{isLoading ? (
<div className="text-center py-8 text-muted-foreground">Cargando...</div>
) : !data || data.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">No hay CFDIs que coincidan con los filtros</div>
) : (
<>
<div className="flex items-center justify-between mb-4">
<p className="text-sm text-muted-foreground">{data.length} CFDIs encontrados</p>
<div className="flex items-center gap-4 text-sm">
<span>Total MXN: <strong>{formatCurrency(totalMxn)}</strong></span>
{totalPagos > 0 && <span>Pagos MXN: <strong>{formatCurrency(totalPagos)}</strong></span>}
<Button variant="outline" size="sm" onClick={handleExport}>
<Download className="h-4 w-4 mr-1" />
Excel
</Button>
</div>
</div>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b text-left text-muted-foreground">
<th className="pb-3 font-medium">UUID</th>
<th className="pb-3 font-medium">Comp.</th>
<SortableHeader label="Fecha" active={getSortIndicator('fecha')} onClick={() => toggleSort('fecha')} />
<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>
<SortableHeader label="Total MXN" align="right" active={getSortIndicator('total')} onClick={() => toggleSort('total')} />
<SortableHeader label="Monto Pago" align="right" active={getSortIndicator('pago')} onClick={() => toggleSort('pago')} />
<SortableHeader label="IVA Trasl." align="right" active={getSortIndicator('iva')} onClick={() => toggleSort('iva')} />
<th className="pb-3 font-medium">M. Pago</th>
<th className="pb-3 font-medium">Reg. E</th>
<th className="pb-3 font-medium">Reg. R</th>
<th className="pb-3 font-medium"></th>
</tr>
</thead>
<tbody>
{(sortedData || []).map((cfdi: any) => {
const isNC = cfdi.tipoComprobante === 'E';
return (
<tr key={cfdi.id} className={cn('border-b hover:bg-muted/50', isNC && 'bg-red-50/50 dark:bg-red-950/20')}>
<td className="py-2 font-mono text-xs" title={cfdi.uuid}>{cfdi.uuid?.substring(0, 8)}</td>
<td className={cn('py-2 text-xs font-mono', isNC && 'text-red-600 dark:text-red-400 font-semibold')} title={isNC ? 'Nota de crédito — resta del total' : undefined}>{cfdi.tipoComprobante}</td>
<td className="py-2 text-xs">{toCfdiDate(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]" title={cfdi.nombreEmisor}>{cfdi.nombreEmisor}</td>
<td className="py-2 font-mono text-xs">{cfdi.rfcReceptor}</td>
<td className="py-2 text-xs truncate max-w-[120px]" title={cfdi.nombreReceptor}>{cfdi.nombreReceptor}</td>
<td className={cn('py-2 text-right text-xs font-medium', isNC && 'text-red-600 dark:text-red-400')}>{isNC ? '' : ''}{formatCurrency(Number(cfdi.totalMxn))}</td>
<td className="py-2 text-right text-xs">{cfdi.tipoComprobante === 'P' && cfdi.montoPagoMxn ? formatCurrency(Number(cfdi.montoPagoMxn)) : '-'}</td>
<td className="py-2 text-right text-xs">{cfdi.ivaTrasladoMxn ? formatCurrency(Number(cfdi.ivaTrasladoMxn)) : '-'}</td>
<td className="py-2 text-xs">{cfdi.metodoPago || '-'}</td>
<td className="py-2 text-xs font-mono">{cfdi.regimenEmisor || '-'}</td>
<td className="py-2 text-xs font-mono">{cfdi.regimenReceptor || '-'}</td>
<td className="py-2">
<Button variant="ghost" size="sm" onClick={() => setSelectedCfdi(cfdi)} title="Ver factura">
<Eye className="h-4 w-4" />
</Button>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</>
)}
</CardContent>
</Card>
<CfdiViewerModal
cfdi={selectedCfdi}
open={!!selectedCfdi}
onClose={() => setSelectedCfdi(null)}
/>
</DashboardShell>
);
}