Report Outputs & Generation
Status: Final
Version: 1.0
Purpose
Define report types, export formats, generation pipeline, versioning, and audit trail for ESG reporting outputs.
Report Types (v1)
| Report Type |
Audience |
Format |
Content |
Frequency |
| GRI Sustainability Report |
External stakeholders |
PDF, DOCX |
Full GRI disclosures (E, S, G) |
Annual |
| Period Summary |
Internal management |
PDF, XLSX |
Metrics by site, period totals |
Quarterly/Annual |
| Site-Level Report |
Site managers |
PDF, XLSX |
Single site metrics for period |
On-demand |
| Disclosure Export |
Analysts |
JSON, CSV, XLSX |
Raw metric data |
On-demand |
| Audit Package |
External auditors |
PDF + ZIP |
All approved data + evidence |
Annual |
PDF
- Use Case: External reports, board presentations
- Library: Laravel DomPDF or Snappy (wkhtmltopdf)
- Template: Blade views with CSS styling
XLSX
- Use Case: Data analysis, pivot tables
- Library: Laravel Excel (PhpSpreadsheet)
- Structure: One sheet per metric category (Environmental, Social, Governance)
JSON/CSV
- Use Case: API integrations, data exports
- Structure: Flat or nested JSON, CSV with headers
Report Versioning
| Version Type |
Description |
Use Case |
| Draft |
Work-in-progress, data not locked |
Internal review |
| Final |
Locked period, ready for publication |
Official disclosure |
| Restated |
Corrected after initial publication |
Error correction, methodology change |
Version Schema
class Report extends Model
{
protected $fillable = [
'tenant_id',
'reporting_period_id',
'report_type',
'version_type', // ENUM: 'draft', 'final', 'restated'
'version_number', // Incremented on restatements
'generated_at',
'generated_by_user_id',
'file_path',
'file_format',
'content_hash', // SHA-256 of PDF/file
'metadata', // JSONB: report params, filters
];
}
Generation Pipeline
Asynchronous Job
class GenerateReportJob implements ShouldQueue
{
public $queue = 'reporting';
public $tries = 3;
public $timeout = 600; // 10 minutes
public function handle()
{
$period = ReportingPeriod::find($this->reportingPeriodId);
$report = Report::create([
'tenant_id' => $this->tenantId,
'reporting_period_id' => $period->id,
'report_type' => $this->reportType,
'version_type' => $period->state === 'LOCKED' ? 'final' : 'draft',
'generated_by_user_id' => $this->userId,
]);
// Generate PDF
$pdf = $this->generatePDF($period);
// Store
$path = $pdf->save("reports/{$this->tenantId}/{$report->id}.pdf");
$report->update([
'file_path' => $path,
'file_format' => 'pdf',
'generated_at' => now(),
'content_hash' => hash_file('sha256', storage_path($path)),
]);
// Notify user
User::find($this->userId)->notify(new ReportGenerated($report));
}
protected function generatePDF(ReportingPeriod $period)
{
$data = [
'period' => $period,
'metrics' => $this->getApprovedMetrics($period),
'restatements' => $period->restatements,
];
return PDF::loadView('reports.gri-sustainability', $data)
->setPaper('a4')
->setOption('margin-top', 20)
->setOption('margin-bottom', 20);
}
}
Blade Template Example (GRI Report)
<!-- resources/views/reports/gri-sustainability.blade.php -->
<!DOCTYPE html>
<html>
<head>
<title>{{ $period->organisation->name }} - GRI Sustainability Report {{ $period->fiscal_year }}</title>
<style>
body { font-family: Arial, sans-serif; font-size: 10pt; }
h1 { color: #2c3e50; font-size: 18pt; }
table { width: 100%; border-collapse: collapse; margin: 20px 0; }
th, td { border: 1px solid #ddd; padding: 8px; text-align: left; }
th { background-color: #3498db; color: white; }
</style>
</head>
<body>
<h1>Sustainability Report {{ $period->fiscal_year }}</h1>
<p><strong>Organization:</strong> {{ $period->organisation->name }}</p>
<p><strong>Reporting Period:</strong> {{ $period->start_date->format('Y-m-d') }} to {{ $period->end_date->format('Y-m-d') }}</p>
<h2>GRI 302: Energy</h2>
<table>
<thead>
<tr>
<th>Metric</th>
<th>Value</th>
<th>Unit</th>
</tr>
</thead>
<tbody>
@foreach($metrics->where('disclosure.code', '302-1') as $metric)
<tr>
<td>{{ $metric->name }}</td>
<td>{{ number_format($metric->pivot->value, 2) }}</td>
<td>{{ $metric->unit }}</td>
</tr>
@endforeach
</tbody>
</table>
@if($restatements->isNotEmpty())
<h2>GRI 2-4: Restatements of Information</h2>
<table>
<thead>
<tr>
<th>Metric</th>
<th>Original</th>
<th>Restated</th>
<th>Reason</th>
</tr>
</thead>
<tbody>
@foreach($restatements as $r)
<tr>
<td>{{ $r->metric_name }}</td>
<td>{{ $r->before_value }}</td>
<td>{{ $r->after_value }}</td>
<td>{{ $r->description }}</td>
</tr>
@endforeach
</tbody>
</table>
@endif
</body>
</html>
Audit Trail
AuditLog::create([
'action' => 'report.generated',
'entity_type' => 'Report',
'entity_id' => $report->id,
'actor_user_id' => $this->userId,
'metadata' => [
'report_type' => $this->reportType,
'period_id' => $period->id,
'file_format' => 'pdf',
'file_size_bytes' => Storage::size($report->file_path),
],
]);
Acceptance Criteria
Cross-References
Change Log
| Version |
Date |
Author |
Changes |
| 1.0 |
2026-01-03 |
Senior Product Architect |
Initial reporting outputs specification |