260 lines
8.9 KiB
TypeScript
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();
|
|
});
|