/** * Script: add-demo-cfdis.ts * * Agrega CFDIs sintéticos adicionales a los contribuyentes del tenant * "Demo Ventas" (horux_demoventas). Los CFDIs se generan con UUIDs * deterministas, por lo que el script es idempotente: volverlo a correr no * duplica registros. * * Uso: * cd apps/api && npx tsx scripts/add-demo-cfdis.ts * * Opciones via env: * DEMO_CFDIS_POR_CONTRIBUYENTE=80 # default: 80 * DEMO_DIAS_ATRAS=540 # default: 540 (~18 meses) */ import { PrismaClient } from '@prisma/client'; import { Pool } from 'pg'; import { createHash } from 'crypto'; import { tenantDb } from '../src/config/database.ts'; import { markForInvalidation } from '../src/services/metricas.service.js'; const prisma = new PrismaClient(); const DEMO_RFC = 'DEMO2501019X2'; const CFDIS_POR_CONTRIBUYENTE = parseInt(process.env.DEMO_CFDIS_POR_CONTRIBUYENTE || '80', 10); const DIAS_ATRAS = parseInt(process.env.DEMO_DIAS_ATRAS || '540', 10); const CLIENTES = [ { rfc: 'CLI123456AB1', nombre: 'Cliente Alfa SA' }, { rfc: 'CLI123456AB2', nombre: 'Cliente Beta SA' }, { rfc: 'CLI123456AB3', nombre: 'Cliente Gamma SA' }, { rfc: 'CLI123456AB4', nombre: 'Cliente Delta SA' }, { rfc: 'CLI123456AB5', nombre: 'Cliente Epsilon SA' }, { rfc: 'CLI123456AB6', nombre: 'Cliente Zeta SA' }, { rfc: 'CLI123456AB7', nombre: 'Cliente Eta SA' }, ]; const PROVEEDORES = [ { rfc: 'PRO123456AB1', nombre: 'Proveedor Materiales SA' }, { rfc: 'PRO123456AB2', nombre: 'Proveedor Servicios SA' }, { rfc: 'PRO123456AB3', nombre: 'Proveedor Logistica SA' }, { rfc: 'PRO123456AB4', nombre: 'Proveedor Tecnologia SA' }, { rfc: 'PRO123456AB5', nombre: 'Proveedor Papeleria SA' }, { rfc: 'PRO123456AB6', nombre: 'Proveedor Telecom SA' }, { rfc: 'PRO123456AB7', nombre: 'Proveedor Asesoria SA' }, ]; const PRODUCTOS = [ { clave: '84111506', descripcion: 'Servicio de consultoria', unidad: 'Servicio' }, { clave: '43232408', descripcion: 'Licencia de software', unidad: 'Licencia' }, { clave: '81141500', descripcion: 'Soporte tecnico', unidad: 'Servicio' }, { clave: '81121700', descripcion: 'Desarrollo web', unidad: 'Servicio' }, { clave: '86101500', descripcion: 'Capacitacion', unidad: 'Servicio' }, { clave: '50151500', descripcion: 'Materiales de oficina', unidad: 'Pieza' }, { clave: '80181600', descripcion: 'Publicidad', unidad: 'Servicio' }, { clave: '81112200', descripcion: 'Diseno grafico', unidad: 'Servicio' }, { clave: '72121000', descripcion: 'Renta de oficinas', unidad: 'Servicio' }, { clave: '73101500', descripcion: 'Servicios de telecomunicaciones', unidad: 'Servicio' }, { clave: '43231500', descripcion: 'Infraestructura en la nube', unidad: 'Servicio' }, { clave: '81141800', descripcion: 'Mantenimiento de sistemas', unidad: 'Servicio' }, ]; const FORMAS_PAGO = ['01', '02', '03', '04', '28', '99']; function deterministicUuid(seed: string): string { const hex = createHash('sha256').update(seed).digest('hex'); return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-4${hex.slice(13, 16)}-${hex.slice(16, 20)}-${hex.slice(20, 32)}`; } function round2(n: number): number { return Math.round(n * 100) / 100; } function randomDateWithin(daysBack: number): Date { // Sesgar hacia fechas recientes: random^2 produce valores pequenos con mayor probabilidad const daysAgo = Math.floor(Math.pow(Math.random(), 2) * daysBack); const fecha = new Date(); fecha.setDate(fecha.getDate() - daysAgo); fecha.setHours(8 + Math.floor(Math.random() * 10), Math.floor(Math.random() * 60), 0, 0); return fecha; } async function main() { console.log(`🌱 Agregando ${CFDIS_POR_CONTRIBUYENTE} CFDIs adicionales por contribuyente en Demo Ventas...\n`); const tenant = await prisma.tenant.findUnique({ where: { rfc: DEMO_RFC } }); if (!tenant) throw new Error(`Tenant ${DEMO_RFC} no encontrado`); const pool = await tenantDb.getPool(tenant.id, tenant.databaseName); const { rows: contribuyentes } = await pool.query<{ entidad_id: string; rfc: string; nombre: string }>(` SELECT c.entidad_id, c.rfc, eg.nombre FROM contribuyentes c JOIN entidades_gestionadas eg ON eg.id = c.entidad_id ORDER BY c.rfc `); if (contribuyentes.length === 0) throw new Error('No hay contribuyentes demo'); let totalCreados = 0; let totalExistentes = 0; for (const c of contribuyentes) { const { creados, existentes } = await agregarCfdisContribuyente(pool, c); console.log(`✅ ${c.rfc}: ${creados} CFDIs creados, ${existentes} ya existian`); totalCreados += creados; totalExistentes += existentes; } console.log(`\n🎉 Total: ${totalCreados} CFDIs nuevos, ${totalExistentes} ya existian`); } async function agregarCfdisContribuyente( pool: Pool, contribuyente: { entidad_id: string; rfc: string; nombre: string }, ): Promise<{ creados: number; existentes: number }> { const client = await pool.connect(); const mesesAfectados = new Set(); try { await client.query('BEGIN'); // Asegurar RFCs de clientes/proveedores y el contribuyente mismo const rfcs = new Map(); for (const p of [...CLIENTES, ...PROVEEDORES]) { const { rows: [r] } = await client.query<{ id: number }>(` INSERT INTO rfcs (rfc, razon_social, regimen_fiscal) VALUES ($1, $2, '601') ON CONFLICT (rfc) DO UPDATE SET razon_social = EXCLUDED.razon_social RETURNING id `, [p.rfc, p.nombre]); rfcs.set(p.rfc, r.id); } const { rows: [principal] } = await client.query<{ id: number }>(` INSERT INTO rfcs (rfc, razon_social, regimen_fiscal) VALUES ($1, $2, '601') ON CONFLICT (rfc) DO UPDATE SET razon_social = EXCLUDED.razon_social RETURNING id `, [contribuyente.rfc, contribuyente.nombre]); rfcs.set(contribuyente.rfc, principal.id); let creados = 0; let existentes = 0; for (let i = 0; i < CFDIS_POR_CONTRIBUYENTE; i++) { const esEmitido = i % 2 === 0; const contraparte = esEmitido ? CLIENTES[i % CLIENTES.length] : PROVEEDORES[i % PROVEEDORES.length]; // Distribucion sesgada: muchos CFDIs pequenos, pocos grandes const raw = Math.random() * Math.random(); const subtotal = Math.floor(raw * 60000) + 1500; const iva = round2(subtotal * 0.16); const total = round2(subtotal + iva); const fecha = randomDateWithin(DIAS_ATRAS); const year = String(fecha.getFullYear()); const month = String(fecha.getMonth() + 1).padStart(2, '0'); const fechaStr = fecha.toISOString(); const metodoPago = Math.random() > 0.35 ? 'PUE' : 'PPD'; const formaPago = FORMAS_PAGO[i % FORMAS_PAGO.length]; const usoCfdi = esEmitido ? 'G03' : 'G01'; const tipo = esEmitido ? 'EMITIDO' : 'RECIBIDO'; const rfcEmisor = esEmitido ? contribuyente.rfc : contraparte.rfc; const nombreEmisor = esEmitido ? contribuyente.nombre : contraparte.nombre; const rfcReceptor = esEmitido ? contraparte.rfc : contribuyente.rfc; const nombreReceptor = esEmitido ? contraparte.nombre : contribuyente.nombre; const uuid = deterministicUuid(`${contribuyente.rfc}-add-demo-cfdis-${i}`); // Idempotencia: si el UUID ya existe, lo contamos y saltamos const { rows: duplicados } = await client.query(`SELECT 1 FROM cfdis WHERE lower(uuid) = lower($1) LIMIT 1`, [uuid]); if (duplicados.length > 0) { existentes++; continue; } const { rows: [cfdi] } = await client.query<{ id: number }>(` INSERT INTO cfdis ( year, month, type, uuid, serie, folio, status, fecha_emision, rfc_emisor_id, rfc_emisor, nombre_emisor, rfc_receptor_id, rfc_receptor, nombre_receptor, subtotal, subtotal_mxn, descuento, descuento_mxn, total, total_mxn, moneda, tipo_cambio, tipo_comprobante, metodo_pago, forma_pago, uso_cfdi, iva_traslado, iva_traslado_mxn, regimen_fiscal_emisor, regimen_fiscal_receptor, contribuyente_id, fecha_efectiva, meses_global, año_global ) VALUES ( $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26, $27, $28, $29, $30, $31, $32, $33, $34 ) RETURNING id `, [ year, month, tipo, uuid, 'DEMO', String(100000 + i), 'Vigente', fechaStr, rfcs.get(rfcEmisor), rfcEmisor, nombreEmisor, rfcs.get(rfcReceptor), rfcReceptor, nombreReceptor, subtotal, subtotal, 0, 0, total, total, 'MXN', 1, 'I', metodoPago, formaPago, usoCfdi, iva, iva, '601', '601', contribuyente.entidad_id, fechaStr, month, year, ]); // Conceptos: de 1 a 3, repartiendo exactamente el subtotal const numConceptos = Math.floor(Math.random() * 3) + 1; let importeRestante = round2(subtotal); for (let j = 0; j < numConceptos; j++) { const prod = PRODUCTOS[(i + j) % PRODUCTOS.length]; const esUltimo = j === numConceptos - 1; const cantidad = Math.floor(Math.random() * 5) + 1; let importe: number; if (esUltimo) { importe = importeRestante; } else { const promedio = importeRestante / (numConceptos - j); const factor = 0.7 + Math.random() * 0.6; // 70% - 130% del promedio importe = round2(promedio * factor); importe = Math.min(importe, importeRestante - 0.01); } importeRestante = round2(importeRestante - importe); const valorUnitario = round2(importe / cantidad); const ivaConcepto = round2(importe * 0.16); await client.query(` INSERT INTO cfdi_conceptos ( cfdi_id, clave_prod_serv, descripcion, cantidad, clave_unidad, unidad, valor_unitario, valor_unitario_mxn, importe, importe_mxn, iva_traslado, iva_traslado_mxn ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) `, [ cfdi.id, prod.clave, prod.descripcion, cantidad, 'E48', prod.unidad, valorUnitario, valorUnitario, importe, importe, ivaConcepto, ivaConcepto, ]); } creados++; mesesAfectados.add(`${year}-${month}`); } // Marcar meses afectados para recomputo de metricas for (const ym of mesesAfectados) { const [anio, mes] = ym.split('-').map(Number); await markForInvalidation(pool, contribuyente.entidad_id, anio, mes, 'add-demo-cfdis'); } await client.query('COMMIT'); return { creados, existentes }; } catch (err) { await client.query('ROLLBACK').catch(() => {}); throw err; } finally { client.release(); } } main() .catch((e) => { console.error('\n❌ Error:', e); process.exit(1); }) .finally(async () => { await prisma.$disconnect(); await tenantDb.shutdown(); });