1177 lines
37 KiB
Markdown
1177 lines
37 KiB
Markdown
# Opinión de Cumplimiento Implementation Plan
|
|
|
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
|
|
**Goal:** Integrate automated SAT Opinión de Cumplimiento download into Horux360 with weekly cron, tenant DB storage, REST endpoints, auto-alert, and a new Documentos frontend page.
|
|
|
|
**Architecture:** Adapt the standalone Playwright prototype (sat-opinion-prototype) into Horux360's backend as new SAT opinion services. Store PDFs in tenant DB (bytea). Weekly cron downloads for all tenants with FIEL. Frontend shows last 5 opinions with status badges and PDF download.
|
|
|
|
**Tech Stack:** Playwright (Chromium headless), pdf-parse v2, existing Express/Prisma/pg stack, Next.js frontend
|
|
|
|
---
|
|
|
|
### Task 1: Add shared types and plan feature
|
|
|
|
**Files:**
|
|
- Create: `packages/shared/src/types/documentos.ts`
|
|
- Modify: `packages/shared/src/index.ts`
|
|
- Modify: `packages/shared/src/constants/plans.ts`
|
|
|
|
- [ ] **Step 1: Create shared types file**
|
|
|
|
Create `packages/shared/src/types/documentos.ts`:
|
|
|
|
```typescript
|
|
export interface OpinionCumplimiento {
|
|
id: number;
|
|
rfc: string;
|
|
razonSocial: string;
|
|
estatus: string;
|
|
folio: string;
|
|
cadenaOriginal: string;
|
|
fechaConsulta: string;
|
|
createdAt: string;
|
|
}
|
|
|
|
export interface OpinionCumplimientoFull extends OpinionCumplimiento {
|
|
pdf: Buffer;
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Export from shared index**
|
|
|
|
In `packages/shared/src/index.ts`, add after the last type export:
|
|
|
|
```typescript
|
|
export * from './types/documentos';
|
|
```
|
|
|
|
- [ ] **Step 3: Add `documentos` feature to business and enterprise plans**
|
|
|
|
In `packages/shared/src/constants/plans.ts`, add `'documentos'` to the `features` arrays:
|
|
|
|
- business features: add `'documentos'` after `'xml_sat'`
|
|
- enterprise features: add `'documentos'` after `'xml_sat'`
|
|
|
|
- [ ] **Step 4: Commit**
|
|
|
|
```bash
|
|
git add packages/shared/src/types/documentos.ts packages/shared/src/index.ts packages/shared/src/constants/plans.ts
|
|
git commit -m "feat: add documentos shared types and plan feature"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 2: Create tenant DB migration
|
|
|
|
**Files:**
|
|
- Create: `apps/api/src/migrations/tenant/002_create_opiniones_cumplimiento.sql`
|
|
|
|
- [ ] **Step 1: Create migration file**
|
|
|
|
Create `apps/api/src/migrations/tenant/002_create_opiniones_cumplimiento.sql`:
|
|
|
|
```sql
|
|
-- 002_create_opiniones_cumplimiento
|
|
-- Table for storing SAT Opinión de Cumplimiento PDFs and metadata
|
|
|
|
CREATE TABLE IF NOT EXISTS opiniones_cumplimiento (
|
|
id SERIAL PRIMARY KEY,
|
|
rfc VARCHAR(14) NOT NULL,
|
|
razon_social VARCHAR(255),
|
|
estatus VARCHAR(50) NOT NULL,
|
|
folio VARCHAR(50),
|
|
cadena_original TEXT,
|
|
fecha_consulta TIMESTAMP NOT NULL,
|
|
pdf BYTEA NOT NULL,
|
|
created_at TIMESTAMP DEFAULT NOW()
|
|
);
|
|
|
|
CREATE INDEX IF NOT EXISTS idx_opiniones_fecha ON opiniones_cumplimiento(fecha_consulta DESC);
|
|
```
|
|
|
|
- [ ] **Step 2: Run eager migration to apply to existing tenants**
|
|
|
|
```bash
|
|
pnpm --filter @horux/api db:migrate-tenants
|
|
```
|
|
|
|
Expected: All tenants get the new table.
|
|
|
|
- [ ] **Step 3: Commit**
|
|
|
|
```bash
|
|
git add apps/api/src/migrations/tenant/002_create_opiniones_cumplimiento.sql
|
|
git commit -m "feat: add 002 migration for opiniones_cumplimiento table"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 3: Add Playwright and pdf-parse dependencies
|
|
|
|
**Files:**
|
|
- Modify: `apps/api/package.json`
|
|
|
|
- [ ] **Step 1: Install dependencies**
|
|
|
|
```bash
|
|
cd apps/api && pnpm add playwright pdf-parse
|
|
```
|
|
|
|
- [ ] **Step 2: Install Chromium browser**
|
|
|
|
```bash
|
|
npx playwright install chromium
|
|
```
|
|
|
|
- [ ] **Step 3: Commit**
|
|
|
|
```bash
|
|
git add apps/api/package.json ../../pnpm-lock.yaml
|
|
git commit -m "feat: add playwright and pdf-parse dependencies"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 4: Create SAT opinion login service
|
|
|
|
**Files:**
|
|
- Create: `apps/api/src/services/sat/sat-opinion-login.ts`
|
|
|
|
This is adapted from the prototype's `sat-login.ts`. Key changes:
|
|
- Accepts file paths (temp files from decrypted FIEL) instead of config object
|
|
- No debug screenshots in production (controlled by NODE_ENV)
|
|
- Returns the active page after login
|
|
|
|
- [ ] **Step 1: Create sat-opinion-login.ts**
|
|
|
|
Create `apps/api/src/services/sat/sat-opinion-login.ts`:
|
|
|
|
```typescript
|
|
import { type Page } from 'playwright';
|
|
|
|
const TIMEOUT = 60_000;
|
|
|
|
/**
|
|
* Navigates the SAT portal and authenticates with FIEL to reach the
|
|
* Opinión de Cumplimiento report page.
|
|
*
|
|
* Returns the active page (may be a new tab opened by SAT).
|
|
*/
|
|
export async function loginToSatOpinion(
|
|
page: Page,
|
|
cerPath: string,
|
|
keyPath: string,
|
|
password: string,
|
|
): Promise<Page> {
|
|
// Step 1: Navigate to SAT public page
|
|
const publicUrl = 'https://www.sat.gob.mx/portal/public/tramites/opinion-del-cumplimiento';
|
|
console.log('[SAT Opinion] Navigating to SAT public page...');
|
|
await page.goto(publicUrl, { waitUntil: 'domcontentloaded', timeout: TIMEOUT });
|
|
await page.waitForTimeout(2000);
|
|
|
|
// Step 2: Click "Obtén la Opinión del cumplimiento" tab
|
|
console.log('[SAT Opinion] Clicking "Obtén la Opinión del cumplimiento"...');
|
|
const obtenerOpcion = page.locator('text=Obt').first();
|
|
await obtenerOpcion.waitFor({ state: 'visible', timeout: TIMEOUT });
|
|
await obtenerOpcion.click();
|
|
await page.waitForTimeout(2000);
|
|
|
|
// Step 3: Expand "De tu empresa" accordion
|
|
console.log('[SAT Opinion] Expanding "De tu empresa" section...');
|
|
const empresaOption = page.locator('text=De tu empresa').first();
|
|
await empresaOption.waitFor({ state: 'visible', timeout: TIMEOUT });
|
|
await empresaOption.click();
|
|
await page.waitForTimeout(2000);
|
|
|
|
// Step 4: Click "Ingresa" link — opens new tab (target=_blank)
|
|
console.log('[SAT Opinion] Clicking "Ingresa" (opens new tab)...');
|
|
const ingresaLink = page.locator('a:has-text("Ingresa")').first();
|
|
await ingresaLink.waitFor({ state: 'visible', timeout: TIMEOUT });
|
|
|
|
const [loginPage] = await Promise.all([
|
|
page.context().waitForEvent('page', { timeout: TIMEOUT }),
|
|
ingresaLink.click(),
|
|
]);
|
|
await loginPage.waitForLoadState('domcontentloaded', { timeout: TIMEOUT });
|
|
await loginPage.waitForTimeout(2000);
|
|
|
|
// Step 5: Switch to e.firma login
|
|
console.log('[SAT Opinion] Clicking e.firma button...');
|
|
const efirmaButton = loginPage.locator('button:has-text("e.firma"), input[value*="firma"], a:has-text("e.firma")').first();
|
|
await efirmaButton.waitFor({ state: 'visible', timeout: TIMEOUT });
|
|
await efirmaButton.click();
|
|
await loginPage.waitForLoadState('domcontentloaded', { timeout: TIMEOUT });
|
|
await loginPage.waitForTimeout(2000);
|
|
|
|
// Step 6: Upload .cer
|
|
console.log('[SAT Opinion] Uploading .cer...');
|
|
const cerInput = loginPage.locator('input[type="file"]').first();
|
|
await cerInput.waitFor({ state: 'attached', timeout: TIMEOUT });
|
|
await cerInput.setInputFiles(cerPath);
|
|
await loginPage.waitForTimeout(500);
|
|
|
|
// Step 7: Upload .key
|
|
console.log('[SAT Opinion] Uploading .key...');
|
|
const keyInput = loginPage.locator('input[type="file"]').nth(1);
|
|
await keyInput.waitFor({ state: 'attached', timeout: TIMEOUT });
|
|
await keyInput.setInputFiles(keyPath);
|
|
await loginPage.waitForTimeout(500);
|
|
|
|
// Step 8: Enter password
|
|
console.log('[SAT Opinion] Entering password...');
|
|
const passwordInput = loginPage.locator('input[type="password"]').first();
|
|
await passwordInput.waitFor({ state: 'visible', timeout: TIMEOUT });
|
|
await passwordInput.fill(password);
|
|
|
|
// Step 9: Submit
|
|
console.log('[SAT Opinion] Submitting login...');
|
|
const submitButton = loginPage.locator('button:has-text("Enviar"), input[value="Enviar"], a:has-text("Enviar"), input[type="submit"], button[type="submit"]').first();
|
|
await submitButton.waitFor({ state: 'visible', timeout: TIMEOUT });
|
|
await submitButton.click();
|
|
|
|
// Step 10: Wait for auth + redirect to report
|
|
console.log('[SAT Opinion] Waiting for authentication...');
|
|
await loginPage.waitForTimeout(8000);
|
|
|
|
const currentUrl = loginPage.url();
|
|
if (!currentUrl.includes('reporteOpinion32DContribuyente')) {
|
|
const baseUrl = currentUrl.replace(/#.*$/, '').replace(/\?.*$/, '');
|
|
const reporteUrl = baseUrl + '#/reporteOpinion32DContribuyente';
|
|
console.log(`[SAT Opinion] Navigating to report: ${reporteUrl}`);
|
|
await loginPage.goto(reporteUrl, { waitUntil: 'domcontentloaded', timeout: TIMEOUT });
|
|
await loginPage.waitForTimeout(5000);
|
|
}
|
|
|
|
console.log('[SAT Opinion] Login completed.');
|
|
return loginPage;
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Commit**
|
|
|
|
```bash
|
|
git add apps/api/src/services/sat/sat-opinion-login.ts
|
|
git commit -m "feat: add SAT opinion login service (Playwright)"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 5: Create SAT opinion scraper service
|
|
|
|
**Files:**
|
|
- Create: `apps/api/src/services/sat/sat-opinion-scraper.ts`
|
|
|
|
Adapted from prototype's `opinion-scraper.ts`. Simplified: no config dependency, no screenshots, returns Buffer directly.
|
|
|
|
- [ ] **Step 1: Create sat-opinion-scraper.ts**
|
|
|
|
Create `apps/api/src/services/sat/sat-opinion-scraper.ts`:
|
|
|
|
```typescript
|
|
import { type Page } from 'playwright';
|
|
|
|
/**
|
|
* Extracts the Opinión de Cumplimiento PDF from the SAT Angular SPA.
|
|
* Uses 4 strategies to find the base64-encoded PDF in the DOM.
|
|
* Returns the raw PDF buffer.
|
|
*/
|
|
export async function extractOpinionPdf(page: Page): Promise<Buffer> {
|
|
const TIMEOUT = 120_000;
|
|
const POLL_INTERVAL = 3_000;
|
|
|
|
console.log('[SAT Opinion Scraper] Waiting for PDF to appear...');
|
|
|
|
// Set up network interception for PDF responses
|
|
let interceptedPdf: Buffer | null = null;
|
|
page.on('response', async (response) => {
|
|
try {
|
|
const contentType = response.headers()['content-type'] || '';
|
|
if (contentType.includes('application/pdf') || response.url().endsWith('.pdf')) {
|
|
const body = await response.body();
|
|
if (body.length > 100) {
|
|
interceptedPdf = body;
|
|
console.log(`[SAT Opinion Scraper] PDF intercepted via network: ${body.length} bytes`);
|
|
}
|
|
}
|
|
} catch { /* response body may not be available */ }
|
|
});
|
|
|
|
const startTime = Date.now();
|
|
|
|
while (Date.now() - startTime < TIMEOUT) {
|
|
// Check network interception first
|
|
if (interceptedPdf) return interceptedPdf;
|
|
|
|
// Strategy 1: <embed> or <object> with PDF data URI
|
|
const embedData = await page.evaluate(() => {
|
|
for (const el of document.querySelectorAll('embed, object')) {
|
|
const src = el.getAttribute('src') || el.getAttribute('data') || '';
|
|
if (src.startsWith('data:application/pdf;base64,')) return src;
|
|
}
|
|
return null;
|
|
}).catch(() => null);
|
|
|
|
if (embedData) {
|
|
console.log('[SAT Opinion Scraper] PDF found via <embed>/<object>');
|
|
return decodeDataUri(embedData);
|
|
}
|
|
|
|
// Strategy 2: Scan full HTML for base64 PDF
|
|
const html = await page.content().catch(() => '');
|
|
const match = html.match(/data:application\/pdf;base64,([A-Za-z0-9+/=\s]+)/);
|
|
if (match) {
|
|
console.log('[SAT Opinion Scraper] PDF found via page content scan');
|
|
return decodeDataUri(`data:application/pdf;base64,${match[1]}`);
|
|
}
|
|
|
|
// Strategy 3: Check iframes
|
|
for (const frame of page.frames()) {
|
|
try {
|
|
const frameUrl = frame.url();
|
|
if (frameUrl.startsWith('data:application/pdf;base64,')) {
|
|
console.log('[SAT Opinion Scraper] PDF found via iframe URL');
|
|
return decodeDataUri(frameUrl);
|
|
}
|
|
const frameHtml = await frame.content();
|
|
const frameMatch = frameHtml.match(/data:application\/pdf;base64,([A-Za-z0-9+/=\s]+)/);
|
|
if (frameMatch) {
|
|
console.log('[SAT Opinion Scraper] PDF found via iframe content');
|
|
return decodeDataUri(`data:application/pdf;base64,${frameMatch[1]}`);
|
|
}
|
|
} catch { /* cross-origin frame */ }
|
|
}
|
|
|
|
// Strategy 4: Page URL itself
|
|
if (page.url().startsWith('data:application/pdf;base64,')) {
|
|
console.log('[SAT Opinion Scraper] PDF found via page URL');
|
|
return decodeDataUri(page.url());
|
|
}
|
|
|
|
console.log(`[SAT Opinion Scraper] PDF not found, retrying in ${POLL_INTERVAL / 1000}s...`);
|
|
await page.waitForTimeout(POLL_INTERVAL);
|
|
}
|
|
|
|
throw new Error(`PDF not found after ${TIMEOUT / 1000}s`);
|
|
}
|
|
|
|
function decodeDataUri(dataUri: string): Buffer {
|
|
const prefix = 'data:application/pdf;base64,';
|
|
const base64 = dataUri.substring(prefix.length).replace(/\s/g, '');
|
|
return Buffer.from(base64, 'base64');
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Commit**
|
|
|
|
```bash
|
|
git add apps/api/src/services/sat/sat-opinion-scraper.ts
|
|
git commit -m "feat: add SAT opinion PDF scraper service"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 6: Create SAT opinion PDF parser service
|
|
|
|
**Files:**
|
|
- Create: `apps/api/src/services/sat/sat-opinion-parser.ts`
|
|
|
|
Adapted from prototype's `pdf-parser.ts`.
|
|
|
|
- [ ] **Step 1: Create sat-opinion-parser.ts**
|
|
|
|
Create `apps/api/src/services/sat/sat-opinion-parser.ts`:
|
|
|
|
```typescript
|
|
import { PDFParse } from 'pdf-parse';
|
|
|
|
export interface ParsedOpinion {
|
|
rfc: string;
|
|
razonSocial: string;
|
|
estatus: string;
|
|
folio: string;
|
|
cadenaOriginal: string;
|
|
fechaConsulta: string;
|
|
}
|
|
|
|
/**
|
|
* Parses the SAT Opinión de Cumplimiento PDF and extracts structured data.
|
|
* The PDF layout uses line-header / line-value format (NOT key:value).
|
|
*/
|
|
export async function parseOpinionPdf(pdfBuffer: Buffer): Promise<ParsedOpinion> {
|
|
const pdfParse = new PDFParse({ data: new Uint8Array(pdfBuffer) });
|
|
try {
|
|
const textResult = await pdfParse.getText();
|
|
const text = textResult.text;
|
|
|
|
return {
|
|
rfc: extractRfc(text),
|
|
razonSocial: extractRazonSocial(text),
|
|
estatus: extractEstatus(text),
|
|
folio: extractFolio(text),
|
|
cadenaOriginal: extractCadenaOriginal(text),
|
|
fechaConsulta: extractFecha(text),
|
|
};
|
|
} finally {
|
|
await pdfParse.destroy();
|
|
}
|
|
}
|
|
|
|
function extractRfc(text: string): string {
|
|
const match = text.match(/RFC\s+Folio\s*\n\s*([A-ZÑ&]{3,4}\d{6}[A-Z\d]{3})/i);
|
|
if (match) return match[1].trim();
|
|
const fallback = text.match(/\b([A-ZÑ&]{3,4}\d{6}[A-Z\d]{3})\b/);
|
|
return fallback ? fallback[1] : 'NO_ENCONTRADO';
|
|
}
|
|
|
|
function extractRazonSocial(text: string): string {
|
|
const match = text.match(/(?:Nombre|denominaci[oó]n|raz[oó]n social)\s+Sentido\s*\n\s*(.+)/i);
|
|
if (match) {
|
|
return match[1].trim().replace(/\s+(POSITIVO|NEGATIVO|EN SUSPENSI[OÓ]N|NO INSCRITO|INSCRITO SIN OBLIGACIONES)\s*$/i, '').trim();
|
|
}
|
|
return 'NO_ENCONTRADO';
|
|
}
|
|
|
|
function extractEstatus(text: string): string {
|
|
const match = text.match(/Sentido\s*\n\s*.+\s+(POSITIVO|NEGATIVO|EN SUSPENSI[OÓ]N|NO INSCRITO|INSCRITO SIN OBLIGACIONES)\s*$/im);
|
|
if (match) {
|
|
const raw = match[1].trim().toUpperCase();
|
|
if (raw === 'POSITIVO') return 'Positiva';
|
|
if (raw === 'NEGATIVO') return 'Negativa';
|
|
if (raw.includes('SUSPENSI')) return 'En suspensión';
|
|
if (raw.includes('NO INSCRITO')) return 'No inscrito';
|
|
if (raw.includes('SIN OBLIGACIONES')) return 'Inscrito sin obligaciones';
|
|
}
|
|
if (/POSITIVO/i.test(text)) return 'Positiva';
|
|
if (/NEGATIVO/i.test(text)) return 'Negativa';
|
|
return 'NO_DETERMINADO';
|
|
}
|
|
|
|
function extractFolio(text: string): string {
|
|
const match = text.match(/RFC\s+Folio\s*\n\s*[A-ZÑ&]{3,4}\d{6}[A-Z\d]{3}\s+(\S+)/i);
|
|
return match ? match[1].trim() : 'NO_ENCONTRADO';
|
|
}
|
|
|
|
function extractCadenaOriginal(text: string): string {
|
|
const match = text.match(/Cadena Original\s*\n\s*(\|\|.+\|\|)/i);
|
|
return match ? match[1].trim() : 'NO_ENCONTRADO';
|
|
}
|
|
|
|
function extractFecha(text: string): string {
|
|
const match = text.match(/Fecha\s+y\s+hora\s+de\s+emisi[oó]n\s*\n\s*(.+)/i);
|
|
if (match) return match[1].trim();
|
|
const fallback = text.match(/(\d{1,2}\s+de\s+\w+\s+de\s+\d{4}\s+a\s+las\s+[\d:]+\s+horas)/i);
|
|
return fallback ? fallback[1].trim() : 'NO_ENCONTRADO';
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Commit**
|
|
|
|
```bash
|
|
git add apps/api/src/services/sat/sat-opinion-parser.ts
|
|
git commit -m "feat: add SAT opinion PDF parser service"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 7: Create opinion-cumplimiento orchestration service
|
|
|
|
**Files:**
|
|
- Create: `apps/api/src/services/opinion-cumplimiento.service.ts`
|
|
|
|
This is the main service that ties everything together: decrypt FIEL → temp files → Playwright → scrape → parse → save to DB → cleanup.
|
|
|
|
- [ ] **Step 1: Create opinion-cumplimiento.service.ts**
|
|
|
|
Create `apps/api/src/services/opinion-cumplimiento.service.ts`:
|
|
|
|
```typescript
|
|
import { chromium } from 'playwright';
|
|
import { writeFileSync, unlinkSync, mkdirSync } from 'fs';
|
|
import { join } from 'path';
|
|
import { tmpdir } from 'os';
|
|
import { randomUUID } from 'crypto';
|
|
import type { Pool } from 'pg';
|
|
import type { OpinionCumplimiento } from '@horux/shared';
|
|
import { getDecryptedFiel } from './fiel.service.js';
|
|
import { loginToSatOpinion } from './sat/sat-opinion-login.js';
|
|
import { extractOpinionPdf } from './sat/sat-opinion-scraper.js';
|
|
import { parseOpinionPdf } from './sat/sat-opinion-parser.js';
|
|
import { prisma, tenantDb } from '../config/database.js';
|
|
|
|
const PROCESS_TIMEOUT = 180_000; // 3 minutes per tenant
|
|
|
|
/**
|
|
* Downloads and stores the Opinión de Cumplimiento for a tenant.
|
|
* Decrypts FIEL → writes temp files → Playwright login → extract PDF → parse → save to DB.
|
|
*/
|
|
export async function consultarOpinion(
|
|
tenantId: string,
|
|
): Promise<OpinionCumplimiento> {
|
|
const fiel = await getDecryptedFiel(tenantId);
|
|
if (!fiel) {
|
|
throw new Error('No hay FIEL configurada o está vencida');
|
|
}
|
|
|
|
// Write temp files with restricted permissions
|
|
const tempId = randomUUID();
|
|
const tempDir = join(tmpdir(), `horux-fiel-${tempId}`);
|
|
mkdirSync(tempDir, { recursive: true, mode: 0o700 });
|
|
|
|
const cerPath = join(tempDir, 'cert.cer');
|
|
const keyPath = join(tempDir, 'key.key');
|
|
|
|
try {
|
|
writeFileSync(cerPath, Buffer.from(fiel.cerContent, 'binary'), { mode: 0o600 });
|
|
writeFileSync(keyPath, Buffer.from(fiel.keyContent, 'binary'), { mode: 0o600 });
|
|
|
|
// Launch Playwright
|
|
const browser = await chromium.launch({ headless: true });
|
|
|
|
try {
|
|
const context = await browser.newContext({
|
|
viewport: { width: 1280, height: 720 },
|
|
});
|
|
const page = await context.newPage();
|
|
|
|
// Set a global timeout
|
|
const timeoutPromise = new Promise<never>((_, reject) =>
|
|
setTimeout(() => reject(new Error('Timeout: proceso de opinión excedió 3 minutos')), PROCESS_TIMEOUT)
|
|
);
|
|
|
|
const resultPromise = (async () => {
|
|
// Login and navigate to report
|
|
const reportPage = await loginToSatOpinion(page, cerPath, keyPath, fiel.password);
|
|
|
|
// Extract PDF
|
|
const pdfBuffer = await extractOpinionPdf(reportPage);
|
|
|
|
// Parse PDF
|
|
const parsed = await parseOpinionPdf(pdfBuffer);
|
|
|
|
// Get tenant pool and save to DB
|
|
const tenant = await prisma.tenant.findUnique({
|
|
where: { id: tenantId },
|
|
select: { databaseName: true },
|
|
});
|
|
if (!tenant) throw new Error('Tenant no encontrado');
|
|
|
|
const pool = await tenantDb.getPool(tenantId, tenant.databaseName);
|
|
|
|
const { rows } = await pool.query(`
|
|
INSERT INTO opiniones_cumplimiento (rfc, razon_social, estatus, folio, cadena_original, fecha_consulta, pdf)
|
|
VALUES ($1, $2, $3, $4, $5, NOW(), $6)
|
|
RETURNING id, rfc, razon_social as "razonSocial", estatus, folio, cadena_original as "cadenaOriginal",
|
|
fecha_consulta as "fechaConsulta", created_at as "createdAt"
|
|
`, [parsed.rfc, parsed.razonSocial, parsed.estatus, parsed.folio, parsed.cadenaOriginal, pdfBuffer]);
|
|
|
|
return rows[0] as OpinionCumplimiento;
|
|
})();
|
|
|
|
return await Promise.race([resultPromise, timeoutPromise]);
|
|
} finally {
|
|
await browser.close();
|
|
}
|
|
} finally {
|
|
// Cleanup temp files — guaranteed
|
|
try { unlinkSync(cerPath); } catch { /* already deleted or never created */ }
|
|
try { unlinkSync(keyPath); } catch { /* already deleted or never created */ }
|
|
try { const { rmdirSync } = await import('fs'); rmdirSync(tempDir); } catch { /* ignore */ }
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get last N opinions for a tenant (metadata only, no PDF).
|
|
*/
|
|
export async function getOpiniones(pool: Pool, limit = 5): Promise<OpinionCumplimiento[]> {
|
|
const { rows } = await pool.query(`
|
|
SELECT id, rfc, razon_social as "razonSocial", estatus, folio,
|
|
cadena_original as "cadenaOriginal",
|
|
fecha_consulta as "fechaConsulta", created_at as "createdAt"
|
|
FROM opiniones_cumplimiento
|
|
ORDER BY fecha_consulta DESC
|
|
LIMIT $1
|
|
`, [limit]);
|
|
return rows;
|
|
}
|
|
|
|
/**
|
|
* Get PDF binary for a specific opinion.
|
|
*/
|
|
export async function getOpinionPdf(pool: Pool, id: number): Promise<Buffer | null> {
|
|
const { rows } = await pool.query(
|
|
`SELECT pdf FROM opiniones_cumplimiento WHERE id = $1`,
|
|
[id]
|
|
);
|
|
return rows.length > 0 ? rows[0].pdf : null;
|
|
}
|
|
|
|
/**
|
|
* Delete opinions older than 6 months.
|
|
*/
|
|
export async function limpiarOpinionesAntiguas(pool: Pool): Promise<number> {
|
|
const { rowCount } = await pool.query(
|
|
`DELETE FROM opiniones_cumplimiento WHERE fecha_consulta < NOW() - interval '6 months'`
|
|
);
|
|
return rowCount ?? 0;
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Commit**
|
|
|
|
```bash
|
|
git add apps/api/src/services/opinion-cumplimiento.service.ts
|
|
git commit -m "feat: add opinion-cumplimiento orchestration service"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 8: Create documentos controller and routes
|
|
|
|
**Files:**
|
|
- Create: `apps/api/src/controllers/documentos.controller.ts`
|
|
- Create: `apps/api/src/routes/documentos.routes.ts`
|
|
- Modify: `apps/api/src/app.ts`
|
|
|
|
- [ ] **Step 1: Create documentos.controller.ts**
|
|
|
|
Create `apps/api/src/controllers/documentos.controller.ts`:
|
|
|
|
```typescript
|
|
import type { Request, Response, NextFunction } from 'express';
|
|
import { getOpiniones, getOpinionPdf, consultarOpinion } from '../services/opinion-cumplimiento.service.js';
|
|
|
|
function effectiveTenantId(req: Request): string {
|
|
return req.viewingTenantId || req.user!.tenantId;
|
|
}
|
|
|
|
/**
|
|
* GET /api/documentos/opiniones — Last 5 opinions (metadata only)
|
|
*/
|
|
export async function listarOpiniones(req: Request, res: Response, next: NextFunction) {
|
|
try {
|
|
const opiniones = await getOpiniones(req.tenantPool!);
|
|
res.json(opiniones);
|
|
} catch (error) {
|
|
next(error);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* GET /api/documentos/opiniones/:id/pdf — Download PDF binary
|
|
*/
|
|
export async function descargarPdf(req: Request, res: Response, next: NextFunction) {
|
|
try {
|
|
const id = parseInt(req.params.id);
|
|
if (isNaN(id)) return res.status(400).json({ error: 'ID inválido' });
|
|
|
|
const pdf = await getOpinionPdf(req.tenantPool!, id);
|
|
if (!pdf) return res.status(404).json({ error: 'Opinión no encontrada' });
|
|
|
|
res.setHeader('Content-Type', 'application/pdf');
|
|
res.setHeader('Content-Disposition', `attachment; filename="opinion_cumplimiento_${id}.pdf"`);
|
|
res.send(pdf);
|
|
} catch (error) {
|
|
next(error);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* POST /api/documentos/opiniones/consultar — Trigger manual download (admin only)
|
|
*/
|
|
export async function consultarManual(req: Request, res: Response, next: NextFunction) {
|
|
try {
|
|
const tenantId = effectiveTenantId(req);
|
|
const opinion = await consultarOpinion(tenantId);
|
|
res.json(opinion);
|
|
} catch (error: any) {
|
|
if (error.message?.includes('FIEL')) {
|
|
return res.status(400).json({ error: error.message });
|
|
}
|
|
next(error);
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Create documentos.routes.ts**
|
|
|
|
Create `apps/api/src/routes/documentos.routes.ts`:
|
|
|
|
```typescript
|
|
import { Router, type IRouter } from 'express';
|
|
import { authenticate, authorize } from '../middlewares/auth.middleware.js';
|
|
import { tenantMiddleware } from '../middlewares/tenant.middleware.js';
|
|
import { requireFeature } from '../middlewares/feature-gate.middleware.js';
|
|
import * as documentosController from '../controllers/documentos.controller.js';
|
|
|
|
const router: IRouter = Router();
|
|
|
|
router.use(authenticate);
|
|
router.use(tenantMiddleware);
|
|
router.use(requireFeature('documentos'));
|
|
|
|
// Opinión de Cumplimiento
|
|
router.get('/opiniones', documentosController.listarOpiniones);
|
|
router.get('/opiniones/:id/pdf', documentosController.descargarPdf);
|
|
router.post('/opiniones/consultar', authorize('admin'), documentosController.consultarManual);
|
|
|
|
export { router as documentosRoutes };
|
|
```
|
|
|
|
- [ ] **Step 3: Register routes in app.ts**
|
|
|
|
In `apps/api/src/app.ts`, add the import and route registration:
|
|
|
|
Import (after the last route import):
|
|
```typescript
|
|
import { documentosRoutes } from './routes/documentos.routes.js';
|
|
```
|
|
|
|
Route registration (after the last `app.use` route, before error middleware):
|
|
```typescript
|
|
app.use('/api/documentos', documentosRoutes);
|
|
```
|
|
|
|
- [ ] **Step 4: Commit**
|
|
|
|
```bash
|
|
git add apps/api/src/controllers/documentos.controller.ts apps/api/src/routes/documentos.routes.ts apps/api/src/app.ts
|
|
git commit -m "feat: add documentos controller, routes, and register in app"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 9: Add weekly cron job and retention cleanup
|
|
|
|
**Files:**
|
|
- Modify: `apps/api/src/jobs/sat-sync.job.ts`
|
|
|
|
- [ ] **Step 1: Add opinion cron to sat-sync.job.ts**
|
|
|
|
Add imports at the top:
|
|
```typescript
|
|
import { consultarOpinion, limpiarOpinionesAntiguas } from '../services/opinion-cumplimiento.service.js';
|
|
import { tenantDb } from '../config/database.js';
|
|
```
|
|
|
|
Add the opinion sync function before `startSatSyncJob()`:
|
|
|
|
```typescript
|
|
const OPINION_CRON_SCHEDULE = '0 4 * * 0'; // Sundays 4:00 AM
|
|
|
|
async function runOpinionJob(): Promise<void> {
|
|
console.log('[Opinion Cron] Iniciando descarga semanal de Opinión de Cumplimiento');
|
|
|
|
const tenants = await prisma.tenant.findMany({
|
|
where: { active: true },
|
|
select: { id: true, rfc: true, databaseName: true },
|
|
});
|
|
|
|
let success = 0;
|
|
let failed = 0;
|
|
let skipped = 0;
|
|
|
|
for (const tenant of tenants) {
|
|
const hasFiel = await hasFielConfigured(tenant.id);
|
|
if (!hasFiel) {
|
|
skipped++;
|
|
continue;
|
|
}
|
|
|
|
try {
|
|
console.log(`[Opinion Cron] Consultando opinión para ${tenant.rfc}...`);
|
|
await consultarOpinion(tenant.id);
|
|
success++;
|
|
|
|
// Cleanup old records
|
|
const pool = await tenantDb.getPool(tenant.id, tenant.databaseName);
|
|
const deleted = await limpiarOpinionesAntiguas(pool);
|
|
if (deleted > 0) {
|
|
console.log(`[Opinion Cron] ${tenant.rfc}: ${deleted} opiniones antiguas eliminadas`);
|
|
}
|
|
} catch (error: any) {
|
|
console.error(`[Opinion Cron] Error para ${tenant.rfc}:`, error.message);
|
|
failed++;
|
|
}
|
|
}
|
|
|
|
console.log(`[Opinion Cron] Completado — éxito: ${success}, fallidos: ${failed}, sin FIEL: ${skipped}`);
|
|
}
|
|
```
|
|
|
|
Add `opinionTask` variable next to `retryTask`:
|
|
```typescript
|
|
let opinionTask: ReturnType<typeof cron.schedule> | null = null;
|
|
```
|
|
|
|
In `startSatSyncJob()`, after the retry cron setup, add:
|
|
```typescript
|
|
opinionTask = cron.schedule(OPINION_CRON_SCHEDULE, async () => {
|
|
try {
|
|
await runOpinionJob();
|
|
} catch (error: any) {
|
|
console.error('[Opinion Cron] Error:', error.message);
|
|
}
|
|
}, {
|
|
timezone: 'America/Mexico_City',
|
|
});
|
|
|
|
console.log(`[Opinion Cron] Programado para: ${OPINION_CRON_SCHEDULE} (America/Mexico_City)`);
|
|
```
|
|
|
|
In `stopSatSyncJob()`, add:
|
|
```typescript
|
|
if (opinionTask) {
|
|
opinionTask.stop();
|
|
opinionTask = null;
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Commit**
|
|
|
|
```bash
|
|
git add apps/api/src/jobs/sat-sync.job.ts
|
|
git commit -m "feat: add weekly cron for opinion de cumplimiento download"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 10: Add auto-alert for non-Positiva status
|
|
|
|
**Files:**
|
|
- Modify: `apps/api/src/services/alertas-auto.service.ts`
|
|
|
|
- [ ] **Step 1: Add alertaOpinionCumplimiento function**
|
|
|
|
Add before `generarAlertasAutomaticas()`:
|
|
|
|
```typescript
|
|
/**
|
|
* Alerta si la última Opinión de Cumplimiento no es Positiva.
|
|
*/
|
|
async function alertaOpinionCumplimiento(pool: Pool): Promise<AlertaAuto | null> {
|
|
const { rows } = await pool.query(`
|
|
SELECT estatus, fecha_consulta
|
|
FROM opiniones_cumplimiento
|
|
ORDER BY fecha_consulta DESC
|
|
LIMIT 1
|
|
`);
|
|
|
|
if (rows.length === 0) return null;
|
|
|
|
const { estatus, fecha_consulta } = rows[0];
|
|
if (estatus === 'Positiva') return null;
|
|
|
|
const fecha = new Date(fecha_consulta).toLocaleDateString('es-MX');
|
|
|
|
return {
|
|
id: 'opinion-cumplimiento-negativa',
|
|
tipo: 'opinion-cumplimiento',
|
|
titulo: `Opinión de Cumplimiento: ${estatus}`,
|
|
mensaje: `Tu Opinión de Cumplimiento ante el SAT es ${estatus}. Última consulta: ${fecha}. Revisa tus obligaciones fiscales.`,
|
|
prioridad: 'alta',
|
|
valor: 1,
|
|
};
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Register in generarAlertasAutomaticas**
|
|
|
|
Add `alertaOpinionCumplimiento(pool)` to the `Promise.all` array, after `alertaCancelacionPeriodoAnterior(pool)`:
|
|
|
|
```typescript
|
|
alertaCancelacionPeriodoAnterior(pool),
|
|
alertaOpinionCumplimiento(pool),
|
|
```
|
|
|
|
- [ ] **Step 3: Commit**
|
|
|
|
```bash
|
|
git add apps/api/src/services/alertas-auto.service.ts
|
|
git commit -m "feat: add auto-alert for non-Positiva opinion de cumplimiento"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 11: Create frontend API client and hooks
|
|
|
|
**Files:**
|
|
- Create: `apps/web/lib/api/documentos.ts`
|
|
- Create: `apps/web/lib/hooks/use-documentos.ts`
|
|
|
|
- [ ] **Step 1: Create API client**
|
|
|
|
Create `apps/web/lib/api/documentos.ts`:
|
|
|
|
```typescript
|
|
import { apiClient } from './client';
|
|
import type { OpinionCumplimiento } from '@horux/shared';
|
|
|
|
export async function getOpiniones(): Promise<OpinionCumplimiento[]> {
|
|
const { data } = await apiClient.get('/documentos/opiniones');
|
|
return data;
|
|
}
|
|
|
|
export async function descargarOpinionPdf(id: number): Promise<Blob> {
|
|
const { data } = await apiClient.get(`/documentos/opiniones/${id}/pdf`, {
|
|
responseType: 'blob',
|
|
});
|
|
return data;
|
|
}
|
|
|
|
export async function consultarOpinion(): Promise<OpinionCumplimiento> {
|
|
const { data } = await apiClient.post('/documentos/opiniones/consultar');
|
|
return data;
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Create React Query hooks**
|
|
|
|
Create `apps/web/lib/hooks/use-documentos.ts`:
|
|
|
|
```typescript
|
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
|
import { getOpiniones, descargarOpinionPdf, consultarOpinion } from '../api/documentos';
|
|
import { useTenantViewStore } from '../../stores/tenant-view-store';
|
|
|
|
export function useOpiniones() {
|
|
const viewingTenantId = useTenantViewStore((s) => s.viewingTenantId);
|
|
|
|
return useQuery({
|
|
queryKey: ['opiniones', viewingTenantId],
|
|
queryFn: getOpiniones,
|
|
});
|
|
}
|
|
|
|
export function useConsultarOpinion() {
|
|
const queryClient = useQueryClient();
|
|
const viewingTenantId = useTenantViewStore((s) => s.viewingTenantId);
|
|
|
|
return useMutation({
|
|
mutationFn: consultarOpinion,
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey: ['opiniones', viewingTenantId] });
|
|
},
|
|
});
|
|
}
|
|
|
|
export function useDescargarPdf() {
|
|
return useMutation({
|
|
mutationFn: async (id: number) => {
|
|
const blob = await descargarOpinionPdf(id);
|
|
const url = URL.createObjectURL(blob);
|
|
const a = document.createElement('a');
|
|
a.href = url;
|
|
a.download = `opinion_cumplimiento_${id}.pdf`;
|
|
a.click();
|
|
URL.revokeObjectURL(url);
|
|
},
|
|
});
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 3: Commit**
|
|
|
|
```bash
|
|
git add apps/web/lib/api/documentos.ts apps/web/lib/hooks/use-documentos.ts
|
|
git commit -m "feat: add documentos API client and React Query hooks"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 12: Create Documentos frontend page
|
|
|
|
**Files:**
|
|
- Create: `apps/web/app/(dashboard)/documentos/page.tsx`
|
|
- Modify: `apps/web/components/layouts/sidebar.tsx`
|
|
|
|
- [ ] **Step 1: Create Documentos page**
|
|
|
|
Create `apps/web/app/(dashboard)/documentos/page.tsx`:
|
|
|
|
```tsx
|
|
'use client';
|
|
|
|
import { useOpiniones, useConsultarOpinion, useDescargarPdf } from '../../../lib/hooks/use-documentos';
|
|
import { useAuthStore } from '../../../stores/auth-store';
|
|
import { FileCheck, Download, RefreshCw, Loader2, AlertTriangle, CheckCircle2, XCircle, Clock } from 'lucide-react';
|
|
|
|
function EstatusBadge({ estatus }: { estatus: string }) {
|
|
if (estatus === 'Positiva') {
|
|
return (
|
|
<span className="inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400">
|
|
<CheckCircle2 className="h-3 w-3" /> {estatus}
|
|
</span>
|
|
);
|
|
}
|
|
if (estatus === 'Negativa') {
|
|
return (
|
|
<span className="inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-xs font-medium bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400">
|
|
<XCircle className="h-3 w-3" /> {estatus}
|
|
</span>
|
|
);
|
|
}
|
|
return (
|
|
<span className="inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-xs font-medium bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-400">
|
|
<AlertTriangle className="h-3 w-3" /> {estatus}
|
|
</span>
|
|
);
|
|
}
|
|
|
|
export default function DocumentosPage() {
|
|
const { data: opiniones, isLoading, error } = useOpiniones();
|
|
const consultar = useConsultarOpinion();
|
|
const descargar = useDescargarPdf();
|
|
const user = useAuthStore((s) => s.user);
|
|
const isAdmin = user?.role === 'admin';
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<h1 className="text-2xl font-bold">Documentos</h1>
|
|
<p className="text-sm text-muted-foreground mt-1">Documentos fiscales consultados ante el SAT</p>
|
|
</div>
|
|
{isAdmin && (
|
|
<button
|
|
onClick={() => consultar.mutate()}
|
|
disabled={consultar.isPending}
|
|
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-50 text-sm font-medium"
|
|
>
|
|
{consultar.isPending ? (
|
|
<><Loader2 className="h-4 w-4 animate-spin" /> Consultando...</>
|
|
) : (
|
|
<><RefreshCw className="h-4 w-4" /> Consultar ahora</>
|
|
)}
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
{consultar.isError && (
|
|
<div className="p-3 rounded-lg bg-red-50 dark:bg-red-900/20 text-red-700 dark:text-red-400 text-sm">
|
|
Error: {(consultar.error as Error).message}
|
|
</div>
|
|
)}
|
|
|
|
<div>
|
|
<h2 className="text-lg font-semibold mb-4 flex items-center gap-2">
|
|
<FileCheck className="h-5 w-5" /> Opinión de Cumplimiento
|
|
</h2>
|
|
|
|
{isLoading && (
|
|
<div className="flex items-center justify-center py-12">
|
|
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
|
</div>
|
|
)}
|
|
|
|
{error && (
|
|
<div className="p-4 rounded-lg bg-red-50 dark:bg-red-900/20 text-red-700 dark:text-red-400 text-sm">
|
|
Error al cargar opiniones: {(error as Error).message}
|
|
</div>
|
|
)}
|
|
|
|
{!isLoading && !error && opiniones?.length === 0 && (
|
|
<div className="text-center py-12 text-muted-foreground">
|
|
<FileCheck className="h-12 w-12 mx-auto mb-3 opacity-30" />
|
|
<p>No hay opiniones registradas.</p>
|
|
<p className="text-sm mt-1">La consulta automática se ejecuta cada semana.</p>
|
|
</div>
|
|
)}
|
|
|
|
{!isLoading && opiniones && opiniones.length > 0 && (
|
|
<div className="overflow-x-auto">
|
|
<table className="w-full text-sm">
|
|
<thead>
|
|
<tr className="border-b text-left text-muted-foreground">
|
|
<th className="pb-2 font-medium">Fecha de consulta</th>
|
|
<th className="pb-2 font-medium">Estatus</th>
|
|
<th className="pb-2 font-medium">Folio</th>
|
|
<th className="pb-2 font-medium">RFC</th>
|
|
<th className="pb-2 font-medium text-right">PDF</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y">
|
|
{opiniones.map((op) => (
|
|
<tr key={op.id} className="hover:bg-muted/50">
|
|
<td className="py-3">
|
|
<div className="flex items-center gap-2">
|
|
<Clock className="h-4 w-4 text-muted-foreground" />
|
|
{new Date(op.fechaConsulta).toLocaleDateString('es-MX', {
|
|
year: 'numeric', month: 'long', day: 'numeric',
|
|
hour: '2-digit', minute: '2-digit',
|
|
})}
|
|
</div>
|
|
</td>
|
|
<td className="py-3"><EstatusBadge estatus={op.estatus} /></td>
|
|
<td className="py-3 font-mono text-xs">{op.folio}</td>
|
|
<td className="py-3 font-mono text-xs">{op.rfc}</td>
|
|
<td className="py-3 text-right">
|
|
<button
|
|
onClick={() => descargar.mutate(op.id)}
|
|
disabled={descargar.isPending}
|
|
className="inline-flex items-center gap-1 px-2 py-1 rounded text-xs hover:bg-muted"
|
|
>
|
|
<Download className="h-3.5 w-3.5" /> Descargar
|
|
</button>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Add Documentos to sidebar**
|
|
|
|
In `apps/web/components/layouts/sidebar.tsx`, add `FileCheck` to the lucide imports:
|
|
|
|
```typescript
|
|
import { ..., FileCheck } from 'lucide-react';
|
|
```
|
|
|
|
Add the nav item in the `navigation` array, after Facturación and before Usuarios:
|
|
|
|
```typescript
|
|
{ name: 'Documentos', href: '/documentos', icon: FileCheck, feature: 'documentos' },
|
|
```
|
|
|
|
- [ ] **Step 3: Commit**
|
|
|
|
```bash
|
|
git add apps/web/app/\(dashboard\)/documentos/page.tsx apps/web/components/layouts/sidebar.tsx
|
|
git commit -m "feat: add Documentos page with Opinión de Cumplimiento UI"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 13: Update documentation
|
|
|
|
**Files:**
|
|
- Modify: `CLAUDE.md`
|
|
- Modify: `README.md`
|
|
|
|
- [ ] **Step 1: Update CLAUDE.md**
|
|
|
|
Add a new section after "Calendario y Recordatorios" documenting:
|
|
- Documentos page and Opinión de Cumplimiento
|
|
- Files involved (service, controller, routes, cron)
|
|
- Weekly cron schedule
|
|
- FIEL security (temp files, cleanup)
|
|
- Data retention (6 months DB, 5 shown)
|
|
|
|
- [ ] **Step 2: Update README.md changelog**
|
|
|
|
Add entry for this feature in the changelog.
|
|
|
|
- [ ] **Step 3: Commit**
|
|
|
|
```bash
|
|
git add CLAUDE.md README.md
|
|
git commit -m "docs: document Opinión de Cumplimiento integration"
|
|
```
|