feat: drill-down en pestaña nueva, rol Vendedor y scripts demo
This commit is contained in:
259
apps/api/scripts/add-demo-notas-credito.ts
Normal file
259
apps/api/scripts/add-demo-notas-credito.ts
Normal file
@@ -0,0 +1,259 @@
|
||||
/**
|
||||
* 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();
|
||||
});
|
||||
Reference in New Issue
Block a user