/** * Script: add-demo-notas-credito.ts * * Agrega notas de crédito (NC) sintéticas a los contribuyentes del tenant * "Demo Ventas" (horux_demoventas). Cada NC se relaciona con una factura * existente (tipo_comprobante = 'I', metodo_pago = 'PUE') mediante * cfdi_tipo_relacion = '01' y cfdis_relacionados = uuid de la factura origen. * * El script es idempotente: usa UUIDs deterministas, por lo que volverlo a * correr no duplica registros. * * Uso: * cd apps/api && npx tsx scripts/add-demo-notas-credito.ts * * Opciones via env: * DEMO_NC_POR_CONTRIBUYENTE=4 # default: 4 (2 emitidas + 2 recibidas) * DEMO_NC_DIAS_DESPUES=90 # default: 90 (max dias despues de la factura) */ import { PrismaClient } from '@prisma/client'; import { Pool, type PoolClient } 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 NC_POR_CONTRIBUYENTE = parseInt(process.env.DEMO_NC_POR_CONTRIBUYENTE || '4', 10); const MAX_DIAS_DESPUES = parseInt(process.env.DEMO_NC_DIAS_DESPUES || '90', 10); 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 addDays(fecha: Date, dias: number): Date { const r = new Date(fecha); r.setDate(r.getDate() + dias); r.setHours(8 + Math.floor(Math.random() * 10), Math.floor(Math.random() * 60), 0, 0); return r; } interface FacturaOrigen { id: number; uuid: string; total: number; fecha_emision: Date; type: 'EMITIDO' | 'RECIBIDO'; rfc_emisor_id: number; rfc_emisor: string; nombre_emisor: string; rfc_receptor_id: number; rfc_receptor: string; nombre_receptor: string; forma_pago: string; uso_cfdi: string; regimen_fiscal_emisor: string; regimen_fiscal_receptor: string; } async function main() { console.log(`🌱 Agregando ${NC_POR_CONTRIBUYENTE} notas de crédito 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 totalCreadas = 0; let totalExistentes = 0; for (const c of contribuyentes) { const { creadas, existentes } = await agregarNcContribuyente(pool, c); console.log(`✅ ${c.rfc}: ${creadas} NCs creadas, ${existentes} ya existian`); totalCreadas += creadas; totalExistentes += existentes; } console.log(`\n🎉 Total: ${totalCreadas} notas de crédito nuevas, ${totalExistentes} ya existian`); } async function agregarNcContribuyente( pool: Pool, contribuyente: { entidad_id: string; rfc: string; nombre: string }, ): Promise<{ creadas: number; existentes: number }> { const client = await pool.connect(); const mesesAfectados = new Set(); try { await client.query('BEGIN'); const mitad = Math.ceil(NC_POR_CONTRIBUYENTE / 2); const facturasEmitidas = await obtenerFacturasPUE(client, contribuyente.entidad_id, 'EMITIDO', mitad); const facturasRecibidas = await obtenerFacturasPUE(client, contribuyente.entidad_id, 'RECIBIDO', NC_POR_CONTRIBUYENTE - mitad); let creadas = 0; let existentes = 0; const usadas = new Set(); let idxEmitida = 0; let idxRecibida = 0; for (let i = 0; i < NC_POR_CONTRIBUYENTE; i++) { const esEmitida = i % 2 === 0; const origen = esEmitida ? facturasEmitidas[idxEmitida++ % facturasEmitidas.length] : facturasRecibidas[idxRecibida++ % facturasRecibidas.length]; if (!origen || usadas.has(origen.uuid)) { // Si no hay suficientes facturas distintas, saltar continue; } usadas.add(origen.uuid); // Monto de la NC: entre 10% y 40% del total de la factura origen const porcentaje = 0.1 + Math.random() * 0.3; const ncTotal = round2(origen.total * porcentaje); const ncSubtotal = round2(ncTotal / 1.16); const ncIva = round2(ncTotal - ncSubtotal); // Fecha: entre 5 y MAX_DIAS_DESPUES dias despues de la factura origen, sin pasar de hoy const diasDespues = 5 + Math.floor(Math.random() * (MAX_DIAS_DESPUES - 5)); let ncFecha = addDays(origen.fecha_emision, diasDespues); const ahora = new Date(); if (ncFecha > ahora) ncFecha = ahora; const year = String(ncFecha.getFullYear()); const month = String(ncFecha.getMonth() + 1).padStart(2, '0'); const fechaStr = ncFecha.toISOString(); const uuid = deterministicUuid(`${contribuyente.rfc}-demo-nc-${esEmitida ? 'E' : 'R'}-${i}`); 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: [nc] } = 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, cfdi_tipo_relacion, cfdis_relacionados ) 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, $35, $36 ) RETURNING id `, [ year, month, esEmitida ? 'EMITIDO' : 'RECIBIDO', uuid, 'NC', String(200000 + i), 'Vigente', fechaStr, origen.rfc_emisor_id, origen.rfc_emisor, origen.nombre_emisor, origen.rfc_receptor_id, origen.rfc_receptor, origen.nombre_receptor, ncSubtotal, ncSubtotal, 0, 0, ncTotal, ncTotal, 'MXN', 1, 'E', 'PUE', origen.forma_pago, origen.uso_cfdi, ncIva, ncIva, origen.regimen_fiscal_emisor, origen.regimen_fiscal_receptor, contribuyente.entidad_id, fechaStr, month, year, '01', origen.uuid.toLowerCase(), ]); 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) `, [ nc.id, '84111506', 'Descuento por nota de credito', 1, 'E48', 'Servicio', ncSubtotal, ncSubtotal, ncSubtotal, ncSubtotal, ncIva, ncIva, ]); creadas++; mesesAfectados.add(`${year}-${month}`); } for (const ym of mesesAfectados) { const [anio, mes] = ym.split('-').map(Number); await markForInvalidation(pool, contribuyente.entidad_id, anio, mes, 'demo-nc'); } await client.query('COMMIT'); return { creadas, existentes }; } catch (err) { await client.query('ROLLBACK').catch(() => {}); throw err; } finally { client.release(); } } async function obtenerFacturasPUE( client: PoolClient, contribuyenteId: string, type: 'EMITIDO' | 'RECIBIDO', limite: number, ): Promise { const { rows } = await client.query(` SELECT id, uuid, total_mxn AS total, fecha_emision, type, rfc_emisor_id, rfc_emisor, nombre_emisor, rfc_receptor_id, rfc_receptor, nombre_receptor, forma_pago, uso_cfdi, regimen_fiscal_emisor, regimen_fiscal_receptor FROM cfdis WHERE contribuyente_id = $1 AND type = $2 AND tipo_comprobante = 'I' AND metodo_pago = 'PUE' AND status = 'Vigente' AND total_mxn > 5000 ORDER BY random() LIMIT $3 `, [contribuyenteId, type, Math.max(limite * 3, 20)]); // Mezclar y retornar hasta `limite` const mezcladas = rows.sort(() => Math.random() - 0.5); return mezcladas.slice(0, limite); } main() .catch((e) => { console.error('\n❌ Error:', e); process.exit(1); }) .finally(async () => { await prisma.$disconnect(); await tenantDb.shutdown(); });