Factura Global & fecha_efectiva: - Migracion 045_factura_global.sql: periodicidad, meses_global, año_global, fecha_efectiva - sat-parser.service.ts: extrae InformacionGlobal del XML - sat.service.ts: calcFechaEfectiva con soporte bimestral (periodicidad 05) - metricas-compute, dashboard, impuestos, cfdi, export, conciliacion, alertas: reemplaza fecha_emision-1h por COALESCE(fecha_efectiva, fecha_emision-1h) - Script recalc-metricas.ts para recalculo manual Fallback datos fiscales tenant → contribuyente: - contribuyente.service.ts: fetchTenantFiscalData + mergeContribuyenteWithTenant rellena regimenFiscal, codigoPostal y domicilio cuando el contribuyente tiene el mismo RFC que el tenant y sus campos estan vacios - contribuyente.controller.ts y contribuyente-config.controller.ts: pasan req.user!.tenantId al servicio Fix critico SAT sync: - sat.service.ts: anio_global → año_global en INSERT/UPDATE de CFDIs (la migracion creo 'año_global' con tilde; el codigo usaba 'anio_global', causando fallo en 100% de inserciones de CFDI) - determineChunkMonths: salta sondeo si existe job previo con requestIds - MAX_POLL_ATTEMPTS: 45 → 500 (~8h) para syncs iniciales grandes Docs: - docs/sessions/2026-05-22-factura-global-contribuyente-fallback.md
260 lines
8.7 KiB
TypeScript
260 lines
8.7 KiB
TypeScript
import type { Pool } from 'pg';
|
|
import { prisma } from '../config/database.js';
|
|
|
|
export interface CreateContribuyenteData {
|
|
rfc: string;
|
|
razonSocial: string;
|
|
regimenFiscal?: string;
|
|
codigoPostal?: string;
|
|
domicilio?: Record<string, unknown>;
|
|
supervisorUserId?: string;
|
|
}
|
|
|
|
export interface ContribuyenteRow {
|
|
id: string;
|
|
tipo: string;
|
|
nombre: string;
|
|
identificador: string;
|
|
supervisorUserId: string | null;
|
|
active: boolean;
|
|
createdAt: string;
|
|
rfc: string;
|
|
regimenFiscal: string | null;
|
|
codigoPostal: string | null;
|
|
domicilio: Record<string, unknown> | null;
|
|
}
|
|
|
|
async function fetchTenantFiscalData(tenantId: string) {
|
|
const tenant = await prisma.tenant.findUnique({
|
|
where: { id: tenantId },
|
|
select: {
|
|
rfc: true,
|
|
codigoPostal: true,
|
|
calle: true,
|
|
numExterior: true,
|
|
numInterior: true,
|
|
colonia: true,
|
|
ciudad: true,
|
|
municipio: true,
|
|
estado: true,
|
|
telefono: true,
|
|
},
|
|
});
|
|
if (!tenant) return null;
|
|
|
|
const regimenes = await prisma.tenantRegimenActivo.findMany({
|
|
where: { tenantId },
|
|
select: { regimen: { select: { clave: true } } },
|
|
});
|
|
const regimenFiscal = regimenes.map(r => r.regimen.clave).join(',') || null;
|
|
|
|
const hasAnyAddress = tenant.calle || tenant.colonia || tenant.ciudad || tenant.municipio || tenant.estado || tenant.codigoPostal;
|
|
const domicilio = hasAnyAddress
|
|
? {
|
|
calle: tenant.calle || '',
|
|
numExterior: tenant.numExterior || '',
|
|
numInterior: tenant.numInterior || '',
|
|
colonia: tenant.colonia || '',
|
|
ciudad: tenant.ciudad || '',
|
|
municipio: tenant.municipio || '',
|
|
estado: tenant.estado || '',
|
|
codigoPostal: tenant.codigoPostal || '',
|
|
telefono: tenant.telefono || '',
|
|
}
|
|
: null;
|
|
|
|
return { tenantRfc: tenant.rfc, regimenFiscal, codigoPostal: tenant.codigoPostal, domicilio };
|
|
}
|
|
|
|
function mergeContribuyenteWithTenant(
|
|
row: ContribuyenteRow,
|
|
tenantData: NonNullable<Awaited<ReturnType<typeof fetchTenantFiscalData>>>
|
|
): ContribuyenteRow {
|
|
return {
|
|
...row,
|
|
regimenFiscal: row.regimenFiscal || tenantData.regimenFiscal,
|
|
codigoPostal: row.codigoPostal || tenantData.codigoPostal,
|
|
domicilio: row.domicilio || tenantData.domicilio,
|
|
};
|
|
}
|
|
|
|
export async function listContribuyentes(pool: Pool, entidadIds?: string[], tenantId?: string): Promise<ContribuyenteRow[]> {
|
|
let query = `
|
|
SELECT
|
|
e.id, e.tipo, e.nombre, e.identificador,
|
|
e.supervisor_user_id AS "supervisorUserId",
|
|
e.active, e.created_at AS "createdAt",
|
|
c.rfc, c.regimen_fiscal AS "regimenFiscal",
|
|
c.codigo_postal AS "codigoPostal", c.domicilio
|
|
FROM entidades_gestionadas e
|
|
JOIN contribuyentes c ON c.entidad_id = e.id
|
|
WHERE e.active = true
|
|
`;
|
|
const params: unknown[] = [];
|
|
|
|
if (entidadIds !== undefined) {
|
|
if (entidadIds.length === 0) return []; // No access = empty list
|
|
query += ` AND e.id = ANY($1)`;
|
|
params.push(entidadIds);
|
|
}
|
|
|
|
query += ' ORDER BY e.created_at DESC';
|
|
const { rows } = await pool.query(query, params);
|
|
|
|
if (!tenantId) return rows;
|
|
|
|
const tenantData = await fetchTenantFiscalData(tenantId);
|
|
if (!tenantData) return rows;
|
|
|
|
return rows.map((r: ContribuyenteRow) => {
|
|
if (r.rfc !== tenantData.tenantRfc) return r;
|
|
if (r.regimenFiscal && r.codigoPostal && r.domicilio) return r;
|
|
return mergeContribuyenteWithTenant(r, tenantData);
|
|
});
|
|
}
|
|
|
|
export async function getContribuyenteById(pool: Pool, id: string, tenantId?: string): Promise<ContribuyenteRow | null> {
|
|
const { rows } = await pool.query(`
|
|
SELECT
|
|
e.id, e.tipo, e.nombre, e.identificador,
|
|
e.supervisor_user_id AS "supervisorUserId",
|
|
e.active, e.created_at AS "createdAt",
|
|
c.rfc, c.regimen_fiscal AS "regimenFiscal",
|
|
c.codigo_postal AS "codigoPostal", c.domicilio
|
|
FROM entidades_gestionadas e
|
|
JOIN contribuyentes c ON c.entidad_id = e.id
|
|
WHERE e.id = $1
|
|
`, [id]);
|
|
const row = rows[0] ?? null;
|
|
if (!row || !tenantId) return row;
|
|
|
|
const tenantData = await fetchTenantFiscalData(tenantId);
|
|
if (!tenantData || row.rfc !== tenantData.tenantRfc) return row;
|
|
if (row.regimenFiscal && row.codigoPostal && row.domicilio) return row;
|
|
|
|
return mergeContribuyenteWithTenant(row, tenantData);
|
|
}
|
|
|
|
export async function createContribuyente(pool: Pool, data: CreateContribuyenteData): Promise<ContribuyenteRow> {
|
|
const client = await pool.connect();
|
|
try {
|
|
await client.query('BEGIN');
|
|
const { rows: [entidad] } = await client.query(`
|
|
INSERT INTO entidades_gestionadas (tipo, nombre, identificador, supervisor_user_id)
|
|
VALUES ('CONTRIBUYENTE', $1, $2, $3)
|
|
RETURNING id
|
|
`, [data.razonSocial, data.rfc.toUpperCase(), data.supervisorUserId ?? null]);
|
|
|
|
await client.query(`
|
|
INSERT INTO contribuyentes (entidad_id, rfc, regimen_fiscal, codigo_postal, domicilio)
|
|
VALUES ($1, $2, $3, $4, $5)
|
|
`, [entidad.id, data.rfc.toUpperCase(), data.regimenFiscal ?? null, data.codigoPostal ?? null, data.domicilio ? JSON.stringify(data.domicilio) : null]);
|
|
|
|
await client.query('COMMIT');
|
|
|
|
// Backfill: claim existing CFDIs that match this RFC
|
|
await backfillCfdiContribuyente(pool, entidad.id, data.rfc.toUpperCase()).catch(
|
|
(err) => console.error('[Contribuyente] Backfill CFDIs failed (non-blocking):', err)
|
|
);
|
|
|
|
return (await getContribuyenteById(pool, entidad.id))!;
|
|
} catch (err) {
|
|
await client.query('ROLLBACK');
|
|
throw err;
|
|
} finally {
|
|
client.release();
|
|
}
|
|
}
|
|
|
|
export async function updateContribuyente(pool: Pool, id: string, data: Partial<CreateContribuyenteData>): Promise<ContribuyenteRow | null> {
|
|
const existing = await getContribuyenteById(pool, id);
|
|
if (!existing) return null;
|
|
|
|
const client = await pool.connect();
|
|
try {
|
|
await client.query('BEGIN');
|
|
|
|
// Update entidades_gestionadas if needed
|
|
const entidadSets: string[] = [];
|
|
const entidadVals: unknown[] = [];
|
|
let idx = 1;
|
|
|
|
if (data.razonSocial) {
|
|
entidadSets.push(`nombre = $${idx}`, `identificador = $${idx}`);
|
|
entidadVals.push(data.razonSocial);
|
|
idx++;
|
|
}
|
|
if (data.supervisorUserId !== undefined) {
|
|
entidadSets.push(`supervisor_user_id = $${idx}`);
|
|
entidadVals.push(data.supervisorUserId);
|
|
idx++;
|
|
}
|
|
if (entidadSets.length > 0) {
|
|
entidadSets.push('updated_at = now()');
|
|
entidadVals.push(id);
|
|
await client.query(`UPDATE entidades_gestionadas SET ${entidadSets.join(', ')} WHERE id = $${idx}`, entidadVals);
|
|
}
|
|
|
|
// Update contribuyentes if needed
|
|
const contribSets: string[] = [];
|
|
const contribVals: unknown[] = [];
|
|
idx = 1;
|
|
|
|
if (data.regimenFiscal !== undefined) { contribSets.push(`regimen_fiscal = $${idx}`); contribVals.push(data.regimenFiscal); idx++; }
|
|
if (data.codigoPostal !== undefined) { contribSets.push(`codigo_postal = $${idx}`); contribVals.push(data.codigoPostal); idx++; }
|
|
if (data.domicilio !== undefined) { contribSets.push(`domicilio = $${idx}`); contribVals.push(JSON.stringify(data.domicilio)); idx++; }
|
|
|
|
if (contribSets.length > 0) {
|
|
contribVals.push(id);
|
|
await client.query(`UPDATE contribuyentes SET ${contribSets.join(', ')} WHERE entidad_id = $${idx}`, contribVals);
|
|
}
|
|
|
|
await client.query('COMMIT');
|
|
return (await getContribuyenteById(pool, id))!;
|
|
} catch (err) {
|
|
await client.query('ROLLBACK');
|
|
throw err;
|
|
} finally {
|
|
client.release();
|
|
}
|
|
}
|
|
|
|
export async function deactivateContribuyente(pool: Pool, id: string): Promise<boolean> {
|
|
const { rowCount } = await pool.query(
|
|
'UPDATE entidades_gestionadas SET active = false, updated_at = now() WHERE id = $1',
|
|
[id]
|
|
);
|
|
return (rowCount ?? 0) > 0;
|
|
}
|
|
|
|
/**
|
|
* Assigns contribuyente_id to CFDIs that match the RFC (emisor or receptor).
|
|
* Runs after contribuyente creation and can be called manually for backfill.
|
|
* Only updates CFDIs where contribuyente_id IS NULL (doesn't override).
|
|
*/
|
|
export async function backfillCfdiContribuyente(pool: Pool, contribuyenteId: string, rfc: string): Promise<number> {
|
|
const { rowCount } = await pool.query(`
|
|
UPDATE cfdis
|
|
SET contribuyente_id = $1
|
|
WHERE contribuyente_id IS NULL
|
|
AND (rfc_emisor = $2 OR rfc_receptor = $2)
|
|
`, [contribuyenteId, rfc]);
|
|
const count = rowCount ?? 0;
|
|
if (count > 0) {
|
|
console.log(`[Backfill] Assigned ${count} CFDIs to contribuyente ${rfc} (${contribuyenteId})`);
|
|
}
|
|
return count;
|
|
}
|
|
|
|
/**
|
|
* Backfills ALL contribuyentes in the tenant BD. Useful after initial SAT sync.
|
|
*/
|
|
export async function backfillAllContribuyentes(pool: Pool): Promise<number> {
|
|
const { rows } = await pool.query('SELECT entidad_id, rfc FROM contribuyentes');
|
|
let total = 0;
|
|
for (const { entidad_id, rfc } of rows) {
|
|
total += await backfillCfdiContribuyente(pool, entidad_id, rfc);
|
|
}
|
|
return total;
|
|
}
|