280 lines
11 KiB
TypeScript
280 lines
11 KiB
TypeScript
/**
|
|
* 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<string>();
|
|
|
|
try {
|
|
await client.query('BEGIN');
|
|
|
|
// Asegurar RFCs de clientes/proveedores y el contribuyente mismo
|
|
const rfcs = new Map<string, number>();
|
|
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();
|
|
});
|