Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions backend/composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,9 @@
"doctrine/doctrine-migrations-bundle": "^4.0",
"doctrine/orm": "^3.6.2",
"dompdf/dompdf": "^3.1.5",
"easycorp/easyadmin-bundle": "^5.0.1",
"easycorp/easyadmin-bundle": "^5.0.2",
"nelmio/cors-bundle": "^2.6.1",
"phpdocumentor/reflection-docblock": "^6.0.2",
"phpdocumentor/reflection-docblock": "^6.0.3",
"phpstan/phpdoc-parser": "^2.3.2",
"symfony/browser-kit": "8.0.*",
"symfony/console": "8.0.*",
Expand Down Expand Up @@ -107,9 +107,9 @@
"require-dev": {
"deptrac/deptrac": "^4.6",
"friendsofphp/php-cs-fixer": "^3.94.2",
"phpstan/phpstan": "^2.1.40",
"phpstan/phpstan": "^2.1.42",
"phpunit/phpunit": "^13.0.5",
"symfony/maker-bundle": "^1.66.0",
"symfony/maker-bundle": "^1.67.0",
"symfony/stopwatch": "8.0.*",
"symfony/web-profiler-bundle": "8.0.*"
}
Expand Down
332 changes: 168 additions & 164 deletions backend/composer.lock

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion backend/config/packages/monolog.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,4 @@ monolog:
type: stream
path: "php://stdout"
level: info
formatter: monolog.formatter.json
formatter: App\Instrumentation\PsrLog\CustomLineFormatter
26 changes: 26 additions & 0 deletions backend/migrations/Version20260320090903.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
<?php

declare(strict_types=1);

namespace DoctrineMigrations;

use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;

final class Version20260320090903 extends AbstractMigration
{
public function getDescription(): string
{
return 'Create report table';
}

public function up(Schema $schema): void
{
$this->addSql('CREATE TABLE report (id INT AUTO_INCREMENT NOT NULL, uuid CHAR(36) NOT NULL COMMENT \'(DC2Type:uuid)\', status VARCHAR(50) NOT NULL, compensation_id LONGTEXT NOT NULL, checksum VARCHAR(64) NOT NULL, file_path VARCHAR(255) DEFAULT NULL, created_at DATETIME NOT NULL COMMENT \'(DC2Type:datetime_immutable)\', completed_at DATETIME DEFAULT NULL COMMENT \'(DC2Type:datetime_immutable)\', error_message LONGTEXT DEFAULT NULL, UNIQUE INDEX UNIQ_D862F276D17F50A6 (uuid), INDEX idx_status (status), INDEX idx_created_at (created_at), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB');
}

public function down(Schema $schema): void
{
$this->addSql('DROP TABLE report');
}
}
6 changes: 6 additions & 0 deletions backend/phpstan-baseline.neon
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,12 @@ parameters:
count: 1
path: public/index.php

-
message: '#^Property App\\Entity\\Report\:\:\$id \(int\|null\) is never assigned int so it can be removed from the property type\.$#'
identifier: property.unusedType
count: 1
path: src/Entity/Report.php

-
message: '#^Property App\\Entity\\User\:\:\$id \(int\|null\) is never assigned int so it can be removed from the property type\.$#'
identifier: property.unusedType
Expand Down
113 changes: 113 additions & 0 deletions backend/src/Async/GenerateReportHandler.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
<?php

declare(strict_types=1);

namespace App\Async;

use App\Entity\Report;
use App\Instrumentation\Instrumentation;
use App\Invariant\Ensure;
use App\Repository\ReportRepository;
use App\SplitFairly\Calculator;
use App\SplitFairly\Compensation;
use Doctrine\ORM\EntityManagerInterface;
use Dompdf\Dompdf;
use Dompdf\Options;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
use Twig\Environment;

#[AsMessageHandler]
final class GenerateReportHandler
{
public function __construct(
private readonly Instrumentation $instrumentation,
private readonly EntityManagerInterface $entityManager,
private readonly ReportRepository $reportRepository,
private readonly Calculator $calculator,
private readonly Environment $twig,
#[Autowire('%kernel.project_dir%/var/reports')] private readonly string $reportsDir,
) {
}

public function __invoke(GenerateReportMessage $message): void
{
$report = null;
$timer = Stopwatch::start();

try {
// Clear any stale entity state
$this->entityManager->clear();

$report = $this->reportRepository->find($message->reportId);
if (!$report instanceof Report) {
throw new \RuntimeException(sprintf('Report with ID %d not found', $message->reportId));
}

$report->setStatus(Report::STATUS_GENERATING);
$this->entityManager->persist($report);
$this->entityManager->flush();

$expenses = $this->calculator->calculate();

if (2 !== count($expenses)) {
throw new \RuntimeException('Expected exactly 2 users in calculation');
}

$compensation = Compensation::calculate($expenses[0], $expenses[1]);

$html = $this->twig->render('report/calculation.html.twig', [
'expenses' => $expenses,
'compensation' => $compensation,
]);

$dompdf = new Dompdf($this->createDompdfOptions());
$dompdf->loadHtml($html);
$dompdf->setPaper('A4', 'portrait');
$dompdf->render();

$pdfContent = $dompdf->output();
$fileName = sprintf('report-%s.pdf', $report->getUuid()->toRfc4122());
$filePath = $this->reportsDir.DIRECTORY_SEPARATOR.$fileName;

Ensure::that(false !== file_put_contents($filePath, $pdfContent));

$report->setFilePath($filePath);
$report->setStatus(Report::STATUS_COMPLETED);
$report->setCompletedAt(new \DateTimeImmutable());
$this->entityManager->flush();

$this->instrumentation->getLogging()->info(sprintf(
'Report generated successfully: %s (%.0fms)',
$fileName,
$timer->getMillisecondsElapsed()
));
} catch (\Exception $e) {
$this->instrumentation->getLogging()->info(sprintf(
'Failed to generate report: %s',
$e->getMessage()
));

if ($report instanceof Report) {
$report->setStatus(Report::STATUS_FAILED);
$report->setErrorMessage($e->getMessage());
$report->setCompletedAt(new \DateTimeImmutable());
$this->entityManager->flush();
}

throw $e;
}
}

private function createDompdfOptions(): Options
{
$options = new Options();
$options->set('isRemoteEnabled', true);
$options->set('isHtml5ParserEnabled', true);
$options->set('defaultMediaType', 'print');
$options->set('isFontSubsettingEnabled', true);
$options->set('isPhpEnabled', false);

return $options;
}
}
17 changes: 17 additions & 0 deletions backend/src/Async/GenerateReportMessage.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
<?php

declare(strict_types=1);

namespace App\Async;

use Symfony\Component\Messenger\Attribute\AsMessage;

#[AsMessage(transport: 'async')]
final class GenerateReportMessage
{
public function __construct(
public readonly int $reportId,
public readonly string $compensationId,
) {
}
}
71 changes: 71 additions & 0 deletions backend/src/Command/ReportsCleanupCommand.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
<?php

declare(strict_types=1);

namespace App\Command;

use App\Repository\ReportRepository;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;

#[AsCommand(
name: 'app:reports:cleanup',
description: 'Remove old report files and database entries',
)]
final class ReportsCleanupCommand extends Command
{
public function __construct(
private readonly EntityManagerInterface $entityManager,
private readonly ReportRepository $reportRepository,
) {
parent::__construct();
}

protected function configure(): void
{
$this->addOption(
'days',
'd',
InputOption::VALUE_OPTIONAL,
'Remove reports older than N days',
7
);
}

protected function execute(InputInterface $input, OutputInterface $output): int
{
$daysOption = $input->getOption('days');
assert(is_numeric($daysOption), 'Days option is not a number!');
$days = (int) $daysOption;
$cutoffDate = new \DateTimeImmutable("-{$days} days");

$oldReports = $this->reportRepository->findOlderThan($cutoffDate);

if (empty($oldReports)) {
$output->writeln('No old reports found.');

return Command::SUCCESS;
}

$deletedCount = 0;
foreach ($oldReports as $report) {
$filePath = $report->getFilePath();
if (null !== $filePath && file_exists($filePath)) {
unlink($filePath);
}

$this->entityManager->remove($report);
++$deletedCount;
}

$this->entityManager->flush();

$output->writeln(sprintf('Deleted %d report(s).', $deletedCount));

return Command::SUCCESS;
}
}
Loading
Loading