Files
HoruxDespachosNuevo/apps/api/scripts/add-demo-notas-credito.ts

260 lines
8.9 KiB
TypeScript

/**
* 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<string>();
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<string>();
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<FacturaOrigen[]> {
const { rows } = await client.query<FacturaOrigen>(`
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();
});