feat: conceptos tab, filters, backfill, facturapi live keys, fixes
- Add Conceptos tab in CFDI page with column filters, sorting, pagination - Add GET /cfdi/conceptos endpoint with filters and orderBy - Backfill cfdi_conceptos from legacy XMLs (824k concepts inserted) - Fix CFDI delete button (bypass subscription check, add alerts) - Fix export to Excel (fetch all filtered results, limit 10k) - Fix facturacion page concepto delete bug (immutable updates, unique ids) - Add Facturapi live key auto-generation and caching - Fix SAT fechaPagoP parsing - Add metrics cache support for current year - Increase DB pool max to 15
This commit is contained in:
34
apps/api/.env.save
Normal file
34
apps/api/.env.save
Normal file
@@ -0,0 +1,34 @@
|
||||
NODE_ENV=development
|
||||
PORT=4000
|
||||
DATABASE_URL="postgresql://postgres:ZxHMrmnwanvLfLDdNJdRthFjWF2Lj1Rb@localhost:5432/horux_despachos?schema=public"
|
||||
JWT_SECRET=n8tbAhp+H2wBKvGqhzq7qgCVqEztby5FzjWZM4oa9epq0CFTgSyvJN8LFLid5J1O
|
||||
JWT_EXPIRES_IN=15m
|
||||
JWT_REFRESH_EXPIRES_IN=7d
|
||||
CORS_ORIGIN=http://localhost:3000
|
||||
FRONTEND_URL=https://horuxfin.com
|
||||
FIEL_ENCRYPTION_KEY=8E46fcpVBbnu0qxEECIE9wrE0nLugDca09YqYBytgY8DTOE0XaJgxrjuS4N6zQ3/
|
||||
FIEL_STORAGE_PATH=/var/horux/fiel
|
||||
MP_ACCESS_TOKEN=
|
||||
MP_WEBHOOK_SECRET=
|
||||
MP_NOTIFICATION_URL=
|
||||
SMTP_HOST=smtp.gmail.com
|
||||
SMTP_PORT=587
|
||||
SMTP_USER=soporte@horuxfin.com
|
||||
SMTP_PASS=fumuflfahizubnok
|
||||
SMTP_FROM=Horux Despachos <soporte@horuxfin.com>
|
||||
ADMIN_EMAIL=soporte@horuxfin.com
|
||||
FACTURAPI_USER_KEY=sk_user_8NGmCxP6NT4Wb6QF3NBR9W8kNAvcfDbYXZYzCois5U
|
||||
FACTURAPI_MODE=live
|
||||
CLOUDFLARE_API_TOKEN=
|
||||
CLOUDFLARE_ACCOUNT_ID=
|
||||
CLOUDFLARE_TUNNEL_DOMAIN=tunnel.horux.mx
|
||||
CONNECTOR_ENCRYPTION_KEY=8E46fcpVBbnu0qxEECIE9wrE0nLugDca09YqYBytgY8DTOE0XaJgxrjuS4N6zQ3/
|
||||
|
||||
# Metabase integration
|
||||
METABASE_URL=http://192.168.10.170:3000
|
||||
METABASE_USERNAME=ialcarazsalazar@consultoria-as.com
|
||||
METABASE_PASSWORD=Aasi940812
|
||||
METABASE_PG_HOST=192.168.10.90
|
||||
METABASE_PG_PORT=5432
|
||||
METABASE_PG_USER=postgres
|
||||
METABASE_PG_PASSWORD=ZxHMrmnwanvLfLDdNJdRthFjWF2Lj1Rb
|
||||
@@ -71,7 +71,7 @@ class TenantConnectionManager {
|
||||
user: connectionOverride?.user ?? this.dbConfig.user,
|
||||
password: connectionOverride?.password ?? this.dbConfig.password,
|
||||
database: databaseName,
|
||||
max: 3,
|
||||
max: 15,
|
||||
idleTimeoutMillis: 300_000,
|
||||
connectionTimeoutMillis: 10_000,
|
||||
};
|
||||
|
||||
@@ -38,6 +38,7 @@ const envSchema = z.object({
|
||||
|
||||
// Facturapi
|
||||
FACTURAPI_USER_KEY: z.string().optional(),
|
||||
FACTURAPI_MODE: z.enum(['test', 'live']).optional(),
|
||||
|
||||
// Cloudflare Tunnel (connector BYO-DB)
|
||||
CLOUDFLARE_API_TOKEN: z.string().optional(),
|
||||
|
||||
@@ -85,6 +85,37 @@ export async function getConceptos(req: Request, res: Response, next: NextFuncti
|
||||
}
|
||||
}
|
||||
|
||||
export async function getAllConceptos(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
if (!req.tenantPool) {
|
||||
return next(new AppError(400, 'Tenant no configurado'));
|
||||
}
|
||||
|
||||
const filters = {
|
||||
fechaInicio: req.query.fechaInicio as string,
|
||||
fechaFin: req.query.fechaFin as string,
|
||||
tipo: req.query.tipo as any,
|
||||
tipoComprobante: req.query.tipoComprobante as any,
|
||||
estado: req.query.estado as any,
|
||||
rfc: req.query.rfc as string,
|
||||
search: req.query.search as string,
|
||||
uuid: req.query.uuid as string,
|
||||
claveProdServ: req.query.claveProdServ as string,
|
||||
descripcion: req.query.descripcion as string,
|
||||
orderBy: req.query.orderBy as 'fecha' | 'importe',
|
||||
orderDir: req.query.orderDir as 'asc' | 'desc',
|
||||
contribuyenteId: req.query.contribuyenteId as string,
|
||||
page: parseInt(req.query.page as string) || 1,
|
||||
limit: parseInt(req.query.limit as string) || 20,
|
||||
};
|
||||
|
||||
const result = await cfdiService.getAllConceptos(req.tenantPool, filters);
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function drillDown(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
if (!req.tenantPool) {
|
||||
@@ -444,3 +475,21 @@ export async function deleteCfdi(req: Request, res: Response, next: NextFunction
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function backfillConceptos(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
if (!req.tenantPool) {
|
||||
return next(new AppError(400, 'Tenant no configurado'));
|
||||
}
|
||||
|
||||
if (req.user!.role !== 'owner') {
|
||||
return next(new AppError(403, 'Solo el owner puede ejecutar backfill'));
|
||||
}
|
||||
|
||||
const batchSize = parseInt(req.query.batchSize as string) || 100;
|
||||
const result = await cfdiService.backfillConceptos(req.tenantPool, batchSize);
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -101,6 +101,37 @@ export async function emitir(req: Request, res: Response, next: NextFunction) {
|
||||
const tenantId = effectiveTenantId(req);
|
||||
const contribuyenteId = req.body.contribuyenteId as string | undefined;
|
||||
|
||||
// ── Validar CFDIs relacionados ──
|
||||
// Cada UUID relacionado debe existir en la BD del tenant, estar vigente,
|
||||
// y su rfc_receptor debe coincidir con el RFC del receptor de la factura
|
||||
// que se está emitiendo (customer.taxId).
|
||||
const relatedDocs: Array<{ relationship: string; uuids: string[] }> = req.body.relatedDocuments || [];
|
||||
const customerRfc = req.body.customer?.taxId?.toUpperCase()?.trim();
|
||||
if (relatedDocs.length > 0 && customerRfc) {
|
||||
const allUuids = relatedDocs.flatMap(r => r.uuids).filter(u => typeof u === 'string' && u.trim() !== '');
|
||||
for (const uuid of allUuids) {
|
||||
const { rows } = await req.tenantPool!.query(
|
||||
`SELECT rfc_receptor, status FROM cfdis WHERE LOWER(uuid) = LOWER($1) LIMIT 1`,
|
||||
[uuid.trim()],
|
||||
);
|
||||
if (rows.length === 0) {
|
||||
throw new AppError(400, `El CFDI relacionado con UUID ${uuid} no existe en el sistema.`);
|
||||
}
|
||||
const relacionado = rows[0];
|
||||
if (relacionado.status === 'Cancelado' || relacionado.status === '0') {
|
||||
throw new AppError(400, `El CFDI relacionado con UUID ${uuid} está cancelado.`);
|
||||
}
|
||||
const rfcReceptorRelacionado = (relacionado.rfc_receptor || '').toUpperCase().trim();
|
||||
if (rfcReceptorRelacionado !== customerRfc) {
|
||||
throw new AppError(
|
||||
400,
|
||||
`El CFDI relacionado con UUID ${uuid} no corresponde al RFC del receptor de esta factura. ` +
|
||||
`RFC esperado: ${customerRfc}. RFC del receptor del CFDI relacionado: ${rfcReceptorRelacionado}.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Reservar timbre — si falla emisión en Facturapi, revertimos abajo
|
||||
const consumedTimbre = await facturapiService.consumeTimbre(tenantId);
|
||||
|
||||
@@ -182,8 +213,7 @@ export async function emitir(req: Request, res: Response, next: NextFunction) {
|
||||
[parsed.rfcReceptor, parsed.nombreReceptor || null, parsed.regimenFiscalReceptor || null, req.body.customer?.zip || null],
|
||||
);
|
||||
|
||||
// Extraer relaciones para persistencia en BD
|
||||
const relatedDocs: Array<{ relationship: string; uuids: string[] }> = req.body.relatedDocuments || [];
|
||||
// Extraer relaciones para persistencia en BD (ya declarado arriba en la validación)
|
||||
const cfdiTipoRelacion = relatedDocs.length > 0 ? relatedDocs[0].relationship : null;
|
||||
const cfdisRelacionados = relatedDocs.length > 0
|
||||
? relatedDocs.flatMap(r => r.uuids).join('|')
|
||||
|
||||
@@ -10,6 +10,10 @@ const router: IRouter = Router();
|
||||
|
||||
router.use(authenticate);
|
||||
router.use(tenantMiddleware);
|
||||
|
||||
// DELETE: permitir eliminación local sin suscripción activa (operación de limpieza)
|
||||
router.delete('/:id', cfdiController.deleteCfdi);
|
||||
|
||||
router.use(checkPlanLimits);
|
||||
|
||||
router.get('/', cfdiController.getCfdis);
|
||||
@@ -17,12 +21,13 @@ router.get('/resumen', cfdiController.getResumen);
|
||||
router.get('/emisores', cfdiController.getEmisores);
|
||||
router.get('/receptores', cfdiController.getReceptores);
|
||||
router.get('/drill-down', cfdiController.drillDown);
|
||||
router.get('/conceptos', cfdiController.getAllConceptos);
|
||||
router.post('/backfill-conceptos', cfdiController.backfillConceptos);
|
||||
router.get('/:id', cfdiController.getCfdiById);
|
||||
router.get('/:id/conceptos', cfdiController.getConceptos);
|
||||
router.get('/:id/xml', cfdiController.getXml);
|
||||
router.post('/', checkCfdiLimit, cfdiController.createCfdi);
|
||||
// Bulk upload: 10/hora — procesa hasta 50MB, pesado en parseo + inserts
|
||||
router.post('/bulk', strictLimit, express.json({ limit: '50mb' }), checkCfdiLimit, cfdiController.createManyCfdis);
|
||||
router.delete('/:id', cfdiController.deleteCfdi);
|
||||
|
||||
export { router as cfdiRoutes };
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import type { Pool } from 'pg';
|
||||
import type { Cfdi, CfdiFilters, CfdiListResponse } from '@horux/shared';
|
||||
import type { Cfdi, CfdiFilters, CfdiListResponse, CfdiConceptoFilters, CfdiConceptoListResponse } from '@horux/shared';
|
||||
import { markForInvalidation } from './metricas.service.js';
|
||||
import { recomputarSaldoPendiente, uuidsAfectadosPorCfdi } from '../utils/saldo.js';
|
||||
import { parseXml } from './sat/sat-parser.service.js';
|
||||
|
||||
// Common SELECT columns mapping DB → camelCase
|
||||
const CFDI_SELECT = `
|
||||
@@ -208,6 +209,124 @@ export async function getConceptos(pool: Pool, cfdiId: string): Promise<any[]> {
|
||||
return rows;
|
||||
}
|
||||
|
||||
export async function getAllConceptos(pool: Pool, filters: CfdiConceptoFilters): Promise<CfdiConceptoListResponse> {
|
||||
const page = filters.page || 1;
|
||||
const limit = filters.limit || 20;
|
||||
const offset = (page - 1) * limit;
|
||||
|
||||
let whereClause = 'WHERE 1=1';
|
||||
const params: any[] = [];
|
||||
let paramIndex = 1;
|
||||
|
||||
if (filters.fechaInicio) {
|
||||
whereClause += ` AND c.fecha_emision >= $${paramIndex++}::date`;
|
||||
params.push(filters.fechaInicio);
|
||||
}
|
||||
if (filters.fechaFin) {
|
||||
whereClause += ` AND c.fecha_emision <= ($${paramIndex++}::date + interval '1 day')`;
|
||||
params.push(filters.fechaFin);
|
||||
}
|
||||
if (filters.tipoComprobante) {
|
||||
whereClause += ` AND c.tipo_comprobante = $${paramIndex++}`;
|
||||
params.push(filters.tipoComprobante);
|
||||
}
|
||||
if (filters.estado) {
|
||||
whereClause += ` AND c.status = $${paramIndex++}`;
|
||||
params.push(filters.estado);
|
||||
}
|
||||
if (filters.rfc) {
|
||||
whereClause += ` AND (c.rfc_emisor ILIKE $${paramIndex} OR c.rfc_receptor ILIKE $${paramIndex++})`;
|
||||
params.push(`%${filters.rfc}%`);
|
||||
}
|
||||
if (filters.search) {
|
||||
whereClause += ` AND (cc.descripcion ILIKE $${paramIndex} OR cc.clave_prod_serv ILIKE $${paramIndex} OR c.uuid ILIKE $${paramIndex++})`;
|
||||
params.push(`%${filters.search}%`);
|
||||
}
|
||||
if (filters.uuid) {
|
||||
whereClause += ` AND c.uuid ILIKE $${paramIndex++}`;
|
||||
params.push(`%${filters.uuid}%`);
|
||||
}
|
||||
if (filters.claveProdServ) {
|
||||
whereClause += ` AND cc.clave_prod_serv ILIKE $${paramIndex++}`;
|
||||
params.push(`%${filters.claveProdServ}%`);
|
||||
}
|
||||
if (filters.descripcion) {
|
||||
whereClause += ` AND cc.descripcion ILIKE $${paramIndex++}`;
|
||||
params.push(`%${filters.descripcion}%`);
|
||||
}
|
||||
|
||||
// Contribuyente filter (same logic as getCfdis)
|
||||
if (filters.contribuyenteId) {
|
||||
const safeId = filters.contribuyenteId.replace(/[^a-f0-9-]/gi, '');
|
||||
const { rows: contribRows } = await pool.query(
|
||||
'SELECT rfc FROM contribuyentes WHERE entidad_id = $1',
|
||||
[safeId],
|
||||
);
|
||||
const rfc = (contribRows[0]?.rfc || '').replace(/[^A-Z0-9]/gi, '').toUpperCase();
|
||||
|
||||
if (filters.tipo === 'EMITIDO' && rfc) {
|
||||
whereClause += ` AND UPPER(c.rfc_emisor) = '${rfc}'`;
|
||||
} else if (filters.tipo === 'RECIBIDO' && rfc) {
|
||||
whereClause += ` AND UPPER(c.rfc_receptor) = '${rfc}'`;
|
||||
} else if (rfc) {
|
||||
whereClause += ` AND (c.contribuyente_id = '${safeId}' OR UPPER(c.rfc_emisor) = '${rfc}' OR UPPER(c.rfc_receptor) = '${rfc}')`;
|
||||
}
|
||||
} else if (filters.tipo) {
|
||||
whereClause += ` AND c.type = $${paramIndex++}`;
|
||||
params.push(filters.tipo);
|
||||
}
|
||||
|
||||
// Order by
|
||||
const orderBy = filters.orderBy === 'importe' ? 'cc.importe' : 'c.fecha_emision';
|
||||
const orderDir = filters.orderDir === 'asc' ? 'ASC' : 'DESC';
|
||||
|
||||
params.push(limit, offset);
|
||||
const { rows: dataWithCount } = await pool.query(`
|
||||
SELECT
|
||||
cc.id, cc.cfdi_id as "cfdiId",
|
||||
cc.clave_prod_serv as "claveProdServ",
|
||||
cc.no_identificacion as "noIdentificacion",
|
||||
cc.descripcion, cc.cantidad,
|
||||
cc.clave_unidad as "claveUnidad", cc.unidad,
|
||||
cc.valor_unitario as "valorUnitario",
|
||||
cc.valor_unitario_mxn as "valorUnitarioMxn",
|
||||
cc.importe, cc.importe_mxn as "importeMxn",
|
||||
cc.descuento, cc.descuento_mxn as "descuentoMxn",
|
||||
cc.isr_retencion as "isrRetencion",
|
||||
cc.isr_retencion_mxn as "isrRetencionMxn",
|
||||
cc.iva_traslado as "ivaTraslado",
|
||||
cc.iva_traslado_mxn as "ivaTrasladoMxn",
|
||||
cc.iva_retencion as "ivaRetencion",
|
||||
cc.iva_retencion_mxn as "ivaRetencionMxn",
|
||||
c.uuid as "cfdiUuid",
|
||||
c.fecha_emision as "cfdiFechaEmision",
|
||||
c.rfc_emisor as "cfdiRfcEmisor",
|
||||
c.nombre_emisor as "cfdiNombreEmisor",
|
||||
c.rfc_receptor as "cfdiRfcReceptor",
|
||||
c.nombre_receptor as "cfdiNombreReceptor",
|
||||
c.tipo_comprobante as "cfdiTipoComprobante",
|
||||
c.status as "cfdiStatus",
|
||||
c.type as "cfdiTipo",
|
||||
COUNT(*) OVER() as total_count
|
||||
FROM cfdi_conceptos cc
|
||||
JOIN cfdis c ON c.id = cc.cfdi_id
|
||||
${whereClause}
|
||||
ORDER BY ${orderBy} ${orderDir}, cc.id
|
||||
LIMIT $${paramIndex++} OFFSET $${paramIndex}
|
||||
`, params);
|
||||
|
||||
const total = Number(dataWithCount[0]?.total_count || 0);
|
||||
const data = dataWithCount.map(({ total_count, ...row }: any) => row);
|
||||
|
||||
return {
|
||||
data,
|
||||
total,
|
||||
page,
|
||||
limit,
|
||||
totalPages: Math.ceil(total / limit),
|
||||
};
|
||||
}
|
||||
|
||||
export async function getXmlById(pool: Pool, id: string): Promise<string | null> {
|
||||
const { rows } = await pool.query(`
|
||||
SELECT xml_original FROM cfdis WHERE id = $1
|
||||
@@ -553,7 +672,18 @@ export async function deleteCfdi(pool: Pool, id: string): Promise<void> {
|
||||
`SELECT fecha_emision, contribuyente_id FROM cfdis WHERE id = $1`,
|
||||
[id]
|
||||
);
|
||||
if (pre.length === 0) {
|
||||
throw new Error('CFDI no encontrado');
|
||||
}
|
||||
|
||||
try {
|
||||
await pool.query(`DELETE FROM cfdis WHERE id = $1`, [id]);
|
||||
} catch (err: any) {
|
||||
if (err.code === '23503') {
|
||||
throw new Error('No se puede eliminar este CFDI porque está vinculado a una conciliación u otro registro. Elimina primero las relaciones.');
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
// Retroactive invalidation hook: mark cached metrics stale for prior-year CFDIs
|
||||
try {
|
||||
@@ -632,3 +762,74 @@ export async function getResumenCfdis(pool: Pool, año: number, mes: number, con
|
||||
ivaAcreditable: Number(r?.iva_acreditable || 0),
|
||||
};
|
||||
}
|
||||
|
||||
export async function backfillConceptos(pool: Pool, batchSize: number = 100): Promise<{ processed: number; conceptosInserted: number; remaining: number }> {
|
||||
// CFDIs con XML pero sin conceptos
|
||||
const { rows: cfdis } = await pool.query(`
|
||||
SELECT c.id, c.xml_original, c.type
|
||||
FROM cfdis c
|
||||
LEFT JOIN cfdi_conceptos cc ON cc.cfdi_id = c.id
|
||||
WHERE c.xml_original IS NOT NULL AND cc.id IS NULL
|
||||
LIMIT $1
|
||||
`, [batchSize]);
|
||||
|
||||
let conceptosInserted = 0;
|
||||
|
||||
for (const row of cfdis) {
|
||||
try {
|
||||
const parsed = parseXml(row.xml_original, row.type === 'RECIBIDO' ? 'recibidos' : 'emitidos');
|
||||
if (!parsed || !parsed.conceptos || parsed.conceptos.length === 0) continue;
|
||||
|
||||
const tc = parsed.tipoCambio || 1;
|
||||
const m = (v: number) => v * tc;
|
||||
|
||||
for (const c of parsed.conceptos) {
|
||||
await pool.query(`
|
||||
INSERT INTO cfdi_conceptos (
|
||||
cfdi_id,
|
||||
clave_prod_serv, no_identificacion, descripcion, cantidad,
|
||||
clave_unidad, unidad,
|
||||
valor_unitario, valor_unitario_mxn, importe, importe_mxn,
|
||||
descuento, descuento_mxn,
|
||||
isr_retencion, isr_retencion_mxn,
|
||||
iva_traslado, iva_traslado_mxn,
|
||||
iva_retencion, iva_retencion_mxn,
|
||||
ieps_traslado, ieps_traslado_mxn,
|
||||
ieps_retencion, ieps_retencion_mxn
|
||||
) VALUES (
|
||||
$1,$2,$3,$4,$5,$6,$7,$8,$9,$10,
|
||||
$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,
|
||||
$21,$22,$23
|
||||
)
|
||||
`, [
|
||||
row.id,
|
||||
c.claveProdServ, c.noIdentificacion, c.descripcion, c.cantidad,
|
||||
c.claveUnidad, c.unidad,
|
||||
c.valorUnitario, m(c.valorUnitario), c.importe, m(c.importe),
|
||||
c.descuento, m(c.descuento),
|
||||
c.isrRetencion, m(c.isrRetencion),
|
||||
c.ivaTraslado, m(c.ivaTraslado),
|
||||
c.ivaRetencion, m(c.ivaRetencion),
|
||||
c.iepsTraslado, m(c.iepsTraslado),
|
||||
c.iepsRetencion, m(c.iepsRetencion),
|
||||
]);
|
||||
conceptosInserted++;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`[BackfillConceptos] Error procesando CFDI ${row.id}:`, err);
|
||||
}
|
||||
}
|
||||
|
||||
const { rows: remainingRow } = await pool.query(`
|
||||
SELECT COUNT(*) as count
|
||||
FROM cfdis c
|
||||
LEFT JOIN cfdi_conceptos cc ON cc.cfdi_id = c.id
|
||||
WHERE c.xml_original IS NOT NULL AND cc.id IS NULL
|
||||
`);
|
||||
|
||||
return {
|
||||
processed: cfdis.length,
|
||||
conceptosInserted,
|
||||
remaining: Number(remainingRow[0]?.count || 0),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -8,10 +8,37 @@ function getUserClient(): Facturapi {
|
||||
return new Facturapi(env.FACTURAPI_USER_KEY);
|
||||
}
|
||||
|
||||
async function getOrgApiKey(orgId: string): Promise<string> {
|
||||
async function getOrgApiKey(pool: Pool, orgId: string): Promise<string> {
|
||||
const userKey = env.FACTURAPI_USER_KEY!;
|
||||
let apiKey: string | undefined;
|
||||
|
||||
// 1. Reutilizar Live Secret Key si ya fue guardada en BD (evita PUT en cada emit)
|
||||
const { rows: [orgRow] } = await pool.query(
|
||||
'SELECT api_key FROM facturapi_orgs WHERE facturapi_org_id = $1 LIMIT 1',
|
||||
[orgId],
|
||||
);
|
||||
if (orgRow?.api_key) {
|
||||
return orgRow.api_key;
|
||||
}
|
||||
|
||||
// 2. Obtener Live Secret Key vía PUT /apikeys/live (idempotente)
|
||||
let apiKey: string | undefined;
|
||||
try {
|
||||
const res = await fetch(`https://www.facturapi.io/v2/organizations/${orgId}/apikeys/live`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${userKey}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
if (res.ok) {
|
||||
const key = await res.text();
|
||||
apiKey = key.replace(/"/g, '').trim();
|
||||
}
|
||||
} catch { /* fallback below */ }
|
||||
|
||||
// 3. Fallback a test key si el PUT falla (modo desarrollo/prueba)
|
||||
if (!apiKey) {
|
||||
try {
|
||||
const res = await fetch(`https://www.facturapi.io/v2/organizations/${orgId}/apikeys/test`, {
|
||||
headers: { 'Authorization': `Bearer ${userKey}` },
|
||||
@@ -21,20 +48,18 @@ async function getOrgApiKey(orgId: string): Promise<string> {
|
||||
apiKey = key.replace(/"/g, '').trim();
|
||||
}
|
||||
} catch { /* no test key */ }
|
||||
|
||||
if (!apiKey) {
|
||||
try {
|
||||
const res = await fetch(`https://www.facturapi.io/v2/organizations/${orgId}/apikeys`, {
|
||||
headers: { 'Authorization': `Bearer ${userKey}` },
|
||||
});
|
||||
if (res.ok) {
|
||||
const data = await res.json() as any;
|
||||
if (data?.data?.length > 0) apiKey = data.data[0].api_key;
|
||||
}
|
||||
} catch { /* no live keys */ }
|
||||
}
|
||||
|
||||
if (!apiKey) throw new Error('Organización Facturapi sin API key');
|
||||
|
||||
// 4. Guardar en BD para reutilizar en siguientes emisiones
|
||||
if (apiKey.startsWith('sk_live_')) {
|
||||
await pool.query(
|
||||
'UPDATE facturapi_orgs SET api_key = $1 WHERE facturapi_org_id = $2',
|
||||
[apiKey, orgId],
|
||||
);
|
||||
}
|
||||
|
||||
return apiKey;
|
||||
}
|
||||
|
||||
@@ -180,7 +205,7 @@ export async function getOrgClientContribuyente(
|
||||
);
|
||||
if (rows.length === 0) throw new Error('Contribuyente no tiene organización Facturapi configurada');
|
||||
|
||||
const apiKey = await getOrgApiKey(rows[0].facturapi_org_id);
|
||||
const apiKey = await getOrgApiKey(pool, rows[0].facturapi_org_id);
|
||||
return new Facturapi(apiKey);
|
||||
}
|
||||
|
||||
|
||||
@@ -299,10 +299,11 @@ export async function calcularIngresosPorRegimen(
|
||||
const ignorados = await getIgnorados(tenantId, _ignorados);
|
||||
const descMap = await getDescMap(_descMap);
|
||||
|
||||
// Read-through cache: si el rango cae en años pasados con meses completos
|
||||
// y hay un contribuyente seleccionado, lee de metricas_mensuales. Si hit,
|
||||
// retorna de inmediato (evita ~3 queries SQL por régimen).
|
||||
const cacheRange = planCache(fechaInicio, fechaFin, conciliacion, contribuyenteId);
|
||||
// Read-through cache: solo si flags son default (true) para garantizar
|
||||
// que las queries filtradas no lean datos del caché completo.
|
||||
const cacheRange = considerarActivos && considerarNCs
|
||||
? planCache(fechaInicio, fechaFin, conciliacion, contribuyenteId)
|
||||
: null;
|
||||
if (cacheRange) {
|
||||
const cached = await readIngresosFromCache(pool, cacheRange, ignorados, descMap);
|
||||
if (cached) return cached;
|
||||
@@ -474,8 +475,11 @@ export async function calcularEgresosPorRegimen(
|
||||
const ignorados = await getIgnorados(tenantId, _ignorados);
|
||||
const descMap = await getDescMap(_descMap);
|
||||
|
||||
// Read-through cache: ver nota en calcularIngresosPorRegimen.
|
||||
const cacheRange = planCache(fechaInicio, fechaFin, conciliacion, contribuyenteId);
|
||||
// Read-through cache: solo si flags son default (true) para garantizar
|
||||
// que las queries filtradas no lean datos del caché completo.
|
||||
const cacheRange = considerarActivos && considerarNCs
|
||||
? planCache(fechaInicio, fechaFin, conciliacion, contribuyenteId)
|
||||
: null;
|
||||
if (cacheRange) {
|
||||
const cached = await readEgresosFromCache(pool, cacheRange, ignorados, descMap);
|
||||
if (cached) return cached;
|
||||
|
||||
@@ -20,7 +20,7 @@ function getUserClient(): Facturapi {
|
||||
async function getOrgClient(tenantId: string): Promise<Facturapi> {
|
||||
const tenant = await prisma.tenant.findUnique({
|
||||
where: { id: tenantId },
|
||||
select: { facturapiOrgId: true },
|
||||
select: { id: true, facturapiOrgId: true, facturapiOrgKey: true },
|
||||
});
|
||||
|
||||
if (!tenant?.facturapiOrgId) {
|
||||
@@ -31,9 +31,30 @@ async function getOrgClient(tenantId: string): Promise<Facturapi> {
|
||||
const orgId = tenant.facturapiOrgId;
|
||||
const userKey = env.FACTURAPI_USER_KEY!;
|
||||
|
||||
let apiKey: string | undefined;
|
||||
// 1. Reutilizar Live Secret Key si ya fue guardada en BD (evita PUT en cada emit)
|
||||
if (tenant.facturapiOrgKey) {
|
||||
return new Facturapi(tenant.facturapiOrgKey);
|
||||
}
|
||||
|
||||
// Intentar test key primero (más común en desarrollo)
|
||||
// 2. Obtener Live Secret Key vía PUT /apikeys/live (idempotente)
|
||||
let apiKey: string | undefined;
|
||||
try {
|
||||
const res = await fetch(`https://www.facturapi.io/v2/organizations/${orgId}/apikeys/live`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${userKey}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
if (res.ok) {
|
||||
const key = await res.text();
|
||||
apiKey = key.replace(/"/g, '').trim();
|
||||
}
|
||||
} catch { /* fallback below */ }
|
||||
|
||||
// 3. Fallback a test key si el PUT falla (modo desarrollo/prueba)
|
||||
if (!apiKey) {
|
||||
try {
|
||||
const res = await fetch(`https://www.facturapi.io/v2/organizations/${orgId}/apikeys/test`, {
|
||||
headers: { 'Authorization': `Bearer ${userKey}` },
|
||||
@@ -43,24 +64,20 @@ async function getOrgClient(tenantId: string): Promise<Facturapi> {
|
||||
apiKey = key.replace(/"/g, '').trim();
|
||||
}
|
||||
} catch { /* no test key */ }
|
||||
|
||||
// Fallback a live key
|
||||
if (!apiKey) {
|
||||
try {
|
||||
const res = await fetch(`https://www.facturapi.io/v2/organizations/${orgId}/apikeys`, {
|
||||
headers: { 'Authorization': `Bearer ${userKey}` },
|
||||
});
|
||||
if (res.ok) {
|
||||
const data = await res.json() as any;
|
||||
if (data?.data?.length > 0) apiKey = data.data[0].api_key;
|
||||
}
|
||||
} catch { /* no live keys */ }
|
||||
}
|
||||
|
||||
if (!apiKey) {
|
||||
throw new Error('Organización Facturapi sin API key');
|
||||
}
|
||||
|
||||
// 4. Guardar en BD para reutilizar en siguientes emisiones
|
||||
if (tenant && apiKey.startsWith('sk_live_')) {
|
||||
await prisma.tenant.update({
|
||||
where: { id: tenant.id },
|
||||
data: { facturapiOrgKey: apiKey },
|
||||
});
|
||||
}
|
||||
|
||||
return new Facturapi(apiKey);
|
||||
}
|
||||
|
||||
|
||||
@@ -353,11 +353,12 @@ export async function getIvaMensual(
|
||||
considerarActivos: boolean = true,
|
||||
considerarNCs: boolean = true,
|
||||
): Promise<IvaMensual[]> {
|
||||
// Cache read-through: solo si año pasado, sin conciliación, con contribuyente y flags default
|
||||
const currentYear = new Date().getFullYear();
|
||||
// Cache read-through: sin conciliación, con contribuyente y flags default.
|
||||
// El año actual YA NO se excluye: si los meses están precalculados en
|
||||
// metricas_mensuales se usan; si faltan, readIvaMensualFromCache retorna
|
||||
// null y caemos al path on-the-fly.
|
||||
const cacheable =
|
||||
process.env.METRICAS_BYPASS_CACHE !== '1' &&
|
||||
año < currentYear &&
|
||||
!conciliacion &&
|
||||
considerarActivos &&
|
||||
considerarNCs &&
|
||||
@@ -607,25 +608,20 @@ async function readResumenIvaFromCache(
|
||||
|
||||
const resultado = trasladado - acreditable - retenido;
|
||||
|
||||
// Acumulado anual: su rango (year-01-01 → fechaFin) difiere del rango cacheado;
|
||||
// se calcula on-the-fly contra raw cfdis. Una sola query.
|
||||
// Acumulado anual: en vez de consultar raw cfdis, sumamos desde
|
||||
// metricas_mensuales (enero → fechaFin). Como planCache garantiza que
|
||||
// fechaFin es último día de mes, el acumulado coincide mes a mes.
|
||||
const añoInicio = new Date(fechaInicio + 'T00:00:00').getFullYear();
|
||||
const acumFR = conciliacion ? FECHA_RANGO_CONCILIACION : FECHA_RANGO;
|
||||
const REGIMEN_TENANT = regimenTenantExpr(ctx);
|
||||
const acumRow = (await pool.query(`
|
||||
const acumEnd = `${añoInicio}-${String(new Date(fechaFin + 'T00:00:00').getMonth() + 1).padStart(2, '0')}-01`;
|
||||
const { rows: [acumRow] } = await pool.query(`
|
||||
SELECT
|
||||
COALESCE(SUM(${signedCausadoTras(ctx, considerarActivos, considerarNCs)}), 0) -
|
||||
COALESCE(SUM(${signedAcreditableTras(ctx, considerarActivos, considerarNCs)}), 0) -
|
||||
(
|
||||
COALESCE(SUM(${signedCausadoRet(ctx, considerarActivos, considerarNCs)}), 0) -
|
||||
COALESCE(SUM(${signedAcreditableRet(ctx, considerarActivos, considerarNCs)}), 0)
|
||||
) as total
|
||||
FROM cfdis
|
||||
WHERE ${VIGENTE}
|
||||
AND (${REGIMEN_TENANT}) = ANY($3)
|
||||
AND ${acumFR}
|
||||
AND (${ctx.esEmisor} OR ${ctx.esReceptor})
|
||||
`, [`${añoInicio}-01-01`, fechaFin, TODOS_REGIMENES])).rows[0];
|
||||
COALESCE(SUM(iva_trasladado_total), 0) -
|
||||
COALESCE(SUM(iva_acreditable), 0) -
|
||||
COALESCE(SUM(iva_retenido_cobrado), 0) as total
|
||||
FROM metricas_mensuales
|
||||
WHERE contribuyente_id = $1
|
||||
AND make_date(anio, mes, 1) BETWEEN $2::date AND $3::date
|
||||
`, [range.contribuyenteId, `${añoInicio}-01-01`, acumEnd]);
|
||||
|
||||
return {
|
||||
trasladado,
|
||||
|
||||
@@ -9,9 +9,13 @@ export interface CacheRange {
|
||||
* (read-through cache de Tanda B). Requisitos:
|
||||
* - `contribuyenteId` presente (la tabla solo tiene datos por contribuyente)
|
||||
* - `conciliacion` desactivada (la tabla guarda flujo normal, no id_conciliacion)
|
||||
* - `fechaFin` antes del primer día del año actual (años cerrados)
|
||||
* - `fechaInicio` es día 1 del mes; `fechaFin` es último día del mes
|
||||
*
|
||||
* El año actual YA NO se excluye: si los meses están precalculados en
|
||||
* metricas_mensuales, se usan; si faltan, read*FromCache retorna null y el
|
||||
* caller cae a on-the-fly. Esto permite cachear 2026 y años futuros sin
|
||||
* modificar esta función cada año.
|
||||
*
|
||||
* Retorna `null` si no califica — el caller debe caer al path on-the-fly.
|
||||
*/
|
||||
export function planCache(
|
||||
@@ -27,8 +31,8 @@ export function planCache(
|
||||
const fi = new Date(fechaInicio + 'T00:00:00Z');
|
||||
const ff = new Date(fechaFin + 'T00:00:00Z');
|
||||
if (isNaN(fi.getTime()) || isNaN(ff.getTime())) return null;
|
||||
const currentYearStart = new Date(Date.UTC(new Date().getUTCFullYear(), 0, 1));
|
||||
if (ff >= currentYearStart) return null;
|
||||
// Ya no se excluye el año actual: si hay datos precalculados se usan,
|
||||
// si no, read*FromCache retorna null y el caller cae a on-the-fly.
|
||||
if (fi.getUTCDate() !== 1) return null;
|
||||
const lastDay = new Date(Date.UTC(ff.getUTCFullYear(), ff.getUTCMonth() + 1, 0)).getUTCDate();
|
||||
if (ff.getUTCDate() !== lastDay) return null;
|
||||
|
||||
@@ -4,12 +4,12 @@ import { useState, useRef, useCallback, useEffect } from 'react';
|
||||
import { useDebounce } from '@horux/shared-ui';
|
||||
import { Header } from '@/components/layouts/header';
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription, Button, Input, Label, Select, SelectContent, SelectItem, SelectTrigger, SelectValue, Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter, Popover, PopoverTrigger, PopoverContent } from '@horux/shared-ui';
|
||||
import { useCfdis, useCreateCfdi, useDeleteCfdi } from '@/lib/hooks/use-cfdi';
|
||||
import { createManyCfdis, searchEmisores, searchReceptores, type EmisorReceptor } from '@/lib/api/cfdi';
|
||||
import { cancelarFactura } from '@/lib/api/facturacion';
|
||||
import type { CfdiFilters, TipoCfdi, Cfdi } from '@horux/shared';
|
||||
import { useCfdis, useCfdiConceptos, useCreateCfdi, useDeleteCfdi } from '@/lib/hooks/use-cfdi';
|
||||
import { getCfdis, createManyCfdis, searchEmisores, searchReceptores, type EmisorReceptor } from '@/lib/api/cfdi';
|
||||
import { cancelarFactura, downloadPdf, downloadXml } from '@/lib/api/facturacion';
|
||||
import type { CfdiFilters, CfdiConceptoFilters, TipoCfdi, Cfdi } from '@horux/shared';
|
||||
import type { CreateCfdiData } from '@/lib/api/cfdi';
|
||||
import { FileText, Search, ChevronLeft, ChevronRight, Plus, Upload, Trash2, X, FileUp, CheckCircle, AlertCircle, Loader2, Eye, Filter, XCircle, Calendar, User, Building2, Download, Printer } from 'lucide-react';
|
||||
import { FileText, Search, ChevronLeft, ChevronRight, Plus, Upload, Trash2, X, FileUp, CheckCircle, AlertCircle, Loader2, Eye, Filter, XCircle, Calendar, User, Building2, Download, Printer, ArrowUpDown, ArrowUp, ArrowDown } from 'lucide-react';
|
||||
import * as XLSX from 'xlsx';
|
||||
import { saveAs } from 'file-saver';
|
||||
import { CfdiViewerModal } from '@/components/cfdi/cfdi-viewer-modal';
|
||||
@@ -262,6 +262,30 @@ export default function CfdiPage() {
|
||||
const [loadingReceptor, setLoadingReceptor] = useState(false);
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
|
||||
// Tabs: CFDIs vs Conceptos
|
||||
const [activeTab, setActiveTab] = useState<'cfdis' | 'conceptos'>('cfdis');
|
||||
|
||||
// Conceptos filters & state
|
||||
const [conceptoFilters, setConceptoFilters] = useState<CfdiConceptoFilters>({
|
||||
page: 1,
|
||||
limit: 20,
|
||||
});
|
||||
const [conceptoSearch, setConceptoSearch] = useState('');
|
||||
const [conceptoColumnFilters, setConceptoColumnFilters] = useState({
|
||||
fechaInicio: '',
|
||||
fechaFin: '',
|
||||
uuid: '',
|
||||
claveProdServ: '',
|
||||
descripcion: '',
|
||||
});
|
||||
const [conceptoOrder, setConceptoOrder] = useState<{ by: 'fecha' | 'importe'; dir: 'asc' | 'desc' }>({ by: 'fecha', dir: 'desc' });
|
||||
const [openConceptoFilter, setOpenConceptoFilter] = useState<'fecha' | 'uuid' | 'clave' | 'descripcion' | null>(null);
|
||||
const hasConceptoDateFilter = !!conceptoColumnFilters.fechaInicio || !!conceptoColumnFilters.fechaFin;
|
||||
const hasConceptoUuidFilter = !!conceptoColumnFilters.uuid;
|
||||
const hasConceptoClaveFilter = !!conceptoColumnFilters.claveProdServ;
|
||||
const hasConceptoDescFilter = !!conceptoColumnFilters.descripcion;
|
||||
const { data: conceptosData, isLoading: conceptosLoading } = useCfdiConceptos(conceptoFilters);
|
||||
|
||||
// Debounced values for autocomplete
|
||||
const debouncedEmisor = useDebounce(columnFilters.emisor, 300);
|
||||
const debouncedReceptor = useDebounce(columnFilters.receptor, 300);
|
||||
@@ -322,6 +346,21 @@ export default function CfdiPage() {
|
||||
const [viewingCfdi, setViewingCfdi] = useState<Cfdi | null>(null);
|
||||
const [loadingCfdi, setLoadingCfdi] = useState<string | null>(null);
|
||||
|
||||
// Sync shared filters between CFDIs and Conceptos tabs
|
||||
useEffect(() => {
|
||||
setConceptoFilters(prev => ({
|
||||
...prev,
|
||||
fechaInicio: columnFilters.fechaInicio || undefined,
|
||||
fechaFin: columnFilters.fechaFin || undefined,
|
||||
tipo: filters.tipo,
|
||||
tipoComprobante: filters.tipoComprobante,
|
||||
estado: filters.estado,
|
||||
rfc: filters.rfc,
|
||||
search: searchTerm || undefined,
|
||||
page: 1,
|
||||
}));
|
||||
}, [filters.tipo, filters.tipoComprobante, filters.estado, filters.rfc, columnFilters.fechaInicio, columnFilters.fechaFin, searchTerm]);
|
||||
|
||||
// Cancelación Facturapi state
|
||||
const [cancelTarget, setCancelTarget] = useState<any | null>(null);
|
||||
const [cancelMotive, setCancelMotive] = useState<'01' | '02' | '03' | '04'>('02');
|
||||
@@ -343,11 +382,58 @@ export default function CfdiPage() {
|
||||
};
|
||||
|
||||
const canEdit = user?.role === 'owner' || user?.role === 'cfo' || user?.role === 'contador' || user?.role === 'auxiliar';
|
||||
const canDelete = user?.role === 'owner' || user?.role === 'contador';
|
||||
|
||||
const handleSearch = () => {
|
||||
setFilters({ ...filters, search: searchTerm, page: 1 });
|
||||
};
|
||||
|
||||
// Conceptos column filters & sorting
|
||||
const applyConceptoColumnFilters = () => {
|
||||
setConceptoFilters(prev => ({
|
||||
...prev,
|
||||
fechaInicio: conceptoColumnFilters.fechaInicio || undefined,
|
||||
fechaFin: conceptoColumnFilters.fechaFin || undefined,
|
||||
uuid: conceptoColumnFilters.uuid || undefined,
|
||||
claveProdServ: conceptoColumnFilters.claveProdServ || undefined,
|
||||
descripcion: conceptoColumnFilters.descripcion || undefined,
|
||||
orderBy: conceptoOrder.by,
|
||||
orderDir: conceptoOrder.dir,
|
||||
page: 1,
|
||||
}));
|
||||
};
|
||||
|
||||
const toggleConceptoOrder = (by: 'fecha' | 'importe') => {
|
||||
setConceptoOrder(prev => {
|
||||
const dir = prev.by === by && prev.dir === 'desc' ? 'asc' : 'desc';
|
||||
return { by, dir };
|
||||
});
|
||||
// Apply after state update using the new values
|
||||
setConceptoFilters(prev => ({
|
||||
...prev,
|
||||
orderBy: by,
|
||||
orderDir: by === conceptoOrder.by && conceptoOrder.dir === 'desc' ? 'asc' : 'desc',
|
||||
page: 1,
|
||||
}));
|
||||
};
|
||||
|
||||
const clearConceptoDateFilter = () => {
|
||||
setConceptoColumnFilters(prev => ({ ...prev, fechaInicio: '', fechaFin: '' }));
|
||||
setConceptoFilters(prev => ({ ...prev, fechaInicio: undefined, fechaFin: undefined, page: 1 }));
|
||||
};
|
||||
const clearConceptoUuidFilter = () => {
|
||||
setConceptoColumnFilters(prev => ({ ...prev, uuid: '' }));
|
||||
setConceptoFilters(prev => ({ ...prev, uuid: undefined, page: 1 }));
|
||||
};
|
||||
const clearConceptoClaveFilter = () => {
|
||||
setConceptoColumnFilters(prev => ({ ...prev, claveProdServ: '' }));
|
||||
setConceptoFilters(prev => ({ ...prev, claveProdServ: undefined, page: 1 }));
|
||||
};
|
||||
const clearConceptoDescFilter = () => {
|
||||
setConceptoColumnFilters(prev => ({ ...prev, descripcion: '' }));
|
||||
setConceptoFilters(prev => ({ ...prev, descripcion: undefined, page: 1 }));
|
||||
};
|
||||
|
||||
// Export to Excel
|
||||
const [exporting, setExporting] = useState(false);
|
||||
|
||||
@@ -356,7 +442,17 @@ export default function CfdiPage() {
|
||||
|
||||
setExporting(true);
|
||||
try {
|
||||
const exportData = data.data.map(cfdi => ({
|
||||
// Traer TODOS los CFDIs que coinciden con los filtros (sin paginación)
|
||||
const allFilters: CfdiFilters = { ...filters, page: 1, limit: 10000 };
|
||||
const allData = await getCfdis(allFilters);
|
||||
const rows = allData.data;
|
||||
|
||||
if (!rows.length) {
|
||||
alert('No hay datos para exportar');
|
||||
return;
|
||||
}
|
||||
|
||||
const exportData = rows.map(cfdi => ({
|
||||
'Fecha Emisión': new Date(cfdi.fechaEmision).toLocaleDateString('es-MX'),
|
||||
'Tipo Comprobante': formatTipoComprobante(cfdi.tipoComprobante),
|
||||
'Serie': cfdi.serie || '',
|
||||
@@ -399,6 +495,40 @@ export default function CfdiPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleDownloadPdf = async (facturapiId: string | null) => {
|
||||
if (!facturapiId) return;
|
||||
try {
|
||||
const blob = await downloadPdf(facturapiId);
|
||||
const url = window.URL.createObjectURL(new Blob([blob]));
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.setAttribute('download', `factura-${facturapiId}.pdf`);
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
window.URL.revokeObjectURL(url);
|
||||
} catch (error: any) {
|
||||
alert('Error al descargar PDF: ' + (error?.message || 'Desconocido'));
|
||||
}
|
||||
};
|
||||
|
||||
const handleDownloadXml = async (facturapiId: string | null) => {
|
||||
if (!facturapiId) return;
|
||||
try {
|
||||
const blob = await downloadXml(facturapiId);
|
||||
const url = window.URL.createObjectURL(new Blob([blob]));
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.setAttribute('download', `factura-${facturapiId}.xml`);
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
window.URL.revokeObjectURL(url);
|
||||
} catch (error: any) {
|
||||
alert('Error al descargar XML: ' + (error?.message || 'Desconocido'));
|
||||
}
|
||||
};
|
||||
|
||||
const exportSingleCfdiToExcel = (cfdi: Cfdi) => {
|
||||
const row = {
|
||||
'Fecha Emisión': new Date(cfdi.fechaEmision).toLocaleDateString('es-MX'),
|
||||
@@ -741,11 +871,19 @@ export default function CfdiPage() {
|
||||
|
||||
const handleDelete = async (id: string | number) => {
|
||||
const idStr = String(id);
|
||||
console.log('[DeleteCFDI] Intentando eliminar ID:', idStr, 'tipo:', typeof id);
|
||||
if (!id || idStr === 'undefined' || idStr === 'null' || idStr === '0') {
|
||||
alert('ID de CFDI inválido: ' + idStr);
|
||||
return;
|
||||
}
|
||||
if (confirm('¿Eliminar este CFDI?')) {
|
||||
try {
|
||||
await deleteCfdi.mutateAsync(idStr);
|
||||
} catch (error) {
|
||||
alert('CFDI eliminado correctamente');
|
||||
} catch (error: any) {
|
||||
console.error('Error deleting CFDI:', error);
|
||||
const msg = error?.response?.data?.message || error?.message || 'Error al eliminar el CFDI';
|
||||
alert('Error: ' + msg);
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -809,6 +947,21 @@ export default function CfdiPage() {
|
||||
<>
|
||||
<Header title="Gestion de CFDI" />
|
||||
<main className="p-6 space-y-6">
|
||||
{/* Tabs */}
|
||||
<div className="flex gap-2 border-b pb-2">
|
||||
<button
|
||||
onClick={() => setActiveTab('cfdis')}
|
||||
className={`px-4 py-2 text-sm font-medium rounded-t-lg transition-colors ${activeTab === 'cfdis' ? 'bg-primary text-primary-foreground' : 'text-muted-foreground hover:text-foreground hover:bg-muted'}`}
|
||||
>
|
||||
CFDIs
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('conceptos')}
|
||||
className={`px-4 py-2 text-sm font-medium rounded-t-lg transition-colors ${activeTab === 'conceptos' ? 'bg-primary text-primary-foreground' : 'text-muted-foreground hover:text-foreground hover:bg-muted'}`}
|
||||
>
|
||||
Conceptos
|
||||
</button>
|
||||
</div>
|
||||
{/* Filters */}
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
@@ -899,6 +1052,8 @@ export default function CfdiPage() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{activeTab === 'cfdis' && (
|
||||
<>
|
||||
{/* Add CFDI Form */}
|
||||
{showForm && canEdit && (
|
||||
<Card>
|
||||
@@ -1586,6 +1741,7 @@ export default function CfdiPage() {
|
||||
</Popover>
|
||||
</div>
|
||||
</th>
|
||||
<th className="pb-3 font-medium">Uso CFDI</th>
|
||||
<th className="pb-3 font-medium text-right">Total</th>
|
||||
<th className="pb-3 font-medium">Estado</th>
|
||||
<th className="pb-3 font-medium"></th>
|
||||
@@ -1625,6 +1781,9 @@ export default function CfdiPage() {
|
||||
</p>
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-3">
|
||||
<span className="text-xs text-muted-foreground">{cfdi.usoCfdi || '-'}</span>
|
||||
</td>
|
||||
<td className="py-3 text-right font-medium">
|
||||
{formatCurrency(cfdi.total)}
|
||||
</td>
|
||||
@@ -1654,6 +1813,21 @@ export default function CfdiPage() {
|
||||
)}
|
||||
</Button>
|
||||
</td>
|
||||
{(cfdi as any).source === 'facturapi' && cfdi.facturapiId && (
|
||||
<>
|
||||
<td className="py-3">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => handleDownloadPdf(cfdi.facturapiId)}
|
||||
title="Descargar PDF (Facturapi)"
|
||||
>
|
||||
<FileText className="h-4 w-4" />
|
||||
</Button>
|
||||
</td>
|
||||
{/* XML download hidden */}
|
||||
</>
|
||||
)}
|
||||
<td className="py-3">
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -1678,6 +1852,7 @@ export default function CfdiPage() {
|
||||
<XCircle className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
{canDelete && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
@@ -1687,6 +1862,7 @@ export default function CfdiPage() {
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
)}
|
||||
@@ -1729,6 +1905,219 @@ export default function CfdiPage() {
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</>
|
||||
)}
|
||||
|
||||
{activeTab === 'conceptos' && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<FileText className="h-4 w-4" />
|
||||
Conceptos ({conceptosData?.total || 0})
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{conceptosLoading ? (
|
||||
<div className="space-y-3">
|
||||
{[...Array(8)].map((_, i) => (
|
||||
<div key={i} className="flex items-center gap-4 animate-pulse">
|
||||
<div className="h-4 bg-muted rounded w-20"></div>
|
||||
<div className="h-4 bg-muted rounded flex-1 max-w-[200px]"></div>
|
||||
<div className="h-4 bg-muted rounded w-16"></div>
|
||||
<div className="h-4 bg-muted rounded w-16"></div>
|
||||
<div className="h-4 bg-muted rounded w-24"></div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : conceptosData?.data.length === 0 ? (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
No se encontraron conceptos
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b text-left text-sm text-muted-foreground">
|
||||
<th className="pb-3 font-medium">
|
||||
<div className="flex items-center gap-1">
|
||||
<button onClick={() => toggleConceptoOrder('fecha')} className="flex items-center gap-1 hover:text-foreground transition-colors">
|
||||
Fecha CFDI
|
||||
{conceptoOrder.by === 'fecha' ? (
|
||||
conceptoOrder.dir === 'asc' ? <ArrowUp className="h-3 w-3" /> : <ArrowDown className="h-3 w-3" />
|
||||
) : (
|
||||
<ArrowUpDown className="h-3 w-3 opacity-50" />
|
||||
)}
|
||||
</button>
|
||||
<Popover open={openConceptoFilter === 'fecha'} onOpenChange={(open) => setOpenConceptoFilter(open ? 'fecha' : null)}>
|
||||
<PopoverTrigger asChild>
|
||||
<button className={`p-1 rounded hover:bg-muted ${hasConceptoDateFilter ? '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 fecha</h4>
|
||||
<div className="space-y-2">
|
||||
<div>
|
||||
<Label className="text-xs">Desde</Label>
|
||||
<Input type="date" className="h-8 text-sm" value={conceptoColumnFilters.fechaInicio} onChange={(e) => setConceptoColumnFilters(prev => ({ ...prev, fechaInicio: e.target.value }))} />
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs">Hasta</Label>
|
||||
<Input type="date" className="h-8 text-sm" value={conceptoColumnFilters.fechaFin} onChange={(e) => setConceptoColumnFilters(prev => ({ ...prev, fechaFin: e.target.value }))} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button size="sm" className="flex-1" onClick={applyConceptoColumnFilters}>Aplicar</Button>
|
||||
{hasConceptoDateFilter && <Button size="sm" variant="outline" onClick={clearConceptoDateFilter}>Limpiar</Button>}
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
</th>
|
||||
<th className="pb-3 font-medium">
|
||||
<div className="flex items-center gap-1">
|
||||
UUID
|
||||
<Popover open={openConceptoFilter === 'uuid'} onOpenChange={(open) => setOpenConceptoFilter(open ? 'uuid' : null)}>
|
||||
<PopoverTrigger asChild>
|
||||
<button className={`p-1 rounded hover:bg-muted ${hasConceptoUuidFilter ? '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 UUID</h4>
|
||||
<Input placeholder="UUID..." className="h-8 text-sm" value={conceptoColumnFilters.uuid} onChange={(e) => setConceptoColumnFilters(prev => ({ ...prev, uuid: e.target.value }))} onKeyDown={(e) => e.key === 'Enter' && applyConceptoColumnFilters()} />
|
||||
<div className="flex gap-2">
|
||||
<Button size="sm" className="flex-1" onClick={applyConceptoColumnFilters}>Aplicar</Button>
|
||||
{hasConceptoUuidFilter && <Button size="sm" variant="outline" onClick={clearConceptoUuidFilter}>Limpiar</Button>}
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
</th>
|
||||
<th className="pb-3 font-medium">
|
||||
<div className="flex items-center gap-1">
|
||||
Clave
|
||||
<Popover open={openConceptoFilter === 'clave'} onOpenChange={(open) => setOpenConceptoFilter(open ? 'clave' : null)}>
|
||||
<PopoverTrigger asChild>
|
||||
<button className={`p-1 rounded hover:bg-muted ${hasConceptoClaveFilter ? '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 clave</h4>
|
||||
<Input placeholder="Clave prod/serv..." className="h-8 text-sm" value={conceptoColumnFilters.claveProdServ} onChange={(e) => setConceptoColumnFilters(prev => ({ ...prev, claveProdServ: e.target.value }))} onKeyDown={(e) => e.key === 'Enter' && applyConceptoColumnFilters()} />
|
||||
<div className="flex gap-2">
|
||||
<Button size="sm" className="flex-1" onClick={applyConceptoColumnFilters}>Aplicar</Button>
|
||||
{hasConceptoClaveFilter && <Button size="sm" variant="outline" onClick={clearConceptoClaveFilter}>Limpiar</Button>}
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
</th>
|
||||
<th className="pb-3 font-medium">
|
||||
<div className="flex items-center gap-1">
|
||||
Descripción
|
||||
<Popover open={openConceptoFilter === 'descripcion'} onOpenChange={(open) => setOpenConceptoFilter(open ? 'descripcion' : null)}>
|
||||
<PopoverTrigger asChild>
|
||||
<button className={`p-1 rounded hover:bg-muted ${hasConceptoDescFilter ? 'text-primary' : ''}`}>
|
||||
<Filter className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-72" align="start">
|
||||
<div className="space-y-3">
|
||||
<h4 className="font-medium text-sm">Filtrar por descripción</h4>
|
||||
<Input placeholder="Descripción..." className="h-8 text-sm" value={conceptoColumnFilters.descripcion} onChange={(e) => setConceptoColumnFilters(prev => ({ ...prev, descripcion: e.target.value }))} onKeyDown={(e) => e.key === 'Enter' && applyConceptoColumnFilters()} />
|
||||
<div className="flex gap-2">
|
||||
<Button size="sm" className="flex-1" onClick={applyConceptoColumnFilters}>Aplicar</Button>
|
||||
{hasConceptoDescFilter && <Button size="sm" variant="outline" onClick={clearConceptoDescFilter}>Limpiar</Button>}
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
</th>
|
||||
<th className="pb-3 font-medium">RFC Emisor</th>
|
||||
<th className="pb-3 font-medium">RFC Receptor</th>
|
||||
<th className="pb-3 font-medium">Cantidad</th>
|
||||
<th className="pb-3 font-medium">Unidad</th>
|
||||
<th className="pb-3 font-medium text-right">V. Unitario</th>
|
||||
<th className="pb-3 font-medium text-right">
|
||||
<button onClick={() => toggleConceptoOrder('importe')} className="flex items-center gap-1 hover:text-foreground transition-colors ml-auto">
|
||||
Importe
|
||||
{conceptoOrder.by === 'importe' ? (
|
||||
conceptoOrder.dir === 'asc' ? <ArrowUp className="h-3 w-3" /> : <ArrowDown className="h-3 w-3" />
|
||||
) : (
|
||||
<ArrowUpDown className="h-3 w-3 opacity-50" />
|
||||
)}
|
||||
</button>
|
||||
</th>
|
||||
{/* Descuento and IVA columns hidden */}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{conceptosData?.data.map((c) => (
|
||||
<tr key={c.id} className="border-b hover:bg-muted/50">
|
||||
<td className="py-3 text-sm">
|
||||
{c.cfdiFechaEmision ? new Date(c.cfdiFechaEmision).toLocaleDateString('es-MX') : '-'}
|
||||
</td>
|
||||
<td className="py-3 text-xs font-mono text-muted-foreground max-w-[120px] truncate" title={c.cfdiUuid || ''}>
|
||||
{c.cfdiUuid || '-'}
|
||||
</td>
|
||||
<td className="py-3 text-xs text-muted-foreground">{c.claveProdServ || '-'}</td>
|
||||
<td className="py-3 text-sm max-w-[250px] truncate" title={c.descripcion}>{c.descripcion}</td>
|
||||
<td className="py-3 text-xs text-muted-foreground">{c.cfdiRfcEmisor || '-'}</td>
|
||||
<td className="py-3 text-xs text-muted-foreground">{c.cfdiRfcReceptor || '-'}</td>
|
||||
<td className="py-3 text-sm">{c.cantidad}</td>
|
||||
<td className="py-3 text-xs text-muted-foreground">{c.claveUnidad || c.unidad || '-'}</td>
|
||||
<td className="py-3 text-sm text-right">${Number(c.valorUnitario).toLocaleString('es-MX', { minimumFractionDigits: 2 })}</td>
|
||||
<td className="py-3 text-sm text-right">${Number(c.importe).toLocaleString('es-MX', { minimumFractionDigits: 2 })}</td>
|
||||
{/* Descuento and IVA cells hidden */}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Pagination for conceptos */}
|
||||
{conceptosData && conceptosData.totalPages > 1 && (
|
||||
<div className="flex items-center justify-between mt-4 pt-4 border-t">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Pagina {conceptosData.page} de {conceptosData.totalPages}
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={conceptosData.page <= 1}
|
||||
onClick={() =>
|
||||
setConceptoFilters({ ...conceptoFilters, page: (conceptoFilters.page || 1) - 1 })
|
||||
}
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={conceptosData.page >= conceptosData.totalPages}
|
||||
onClick={() =>
|
||||
setConceptoFilters({ ...conceptoFilters, page: (conceptoFilters.page || 1) + 1 })
|
||||
}
|
||||
>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</main>
|
||||
|
||||
<CfdiViewerModal
|
||||
|
||||
@@ -49,6 +49,7 @@ const RELACION_OPTIONS: Record<string, { value: string; label: string }[]> = {
|
||||
};
|
||||
|
||||
interface ConceptoForm {
|
||||
id: string;
|
||||
description: string;
|
||||
productKey: string;
|
||||
productKeyLabel: string;
|
||||
@@ -65,7 +66,7 @@ const defaultTaxes: TaxLine[] = [
|
||||
];
|
||||
|
||||
const emptyConcepto: ConceptoForm = {
|
||||
description: '', productKey: '', productKeyLabel: '',
|
||||
id: '', description: '', productKey: '', productKeyLabel: '',
|
||||
unitKey: 'E48', quantity: 1, price: 0, discount: 0, objetoImp: '02',
|
||||
taxes: [...defaultTaxes],
|
||||
};
|
||||
@@ -327,7 +328,7 @@ export default function FacturacionPage() {
|
||||
const [condiciones, setCondiciones] = useState('');
|
||||
|
||||
// Conceptos
|
||||
const [conceptos, setConceptos] = useState<ConceptoForm[]>([{ ...emptyConcepto }]);
|
||||
const [conceptos, setConceptos] = useState<ConceptoForm[]>([{ ...emptyConcepto, id: crypto.randomUUID() }]);
|
||||
|
||||
// CFDIs relacionados (Ingreso / Egreso)
|
||||
const [relatedDocs, setRelatedDocs] = useState<RelatedDocForm[]>([]);
|
||||
@@ -365,7 +366,7 @@ export default function FacturacionPage() {
|
||||
setSerie('');
|
||||
setFolio('');
|
||||
setCondiciones('');
|
||||
setConceptos([{ ...emptyConcepto, unitKey: defaultUnit }]);
|
||||
setConceptos([{ ...emptyConcepto, unitKey: defaultUnit, id: crypto.randomUUID() }]);
|
||||
setRelatedDocs([]);
|
||||
setPagoUuid('');
|
||||
setPagoMonto(0);
|
||||
@@ -514,7 +515,7 @@ export default function FacturacionPage() {
|
||||
setMetodoPago(c.defaultMetodoPago);
|
||||
// Resetear conceptos con unidad default según tipo
|
||||
const defaultUnit = tipo === 'T' ? 'H87' : 'E48';
|
||||
setConceptos([{ ...emptyConcepto, unitKey: defaultUnit }]);
|
||||
setConceptos([{ ...emptyConcepto, unitKey: defaultUnit, id: crypto.randomUUID() }]);
|
||||
};
|
||||
|
||||
// Unidades de servicio que no aplican para Traslado
|
||||
@@ -532,30 +533,28 @@ export default function FacturacionPage() {
|
||||
};
|
||||
|
||||
const selectProduct = (idx: number, clave: string, descripcion: string) => {
|
||||
const updated = [...conceptos];
|
||||
updated[idx].productKey = clave;
|
||||
updated[idx].productKeyLabel = `${clave} - ${descripcion}`;
|
||||
setConceptos(updated);
|
||||
setConceptos(prev => prev.map((c, i) => i === idx ? { ...c, productKey: clave, productKeyLabel: `${clave} - ${descripcion}` } : c));
|
||||
setProdResults([]);
|
||||
setSearchingIdx(null);
|
||||
};
|
||||
|
||||
const updateConcepto = (idx: number, field: keyof ConceptoForm, value: any) => {
|
||||
const updated = [...conceptos];
|
||||
(updated[idx] as any)[field] = value;
|
||||
setConceptos(prev => prev.map((c, i) => {
|
||||
if (i !== idx) return c;
|
||||
const updated = { ...c, [field]: value } as ConceptoForm;
|
||||
// Si cambió la unidad, re-evaluar recomendación de impuestos
|
||||
if (field === 'unitKey' && tipoComprobante === 'I') {
|
||||
const recommended = getRecommendedTaxes(
|
||||
emisorRfc, emisorRegimen, receptor.taxId, receptor.taxSystem, value
|
||||
);
|
||||
if (recommended) {
|
||||
updated[idx].taxes = recommended;
|
||||
updated.taxes = recommended;
|
||||
} else {
|
||||
// Si ya no aplica retención, dejar solo IVA 16%
|
||||
updated[idx].taxes = [...defaultTaxes];
|
||||
updated.taxes = [...defaultTaxes];
|
||||
}
|
||||
}
|
||||
setConceptos(updated);
|
||||
return updated;
|
||||
}));
|
||||
};
|
||||
|
||||
// Aplica recomendación de impuestos a un concepto si corresponde (solo tipo I)
|
||||
@@ -571,12 +570,14 @@ export default function FacturacionPage() {
|
||||
};
|
||||
|
||||
const addConcepto = () => {
|
||||
const newConcepto = applyTaxRecommendation({ ...emptyConcepto });
|
||||
setConceptos([...conceptos, newConcepto]);
|
||||
const newConcepto = applyTaxRecommendation({ ...emptyConcepto, id: crypto.randomUUID() });
|
||||
setConceptos(prev => [...prev, newConcepto]);
|
||||
};
|
||||
const removeConcepto = (idx: number) => {
|
||||
if (conceptos.length === 1) return;
|
||||
setConceptos(conceptos.filter((_, i) => i !== idx));
|
||||
setConceptos(prev => {
|
||||
if (prev.length === 1) return prev;
|
||||
return prev.filter((_, i) => i !== idx);
|
||||
});
|
||||
};
|
||||
|
||||
// Cálculos
|
||||
@@ -708,7 +709,7 @@ export default function FacturacionPage() {
|
||||
<p className="text-muted-foreground mt-4">Total</p>
|
||||
<p className="text-2xl font-bold">${result.total.toLocaleString('es-MX', { minimumFractionDigits: 2 })}</p>
|
||||
</div>
|
||||
<Button onClick={() => { setResult(null); setConceptos([{ ...emptyConcepto }]); }}>
|
||||
<Button onClick={() => { setResult(null); setConceptos([{ ...emptyConcepto, id: crypto.randomUUID() }]); }}>
|
||||
Emitir otra factura
|
||||
</Button>
|
||||
</CardContent>
|
||||
@@ -1258,7 +1259,7 @@ export default function FacturacionPage() {
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{conceptos.map((c, idx) => (
|
||||
<div key={idx} className="p-4 border rounded-lg space-y-3">
|
||||
<div key={c.id} className="p-4 border rounded-lg space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium text-muted-foreground">Concepto {idx + 1}</span>
|
||||
<div className="flex gap-1">
|
||||
@@ -1350,9 +1351,7 @@ export default function FacturacionPage() {
|
||||
<Select onValueChange={v => {
|
||||
const opt = TAX_OPTIONS.traslado.find(o => `${o.type}-${o.rate}-${o.factor}` === v);
|
||||
if (!opt) return;
|
||||
const updated = [...conceptos];
|
||||
updated[idx].taxes = [...updated[idx].taxes, { category: 'traslado', type: opt.type, rate: opt.rate, factor: opt.factor }];
|
||||
setConceptos(updated);
|
||||
setConceptos(prev => prev.map((c, i) => i === idx ? { ...c, taxes: [...c.taxes, { category: 'traslado', type: opt.type, rate: opt.rate, factor: opt.factor }] } : c));
|
||||
}}>
|
||||
<SelectTrigger className="h-7 text-xs w-auto gap-1">
|
||||
<Plus className="h-3 w-3" />
|
||||
@@ -1367,9 +1366,7 @@ export default function FacturacionPage() {
|
||||
<Select onValueChange={v => {
|
||||
const opt = TAX_OPTIONS.retencion.find(o => `${o.type}-${o.rate}` === v);
|
||||
if (!opt) return;
|
||||
const updated = [...conceptos];
|
||||
updated[idx].taxes = [...updated[idx].taxes, { category: 'retencion', type: opt.type, rate: opt.rate, factor: opt.factor }];
|
||||
setConceptos(updated);
|
||||
setConceptos(prev => prev.map((c, i) => i === idx ? { ...c, taxes: [...c.taxes, { category: 'retencion', type: opt.type, rate: opt.rate, factor: opt.factor }] } : c));
|
||||
}}>
|
||||
<SelectTrigger className="h-7 text-xs w-auto gap-1">
|
||||
<Plus className="h-3 w-3" />
|
||||
@@ -1406,9 +1403,7 @@ export default function FacturacionPage() {
|
||||
{tax.category === 'retencion' ? '-' : ''}${amount.toLocaleString('es-MX', { minimumFractionDigits: 2 })}
|
||||
</span>
|
||||
<button type="button" onClick={() => {
|
||||
const updated = [...conceptos];
|
||||
updated[idx].taxes = updated[idx].taxes.filter((_, i) => i !== tIdx);
|
||||
setConceptos(updated);
|
||||
setConceptos(prev => prev.map((c, i) => i === idx ? { ...c, taxes: c.taxes.filter((_, ti) => ti !== tIdx) } : c));
|
||||
}} className="text-muted-foreground hover:text-destructive">
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</button>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { apiClient } from './client';
|
||||
import type { CfdiListResponse, CfdiFilters, Cfdi } from '@horux/shared';
|
||||
import type { CfdiListResponse, CfdiFilters, Cfdi, CfdiConceptoFilters, CfdiConceptoListResponse } from '@horux/shared';
|
||||
|
||||
export async function getCfdis(filters: CfdiFilters): Promise<CfdiListResponse> {
|
||||
const params = new URLSearchParams();
|
||||
@@ -102,6 +102,28 @@ export async function getCfdiConceptos(id: number | string): Promise<any[]> {
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function getAllCfdiConceptos(filters: CfdiConceptoFilters): Promise<CfdiConceptoListResponse> {
|
||||
const params = new URLSearchParams();
|
||||
if (filters.fechaInicio) params.set('fechaInicio', filters.fechaInicio);
|
||||
if (filters.fechaFin) params.set('fechaFin', filters.fechaFin);
|
||||
if (filters.tipo) params.set('tipo', filters.tipo);
|
||||
if (filters.tipoComprobante) params.set('tipoComprobante', filters.tipoComprobante);
|
||||
if (filters.estado) params.set('estado', filters.estado);
|
||||
if (filters.rfc) params.set('rfc', filters.rfc);
|
||||
if (filters.search) params.set('search', filters.search);
|
||||
if (filters.uuid) params.set('uuid', filters.uuid);
|
||||
if (filters.claveProdServ) params.set('claveProdServ', filters.claveProdServ);
|
||||
if (filters.descripcion) params.set('descripcion', filters.descripcion);
|
||||
if (filters.orderBy) params.set('orderBy', filters.orderBy);
|
||||
if (filters.orderDir) params.set('orderDir', filters.orderDir);
|
||||
if (filters.contribuyenteId) params.set('contribuyenteId', filters.contribuyenteId);
|
||||
if (filters.page) params.set('page', filters.page.toString());
|
||||
if (filters.limit) params.set('limit', filters.limit.toString());
|
||||
|
||||
const response = await apiClient.get<CfdiConceptoListResponse>(`/cfdi/conceptos?${params}`);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function deleteCfdi(id: string): Promise<void> {
|
||||
await apiClient.delete(`/cfdi/${id}`);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import * as cfdiApi from '@/lib/api/cfdi';
|
||||
import type { CfdiFilters } from '@horux/shared';
|
||||
import type { CfdiFilters, CfdiConceptoFilters } from '@horux/shared';
|
||||
import type { CreateCfdiData } from '@/lib/api/cfdi';
|
||||
import { useContribuyenteStore } from '@/stores/contribuyente-store';
|
||||
|
||||
@@ -58,6 +58,18 @@ export function useCreateManyCfdis() {
|
||||
});
|
||||
}
|
||||
|
||||
export function useCfdiConceptos(filters: CfdiConceptoFilters) {
|
||||
const { selectedContribuyenteId } = useContribuyenteStore();
|
||||
const filtersWithContribuyente: CfdiConceptoFilters = {
|
||||
...filters,
|
||||
contribuyenteId: selectedContribuyenteId || undefined,
|
||||
};
|
||||
return useQuery({
|
||||
queryKey: ['cfdi-conceptos', filters, selectedContribuyenteId],
|
||||
queryFn: () => cfdiApi.getAllCfdiConceptos(filtersWithContribuyente),
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteCfdi() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
|
||||
@@ -167,6 +167,44 @@ export interface CfdiConcepto {
|
||||
creadoEn: string;
|
||||
}
|
||||
|
||||
export interface CfdiConceptoFilters {
|
||||
fechaInicio?: string;
|
||||
fechaFin?: string;
|
||||
tipo?: TipoCfdi;
|
||||
tipoComprobante?: string;
|
||||
estado?: string;
|
||||
rfc?: string;
|
||||
search?: string;
|
||||
uuid?: string;
|
||||
claveProdServ?: string;
|
||||
descripcion?: string;
|
||||
orderBy?: 'fecha' | 'importe';
|
||||
orderDir?: 'asc' | 'desc';
|
||||
contribuyenteId?: string;
|
||||
page?: number;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
export interface CfdiConceptoListItem extends CfdiConcepto {
|
||||
cfdiUuid: string | null;
|
||||
cfdiFechaEmision: string | null;
|
||||
cfdiRfcEmisor: string | null;
|
||||
cfdiNombreEmisor: string | null;
|
||||
cfdiRfcReceptor: string | null;
|
||||
cfdiNombreReceptor: string | null;
|
||||
cfdiTipoComprobante: string | null;
|
||||
cfdiStatus: string | null;
|
||||
cfdiTipo: string | null;
|
||||
}
|
||||
|
||||
export interface CfdiConceptoListResponse {
|
||||
data: CfdiConceptoListItem[];
|
||||
total: number;
|
||||
page: number;
|
||||
limit: number;
|
||||
totalPages: number;
|
||||
}
|
||||
|
||||
export interface CfdiListResponse {
|
||||
data: Cfdi[];
|
||||
total: number;
|
||||
|
||||
143
scripts/backfill-conceptos.js
Normal file
143
scripts/backfill-conceptos.js
Normal file
@@ -0,0 +1,143 @@
|
||||
const { Pool } = require('pg');
|
||||
const { XMLParser } = require('fast-xml-parser');
|
||||
|
||||
const DB_NAME = process.argv[2] || 'horux_roem691011ez4';
|
||||
const BATCH_SIZE = parseInt(process.argv[3]) || 100;
|
||||
|
||||
const pool = new Pool({
|
||||
host: 'localhost',
|
||||
port: 5432,
|
||||
user: 'postgres',
|
||||
password: 'ZxHMrmnwanvLfLDdNJdRthFjWF2Lj1Rb',
|
||||
database: DB_NAME,
|
||||
});
|
||||
|
||||
const xmlParser = new XMLParser({
|
||||
ignoreAttributes: false,
|
||||
attributeNamePrefix: '@_',
|
||||
removeNSPrefix: true,
|
||||
});
|
||||
|
||||
function toArray(val) {
|
||||
if (!val) return [];
|
||||
return Array.isArray(val) ? val : [val];
|
||||
}
|
||||
|
||||
function pf(v) {
|
||||
const n = parseFloat(v);
|
||||
return isNaN(n) ? 0 : n;
|
||||
}
|
||||
|
||||
function extractConceptos(comprobante) {
|
||||
const conceptosNode = comprobante.Conceptos?.Concepto;
|
||||
if (!conceptosNode) return [];
|
||||
const conceptos = toArray(conceptosNode);
|
||||
return conceptos.map((c) => {
|
||||
const trasladosC = toArray(c.Impuestos?.Traslados?.Traslado);
|
||||
const retencionesC = toArray(c.Impuestos?.Retenciones?.Retencion);
|
||||
let ivaTraslado = 0, iepsTraslado = 0;
|
||||
for (const t of trasladosC) {
|
||||
const importe = pf(t['@_Importe']);
|
||||
if (t['@_Impuesto'] === '002') ivaTraslado += importe;
|
||||
else if (t['@_Impuesto'] === '003') iepsTraslado += importe;
|
||||
}
|
||||
let isrRetencion = 0, ivaRetencion = 0, iepsRetencion = 0;
|
||||
for (const r of retencionesC) {
|
||||
const importe = pf(r['@_Importe']);
|
||||
if (r['@_Impuesto'] === '001') isrRetencion += importe;
|
||||
else if (r['@_Impuesto'] === '002') ivaRetencion += importe;
|
||||
else if (r['@_Impuesto'] === '003') iepsRetencion += importe;
|
||||
}
|
||||
return {
|
||||
claveProdServ: c['@_ClaveProdServ'] || null,
|
||||
noIdentificacion: c['@_NoIdentificacion'] || null,
|
||||
descripcion: c['@_Descripcion'] || '',
|
||||
cantidad: pf(c['@_Cantidad']) || 1,
|
||||
claveUnidad: c['@_ClaveUnidad'] || null,
|
||||
unidad: c['@_Unidad'] || null,
|
||||
valorUnitario: pf(c['@_ValorUnitario']),
|
||||
importe: pf(c['@_Importe']),
|
||||
descuento: pf(c['@_Descuento']),
|
||||
isrRetencion,
|
||||
ivaTraslado,
|
||||
ivaRetencion,
|
||||
iepsTraslado,
|
||||
iepsRetencion,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function parseXml(xmlContent) {
|
||||
try {
|
||||
const result = xmlParser.parse(xmlContent);
|
||||
const comprobante = result.Comprobante;
|
||||
if (!comprobante) return null;
|
||||
const tc = pf(comprobante['@_TipoCambio']) || 1;
|
||||
return { tipoCambio: tc, conceptos: extractConceptos(comprobante) };
|
||||
} catch (e) {
|
||||
console.error('Parse error:', e.message);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function run() {
|
||||
let totalProcessed = 0;
|
||||
let totalConceptos = 0;
|
||||
let batch = 0;
|
||||
|
||||
while (true) {
|
||||
batch++;
|
||||
const { rows: cfdis } = await pool.query(`
|
||||
SELECT c.id, c.xml_original
|
||||
FROM cfdis c
|
||||
LEFT JOIN cfdi_conceptos cc ON cc.cfdi_id = c.id
|
||||
WHERE c.xml_original IS NOT NULL AND cc.id IS NULL
|
||||
LIMIT $1
|
||||
`, [BATCH_SIZE]);
|
||||
|
||||
if (cfdis.length === 0) break;
|
||||
|
||||
let batchConceptos = 0;
|
||||
for (const row of cfdis) {
|
||||
try {
|
||||
const parsed = parseXml(row.xml_original);
|
||||
if (!parsed || !parsed.conceptos || parsed.conceptos.length === 0) continue;
|
||||
|
||||
const tc = parsed.tipoCambio || 1;
|
||||
const m = (v) => v * tc;
|
||||
|
||||
for (const c of parsed.conceptos) {
|
||||
await pool.query(`
|
||||
INSERT INTO cfdi_conceptos (
|
||||
cfdi_id, clave_prod_serv, no_identificacion, descripcion, cantidad,
|
||||
clave_unidad, unidad, valor_unitario, valor_unitario_mxn, importe, importe_mxn,
|
||||
descuento, descuento_mxn, isr_retencion, isr_retencion_mxn,
|
||||
iva_traslado, iva_traslado_mxn, iva_retencion, iva_retencion_mxn,
|
||||
ieps_traslado, ieps_traslado_mxn, ieps_retencion, ieps_retencion_mxn
|
||||
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21,$22,$23)
|
||||
`, [
|
||||
row.id, c.claveProdServ, c.noIdentificacion, c.descripcion, c.cantidad,
|
||||
c.claveUnidad, c.unidad, c.valorUnitario, m(c.valorUnitario), c.importe, m(c.importe),
|
||||
c.descuento, m(c.descuento), c.isrRetencion, m(c.isrRetencion),
|
||||
c.ivaTraslado, m(c.ivaTraslado), c.ivaRetencion, m(c.ivaRetencion),
|
||||
c.iepsTraslado, m(c.iepsTraslado), c.iepsRetencion, m(c.iepsRetencion),
|
||||
]);
|
||||
batchConceptos++;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`Error CFDI ${row.id}:`, err.message);
|
||||
}
|
||||
}
|
||||
|
||||
totalProcessed += cfdis.length;
|
||||
totalConceptos += batchConceptos;
|
||||
console.log(`Batch ${batch}: procesados ${cfdis.length}, conceptos ${batchConceptos}, total procesados ${totalProcessed}, total conceptos ${totalConceptos}`);
|
||||
|
||||
if (cfdis.length < BATCH_SIZE) break;
|
||||
}
|
||||
|
||||
console.log(`\nBackfill completado. Total CFDIs procesados: ${totalProcessed}, total conceptos insertados: ${totalConceptos}`);
|
||||
await pool.end();
|
||||
}
|
||||
|
||||
run().catch((e) => { console.error(e); process.exit(1); });
|
||||
Reference in New Issue
Block a user