feat: drill-down en pestaña nueva, rol Vendedor y scripts demo
This commit is contained in:
279
apps/api/scripts/add-demo-cfdis.ts
Normal file
279
apps/api/scripts/add-demo-cfdis.ts
Normal file
@@ -0,0 +1,279 @@
|
|||||||
|
/**
|
||||||
|
* 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();
|
||||||
|
});
|
||||||
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();
|
||||||
|
});
|
||||||
119
apps/api/scripts/create-vendedor-fernando.ts
Normal file
119
apps/api/scripts/create-vendedor-fernando.ts
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
/**
|
||||||
|
* Script: create-vendedor-fernando.ts
|
||||||
|
*
|
||||||
|
* Crea la cuenta de Fernando (fernando@horuxfin.com) como Vendedor de Horux 360.
|
||||||
|
* Rol de plataforma: platform_sales (Vendedor).
|
||||||
|
* Membership en el tenant Horux 360 con rol cliente (minimo, solo para login
|
||||||
|
* y acceso a configuracion/cambio de contraseña).
|
||||||
|
*
|
||||||
|
* Si el usuario ya existe, le asigna/actualiza los permisos y envia un correo
|
||||||
|
* de notificacion. Si es nuevo, genera password temporal y envia bienvenida.
|
||||||
|
*
|
||||||
|
* Uso:
|
||||||
|
* cd apps/api && npx tsx scripts/create-vendedor-fernando.ts
|
||||||
|
*/
|
||||||
|
import { randomBytes } from 'crypto';
|
||||||
|
import { prisma } from '../src/config/database.js';
|
||||||
|
import { hashPassword } from '../src/auth/passwords.js';
|
||||||
|
import { emailService } from '../src/services/email/email.service.js';
|
||||||
|
import { invalidatePlatformRolesCache } from '../src/utils/platform-admin.js';
|
||||||
|
|
||||||
|
const EMAIL = 'fernando@horuxfin.com';
|
||||||
|
const NOMBRE = 'Fernando';
|
||||||
|
const HORUX_RFC = 'HTS240708LJA';
|
||||||
|
|
||||||
|
function generarPassword(): string {
|
||||||
|
return randomBytes(6).toString('hex'); // 12 caracteres hex
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
console.log(`🌱 Creando cuenta de Vendedor para ${EMAIL}...\n`);
|
||||||
|
|
||||||
|
// 1. Tenant raiz Horux 360
|
||||||
|
const tenant = await prisma.tenant.findUnique({ where: { rfc: HORUX_RFC } });
|
||||||
|
if (!tenant) throw new Error(`Tenant Horux 360 (${HORUX_RFC}) no encontrado. Ejecuta primero el bootstrap admin global.`);
|
||||||
|
|
||||||
|
// 2. Rol "cliente" para la membership (minimo acceso)
|
||||||
|
const clienteRol = await prisma.rol.findUnique({ where: { nombre: 'cliente' } });
|
||||||
|
if (!clienteRol) throw new Error('Rol "cliente" no encontrado en BD central');
|
||||||
|
|
||||||
|
// 3. Buscar o crear usuario
|
||||||
|
let user = await prisma.user.findUnique({ where: { email: EMAIL } });
|
||||||
|
let tempPassword: string | null = null;
|
||||||
|
let esNuevo = false;
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
tempPassword = generarPassword();
|
||||||
|
const passwordHash = await hashPassword(tempPassword);
|
||||||
|
user = await prisma.user.create({
|
||||||
|
data: {
|
||||||
|
email: EMAIL,
|
||||||
|
passwordHash,
|
||||||
|
nombre: NOMBRE,
|
||||||
|
lastTenantId: tenant.id,
|
||||||
|
active: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
esNuevo = true;
|
||||||
|
console.log(`✅ Usuario creado: ${user.email}`);
|
||||||
|
console.log(` Password temporal: ${tempPassword}`);
|
||||||
|
} else {
|
||||||
|
await prisma.user.update({
|
||||||
|
where: { id: user.id },
|
||||||
|
data: { lastTenantId: tenant.id, active: true },
|
||||||
|
});
|
||||||
|
console.log(`ℹ️ Usuario ya existia: ${user.email}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Membership en Horux 360 con rol cliente
|
||||||
|
await prisma.tenantMembership.upsert({
|
||||||
|
where: { userId_tenantId: { userId: user.id, tenantId: tenant.id } },
|
||||||
|
update: { rolId: clienteRol.id, active: true, isOwner: false },
|
||||||
|
create: {
|
||||||
|
userId: user.id,
|
||||||
|
tenantId: tenant.id,
|
||||||
|
rolId: clienteRol.id,
|
||||||
|
active: true,
|
||||||
|
isOwner: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
console.log(`✅ Membership "cliente" en ${tenant.nombre}`);
|
||||||
|
|
||||||
|
// 5. Rol de plataforma Vendedor (platform_sales)
|
||||||
|
await prisma.userPlatformRole.upsert({
|
||||||
|
where: { userId_role: { userId: user.id, role: 'platform_sales' } },
|
||||||
|
update: {},
|
||||||
|
create: { userId: user.id, role: 'platform_sales' },
|
||||||
|
});
|
||||||
|
console.log(`✅ Rol de plataforma "Vendedor" (platform_sales) asignado`);
|
||||||
|
|
||||||
|
// 6. Invalidar cache de roles de plataforma
|
||||||
|
invalidatePlatformRolesCache(user.id);
|
||||||
|
|
||||||
|
// 7. Enviar correo con accesos (solo si es nuevo; si ya existia, no se reenvia password)
|
||||||
|
if (esNuevo && tempPassword) {
|
||||||
|
await emailService.sendWelcome(EMAIL, {
|
||||||
|
nombre: NOMBRE,
|
||||||
|
email: EMAIL,
|
||||||
|
tempPassword,
|
||||||
|
});
|
||||||
|
console.log(`✅ Correo de bienvenida con credenciales enviado a ${EMAIL}`);
|
||||||
|
} else {
|
||||||
|
console.log(`ℹ️ El usuario ya existia; no se envio correo con password`);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('\n🎉 Cuenta de Vendedor lista');
|
||||||
|
console.log(` Email: ${EMAIL}`);
|
||||||
|
if (tempPassword) console.log(` Password temporal: ${tempPassword}`);
|
||||||
|
console.log(` Tenant: ${tenant.nombre} (${tenant.rfc})`);
|
||||||
|
console.log(` Rol de plataforma: platform_sales (Vendedor)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
main()
|
||||||
|
.catch((err) => {
|
||||||
|
console.error('\n❌ Error:', err.message || err);
|
||||||
|
process.exit(1);
|
||||||
|
})
|
||||||
|
.finally(async () => {
|
||||||
|
await prisma.$disconnect();
|
||||||
|
});
|
||||||
@@ -9,10 +9,10 @@ export async function createInvitation(req: Request, res: Response, next: NextFu
|
|||||||
return res.status(400).json({ message: 'El email es requerido' });
|
return res.status(400).json({ message: 'El email es requerido' });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Solo platform_admin puede crear invitaciones
|
// Admin y Vendedor (platform_sales) pueden crear invitaciones
|
||||||
const isAdmin = await hasAnyPlatformRole(req.user!.userId, 'platform_admin');
|
const isAdmin = await hasAnyPlatformRole(req.user!.userId, 'platform_admin', 'platform_sales');
|
||||||
if (!isAdmin) {
|
if (!isAdmin) {
|
||||||
return res.status(403).json({ message: 'Solo administradores pueden crear invitaciones' });
|
return res.status(403).json({ message: 'Solo administradores o vendedores pueden crear invitaciones' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const invitation = await clientInvitationService.createInvitation({
|
const invitation = await clientInvitationService.createInvitation({
|
||||||
@@ -70,7 +70,7 @@ export async function registerFromInvitation(req: Request, res: Response, next:
|
|||||||
|
|
||||||
export async function resendInvitation(req: Request, res: Response, next: NextFunction) {
|
export async function resendInvitation(req: Request, res: Response, next: NextFunction) {
|
||||||
try {
|
try {
|
||||||
const isAdmin = await hasAnyPlatformRole(req.user!.userId, 'platform_admin');
|
const isAdmin = await hasAnyPlatformRole(req.user!.userId, 'platform_admin', 'platform_sales');
|
||||||
if (!isAdmin) {
|
if (!isAdmin) {
|
||||||
return res.status(403).json({ message: 'No autorizado' });
|
return res.status(403).json({ message: 'No autorizado' });
|
||||||
}
|
}
|
||||||
@@ -88,7 +88,7 @@ export async function resendInvitation(req: Request, res: Response, next: NextFu
|
|||||||
|
|
||||||
export async function listInvitations(req: Request, res: Response, next: NextFunction) {
|
export async function listInvitations(req: Request, res: Response, next: NextFunction) {
|
||||||
try {
|
try {
|
||||||
const isAdmin = await hasAnyPlatformRole(req.user!.userId, 'platform_admin');
|
const isAdmin = await hasAnyPlatformRole(req.user!.userId, 'platform_admin', 'platform_sales');
|
||||||
if (!isAdmin) {
|
if (!isAdmin) {
|
||||||
return res.status(403).json({ message: 'No autorizado' });
|
return res.status(403).json({ message: 'No autorizado' });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,19 +1,19 @@
|
|||||||
import type { Request, Response, NextFunction } from 'express';
|
import type { Request, Response, NextFunction } from 'express';
|
||||||
import * as trialInvitationService from '../services/trial-invitations.service.js';
|
import * as trialInvitationService from '../services/trial-invitations.service.js';
|
||||||
import { isGlobalAdmin } from '../utils/global-admin.js';
|
import { hasAnyPlatformRole } from '../utils/platform-admin.js';
|
||||||
import { prisma } from '../config/database.js';
|
import { prisma } from '../config/database.js';
|
||||||
|
|
||||||
async function requireGlobalAdmin(req: Request, res: Response): Promise<boolean> {
|
async function requireAdminOrSales(req: Request, res: Response): Promise<boolean> {
|
||||||
const isAdmin = await isGlobalAdmin(req.user!.tenantId, req.user!.role, req.user!.userId);
|
const isAdmin = await hasAnyPlatformRole(req.user!.userId, 'platform_admin', 'platform_sales');
|
||||||
if (!isAdmin) {
|
if (!isAdmin) {
|
||||||
res.status(403).json({ message: 'Solo el administrador global puede gestionar invitaciones de trial' });
|
res.status(403).json({ message: 'Solo administradores o vendedores pueden gestionar invitaciones de trial' });
|
||||||
}
|
}
|
||||||
return isAdmin;
|
return isAdmin;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createInvitation(req: Request, res: Response, next: NextFunction) {
|
export async function createInvitation(req: Request, res: Response, next: NextFunction) {
|
||||||
try {
|
try {
|
||||||
if (!(await requireGlobalAdmin(req, res))) return;
|
if (!(await requireAdminOrSales(req, res))) return;
|
||||||
|
|
||||||
const { tenantId, plan, durationDays } = req.body;
|
const { tenantId, plan, durationDays } = req.body;
|
||||||
if (!tenantId || !durationDays || durationDays < 1 || durationDays > 365) {
|
if (!tenantId || !durationDays || durationDays < 1 || durationDays > 365) {
|
||||||
@@ -38,7 +38,7 @@ export async function createInvitation(req: Request, res: Response, next: NextFu
|
|||||||
|
|
||||||
export async function getAllInvitations(req: Request, res: Response, next: NextFunction) {
|
export async function getAllInvitations(req: Request, res: Response, next: NextFunction) {
|
||||||
try {
|
try {
|
||||||
if (!(await requireGlobalAdmin(req, res))) return;
|
if (!(await requireAdminOrSales(req, res))) return;
|
||||||
|
|
||||||
const { tenantId, status } = req.query;
|
const { tenantId, status } = req.query;
|
||||||
const invitations = await trialInvitationService.getInvitations({
|
const invitations = await trialInvitationService.getInvitations({
|
||||||
@@ -85,7 +85,7 @@ export async function acceptInvitation(req: Request, res: Response, next: NextFu
|
|||||||
|
|
||||||
export async function cancelInvitation(req: Request, res: Response, next: NextFunction) {
|
export async function cancelInvitation(req: Request, res: Response, next: NextFunction) {
|
||||||
try {
|
try {
|
||||||
if (!(await requireGlobalAdmin(req, res))) return;
|
if (!(await requireAdminOrSales(req, res))) return;
|
||||||
|
|
||||||
const id = typeof req.params.id === 'string' ? req.params.id : '';
|
const id = typeof req.params.id === 'string' ? req.params.id : '';
|
||||||
const result = await trialInvitationService.cancelInvitation(id);
|
const result = await trialInvitationService.cancelInvitation(id);
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ const ROLE_META: Record<PlatformRole, { label: string; desc: string; icon: any;
|
|||||||
platform_admin: { label: 'Admin', desc: 'Todo: gestión staff, precios, clientes, facturas', icon: ShieldCheck, color: 'bg-red-100 text-red-700 border-red-200' },
|
platform_admin: { label: 'Admin', desc: 'Todo: gestión staff, precios, clientes, facturas', icon: ShieldCheck, color: 'bg-red-100 text-red-700 border-red-200' },
|
||||||
platform_ti: { label: 'TI', desc: 'Equipo de TI. Mismos permisos que Admin (diferencia solo en trazabilidad)', icon: Cpu, color: 'bg-slate-100 text-slate-700 border-slate-200' },
|
platform_ti: { label: 'TI', desc: 'Equipo de TI. Mismos permisos que Admin (diferencia solo en trazabilidad)', icon: Cpu, color: 'bg-slate-100 text-slate-700 border-slate-200' },
|
||||||
platform_support: { label: 'Support', desc: 'Ver tenants, resolver tickets', icon: HeadphonesIcon, color: 'bg-blue-100 text-blue-700 border-blue-200' },
|
platform_support: { label: 'Support', desc: 'Ver tenants, resolver tickets', icon: HeadphonesIcon, color: 'bg-blue-100 text-blue-700 border-blue-200' },
|
||||||
platform_sales: { label: 'Sales', desc: 'Crear/editar clientes, ver suscripciones', icon: TrendingUp, color: 'bg-green-100 text-green-700 border-green-200' },
|
platform_sales: { label: 'Vendedor', desc: 'Enviar invitaciones a nuevos despachos', icon: TrendingUp, color: 'bg-green-100 text-green-700 border-green-200' },
|
||||||
platform_finance: { label: 'Finance', desc: 'Pagos, facturas manuales, editar precios', icon: DollarSign, color: 'bg-amber-100 text-amber-700 border-amber-200' },
|
platform_finance: { label: 'Finance', desc: 'Pagos, facturas manuales, editar precios', icon: DollarSign, color: 'bg-amber-100 text-amber-700 border-amber-200' },
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -232,6 +232,8 @@ export default function DashboardPage() {
|
|||||||
: 'Sin datos del periodo anterior'
|
: 'Sin datos del periodo anterior'
|
||||||
}
|
}
|
||||||
href={drillUrl('Ingresos del Mes - CFDIs', { bucket: 'ingresos' })}
|
href={drillUrl('Ingresos del Mes - CFDIs', { bucket: 'ingresos' })}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
/>
|
/>
|
||||||
<KpiCard
|
<KpiCard
|
||||||
title={regimenSeleccionado ? `NCs Emitidas (${regimenSeleccionado})` : 'NCs Emitidas'}
|
title={regimenSeleccionado ? `NCs Emitidas (${regimenSeleccionado})` : 'NCs Emitidas'}
|
||||||
@@ -251,6 +253,8 @@ export default function DashboardPage() {
|
|||||||
: 'Sin datos del periodo anterior'
|
: 'Sin datos del periodo anterior'
|
||||||
}
|
}
|
||||||
href={drillUrl('Gastos del Mes - CFDIs', { bucket: 'gastos' })}
|
href={drillUrl('Gastos del Mes - CFDIs', { bucket: 'gastos' })}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
/>
|
/>
|
||||||
<KpiCard
|
<KpiCard
|
||||||
title={regimenSeleccionado ? `NCs Recibidas (${regimenSeleccionado})` : 'NCs Recibidas'}
|
title={regimenSeleccionado ? `NCs Recibidas (${regimenSeleccionado})` : 'NCs Recibidas'}
|
||||||
@@ -278,6 +282,8 @@ export default function DashboardPage() {
|
|||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
href={drillUrl('Balance IVA - CFDIs', {})}
|
href={drillUrl('Balance IVA - CFDIs', {})}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -186,6 +186,8 @@ export default function ImpuestosPage() {
|
|||||||
icon={<TrendingUp className="h-4 w-4" />}
|
icon={<TrendingUp className="h-4 w-4" />}
|
||||||
subtitle="Cobrado a clientes"
|
subtitle="Cobrado a clientes"
|
||||||
href={drillUrl('IVA Trasladado - CFDIs Emitidos', { bucket: 'causado' })}
|
href={drillUrl('IVA Trasladado - CFDIs Emitidos', { bucket: 'causado' })}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
/>
|
/>
|
||||||
<KpiCard
|
<KpiCard
|
||||||
title={regimenSeleccionado ? `IVA Acreditable (${regimenSeleccionado})` : 'IVA Acreditable'}
|
title={regimenSeleccionado ? `IVA Acreditable (${regimenSeleccionado})` : 'IVA Acreditable'}
|
||||||
@@ -197,6 +199,8 @@ export default function ImpuestosPage() {
|
|||||||
icon={<TrendingDown className="h-4 w-4" />}
|
icon={<TrendingDown className="h-4 w-4" />}
|
||||||
subtitle="Pagado a proveedores"
|
subtitle="Pagado a proveedores"
|
||||||
href={drillUrl('IVA Acreditable - CFDIs Recibidos', { bucket: 'acreditable' })}
|
href={drillUrl('IVA Acreditable - CFDIs Recibidos', { bucket: 'acreditable' })}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
/>
|
/>
|
||||||
{(() => {
|
{(() => {
|
||||||
const val = regimenSeleccionado
|
const val = regimenSeleccionado
|
||||||
@@ -405,24 +409,32 @@ export default function ImpuestosPage() {
|
|||||||
value={ingSel}
|
value={ingSel}
|
||||||
icon={<TrendingUp className="h-4 w-4" />}
|
icon={<TrendingUp className="h-4 w-4" />}
|
||||||
href={drillUrl('Ingresos ISR - CFDIs Emitidos', { bucket: 'ingresos' })}
|
href={drillUrl('Ingresos ISR - CFDIs Emitidos', { bucket: 'ingresos' })}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
/>
|
/>
|
||||||
<KpiCard
|
<KpiCard
|
||||||
title={regimenSeleccionado ? `NCs Emitidas (${regimenSeleccionado})` : 'NCs Emitidas'}
|
title={regimenSeleccionado ? `NCs Emitidas (${regimenSeleccionado})` : 'NCs Emitidas'}
|
||||||
value={ncsEmSel}
|
value={ncsEmSel}
|
||||||
icon={<TrendingDown className="h-4 w-4" />}
|
icon={<TrendingDown className="h-4 w-4" />}
|
||||||
href={drillUrl('NCs Emitidas - CFDIs', { bucket: 'ncs_emitidas' })}
|
href={drillUrl('NCs Emitidas - CFDIs', { bucket: 'ncs_emitidas' })}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
/>
|
/>
|
||||||
<KpiCard
|
<KpiCard
|
||||||
title={regimenSeleccionado ? `Deducciones (${regimenSeleccionado})` : 'Deducciones'}
|
title={regimenSeleccionado ? `Deducciones (${regimenSeleccionado})` : 'Deducciones'}
|
||||||
value={dedSel}
|
value={dedSel}
|
||||||
icon={<TrendingDown className="h-4 w-4" />}
|
icon={<TrendingDown className="h-4 w-4" />}
|
||||||
href={drillUrl('Deducciones - CFDIs Recibidos', { bucket: 'gastos' })}
|
href={drillUrl('Deducciones - CFDIs Recibidos', { bucket: 'gastos' })}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
/>
|
/>
|
||||||
<KpiCard
|
<KpiCard
|
||||||
title={regimenSeleccionado ? `NCs Recibidas (${regimenSeleccionado})` : 'NCs Recibidas'}
|
title={regimenSeleccionado ? `NCs Recibidas (${regimenSeleccionado})` : 'NCs Recibidas'}
|
||||||
value={ncsRecSel}
|
value={ncsRecSel}
|
||||||
icon={<TrendingUp className="h-4 w-4" />}
|
icon={<TrendingUp className="h-4 w-4" />}
|
||||||
href={drillUrl('NCs Recibidas - CFDIs', { bucket: 'ncs_recibidas' })}
|
href={drillUrl('NCs Recibidas - CFDIs', { bucket: 'ncs_recibidas' })}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
/>
|
/>
|
||||||
<KpiCard
|
<KpiCard
|
||||||
title={regimenSeleccionado ? `Base Gravable (${regimenSeleccionado})` : 'Base Gravable'}
|
title={regimenSeleccionado ? `Base Gravable (${regimenSeleccionado})` : 'Base Gravable'}
|
||||||
@@ -459,6 +471,8 @@ export default function ImpuestosPage() {
|
|||||||
icon={<TrendingDown className="h-4 w-4" />}
|
icon={<TrendingDown className="h-4 w-4" />}
|
||||||
subtitle="Efectivo > $2,000"
|
subtitle="Efectivo > $2,000"
|
||||||
href={drillUrl('No Deducibles - Efectivo > $2,000', { bucket: 'no_deducibles_efectivo' })}
|
href={drillUrl('No Deducibles - Efectivo > $2,000', { bucket: 'no_deducibles_efectivo' })}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -72,6 +72,11 @@ const adminNavigation: NavItem[] = [
|
|||||||
{ name: 'Audit Log', href: '/admin/audit-log', icon: FileWarning },
|
{ name: 'Audit Log', href: '/admin/audit-log', icon: FileWarning },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const vendedorNavigation: NavItem[] = [
|
||||||
|
{ name: 'Invitar cliente', href: '/admin/invitar-cliente', icon: Send },
|
||||||
|
{ name: 'Configuracion', href: '/configuracion', icon: Settings },
|
||||||
|
];
|
||||||
|
|
||||||
export function Sidebar() {
|
export function Sidebar() {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -103,12 +108,15 @@ export function Sidebar() {
|
|||||||
|
|
||||||
const { data: contribuyentes } = useContribuyentes();
|
const { data: contribuyentes } = useContribuyentes();
|
||||||
const isGlobalAdmin = isGlobalAdminRfc(user?.tenantRfc, role, user?.platformRoles);
|
const isGlobalAdmin = isGlobalAdminRfc(user?.tenantRfc, role, user?.platformRoles);
|
||||||
|
const isVendedor = user?.platformRoles?.includes('platform_sales') && !isGlobalAdmin;
|
||||||
// El admin global NO necesita "Configuración inicial" — su tenant raíz
|
// El admin global NO necesita "Configuración inicial" — su tenant raíz
|
||||||
// (Horux 360) no tiene contribuyentes propios y nunca los tendrá.
|
// (Horux 360) no tiene contribuyentes propios y nunca los tendrá.
|
||||||
const showOnboarding = (!contribuyentes || contribuyentes.length === 0) && role !== 'cliente' && !isGlobalAdmin;
|
const showOnboarding = (!contribuyentes || contribuyentes.length === 0) && role !== 'cliente' && !isGlobalAdmin && !isVendedor;
|
||||||
const allNavigation = isGlobalAdmin
|
const allNavigation = isGlobalAdmin
|
||||||
? [...filteredNav.slice(0, -1), ...adminNavigation, filteredNav[filteredNav.length - 1]]
|
? [...filteredNav.slice(0, -1), ...adminNavigation, filteredNav[filteredNav.length - 1]]
|
||||||
: filteredNav;
|
: isVendedor
|
||||||
|
? vendedorNavigation
|
||||||
|
: filteredNav;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside className="fixed left-0 top-0 z-40 h-screen w-64 border-r bg-card">
|
<aside className="fixed left-0 top-0 z-40 h-screen w-64 border-r bg-card">
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ interface KpiCardProps {
|
|||||||
icon?: React.ReactNode;
|
icon?: React.ReactNode;
|
||||||
className?: string;
|
className?: string;
|
||||||
href?: string;
|
href?: string;
|
||||||
|
target?: string;
|
||||||
|
rel?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function KpiCard({
|
export function KpiCard({
|
||||||
@@ -22,6 +24,8 @@ export function KpiCard({
|
|||||||
icon,
|
icon,
|
||||||
className,
|
className,
|
||||||
href,
|
href,
|
||||||
|
target,
|
||||||
|
rel,
|
||||||
}: KpiCardProps) {
|
}: KpiCardProps) {
|
||||||
const formatValue = (val: string | number) => {
|
const formatValue = (val: string | number) => {
|
||||||
if (typeof val === 'number') {
|
if (typeof val === 'number') {
|
||||||
@@ -36,7 +40,7 @@ export function KpiCard({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const Wrapper = href ? 'a' : 'div';
|
const Wrapper = href ? 'a' : 'div';
|
||||||
const wrapperProps = href ? { href, className: 'block' } : {};
|
const wrapperProps = href ? { href, target, rel, className: 'block' } : {};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className={cn(href && 'hover:border-primary/50 hover:shadow-md transition-all cursor-pointer', className)}>
|
<Card className={cn(href && 'hover:border-primary/50 hover:shadow-md transition-all cursor-pointer', className)}>
|
||||||
|
|||||||
Reference in New Issue
Block a user