13 KiB
Reports Backend Rework Plan
Overview
Transform the current hardcoded report system into a flexible, database-driven architecture that allows dynamic report configuration without code changes.
Current Architecture Analysis
Existing Structure
- Report Classes: Individual PHP classes (
ActiveContractsReport,ActivitiesPerPeriodReport, etc.) - Registry Pattern:
ReportRegistrystores report instances in memory - Service Provider:
ReportServiceProviderregisters reports at boot time - Base Class:
BaseEloquentReportprovides common pagination logic - Contract Interface:
Reportinterface defines required methods (slug,name,description,inputs,columns,query) - Controller:
ReportControllerhandles index, show, data, export routes
Current Features
- Report Definition: Each report defines:
- Slug (unique identifier)
- Name & Description
- Input parameters (filters)
- Column definitions
- Eloquent query builder
- Filter Types:
date,string,select:client, etc. - Export: PDF and CSV export functionality
- Pagination: Server-side pagination support
Proposed New Architecture
1. Database Schema
reports Table
CREATE TABLE reports (
id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
slug VARCHAR(255) UNIQUE NOT NULL,
name VARCHAR(255) NOT NULL,
description TEXT NULL,
category VARCHAR(100) NULL, -- e.g., 'contracts', 'activities', 'financial'
enabled BOOLEAN DEFAULT TRUE,
order INT DEFAULT 0,
created_at TIMESTAMP,
updated_at TIMESTAMP,
INDEX idx_slug (slug),
INDEX idx_enabled_order (enabled, order)
);
report_entities Table
Defines which database entities (models) the report queries.
CREATE TABLE report_entities (
id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
report_id BIGINT UNSIGNED NOT NULL,
model_class VARCHAR(255) NOT NULL, -- e.g., 'App\Models\Contract'
alias VARCHAR(50) NULL, -- table alias for joins
join_type ENUM('base', 'join', 'leftJoin', 'rightJoin') DEFAULT 'base',
join_first VARCHAR(100) NULL, -- first column for join
join_operator VARCHAR(10) NULL, -- =, !=, etc.
join_second VARCHAR(100) NULL, -- second column for join
order INT DEFAULT 0,
created_at TIMESTAMP,
updated_at TIMESTAMP,
FOREIGN KEY (report_id) REFERENCES reports(id) ON DELETE CASCADE,
INDEX idx_report_order (report_id, order)
);
report_columns Table
Defines selectable columns and their presentation.
CREATE TABLE report_columns (
id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
report_id BIGINT UNSIGNED NOT NULL,
key VARCHAR(100) NOT NULL, -- column identifier
label VARCHAR(255) NOT NULL, -- display label
type VARCHAR(50) DEFAULT 'string', -- string, number, date, boolean, currency
expression TEXT NOT NULL, -- SQL expression or column path
sortable BOOLEAN DEFAULT TRUE,
visible BOOLEAN DEFAULT TRUE,
order INT DEFAULT 0,
format_options JSON NULL, -- { "decimals": 2, "prefix": "$" }
created_at TIMESTAMP,
updated_at TIMESTAMP,
FOREIGN KEY (report_id) REFERENCES reports(id) ON DELETE CASCADE,
INDEX idx_report_order (report_id, order)
);
report_filters Table
Defines available filter parameters.
CREATE TABLE report_filters (
id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
report_id BIGINT UNSIGNED NOT NULL,
key VARCHAR(100) NOT NULL, -- filter identifier
label VARCHAR(255) NOT NULL,
type VARCHAR(50) NOT NULL, -- date, string, select, multiselect, number, boolean
nullable BOOLEAN DEFAULT TRUE,
default_value TEXT NULL,
options JSON NULL, -- For select/multiselect: [{"label":"...", "value":"..."}]
data_source VARCHAR(255) NULL, -- e.g., 'clients', 'segments' for dynamic selects
order INT DEFAULT 0,
created_at TIMESTAMP,
updated_at TIMESTAMP,
FOREIGN KEY (report_id) REFERENCES reports(id) ON DELETE CASCADE,
INDEX idx_report_order (report_id, order)
);
report_conditions Table
Defines WHERE clause conditions (rules) for filtering data.
CREATE TABLE report_conditions (
id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
report_id BIGINT UNSIGNED NOT NULL,
column VARCHAR(255) NOT NULL, -- e.g., 'contracts.start_date'
operator VARCHAR(50) NOT NULL, -- =, !=, >, <, >=, <=, LIKE, IN, BETWEEN, IS NULL, etc.
value_type VARCHAR(50) NOT NULL, -- static, filter, expression
value TEXT NULL, -- static value or expression
filter_key VARCHAR(100) NULL, -- references report_filters.key
logical_operator ENUM('AND', 'OR') DEFAULT 'AND',
group_id INT NULL, -- for grouping conditions (AND within group, OR between groups)
order INT DEFAULT 0,
enabled BOOLEAN DEFAULT TRUE,
created_at TIMESTAMP,
updated_at TIMESTAMP,
FOREIGN KEY (report_id) REFERENCES reports(id) ON DELETE CASCADE,
INDEX idx_report_group (report_id, group_id, order)
);
report_orders Table
Defines default ORDER BY clauses.
CREATE TABLE report_orders (
id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
report_id BIGINT UNSIGNED NOT NULL,
column VARCHAR(255) NOT NULL,
direction ENUM('ASC', 'DESC') DEFAULT 'ASC',
order INT DEFAULT 0,
created_at TIMESTAMP,
updated_at TIMESTAMP,
FOREIGN KEY (report_id) REFERENCES reports(id) ON DELETE CASCADE,
INDEX idx_report_order (report_id, order)
);
2. Model Structure
Report Model
class Report extends Model
{
protected $fillable = ['slug', 'name', 'description', 'category', 'enabled', 'order'];
protected $casts = ['enabled' => 'boolean'];
public function entities(): HasMany { return $this->hasMany(ReportEntity::class); }
public function columns(): HasMany { return $this->hasMany(ReportColumn::class); }
public function filters(): HasMany { return $this->hasMany(ReportFilter::class); }
public function conditions(): HasMany { return $this->hasMany(ReportCondition::class); }
public function orders(): HasMany { return $this->hasMany(ReportOrder::class); }
}
3. Query Builder Service
Create ReportQueryBuilder service to dynamically construct queries:
class ReportQueryBuilder
{
public function build(Report $report, array $filters = []): Builder
{
// 1. Start with base model query
// 2. Apply joins from report_entities
// 3. Select columns from report_columns
// 4. Apply conditions from report_conditions
// 5. Apply filter values to parameterized conditions
// 6. Apply ORDER BY from report_orders
// 7. Return Builder instance
}
}
4. Backward Compatibility Layer
Keep existing Report classes but load from database:
class DatabaseReport extends BaseEloquentReport implements Report
{
public function __construct(protected Report $dbReport) {}
public function slug(): string { return $this->dbReport->slug; }
public function name(): string { return $this->dbReport->name; }
public function description(): ?string { return $this->dbReport->description; }
public function inputs(): array {
return $this->dbReport->filters()
->orderBy('order')
->get()
->map(fn($f) => [
'key' => $f->key,
'type' => $f->type,
'label' => $f->label,
'nullable' => $f->nullable,
'default' => $f->default_value,
'options' => $f->options,
])
->toArray();
}
public function columns(): array {
return $this->dbReport->columns()
->where('visible', true)
->orderBy('order')
->get()
->map(fn($c) => ['key' => $c->key, 'label' => $c->label])
->toArray();
}
public function query(array $filters): Builder {
return app(ReportQueryBuilder::class)->build($this->dbReport, $filters);
}
}
5. Migration Strategy
Phase 1: Database Setup
- Create migrations for all new tables
- Create models with relationships
- Create seeders to migrate existing hardcoded reports
Phase 2: Service Layer
- Build
ReportQueryBuilderservice - Build
DatabaseReportadapter class - Update
ReportRegistryto load from database - Create report management CRUD (admin UI)
Phase 3: Testing & Validation
- Unit tests for query builder
- Integration tests comparing old vs new results
- Performance benchmarks
- Export functionality validation
Phase 4: Migration Seeder
- Create seeder that converts each hardcoded report into database records
- Example for
ActiveContractsReport:$report = Report::create([ 'slug' => 'active-contracts', 'name' => 'Aktivne pogodbe', 'description' => 'Pogodbe, ki so aktivne...', 'enabled' => true, ]); // Add entities (joins) $report->entities()->create([ 'model_class' => 'App\Models\Contract', 'join_type' => 'base', 'order' => 0, ]); $report->entities()->create([ 'model_class' => 'App\Models\ClientCase', 'join_type' => 'join', 'join_first' => 'contracts.client_case_id', 'join_operator' => '=', 'join_second' => 'client_cases.id', 'order' => 1, ]); // Add columns $report->columns()->create([ 'key' => 'contract_reference', 'label' => 'Pogodba', 'expression' => 'contracts.reference', 'order' => 0, ]); // Add filters $report->filters()->create([ 'key' => 'client_uuid', 'label' => 'Stranka', 'type' => 'select', 'data_source' => 'clients', 'nullable' => true, 'order' => 0, ]); // Add conditions $report->conditions()->create([ 'column' => 'contracts.start_date', 'operator' => '<=', 'value_type' => 'expression', 'value' => 'CURRENT_DATE', 'group_id' => 1, 'order' => 0, ]);
Phase 5: Remove Old Report System
Once the new database-driven system is validated and working:
-
Delete Hardcoded Report Classes:
- Remove
app/Reports/ActiveContractsReport.php - Remove
app/Reports/ActivitiesPerPeriodReport.php - Remove
app/Reports/ActionsDecisionsCountReport.php - Remove
app/Reports/DecisionsCountReport.php - Remove
app/Reports/FieldJobsCompletedReport.php - Remove
app/Reports/SegmentActivityCountsReport.php
- Remove
-
Remove Base Classes/Interfaces (if no longer needed):
- Remove
app/Reports/BaseEloquentReport.php - Remove
app/Reports/Contracts/Report.phpinterface
- Remove
-
Remove/Update Service Provider:
- Remove
app/Providers/ReportServiceProvider.php - Or update it to only load reports from database
- Remove
-
Update ReportRegistry:
- Modify to load from database instead of manual registration
- Remove all hardcoded
register()calls
-
Clean Up Config:
- Remove any report-specific configuration files if they exist
- Update
bootstrap/providers.phpto remove ReportServiceProvider
-
Documentation Cleanup:
- Update any documentation referencing old report classes
- Add migration guide for future report creation
6. Admin UI for Report Management
Create CRUD interface at Settings/Reports/*:
- Index: List all reports with enable/disable toggle
- Create: Wizard-style form for building new reports
- Edit: Visual query builder interface
- Test: Preview report results
- Clone: Duplicate existing report as starting point
7. Advanced Features (Future)
- Calculated Fields: Allow expressions like
(column_a + column_b) / 2 - Aggregations: Support SUM, AVG, COUNT, MIN, MAX
- Subqueries: Define subquery relationships
- Report Templates: Predefined report structures
- Scheduled Reports: Email reports on schedule
- Report Sharing: Share reports with specific users/roles
- Version History: Track report definition changes
- Report Permissions: Control who can view/edit reports
Benefits
- No Code Changes: Add/modify reports through UI
- Flexibility: Non-developers can create reports
- Consistency: All reports follow same structure
- Maintainability: Centralized report logic
- Reusability: Share entities, filters, conditions
- Version Control: Track changes to report definitions
- Performance: Optimize query builder once
- Export: Works with any report automatically
Risks & Considerations
- Complexity: Query builder must handle diverse SQL patterns
- Performance: Dynamic query building overhead
- Security: SQL injection risks with user input
- Learning Curve: Team needs to understand new system
- Testing: Comprehensive test suite required
- Migration: Convert all existing reports correctly
- Edge Cases: Complex queries may be difficult to represent
Timeline Estimate
- Phase 1 (Database): 2-3 days
- Phase 2 (Services): 4-5 days
- Phase 3 (Testing): 2-3 days
- Phase 4 (Migration): 1-2 days
- Phase 5 (Cleanup): 1 day
- Admin UI: 3-4 days
- Total: 13-18 days
Success Criteria
- ✅ All existing reports work identically
- ✅ New reports can be created via UI
- ✅ Export functionality preserved
- ✅ Performance within 10% of current
- ✅ Zero SQL injection vulnerabilities
- ✅ Comprehensive test coverage (>80%)
- ✅ Documentation complete