# ET-REPORT-BACKEND: Servicios y API REST

## Identificacion

| Campo | Valor |
|-------|-------|
| **ID** | ET-REPORT-BACKEND |
| **Modulo** | MGN-009 Reports |
| **Version** | 1.0 |
| **Estado** | En Diseno |
| **Framework** | NestJS |
| **Autor** | Requirements-Analyst |
| **Fecha** | 2025-12-05 |

---

## Estructura de Archivos

```
apps/backend/src/modules/reports/
├── reports.module.ts
├── controllers/
│   ├── reports.controller.ts
│   ├── dashboards.controller.ts
│   ├── widgets.controller.ts
│   ├── custom-reports.controller.ts
│   └── scheduled-reports.controller.ts
├── services/
│   ├── reports.service.ts
│   ├── report-executor.service.ts
│   ├── report-exporter.service.ts
│   ├── dashboards.service.ts
│   ├── widgets.service.ts
│   ├── custom-reports.service.ts
│   ├── report-builder.service.ts
│   └── scheduled-reports.service.ts
├── entities/
│   ├── report-definition.entity.ts
│   ├── report-execution.entity.ts
│   ├── dashboard.entity.ts
│   ├── widget.entity.ts
│   ├── custom-report.entity.ts
│   ├── report-field.entity.ts
│   └── scheduled-report.entity.ts
├── dto/
│   ├── execute-report.dto.ts
│   ├── export-report.dto.ts
│   ├── create-dashboard.dto.ts
│   ├── create-widget.dto.ts
│   ├── create-custom-report.dto.ts
│   └── schedule-report.dto.ts
├── processors/
│   └── report.processor.ts
└── templates/
    └── report-base.hbs
```

---

## Entidades

### ReportDefinition Entity

```typescript
@Entity('report_definitions', { schema: 'core_reports' })
export class ReportDefinition {
  @PrimaryGeneratedColumn('uuid')
  id: string;

  @Column({ length: 50, unique: true })
  code: string;

  @Column({ length: 255 })
  name: string;

  @Column({ type: 'text', nullable: true })
  description: string;

  @Column({ length: 50 })
  category: ReportCategory;

  @Column({ length: 50, nullable: true })
  module: string;

  @Column({ name: 'default_params', type: 'jsonb', default: {} })
  defaultParams: Record<string, any>;

  @Column({ name: 'query_template', type: 'text' })
  queryTemplate: string;

  @Column({ type: 'jsonb', default: [] })
  columns: ColumnDefinition[];

  @Column({ type: 'jsonb', default: [] })
  charts: ChartDefinition[];

  @Column({ name: 'export_formats', type: 'varchar', array: true, default: ['pdf', 'xlsx', 'csv'] })
  exportFormats: ExportFormat[];

  @Column({ name: 'is_cacheable', default: true })
  isCacheable: boolean;

  @Column({ name: 'cache_ttl_seconds', nullable: true })
  cacheTtlSeconds: number;

  @Column({ name: 'is_async', default: false })
  isAsync: boolean;

  @Column({ name: 'max_rows', default: 10000 })
  maxRows: number;

  @Column({ name: 'is_active', default: true })
  isActive: boolean;

  @CreateDateColumn({ name: 'created_at' })
  createdAt: Date;

  @UpdateDateColumn({ name: 'updated_at' })
  updatedAt: Date;
}

export type ReportCategory = 'sales' | 'inventory' | 'finance' | 'hr' | 'operations' | 'audit' | 'custom';
export type ExportFormat = 'pdf' | 'xlsx' | 'csv' | 'json';
```

### Dashboard Entity

```typescript
@Entity('dashboards', { schema: 'core_reports' })
export class Dashboard {
  @PrimaryGeneratedColumn('uuid')
  id: string;

  @Column({ name: 'tenant_id', type: 'uuid' })
  tenantId: string;

  @Column({ name: 'owner_id', type: 'uuid', nullable: true })
  ownerId: string;

  @Column({ length: 255 })
  name: string;

  @Column({ type: 'text', nullable: true })
  description: string;

  @Column({ length: 20, default: 'private' })
  visibility: DashboardVisibility;

  @Column({ type: 'jsonb', default: {} })
  layout: DashboardLayout;

  @Column({ length: 50, nullable: true })
  theme: string;

  @Column({ name: 'refresh_interval', nullable: true })
  refreshInterval: number;

  @Column({ name: 'is_default', default: false })
  isDefault: boolean;

  @Column({ name: 'is_archived', default: false })
  isArchived: boolean;

  @CreateDateColumn({ name: 'created_at' })
  createdAt: Date;

  @UpdateDateColumn({ name: 'updated_at' })
  updatedAt: Date;

  @OneToMany(() => Widget, w => w.dashboard)
  widgets: Widget[];
}

export type DashboardVisibility = 'private' | 'team' | 'tenant';
```

### Widget Entity

```typescript
@Entity('widgets', { schema: 'core_reports' })
export class Widget {
  @PrimaryGeneratedColumn('uuid')
  id: string;

  @Column({ name: 'dashboard_id', type: 'uuid' })
  dashboardId: string;

  @Column({ length: 50 })
  type: WidgetType;

  @Column({ length: 255 })
  title: string;

  @Column({ length: 255, nullable: true })
  subtitle: string;

  @Column({ type: 'jsonb', default: {} })
  config: WidgetConfig;

  @Column({ name: 'data_source', type: 'jsonb' })
  dataSource: DataSource;

  @Column({ type: 'jsonb' })
  position: GridPosition;

  @Column({ type: 'jsonb' })
  size: GridSize;

  @Column({ name: 'refresh_interval', nullable: true })
  refreshInterval: number;

  @Column({ name: 'is_visible', default: true })
  isVisible: boolean;

  @Column({ name: 'sort_order', default: 0 })
  sortOrder: number;

  @ManyToOne(() => Dashboard, d => d.widgets)
  @JoinColumn({ name: 'dashboard_id' })
  dashboard: Dashboard;
}

export type WidgetType =
  | 'metric' | 'chart_line' | 'chart_bar' | 'chart_pie' | 'chart_area'
  | 'table' | 'list' | 'progress' | 'gauge' | 'map' | 'calendar' | 'custom';
```

### CustomReport Entity

```typescript
@Entity('custom_reports', { schema: 'core_reports' })
export class CustomReport {
  @PrimaryGeneratedColumn('uuid')
  id: string;

  @Column({ name: 'tenant_id', type: 'uuid' })
  tenantId: string;

  @Column({ name: 'created_by', type: 'uuid' })
  createdBy: string;

  @Column({ length: 255 })
  name: string;

  @Column({ type: 'text', nullable: true })
  description: string;

  @Column({ name: 'base_entity', length: 100 })
  baseEntity: string;

  @Column({ name: 'selected_fields', type: 'jsonb', default: [] })
  selectedFields: SelectedField[];

  @Column({ type: 'jsonb', default: [] })
  joins: JoinDefinition[];

  @Column({ type: 'jsonb', default: [] })
  filters: FilterDefinition[];

  @Column({ type: 'jsonb', default: [] })
  grouping: string[];

  @Column({ type: 'jsonb', default: [] })
  aggregations: AggregationDefinition[];

  @Column({ type: 'jsonb', default: [] })
  sorting: SortDefinition[];

  @Column({ length: 20, default: 'private' })
  visibility: 'private' | 'team' | 'tenant';

  @Column({ name: 'is_favorite', default: false })
  isFavorite: boolean;

  @Column({ name: 'last_run_at', type: 'timestamptz', nullable: true })
  lastRunAt: Date;

  @Column({ name: 'run_count', default: 0 })
  runCount: number;

  @CreateDateColumn({ name: 'created_at' })
  createdAt: Date;

  @UpdateDateColumn({ name: 'updated_at' })
  updatedAt: Date;
}
```

---

## Servicios

### ReportsService

```typescript
@Injectable()
export class ReportsService {
  constructor(
    @InjectRepository(ReportDefinition)
    private readonly definitionRepo: Repository<ReportDefinition>,
    private readonly executor: ReportExecutorService,
    private readonly exporter: ReportExporterService,
    private readonly cacheManager: Cache,
  ) {}

  async findAll(
    category?: ReportCategory,
    module?: string
  ): Promise<ReportDefinition[]> {
    const qb = this.definitionRepo.createQueryBuilder('r')
      .where('r.is_active = true');

    if (category) {
      qb.andWhere('r.category = :category', { category });
    }

    if (module) {
      qb.andWhere('r.module = :module', { module });
    }

    return qb.orderBy('r.category').addOrderBy('r.name').getMany();
  }

  async findByCode(code: string): Promise<ReportDefinition> {
    const report = await this.definitionRepo.findOne({
      where: { code, isActive: true },
    });

    if (!report) {
      throw new NotFoundException(`Report ${code} not found`);
    }

    return report;
  }

  async execute(
    code: string,
    tenantId: string,
    userId: string,
    params: Record<string, any>
  ): Promise<ReportExecutionResult> {
    const report = await this.findByCode(code);

    // Merge with defaults
    const mergedParams = { ...report.defaultParams, ...params };

    // Check cache
    if (report.isCacheable) {
      const cacheKey = this.getCacheKey(code, tenantId, mergedParams);
      const cached = await this.cacheManager.get<ReportExecutionResult>(cacheKey);

      if (cached) {
        return { ...cached, fromCache: true };
      }
    }

    // Execute
    const result = report.isAsync
      ? await this.executor.executeAsync(report, tenantId, userId, mergedParams)
      : await this.executor.execute(report, tenantId, userId, mergedParams);

    // Cache result
    if (report.isCacheable && result.status === 'completed') {
      const cacheKey = this.getCacheKey(code, tenantId, mergedParams);
      await this.cacheManager.set(cacheKey, result, report.cacheTtlSeconds);
    }

    return result;
  }

  async export(
    code: string,
    tenantId: string,
    userId: string,
    params: Record<string, any>,
    format: ExportFormat
  ): Promise<ExportResult> {
    const report = await this.findByCode(code);

    if (!report.exportFormats.includes(format)) {
      throw new BadRequestException(`Format ${format} not supported for this report`);
    }

    // Execute report first
    const result = await this.execute(code, tenantId, userId, params);

    // Export
    return this.exporter.export(result.data, report.columns, format, report.name);
  }

  private getCacheKey(
    code: string,
    tenantId: string,
    params: Record<string, any>
  ): string {
    const paramsHash = crypto
      .createHash('md5')
      .update(JSON.stringify(params))
      .digest('hex');

    return `report:${code}:${tenantId}:${paramsHash}`;
  }
}
```

### ReportExecutorService

```typescript
@Injectable()
export class ReportExecutorService {
  constructor(
    @InjectRepository(ReportExecution)
    private readonly executionRepo: Repository<ReportExecution>,
    @InjectDataSource()
    private readonly dataSource: DataSource,
    @InjectQueue('reports')
    private readonly queue: Queue,
  ) {}

  async execute(
    report: ReportDefinition,
    tenantId: string,
    userId: string,
    params: Record<string, any>
  ): Promise<ReportExecutionResult> {
    const execution = await this.createExecution(report.id, tenantId, userId, params);

    try {
      const startTime = Date.now();

      // Build query
      const query = this.buildQuery(report.queryTemplate, params, tenantId);

      // Execute with limit
      const result = await this.dataSource.query(query + ` LIMIT ${report.maxRows}`);

      // Calculate summary
      const summary = this.calculateSummary(result, report.columns);

      // Update execution
      execution.status = 'completed';
      execution.rowCount = result.length;
      execution.resultSummary = summary;
      execution.executionTimeMs = Date.now() - startTime;
      execution.completedAt = new Date();

      await this.executionRepo.save(execution);

      return {
        executionId: execution.id,
        status: 'completed',
        data: result,
        rowCount: result.length,
        summary,
        executionTimeMs: execution.executionTimeMs,
      };
    } catch (error) {
      execution.status = 'failed';
      execution.error = error.message;
      execution.completedAt = new Date();
      await this.executionRepo.save(execution);

      throw error;
    }
  }

  async executeAsync(
    report: ReportDefinition,
    tenantId: string,
    userId: string,
    params: Record<string, any>
  ): Promise<ReportExecutionResult> {
    const execution = await this.createExecution(report.id, tenantId, userId, params);

    // Queue job
    await this.queue.add('execute', {
      executionId: execution.id,
      reportId: report.id,
      tenantId,
      params,
    });

    return {
      executionId: execution.id,
      status: 'pending',
      message: 'Report queued for execution',
    };
  }

  async getExecutionStatus(executionId: string): Promise<ReportExecution> {
    return this.executionRepo.findOneOrFail({ where: { id: executionId } });
  }

  private buildQuery(
    template: string,
    params: Record<string, any>,
    tenantId: string
  ): string {
    let query = template;

    // Replace tenant_id
    query = query.replace(/\{\{tenant_id\}\}/g, `'${tenantId}'`);

    // Replace params
    for (const [key, value] of Object.entries(params)) {
      const placeholder = new RegExp(`\\{\\{${key}\\}\\}`, 'g');

      if (typeof value === 'string') {
        query = query.replace(placeholder, `'${value.replace(/'/g, "''")}'`);
      } else if (value === null) {
        query = query.replace(placeholder, 'NULL');
      } else {
        query = query.replace(placeholder, String(value));
      }
    }

    return query;
  }

  private calculateSummary(
    data: any[],
    columns: ColumnDefinition[]
  ): Record<string, any> {
    const summary: Record<string, any> = {};

    for (const col of columns.filter(c => c.summarize)) {
      const values = data.map(row => row[col.field]).filter(v => v != null);

      switch (col.summarize) {
        case 'sum':
          summary[col.field] = values.reduce((a, b) => a + b, 0);
          break;
        case 'avg':
          summary[col.field] = values.length > 0
            ? values.reduce((a, b) => a + b, 0) / values.length
            : 0;
          break;
        case 'min':
          summary[col.field] = Math.min(...values);
          break;
        case 'max':
          summary[col.field] = Math.max(...values);
          break;
        case 'count':
          summary[col.field] = values.length;
          break;
      }
    }

    return summary;
  }
}
```

### DashboardsService

```typescript
@Injectable()
export class DashboardsService {
  constructor(
    @InjectRepository(Dashboard)
    private readonly repo: Repository<Dashboard>,
    @InjectRepository(Widget)
    private readonly widgetRepo: Repository<Widget>,
  ) {}

  async findAll(tenantId: string, userId: string): Promise<Dashboard[]> {
    return this.repo.createQueryBuilder('d')
      .where('d.tenant_id = :tenantId', { tenantId })
      .andWhere('d.is_archived = false')
      .andWhere(
        '(d.visibility = :public OR d.owner_id = :userId)',
        { public: 'tenant', userId }
      )
      .leftJoinAndSelect('d.widgets', 'w')
      .orderBy('d.is_default', 'DESC')
      .addOrderBy('d.name', 'ASC')
      .getMany();
  }

  async findById(id: string): Promise<Dashboard> {
    const dashboard = await this.repo.findOne({
      where: { id },
      relations: ['widgets'],
    });

    if (!dashboard) {
      throw new NotFoundException('Dashboard not found');
    }

    return dashboard;
  }

  async create(
    tenantId: string,
    userId: string,
    dto: CreateDashboardDto
  ): Promise<Dashboard> {
    // If setting as default, unset other defaults
    if (dto.isDefault) {
      await this.repo.update(
        { tenantId, isDefault: true },
        { isDefault: false }
      );
    }

    const dashboard = this.repo.create({
      ...dto,
      tenantId,
      ownerId: userId,
    });

    return this.repo.save(dashboard);
  }

  async update(id: string, dto: UpdateDashboardDto): Promise<Dashboard> {
    const dashboard = await this.findById(id);

    if (dto.isDefault && !dashboard.isDefault) {
      await this.repo.update(
        { tenantId: dashboard.tenantId, isDefault: true },
        { isDefault: false }
      );
    }

    Object.assign(dashboard, dto);
    return this.repo.save(dashboard);
  }

  async duplicate(id: string, userId: string, name: string): Promise<Dashboard> {
    const source = await this.findById(id);

    const newDashboard = this.repo.create({
      tenantId: source.tenantId,
      ownerId: userId,
      name,
      description: source.description,
      visibility: 'private',
      layout: source.layout,
      theme: source.theme,
    });

    const saved = await this.repo.save(newDashboard);

    // Duplicate widgets
    for (const widget of source.widgets) {
      const newWidget = this.widgetRepo.create({
        ...widget,
        id: undefined,
        dashboardId: saved.id,
      });
      await this.widgetRepo.save(newWidget);
    }

    return this.findById(saved.id);
  }

  async archive(id: string): Promise<void> {
    await this.repo.update(id, { isArchived: true });
  }
}
```

### WidgetsService

```typescript
@Injectable()
export class WidgetsService {
  constructor(
    @InjectRepository(Widget)
    private readonly repo: Repository<Widget>,
    private readonly reportsService: ReportsService,
    private readonly customReportsService: CustomReportsService,
    private readonly cacheManager: Cache,
  ) {}

  async create(
    dashboardId: string,
    dto: CreateWidgetDto
  ): Promise<Widget> {
    const widget = this.repo.create({
      ...dto,
      dashboardId,
    });

    return this.repo.save(widget);
  }

  async update(id: string, dto: UpdateWidgetDto): Promise<Widget> {
    const widget = await this.repo.findOneOrFail({ where: { id } });
    Object.assign(widget, dto);
    return this.repo.save(widget);
  }

  async delete(id: string): Promise<void> {
    await this.repo.delete(id);
  }

  async getData(
    widget: Widget,
    tenantId: string,
    userId: string
  ): Promise<WidgetData> {
    const cacheKey = `widget:${widget.id}:${tenantId}`;

    if (widget.config.cacheable !== false) {
      const cached = await this.cacheManager.get<WidgetData>(cacheKey);
      if (cached) return cached;
    }

    let data: WidgetData;

    switch (widget.dataSource.type) {
      case 'report':
        const result = await this.reportsService.execute(
          widget.dataSource.reportCode,
          tenantId,
          userId,
          widget.dataSource.params || {}
        );
        data = this.transformToWidgetData(widget, result.data);
        break;

      case 'custom_report':
        const customResult = await this.customReportsService.execute(
          widget.dataSource.customReportId,
          tenantId,
          userId
        );
        data = this.transformToWidgetData(widget, customResult.data);
        break;

      case 'query':
        data = await this.executeDirectQuery(
          widget.dataSource.query,
          tenantId,
          widget
        );
        break;

      case 'static':
        data = widget.dataSource.data;
        break;

      default:
        throw new BadRequestException('Invalid data source type');
    }

    const ttl = widget.refreshInterval || 300;
    await this.cacheManager.set(cacheKey, data, ttl);

    return data;
  }

  private transformToWidgetData(widget: Widget, rawData: any[]): WidgetData {
    switch (widget.type) {
      case 'metric':
        return {
          value: rawData[0]?.[widget.config.valueField] || 0,
          previousValue: rawData[0]?.[widget.config.previousField],
        };

      case 'chart_line':
      case 'chart_bar':
      case 'chart_area':
        return {
          labels: rawData.map(r => r[widget.config.labelField]),
          datasets: widget.config.datasets.map(ds => ({
            label: ds.label,
            data: rawData.map(r => r[ds.field]),
          })),
        };

      case 'chart_pie':
        return {
          labels: rawData.map(r => r[widget.config.labelField]),
          data: rawData.map(r => r[widget.config.valueField]),
        };

      case 'table':
      case 'list':
        return { rows: rawData };

      default:
        return { raw: rawData };
    }
  }
}
```

### ReportBuilderService

```typescript
@Injectable()
export class ReportBuilderService {
  constructor(
    @InjectRepository(ReportField)
    private readonly fieldRepo: Repository<ReportField>,
    @InjectRepository(CustomReport)
    private readonly customRepo: Repository<CustomReport>,
    @InjectDataSource()
    private readonly dataSource: DataSource,
  ) {}

  async getAvailableEntities(): Promise<string[]> {
    const entities = await this.fieldRepo
      .createQueryBuilder('f')
      .select('DISTINCT f.entity', 'entity')
      .getRawMany();

    return entities.map(e => e.entity);
  }

  async getEntityFields(entity: string): Promise<ReportField[]> {
    return this.fieldRepo.find({
      where: { entity },
      order: { displayName: 'ASC' },
    });
  }

  async validateReport(dto: CreateCustomReportDto): Promise<ValidationResult> {
    const errors: string[] = [];

    // Validate base entity exists
    const baseFields = await this.getEntityFields(dto.baseEntity);
    if (baseFields.length === 0) {
      errors.push(`Unknown entity: ${dto.baseEntity}`);
    }

    // Validate selected fields
    for (const field of dto.selectedFields) {
      const found = baseFields.find(f => f.fieldName === field.field);
      if (!found && !field.isCalculated) {
        errors.push(`Unknown field: ${field.field} in ${dto.baseEntity}`);
      }
    }

    // Validate filters
    for (const filter of dto.filters || []) {
      const found = baseFields.find(f => f.fieldName === filter.field);
      if (!found) {
        errors.push(`Unknown filter field: ${filter.field}`);
      }
    }

    return {
      valid: errors.length === 0,
      errors,
    };
  }

  async preview(
    tenantId: string,
    dto: CreateCustomReportDto,
    limit: number = 10
  ): Promise<any[]> {
    const query = this.buildCustomQuery(dto, tenantId, limit);
    return this.dataSource.query(query);
  }

  async create(
    tenantId: string,
    userId: string,
    dto: CreateCustomReportDto
  ): Promise<CustomReport> {
    const validation = await this.validateReport(dto);
    if (!validation.valid) {
      throw new BadRequestException(validation.errors.join(', '));
    }

    const report = this.customRepo.create({
      ...dto,
      tenantId,
      createdBy: userId,
    });

    return this.customRepo.save(report);
  }

  async execute(
    reportId: string,
    tenantId: string,
    userId: string
  ): Promise<CustomReportResult> {
    const report = await this.customRepo.findOneOrFail({
      where: { id: reportId, tenantId },
    });

    const query = this.buildCustomQuery(report, tenantId);
    const data = await this.dataSource.query(query);

    // Update stats
    report.lastRunAt = new Date();
    report.runCount += 1;
    await this.customRepo.save(report);

    return {
      reportId: report.id,
      name: report.name,
      data,
      rowCount: data.length,
      executedAt: new Date(),
    };
  }

  private buildCustomQuery(
    report: CreateCustomReportDto | CustomReport,
    tenantId: string,
    limit?: number
  ): string {
    const entityMap = this.getEntityTableMap();
    const baseTable = entityMap[report.baseEntity];

    // SELECT clause
    const selectFields = report.selectedFields.map(f => {
      if (f.isCalculated) {
        return `(${f.formula}) AS "${f.alias || f.field}"`;
      }
      if (f.aggregation) {
        return `${f.aggregation}(${f.field}) AS "${f.alias || f.field}"`;
      }
      return `${f.field} AS "${f.alias || f.field}"`;
    }).join(', ');

    // FROM clause with JOINs
    let fromClause = `FROM ${baseTable} AS base`;
    for (const join of report.joins || []) {
      const joinTable = entityMap[join.entity];
      fromClause += ` ${join.type} JOIN ${joinTable} AS ${join.alias} ON ${join.condition}`;
    }

    // WHERE clause
    const conditions: string[] = [`base.tenant_id = '${tenantId}'`];
    for (const filter of report.filters || []) {
      conditions.push(this.buildFilterCondition(filter));
    }
    const whereClause = `WHERE ${conditions.join(' AND ')}`;

    // GROUP BY clause
    let groupByClause = '';
    if (report.grouping?.length) {
      groupByClause = `GROUP BY ${report.grouping.join(', ')}`;
    }

    // ORDER BY clause
    let orderByClause = '';
    if (report.sorting?.length) {
      orderByClause = `ORDER BY ${report.sorting.map(s =>
        `${s.field} ${s.direction}`
      ).join(', ')}`;
    }

    // LIMIT clause
    const limitClause = limit ? `LIMIT ${limit}` : '';

    return `
      SELECT ${selectFields}
      ${fromClause}
      ${whereClause}
      ${groupByClause}
      ${orderByClause}
      ${limitClause}
    `;
  }

  private buildFilterCondition(filter: FilterDefinition): string {
    const { field, operator, value } = filter;

    switch (operator) {
      case 'eq': return `${field} = '${value}'`;
      case 'neq': return `${field} != '${value}'`;
      case 'gt': return `${field} > '${value}'`;
      case 'gte': return `${field} >= '${value}'`;
      case 'lt': return `${field} < '${value}'`;
      case 'lte': return `${field} <= '${value}'`;
      case 'like': return `${field} ILIKE '%${value}%'`;
      case 'in': return `${field} IN (${value.map(v => `'${v}'`).join(',')})`;
      case 'is_null': return `${field} IS NULL`;
      case 'is_not_null': return `${field} IS NOT NULL`;
      case 'between': return `${field} BETWEEN '${value[0]}' AND '${value[1]}'`;
      default: return '1=1';
    }
  }

  private getEntityTableMap(): Record<string, string> {
    return {
      users: 'core_users.users',
      contacts: 'core_catalogs.contacts',
      tenants: 'core_tenants.tenants',
      roles: 'core_rbac.roles',
      sessions: 'core_auth.sessions',
    };
  }
}
```

---

## Controladores

### ReportsController

```typescript
@ApiTags('Reports')
@Controller('reports')
@UseGuards(JwtAuthGuard, RbacGuard)
export class ReportsController {
  constructor(private readonly service: ReportsService) {}

  @Get()
  @Permissions('reports.read')
  async findAll(
    @Query('category') category?: ReportCategory,
    @Query('module') module?: string
  ) {
    return this.service.findAll(category, module);
  }

  @Get(':code')
  @Permissions('reports.read')
  async findByCode(@Param('code') code: string) {
    return this.service.findByCode(code);
  }

  @Post(':code/execute')
  @Permissions('reports.execute')
  async execute(
    @Param('code') code: string,
    @TenantId() tenantId: string,
    @CurrentUser() user: User,
    @Body() dto: ExecuteReportDto
  ) {
    return this.service.execute(code, tenantId, user.id, dto.params);
  }

  @Post(':code/export')
  @Permissions('reports.export')
  async export(
    @Param('code') code: string,
    @TenantId() tenantId: string,
    @CurrentUser() user: User,
    @Body() dto: ExportReportDto,
    @Res() res: Response
  ) {
    const result = await this.service.export(
      code,
      tenantId,
      user.id,
      dto.params,
      dto.format
    );

    res.setHeader('Content-Type', result.contentType);
    res.setHeader('Content-Disposition', `attachment; filename="${result.filename}"`);
    res.send(result.buffer);
  }

  @Get('executions/:id')
  @Permissions('reports.read')
  async getExecutionStatus(@Param('id') id: string) {
    return this.service.getExecutionStatus(id);
  }
}
```

### DashboardsController

```typescript
@ApiTags('Dashboards')
@Controller('dashboards')
@UseGuards(JwtAuthGuard)
export class DashboardsController {
  constructor(
    private readonly service: DashboardsService,
    private readonly widgetsService: WidgetsService,
  ) {}

  @Get()
  async findAll(
    @TenantId() tenantId: string,
    @CurrentUser() user: User
  ) {
    return this.service.findAll(tenantId, user.id);
  }

  @Get(':id')
  async findById(@Param('id') id: string) {
    return this.service.findById(id);
  }

  @Post()
  async create(
    @TenantId() tenantId: string,
    @CurrentUser() user: User,
    @Body() dto: CreateDashboardDto
  ) {
    return this.service.create(tenantId, user.id, dto);
  }

  @Patch(':id')
  async update(
    @Param('id') id: string,
    @Body() dto: UpdateDashboardDto
  ) {
    return this.service.update(id, dto);
  }

  @Post(':id/duplicate')
  async duplicate(
    @Param('id') id: string,
    @CurrentUser() user: User,
    @Body('name') name: string
  ) {
    return this.service.duplicate(id, user.id, name);
  }

  @Delete(':id')
  async archive(@Param('id') id: string) {
    return this.service.archive(id);
  }

  // Widgets
  @Post(':id/widgets')
  async addWidget(
    @Param('id') dashboardId: string,
    @Body() dto: CreateWidgetDto
  ) {
    return this.widgetsService.create(dashboardId, dto);
  }

  @Get(':id/widgets/:widgetId/data')
  async getWidgetData(
    @Param('widgetId') widgetId: string,
    @TenantId() tenantId: string,
    @CurrentUser() user: User
  ) {
    const widget = await this.widgetsService.findById(widgetId);
    return this.widgetsService.getData(widget, tenantId, user.id);
  }
}
```

### CustomReportsController

```typescript
@ApiTags('Custom Reports')
@Controller('reports/custom')
@UseGuards(JwtAuthGuard, RbacGuard)
export class CustomReportsController {
  constructor(
    private readonly service: CustomReportsService,
    private readonly builder: ReportBuilderService,
  ) {}

  @Get('entities')
  @Permissions('reports.builder.read')
  async getEntities() {
    return this.builder.getAvailableEntities();
  }

  @Get('entities/:entity/fields')
  @Permissions('reports.builder.read')
  async getFields(@Param('entity') entity: string) {
    return this.builder.getEntityFields(entity);
  }

  @Post('validate')
  @Permissions('reports.builder.create')
  async validate(@Body() dto: CreateCustomReportDto) {
    return this.builder.validateReport(dto);
  }

  @Post('preview')
  @Permissions('reports.builder.create')
  async preview(
    @TenantId() tenantId: string,
    @Body() dto: CreateCustomReportDto,
    @Query('limit') limit: number = 10
  ) {
    return this.builder.preview(tenantId, dto, limit);
  }

  @Get()
  @Permissions('reports.builder.read')
  async findAll(@TenantId() tenantId: string) {
    return this.service.findAll(tenantId);
  }

  @Post()
  @Permissions('reports.builder.create')
  async create(
    @TenantId() tenantId: string,
    @CurrentUser() user: User,
    @Body() dto: CreateCustomReportDto
  ) {
    return this.builder.create(tenantId, user.id, dto);
  }

  @Post(':id/execute')
  @Permissions('reports.builder.execute')
  async execute(
    @Param('id') id: string,
    @TenantId() tenantId: string,
    @CurrentUser() user: User
  ) {
    return this.builder.execute(id, tenantId, user.id);
  }
}
```

---

## API Endpoints Summary

| Method | Path | Permission | Description |
|--------|------|------------|-------------|
| GET | /reports | reports.read | List report definitions |
| GET | /reports/:code | reports.read | Get report by code |
| POST | /reports/:code/execute | reports.execute | Execute report |
| POST | /reports/:code/export | reports.export | Export report |
| GET | /reports/executions/:id | reports.read | Get execution status |
| GET | /dashboards | - | List dashboards |
| GET | /dashboards/:id | - | Get dashboard |
| POST | /dashboards | - | Create dashboard |
| PATCH | /dashboards/:id | - | Update dashboard |
| POST | /dashboards/:id/duplicate | - | Duplicate dashboard |
| DELETE | /dashboards/:id | - | Archive dashboard |
| POST | /dashboards/:id/widgets | - | Add widget |
| GET | /dashboards/:id/widgets/:wid/data | - | Get widget data |
| PATCH | /widgets/:id | - | Update widget |
| DELETE | /widgets/:id | - | Delete widget |
| GET | /reports/custom/entities | reports.builder.read | List entities |
| GET | /reports/custom/entities/:e/fields | reports.builder.read | List fields |
| POST | /reports/custom/validate | reports.builder.create | Validate report |
| POST | /reports/custom/preview | reports.builder.create | Preview results |
| GET | /reports/custom | reports.builder.read | List custom reports |
| POST | /reports/custom | reports.builder.create | Create custom report |
| POST | /reports/custom/:id/execute | reports.builder.execute | Execute custom |
| GET | /reports/schedules | reports.schedules.read | List schedules |
| POST | /reports/schedules | reports.schedules.manage | Create schedule |
| DELETE | /reports/schedules/:id | reports.schedules.manage | Delete schedule |

---

## Historial

| Version | Fecha | Autor | Cambios |
|---------|-------|-------|---------|
| 1.0 | 2025-12-05 | Requirements-Analyst | Creacion inicial |
