From 06251ceb640dddcecfe441cb5ac4b414aebd659d Mon Sep 17 00:00:00 2001 From: Victor Ferreira Date: Thu, 23 Jul 2026 17:33:25 -0300 Subject: [PATCH 1/6] =?UTF-8?q?fix:=20transmite=20v=C3=ADnculos=20de=20usu?= =?UTF-8?q?=C3=A1rios=20incrementalmente?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Interface/UserRepositoryInterface.php | 25 ++++++++++++++++++- src/Repository/UserRepository.php | 15 ++++++++--- .../Repositories/UserRepositoryTest.php | 11 +++++--- 3 files changed, 43 insertions(+), 8 deletions(-) diff --git a/src/Repository/Interface/UserRepositoryInterface.php b/src/Repository/Interface/UserRepositoryInterface.php index 207de6b2b..99bcffb0c 100644 --- a/src/Repository/Interface/UserRepositoryInterface.php +++ b/src/Repository/Interface/UserRepositoryInterface.php @@ -6,6 +6,26 @@ use App\Entity\User; +/** + * @phpstan-type UserExportRow array{ + * user_id: string, + * user_name: string, + * social_name: ?string, + * email: string, + * status: string, + * roles: string, + * user_created_at: string, + * user_updated_at: ?string, + * agent_id: ?string, + * agent_name: ?string, + * agent_extra_fields: ?string, + * organization_id: ?string, + * organization_name: ?string, + * organization_type: ?string, + * organization_extra_fields: ?string, + * owner_id: ?string + * } + */ interface UserRepositoryInterface { public function save(User $user): User; @@ -18,5 +38,8 @@ public function rollback(): void; public function findOneByRole(string $role): ?User; - public function findAllForExport(): array; + /** + * @return iterable + */ + public function findAllForExport(): iterable; } diff --git a/src/Repository/UserRepository.php b/src/Repository/UserRepository.php index 1b4d15174..2b51aa812 100644 --- a/src/Repository/UserRepository.php +++ b/src/Repository/UserRepository.php @@ -8,6 +8,9 @@ use App\Repository\Interface\UserRepositoryInterface; use Doctrine\Persistence\ManagerRegistry; +/** + * @phpstan-import-type UserExportRow from UserRepositoryInterface + */ class UserRepository extends AbstractRepository implements UserRepositoryInterface { public function __construct(ManagerRegistry $registry) @@ -58,7 +61,10 @@ public function findOneByRole(string $role): ?User return $id ? $this->find($id) : null; } - public function findAllForExport(): array + /** + * @return iterable + */ + public function findAllForExport(): iterable { $sql = <<getEntityManager() + $result = $this->getEntityManager() ->getConnection() - ->executeQuery($sql) - ->fetchAllAssociative(); + ->executeQuery($sql); + + yield from $result->iterateAssociative(); } } diff --git a/tests/Functional/Repositories/UserRepositoryTest.php b/tests/Functional/Repositories/UserRepositoryTest.php index 9b53f3303..456943af9 100644 --- a/tests/Functional/Repositories/UserRepositoryTest.php +++ b/tests/Functional/Repositories/UserRepositoryTest.php @@ -11,6 +11,7 @@ use DateTime; use DateTimeImmutable; use Doctrine\ORM\EntityManagerInterface; +use Generator; use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase; use Symfony\Component\Uid\Uuid; @@ -35,7 +36,11 @@ public function testFindAllForExportReturnsOneRowForEachActiveOrganization(): vo $organizationA = $this->createOrganization($agent, 'Organização A', ['cnpj' => '11']); $this->flush(); - $rows = $this->rowsForUser($user); + $result = $this->repository->findAllForExport(); + + self::assertInstanceOf(Generator::class, $result); + + $rows = $this->rowsForUser($user, $result); self::assertCount(2, $rows); self::assertSame( @@ -223,10 +228,10 @@ private function flush(): void /** * @return array> */ - private function rowsForUser(User $user): array + private function rowsForUser(User $user, ?iterable $result = null): array { return array_values(array_filter( - $this->repository->findAllForExport(), + iterator_to_array($result ?? $this->repository->findAllForExport(), false), static fn (array $row): bool => $row['user_id'] === $user->getId()->toRfc4122() )); } From 4d5e94e79cf93bedaf46434630b83c08893b5048 Mon Sep 17 00:00:00 2001 From: Victor Ferreira Date: Thu, 23 Jul 2026 17:45:28 -0300 Subject: [PATCH 2/6] =?UTF-8?q?feat:=20gera=20planilha=20de=20usu=C3=A1rio?= =?UTF-8?q?s=20e=20v=C3=ADnculos?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/Service/UserExportService.php | 269 +++++++++++++++++++ tests/Unit/Service/UserExportServiceTest.php | 236 ++++++++++++++++ 2 files changed, 505 insertions(+) create mode 100644 src/Service/UserExportService.php create mode 100644 tests/Unit/Service/UserExportServiceTest.php diff --git a/src/Service/UserExportService.php b/src/Service/UserExportService.php new file mode 100644 index 000000000..5738f857f --- /dev/null +++ b/src/Service/UserExportService.php @@ -0,0 +1,269 @@ +createExportDirectory(); + + $filePath = tempnam($this->exportDirectory, 'usuarios_e_vinculos_'); + if (false === $filePath) { + throw new RuntimeException('Não foi possível criar o arquivo temporário da exportação.'); + } + + $spreadsheet = null; + + try { + $spreadsheet = new Spreadsheet(); + $sheet = $spreadsheet->getActiveSheet(); + $this->writeRow($sheet, 1, self::HEADERS); + + $rowNumber = 2; + foreach ($this->userRepository->findAllForExport() as $row) { + $this->writeRow($sheet, $rowNumber, $this->formatRow($row)); + ++$rowNumber; + } + + $writer = new Xlsx($spreadsheet); + $writer->setPreCalculateFormulas(false); + $writer->save($filePath); + + $downloadName = sprintf( + 'usuarios_e_vinculos_%s.xlsx', + $this->clock->now()->format('Y-m-d_H-i-s') + ); + $response = new BinaryFileResponse($filePath, headers: ['Content-Type' => self::CONTENT_TYPE]); + $response->setContentDisposition(ResponseHeaderBag::DISPOSITION_ATTACHMENT, $downloadName); + $response->deleteFileAfterSend(true); + + return $response; + } catch (Throwable $exception) { + if (is_file($filePath)) { + unlink($filePath); + } + + throw $exception; + } finally { + $spreadsheet?->disconnectWorksheets(); + } + } + + private function createExportDirectory(): void + { + if (is_dir($this->exportDirectory)) { + return; + } + + if (!mkdir($this->exportDirectory, 0775, true) && !is_dir($this->exportDirectory)) { + throw new RuntimeException(sprintf( + 'Não foi possível criar o diretório de exportação "%s".', + $this->exportDirectory + )); + } + } + + /** + * @param array $row + * + * @return list + */ + private function formatRow(array $row): array + { + $agentExtraFields = $this->decodeJsonObject($row['agent_extra_fields'] ?? null); + $organizationExtraFields = $this->decodeJsonObject($row['organization_extra_fields'] ?? null); + $organizationId = $this->stringValue($row['organization_id'] ?? null); + $organizationName = $this->stringValue($row['organization_name'] ?? null); + $organizationType = $this->stringValue($row['organization_type'] ?? null); + + $municipality = $this->stringValue( + $organizationExtraFields['municipality'] ?? $organizationExtraFields['city_name'] ?? null + ); + if ( + '' !== $organizationId + && OrganizationTypeEnum::MUNICIPIO->value === $organizationType + && '' === $municipality + ) { + $municipality = $organizationName; + } + + return [ + $this->stringValue($row['user_id'] ?? null), + $this->stringValue($row['user_name'] ?? null), + $this->stringValue($row['social_name'] ?? null), + $this->stringValue($row['email'] ?? null), + $this->formatStatus($row['status'] ?? null), + $this->formatRoles($row['roles'] ?? null), + $this->formatDate($row['user_created_at'] ?? null), + $this->formatDate($row['user_updated_at'] ?? null), + $this->stringValue($row['agent_id'] ?? null), + $this->stringValue($row['agent_name'] ?? null), + $this->stringValue($agentExtraFields['cpf'] ?? null), + $this->stringValue($agentExtraFields['cargo'] ?? null), + $organizationId, + $organizationName, + $this->formatOrganizationType($organizationType), + $this->stringValue($organizationExtraFields['cnpj'] ?? null), + $municipality, + $this->stringValue($organizationExtraFields['uf'] ?? $organizationExtraFields['state'] ?? null), + $this->formatOwner($organizationId, $row['owner_id'] ?? null, $row['agent_id'] ?? null), + ]; + } + + /** + * @param list $values + */ + private function writeRow(Worksheet $sheet, int $rowNumber, array $values): void + { + foreach ($values as $columnOffset => $value) { + $sheet->setCellValueExplicit( + [$columnOffset + 1, $rowNumber], + $value, + DataType::TYPE_STRING + ); + } + } + + private function formatStatus(mixed $status): string + { + return match ($this->stringValue($status)) { + UserStatusEnum::ACTIVE->value => 'Ativo', + UserStatusEnum::BLOCKED->value => 'Bloqueado', + UserStatusEnum::AWAITING_CONFIRMATION->value => 'Aguardando confirmação', + default => '', + }; + } + + private function formatRoles(mixed $encodedRoles): string + { + $roles = []; + if (is_string($encodedRoles)) { + $decodedRoles = json_decode($encodedRoles, true); + if (is_array($decodedRoles)) { + foreach ($decodedRoles as $role) { + if (is_string($role) && !in_array($role, $roles, true)) { + $roles[] = $role; + } + } + } + } + + if (!in_array(UserRolesEnum::ROLE_USER->value, $roles, true)) { + $roles[] = UserRolesEnum::ROLE_USER->value; + } + + return implode('; ', array_map( + static fn (string $role): string => match ($role) { + UserRolesEnum::ROLE_ADMIN->value => 'Administrador Geral', + UserRolesEnum::ROLE_MANAGER->value => 'Gestor', + UserRolesEnum::ROLE_COMPANY->value => 'Administrador de Empresa', + UserRolesEnum::ROLE_MUNICIPALITY->value => 'Administrador de Município', + UserRolesEnum::ROLE_SUPPORT->value => 'Suporte', + UserRolesEnum::ROLE_USER->value => 'Usuário Padrão', + UserRolesEnum::ROLE_CAIXA->value => 'Visualizador Caixa', + default => $role, + }, + $roles + )); + } + + private function formatOrganizationType(string $type): string + { + return match ($type) { + OrganizationTypeEnum::MUNICIPIO->value => 'Município', + OrganizationTypeEnum::EMPRESA->value => 'Empresa', + OrganizationTypeEnum::ENTIDADE->value => 'Entidade', + OrganizationTypeEnum::OSC->value => 'OSC', + OrganizationTypeEnum::UNDEFINED->value => 'Indefinida', + default => '', + }; + } + + private function formatDate(mixed $date): string + { + $date = $this->stringValue($date); + + return '' === $date ? '' : (new DateTimeImmutable($date))->format('d/m/Y H:i:s'); + } + + private function formatOwner(string $organizationId, mixed $ownerId, mixed $agentId): string + { + if ('' === $organizationId) { + return ''; + } + + $agentId = $this->stringValue($agentId); + + return '' !== $agentId && $this->stringValue($ownerId) === $agentId ? 'Sim' : 'Não'; + } + + /** + * @return array + */ + private function decodeJsonObject(mixed $json): array + { + if (!is_string($json) || '' === $json) { + return []; + } + + $decoded = json_decode($json, true); + + return is_array($decoded) ? $decoded : []; + } + + private function stringValue(mixed $value): string + { + return is_scalar($value) || $value instanceof \Stringable ? (string) $value : ''; + } +} diff --git a/tests/Unit/Service/UserExportServiceTest.php b/tests/Unit/Service/UserExportServiceTest.php new file mode 100644 index 000000000..e1624c5aa --- /dev/null +++ b/tests/Unit/Service/UserExportServiceTest.php @@ -0,0 +1,236 @@ +repository = $this->createMock(UserRepositoryInterface::class); + $this->exportDirectory = sprintf( + '%s/pvr-user-export-%s', + sys_get_temp_dir(), + bin2hex(random_bytes(8)) + ); + } + + protected function tearDown(): void + { + if (!is_dir($this->exportDirectory)) { + return; + } + + $files = scandir($this->exportDirectory); + if (false !== $files) { + foreach ($files as $file) { + if ('.' === $file || '..' === $file) { + continue; + } + + $path = $this->exportDirectory.'/'.$file; + if (is_file($path)) { + unlink($path); + } + } + } + + rmdir($this->exportDirectory); + } + + public function testExportsCompleteRowAndHttpMetadata(): void + { + $this->repository->expects(self::once()) + ->method('findAllForExport') + ->willReturn([[ + 'user_id' => 'user-1', + 'user_name' => 'Ana Maria', + 'social_name' => 'Ana', + 'email' => 'ana@example.com', + 'status' => 'Active', + 'roles' => '["ROLE_ADMIN"]', + 'user_created_at' => '2025-01-02 03:04:05', + 'user_updated_at' => '2025-06-07 08:09:10', + 'agent_id' => 'agent-1', + 'agent_name' => 'Ana Agente', + 'agent_extra_fields' => '{"cpf":"012.345.678-90","cargo":"Coordenadora"}', + 'organization_id' => 'org-1', + 'organization_name' => 'Prefeitura de Exemplo', + 'organization_type' => 'Municipio', + 'organization_extra_fields' => '{"cnpj":"12.345.678/0001-90","city_name":"Exemplo","state":"SP"}', + 'owner_id' => 'agent-1', + ]]); + + $response = $this->createService()->export(); + $file = $response->getFile()->getPathname(); + $sheet = $this->loadSheet($file); + + self::assertSame( + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + $response->headers->get('Content-Type') + ); + self::assertSame( + 'attachment; filename=usuarios_e_vinculos_2026-07-23_14-05-06.xlsx', + $response->headers->get('Content-Disposition') + ); + self::assertSame(UserExportService::HEADERS, $sheet->rangeToArray('A1:S1')[0]); + self::assertSame([ + 'user-1', + 'Ana Maria', + 'Ana', + 'ana@example.com', + 'Ativo', + 'Administrador Geral; Usuário Padrão', + '02/01/2025 03:04:05', + '07/06/2025 08:09:10', + 'agent-1', + 'Ana Agente', + '012.345.678-90', + 'Coordenadora', + 'org-1', + 'Prefeitura de Exemplo', + 'Município', + '12.345.678/0001-90', + 'Exemplo', + 'SP', + 'Sim', + ], $sheet->rangeToArray('A2:S2')[0]); + + foreach ($sheet->getCellCollection()->getCoordinates() as $coordinate) { + self::assertSame(DataType::TYPE_STRING, $sheet->getCell($coordinate)->getDataType()); + } + + ob_start(); + $response->sendContent(); + ob_end_clean(); + self::assertFileDoesNotExist($file); + } + + public function testExportsOnlyHeadersWhenThereAreNoUsers(): void + { + $this->repository->method('findAllForExport')->willReturn([]); + + $response = $this->createService()->export(); + $sheet = $this->loadSheet($response->getFile()->getPathname()); + + self::assertSame(UserExportService::HEADERS, $sheet->rangeToArray('A1:S1')[0]); + self::assertSame(1, $sheet->getHighestDataRow()); + } + + public function testPreservesAgentWithoutOrganizationAndLeavesOrganizationColumnsEmpty(): void + { + $this->repository->method('findAllForExport')->willReturn([[ + 'user_id' => 'user-2', + 'user_name' => 'Bruno', + 'social_name' => null, + 'email' => 'bruno@example.com', + 'status' => 'Blocked', + 'roles' => '[]', + 'user_created_at' => '2025-02-03 10:20:30', + 'user_updated_at' => null, + 'agent_id' => 'agent-2', + 'agent_name' => 'Bruno Agente', + 'agent_extra_fields' => '{"cpf":"987.654.321-00","cargo":"Analista"}', + 'organization_id' => null, + 'organization_name' => null, + 'organization_type' => null, + 'organization_extra_fields' => null, + 'owner_id' => null, + ]]); + + $response = $this->createService()->export(); + $row = $this->loadSheet($response->getFile()->getPathname())->rangeToArray('A2:S2', '')[0]; + + self::assertSame('Bloqueado', $row[4]); + self::assertSame('Usuário Padrão', $row[5]); + self::assertSame(['agent-2', 'Bruno Agente', '987.654.321-00', 'Analista'], array_slice($row, 8, 4)); + self::assertSame(array_fill(0, 7, ''), array_slice($row, 12, 7)); + } + + public function testWritesFormulaLikeOrganizationNameAsLiteralString(): void + { + $row = $this->minimalRow(); + $row['organization_id'] = 'org-danger'; + $row['organization_name'] = '=HYPERLINK("https://example.com","click")'; + $row['organization_type'] = 'Empresa'; + $row['organization_extra_fields'] = '{"cnpj":"00.000.000/0001-00","municipality":"Recife","uf":"PE"}'; + $row['owner_id'] = 'another-agent'; + $this->repository->method('findAllForExport')->willReturn([$row]); + + $response = $this->createService()->export(); + $cell = $this->loadSheet($response->getFile()->getPathname())->getCell('N2'); + + self::assertSame('=HYPERLINK("https://example.com","click")', $cell->getValue()); + self::assertSame(DataType::TYPE_STRING, $cell->getDataType()); + } + + public function testConsumesRepositoryGeneratorOnlyOnce(): void + { + $iterations = 0; + $rows = (function () use (&$iterations): iterable { + ++$iterations; + yield $this->minimalRow(); + })(); + $this->repository->expects(self::once())->method('findAllForExport')->willReturn($rows); + + $response = $this->createService()->export(); + $sheet = $this->loadSheet($response->getFile()->getPathname()); + + self::assertSame(1, $iterations); + self::assertSame(2, $sheet->getHighestDataRow()); + } + + private function createService(): UserExportService + { + return new UserExportService( + $this->repository, + new MockClock(new DateTimeImmutable('2026-07-23 14:05:06 America/Sao_Paulo')), + $this->exportDirectory + ); + } + + private function loadSheet(string $path): Worksheet + { + return IOFactory::load($path)->getActiveSheet(); + } + + /** + * @return array + */ + private function minimalRow(): array + { + return [ + 'user_id' => 'user-minimal', + 'user_name' => 'Usuário', + 'social_name' => null, + 'email' => 'user@example.com', + 'status' => 'AwaitingConfirmation', + 'roles' => '["ROLE_USER"]', + 'user_created_at' => '2025-03-04 05:06:07', + 'user_updated_at' => null, + 'agent_id' => null, + 'agent_name' => null, + 'agent_extra_fields' => null, + 'organization_id' => null, + 'organization_name' => null, + 'organization_type' => null, + 'organization_extra_fields' => null, + 'owner_id' => null, + ]; + } +} From 58b22abe7bc6837dde3414bbf21899888d252343 Mon Sep 17 00:00:00 2001 From: Victor Ferreira Date: Thu, 23 Jul 2026 19:37:01 -0300 Subject: [PATCH 3/6] =?UTF-8?q?fix:=20limita=20mem=C3=B3ria=20da=20exporta?= =?UTF-8?q?=C3=A7=C3=A3o=20de=20usu=C3=A1rios?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- config/packages/cache.yaml | 3 ++ src/Service/UserExportService.php | 20 +++++++- tests/Unit/Service/UserExportServiceTest.php | 52 +++++++++++++++++++- 3 files changed, 72 insertions(+), 3 deletions(-) diff --git a/config/packages/cache.yaml b/config/packages/cache.yaml index 9e9ce32bf..5b4580a05 100644 --- a/config/packages/cache.yaml +++ b/config/packages/cache.yaml @@ -4,6 +4,9 @@ framework: app.cache: adapter: cache.adapter.filesystem default_lifetime: 3600 + phpspreadsheet.cache: + adapter: cache.adapter.filesystem + default_lifetime: 0 # Unique name of your app: used to compute stable namespaces for cache keys. #prefix_seed: your_vendor_name/app_name diff --git a/src/Service/UserExportService.php b/src/Service/UserExportService.php index 5738f857f..2e23a3110 100644 --- a/src/Service/UserExportService.php +++ b/src/Service/UserExportService.php @@ -10,10 +10,13 @@ use App\Repository\Interface\UserRepositoryInterface; use DateTimeImmutable; use PhpOffice\PhpSpreadsheet\Cell\DataType; +use PhpOffice\PhpSpreadsheet\Settings; use PhpOffice\PhpSpreadsheet\Spreadsheet; use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet; use PhpOffice\PhpSpreadsheet\Writer\Xlsx; +use Psr\Cache\CacheItemPoolInterface; use RuntimeException; +use Symfony\Component\Cache\Psr16Cache; use Symfony\Component\Clock\ClockInterface; use Symfony\Component\DependencyInjection\Attribute\Autowire; use Symfony\Component\HttpFoundation\BinaryFileResponse; @@ -51,6 +54,8 @@ public function __construct( private ClockInterface $clock, #[Autowire('%kernel.project_dir%/storage/regmel')] private string $exportDirectory, + #[Autowire(service: 'phpspreadsheet.cache')] + private CacheItemPoolInterface $cachePool, ) { } @@ -66,7 +71,7 @@ public function export(): BinaryFileResponse $spreadsheet = null; try { - $spreadsheet = new Spreadsheet(); + $spreadsheet = $this->createSpreadsheet(); $sheet = $spreadsheet->getActiveSheet(); $this->writeRow($sheet, 1, self::HEADERS); @@ -100,6 +105,19 @@ public function export(): BinaryFileResponse } } + private function createSpreadsheet(): Spreadsheet + { + $originalCache = Settings::getCache(); + + try { + Settings::setCache(new Psr16Cache($this->cachePool)); + + return new Spreadsheet(); + } finally { + Settings::setCache($originalCache); + } + } + private function createExportDirectory(): void { if (is_dir($this->exportDirectory)) { diff --git a/tests/Unit/Service/UserExportServiceTest.php b/tests/Unit/Service/UserExportServiceTest.php index e1624c5aa..1a32d4774 100644 --- a/tests/Unit/Service/UserExportServiceTest.php +++ b/tests/Unit/Service/UserExportServiceTest.php @@ -9,19 +9,25 @@ use DateTimeImmutable; use PhpOffice\PhpSpreadsheet\Cell\DataType; use PhpOffice\PhpSpreadsheet\IOFactory; +use PhpOffice\PhpSpreadsheet\Settings; use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; +use Psr\Cache\CacheItemPoolInterface; +use Psr\SimpleCache\CacheInterface; use Symfony\Component\Clock\MockClock; -use Symfony\Component\HttpFoundation\ResponseHeaderBag; +use Symfony\Component\Cache\Adapter\ArrayAdapter; +use Symfony\Component\Cache\Psr16Cache; final class UserExportServiceTest extends TestCase { private UserRepositoryInterface&MockObject $repository; private string $exportDirectory; + private CacheInterface $originalSpreadsheetCache; protected function setUp(): void { + $this->originalSpreadsheetCache = Settings::getCache(); $this->repository = $this->createMock(UserRepositoryInterface::class); $this->exportDirectory = sprintf( '%s/pvr-user-export-%s', @@ -32,6 +38,8 @@ protected function setUp(): void protected function tearDown(): void { + Settings::setCache($this->originalSpreadsheetCache); + if (!is_dir($this->exportDirectory)) { return; } @@ -195,12 +203,52 @@ public function testConsumesRepositoryGeneratorOnlyOnce(): void self::assertSame(2, $sheet->getHighestDataRow()); } + public function testRemovesTemporaryFileWhenGeneratorThrowsAfterYieldingARow(): void + { + $rows = (function (): iterable { + yield $this->minimalRow(); + + throw new \RuntimeException('Falha durante a exportação.'); + })(); + $this->repository->method('findAllForExport')->willReturn($rows); + + try { + $this->createService()->export(); + self::fail('A exceção do generator deveria ter sido propagada.'); + } catch (\RuntimeException $exception) { + self::assertSame('Falha durante a exportação.', $exception->getMessage()); + } + + self::assertSame(['.', '..'], scandir($this->exportDirectory)); + } + + public function testRestoresPhpSpreadsheetGlobalCacheAfterExport(): void + { + $originalCache = new Psr16Cache(new ArrayAdapter()); + Settings::setCache($originalCache); + $this->repository->method('findAllForExport')->willReturn([]); + + $this->createService()->export(); + + self::assertSame($originalCache, Settings::getCache()); + } + + public function testRequiresDedicatedCachePoolInConstructor(): void + { + $parameters = (new \ReflectionMethod(UserExportService::class, '__construct'))->getParameters(); + $cachePoolType = $parameters[3]->getType(); + + self::assertInstanceOf(\ReflectionNamedType::class, $cachePoolType); + self::assertSame(CacheItemPoolInterface::class, $cachePoolType->getName()); + } + private function createService(): UserExportService { return new UserExportService( $this->repository, new MockClock(new DateTimeImmutable('2026-07-23 14:05:06 America/Sao_Paulo')), - $this->exportDirectory + $this->exportDirectory, + new ArrayAdapter() ); } From 4b47c0bc570afb464c78c5669f6667dd80f4eb46 Mon Sep 17 00:00:00 2001 From: Victor Ferreira Date: Thu, 23 Jul 2026 22:59:23 -0300 Subject: [PATCH 4/6] =?UTF-8?q?fix:=20transmite=20planilha=20de=20usu?= =?UTF-8?q?=C3=A1rios=20em=20streaming?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- composer.json | 1 + composer.lock | 95 +++++++++++++++++++- config/packages/cache.yaml | 3 - src/Service/UserExportService.php | 64 +++++-------- tests/Unit/Service/UserExportServiceTest.php | 49 +++++----- 5 files changed, 138 insertions(+), 74 deletions(-) diff --git a/composer.json b/composer.json index 8d522391e..ddd220cb6 100644 --- a/composer.json +++ b/composer.json @@ -21,6 +21,7 @@ "league/flysystem-bundle": "^3.3", "lexik/jwt-authentication-bundle": "^3.1", "nelmio/cors-bundle": "^2.5", + "openspout/openspout": "^5.8", "phpoffice/phpspreadsheet": "^4.3", "runtime/frankenphp-symfony": "^0.2.0", "symfony/asset": "7.2.*", diff --git a/composer.lock b/composer.lock index 5e72d2633..787e79ae5 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "03322ffb12a7d01fd1fb39d799da708e", + "content-hash": "9e2fedeacecf6b30f74ff1bb2a8905c2", "packages": [ { "name": "composer/pcre", @@ -2798,6 +2798,99 @@ }, "time": "2025-10-23T06:57:22+00:00" }, + { + "name": "openspout/openspout", + "version": "v5.8.0", + "source": { + "type": "git", + "url": "https://github.com/openspout/openspout.git", + "reference": "1e1aad228e3e289c7e11d97b07496f2569121afc" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/openspout/openspout/zipball/1e1aad228e3e289c7e11d97b07496f2569121afc", + "reference": "1e1aad228e3e289c7e11d97b07496f2569121afc", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-filter": "*", + "ext-libxml": "*", + "ext-xmlreader": "*", + "ext-zip": "*", + "php": "~8.4.0 || ~8.5.0" + }, + "require-dev": { + "ext-fileinfo": "*", + "ext-zlib": "*", + "friendsofphp/php-cs-fixer": "^3.95.14", + "infection/infection": "^0.34", + "phpbench/phpbench": "^1.7.0", + "phpstan/phpstan": "^2.2.5", + "phpstan/phpstan-phpunit": "^2.0.18", + "phpstan/phpstan-strict-rules": "^2.0.11", + "phpunit/phpunit": "^13.2.4" + }, + "suggest": { + "ext-iconv": "To handle non UTF-8 CSV files (if \"php-mbstring\" is not already installed or is too limited)", + "ext-mbstring": "To handle non UTF-8 CSV files (if \"iconv\" is not already installed)" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.3.x-dev" + } + }, + "autoload": { + "psr-4": { + "OpenSpout\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Adrien Loison", + "email": "adrien@box.com" + } + ], + "description": "PHP Library to read and write spreadsheet files (CSV, XLSX and ODS), in a fast and scalable way", + "homepage": "https://github.com/openspout/openspout", + "keywords": [ + "OOXML", + "csv", + "excel", + "memory", + "odf", + "ods", + "office", + "open", + "php", + "read", + "scale", + "spreadsheet", + "stream", + "write", + "xlsx" + ], + "support": { + "issues": "https://github.com/openspout/openspout/issues", + "source": "https://github.com/openspout/openspout/tree/v5.8.0" + }, + "funding": [ + { + "url": "https://paypal.me/filippotessarotto", + "type": "custom" + }, + { + "url": "https://github.com/Slamdunk", + "type": "github" + } + ], + "time": "2026-07-15T07:34:50+00:00" + }, { "name": "phpoffice/phpspreadsheet", "version": "4.5.0", diff --git a/config/packages/cache.yaml b/config/packages/cache.yaml index 5b4580a05..9e9ce32bf 100644 --- a/config/packages/cache.yaml +++ b/config/packages/cache.yaml @@ -4,9 +4,6 @@ framework: app.cache: adapter: cache.adapter.filesystem default_lifetime: 3600 - phpspreadsheet.cache: - adapter: cache.adapter.filesystem - default_lifetime: 0 # Unique name of your app: used to compute stable namespaces for cache keys. #prefix_seed: your_vendor_name/app_name diff --git a/src/Service/UserExportService.php b/src/Service/UserExportService.php index 2e23a3110..9a38d2390 100644 --- a/src/Service/UserExportService.php +++ b/src/Service/UserExportService.php @@ -9,14 +9,11 @@ use App\Enum\UserStatusEnum; use App\Repository\Interface\UserRepositoryInterface; use DateTimeImmutable; -use PhpOffice\PhpSpreadsheet\Cell\DataType; -use PhpOffice\PhpSpreadsheet\Settings; -use PhpOffice\PhpSpreadsheet\Spreadsheet; -use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet; -use PhpOffice\PhpSpreadsheet\Writer\Xlsx; -use Psr\Cache\CacheItemPoolInterface; +use OpenSpout\Common\Entity\Cell\StringCell; +use OpenSpout\Common\Entity\Row; +use OpenSpout\Writer\XLSX\Options; +use OpenSpout\Writer\XLSX\Writer; use RuntimeException; -use Symfony\Component\Cache\Psr16Cache; use Symfony\Component\Clock\ClockInterface; use Symfony\Component\DependencyInjection\Attribute\Autowire; use Symfony\Component\HttpFoundation\BinaryFileResponse; @@ -54,8 +51,6 @@ public function __construct( private ClockInterface $clock, #[Autowire('%kernel.project_dir%/storage/regmel')] private string $exportDirectory, - #[Autowire(service: 'phpspreadsheet.cache')] - private CacheItemPoolInterface $cachePool, ) { } @@ -68,22 +63,18 @@ public function export(): BinaryFileResponse throw new RuntimeException('Não foi possível criar o arquivo temporário da exportação.'); } - $spreadsheet = null; + $writer = null; try { - $spreadsheet = $this->createSpreadsheet(); - $sheet = $spreadsheet->getActiveSheet(); - $this->writeRow($sheet, 1, self::HEADERS); + $writer = new Writer(new Options(tempFolder: $this->exportDirectory)); + $writer->openToFile($filePath); + $writer->addRow($this->createRow(self::HEADERS)); - $rowNumber = 2; foreach ($this->userRepository->findAllForExport() as $row) { - $this->writeRow($sheet, $rowNumber, $this->formatRow($row)); - ++$rowNumber; + $writer->addRow($this->createRow($this->formatRow($row))); } - $writer = new Xlsx($spreadsheet); - $writer->setPreCalculateFormulas(false); - $writer->save($filePath); + $writer->close(); $downloadName = sprintf( 'usuarios_e_vinculos_%s.xlsx', @@ -95,26 +86,16 @@ public function export(): BinaryFileResponse return $response; } catch (Throwable $exception) { + try { + $writer?->close(); + } catch (Throwable) { + } + if (is_file($filePath)) { - unlink($filePath); + @unlink($filePath); } throw $exception; - } finally { - $spreadsheet?->disconnectWorksheets(); - } - } - - private function createSpreadsheet(): Spreadsheet - { - $originalCache = Settings::getCache(); - - try { - Settings::setCache(new Psr16Cache($this->cachePool)); - - return new Spreadsheet(); - } finally { - Settings::setCache($originalCache); } } @@ -182,15 +163,12 @@ private function formatRow(array $row): array /** * @param list $values */ - private function writeRow(Worksheet $sheet, int $rowNumber, array $values): void + private function createRow(array $values): Row { - foreach ($values as $columnOffset => $value) { - $sheet->setCellValueExplicit( - [$columnOffset + 1, $rowNumber], - $value, - DataType::TYPE_STRING - ); - } + return new Row(array_map( + static fn (string $value): StringCell => new StringCell($value), + $values + )); } private function formatStatus(mixed $status): string diff --git a/tests/Unit/Service/UserExportServiceTest.php b/tests/Unit/Service/UserExportServiceTest.php index 1a32d4774..106854fa6 100644 --- a/tests/Unit/Service/UserExportServiceTest.php +++ b/tests/Unit/Service/UserExportServiceTest.php @@ -7,27 +7,20 @@ use App\Repository\Interface\UserRepositoryInterface; use App\Service\UserExportService; use DateTimeImmutable; -use PhpOffice\PhpSpreadsheet\Cell\DataType; use PhpOffice\PhpSpreadsheet\IOFactory; -use PhpOffice\PhpSpreadsheet\Settings; +use PhpOffice\PhpSpreadsheet\RichText\RichText; use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; -use Psr\Cache\CacheItemPoolInterface; -use Psr\SimpleCache\CacheInterface; use Symfony\Component\Clock\MockClock; -use Symfony\Component\Cache\Adapter\ArrayAdapter; -use Symfony\Component\Cache\Psr16Cache; final class UserExportServiceTest extends TestCase { private UserRepositoryInterface&MockObject $repository; private string $exportDirectory; - private CacheInterface $originalSpreadsheetCache; protected function setUp(): void { - $this->originalSpreadsheetCache = Settings::getCache(); $this->repository = $this->createMock(UserRepositoryInterface::class); $this->exportDirectory = sprintf( '%s/pvr-user-export-%s', @@ -38,8 +31,6 @@ protected function setUp(): void protected function tearDown(): void { - Settings::setCache($this->originalSpreadsheetCache); - if (!is_dir($this->exportDirectory)) { return; } @@ -120,7 +111,7 @@ public function testExportsCompleteRowAndHttpMetadata(): void ], $sheet->rangeToArray('A2:S2')[0]); foreach ($sheet->getCellCollection()->getCoordinates() as $coordinate) { - self::assertSame(DataType::TYPE_STRING, $sheet->getCell($coordinate)->getDataType()); + self::assertSame('inlineStr', $sheet->getCell($coordinate)->getDataType()); } ob_start(); @@ -182,9 +173,13 @@ public function testWritesFormulaLikeOrganizationNameAsLiteralString(): void $response = $this->createService()->export(); $cell = $this->loadSheet($response->getFile()->getPathname())->getCell('N2'); + $value = $cell->getValue(); + if ($value instanceof RichText) { + $value = $value->getPlainText(); + } - self::assertSame('=HYPERLINK("https://example.com","click")', $cell->getValue()); - self::assertSame(DataType::TYPE_STRING, $cell->getDataType()); + self::assertSame('=HYPERLINK("https://example.com","click")', $value); + self::assertSame('inlineStr', $cell->getDataType()); } public function testConsumesRepositoryGeneratorOnlyOnce(): void @@ -222,24 +217,25 @@ public function testRemovesTemporaryFileWhenGeneratorThrowsAfterYieldingARow(): self::assertSame(['.', '..'], scandir($this->exportDirectory)); } - public function testRestoresPhpSpreadsheetGlobalCacheAfterExport(): void + public function testDoesNotDependOnSpreadsheetCachePool(): void { - $originalCache = new Psr16Cache(new ArrayAdapter()); - Settings::setCache($originalCache); - $this->repository->method('findAllForExport')->willReturn([]); - - $this->createService()->export(); + $parameters = (new \ReflectionMethod(UserExportService::class, '__construct'))->getParameters(); - self::assertSame($originalCache, Settings::getCache()); + self::assertCount(3, $parameters); + foreach ($parameters as $parameter) { + self::assertNotSame( + 'Psr\Cache\CacheItemPoolInterface', + $parameter->getType() instanceof \ReflectionNamedType ? $parameter->getType()->getName() : null + ); + } } - public function testRequiresDedicatedCachePoolInConstructor(): void + public function testProductionServiceDoesNotUsePhpSpreadsheet(): void { - $parameters = (new \ReflectionMethod(UserExportService::class, '__construct'))->getParameters(); - $cachePoolType = $parameters[3]->getType(); + $serviceFile = (new \ReflectionClass(UserExportService::class))->getFileName(); - self::assertInstanceOf(\ReflectionNamedType::class, $cachePoolType); - self::assertSame(CacheItemPoolInterface::class, $cachePoolType->getName()); + self::assertIsString($serviceFile); + self::assertStringNotContainsString('PhpOffice\\PhpSpreadsheet', file_get_contents($serviceFile)); } private function createService(): UserExportService @@ -247,8 +243,7 @@ private function createService(): UserExportService return new UserExportService( $this->repository, new MockClock(new DateTimeImmutable('2026-07-23 14:05:06 America/Sao_Paulo')), - $this->exportDirectory, - new ArrayAdapter() + $this->exportDirectory ); } From c3b99ab847d80d51a7229b4e88be6b319e12d85e Mon Sep 17 00:00:00 2001 From: Victor Ferreira Date: Thu, 23 Jul 2026 23:13:47 -0300 Subject: [PATCH 5/6] =?UTF-8?q?feat:=20disponibiliza=20exporta=C3=A7=C3=A3?= =?UTF-8?q?o=20de=20v=C3=ADnculos=20de=20usu=C3=A1rios?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- config/routes/admin/user.yaml | 6 +- .../Web/Admin/UserAdminController.php | 11 ++ .../UserExportAdminWebControllerTest.php | 103 ++++++++++++++++++ 3 files changed, 119 insertions(+), 1 deletion(-) create mode 100644 tests/Functional/Controller/Web/Admin/UserExportAdminWebControllerTest.php diff --git a/config/routes/admin/user.yaml b/config/routes/admin/user.yaml index ff8a5f1d7..55c40bb6a 100644 --- a/config/routes/admin/user.yaml +++ b/config/routes/admin/user.yaml @@ -3,6 +3,11 @@ list: controller: App\Controller\Web\Admin\UserAdminController::list methods: ['GET'] +export_links: + path: /exportar-vinculos + controller: App\Controller\Web\Admin\UserAdminController::exportLinks + methods: ['GET'] + timeline: path: /{id}/timeline controller: App\Controller\Web\Admin\UserAdminController::timeline @@ -32,4 +37,3 @@ edit_roles: path: /{id}/edit-user-roles controller: App\Controller\Web\Admin\UserAdminController::editUserRoles methods: ['POST'] - \ No newline at end of file diff --git a/src/Controller/Web/Admin/UserAdminController.php b/src/Controller/Web/Admin/UserAdminController.php index 72da199fa..1fc2b6f7f 100644 --- a/src/Controller/Web/Admin/UserAdminController.php +++ b/src/Controller/Web/Admin/UserAdminController.php @@ -14,10 +14,12 @@ use App\Security\PasswordHasher; use App\Service\Interface\AgentServiceInterface; use App\Service\Interface\UserServiceInterface; +use App\Service\UserExportService; use Exception; use Lexik\Bundle\JWTAuthenticationBundle\Services\JWTTokenManagerInterface; use Symfony\Bundle\SecurityBundle\Security; use Symfony\Component\ExpressionLanguage\Expression; +use Symfony\Component\HttpFoundation\BinaryFileResponse; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Security\Http\Attribute\IsGranted; @@ -56,6 +58,15 @@ public function list(): Response ]); } + #[IsGranted(new Expression( + 'is_granted("'.UserRolesEnum::ROLE_ADMIN->value.'") or '. + 'is_granted("'.UserRolesEnum::ROLE_MANAGER->value.'")' + ), statusCode: self::ACCESS_DENIED_RESPONSE_CODE)] + public function exportLinks(UserExportService $service): BinaryFileResponse + { + return $service->export(); + } + #[IsGranted(new Expression( 'is_granted("'.UserRolesEnum::ROLE_ADMIN->value.'") or '. 'is_granted("'.UserRolesEnum::ROLE_MANAGER->value.'")' diff --git a/tests/Functional/Controller/Web/Admin/UserExportAdminWebControllerTest.php b/tests/Functional/Controller/Web/Admin/UserExportAdminWebControllerTest.php new file mode 100644 index 000000000..1b41a5b4c --- /dev/null +++ b/tests/Functional/Controller/Web/Admin/UserExportAdminWebControllerTest.php @@ -0,0 +1,103 @@ +entityManager = static::getContainer()->get(EntityManagerInterface::class); + $this->client->loginUser($this->createActiveUser(UserRolesEnum::ROLE_ADMIN), 'web'); + } + + public function testAdminCanExportUserLinksAsXlsx(): void + { + $this->client->request( + Request::METHOD_GET, + $this->router->generate('admin_user_export_links') + ); + + $response = $this->client->getResponse(); + + self::assertInstanceOf(BinaryFileResponse::class, $response); + self::assertResponseIsSuccessful(); + self::assertSame( + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + $response->headers->get('Content-Type') + ); + self::assertMatchesRegularExpression( + '/^attachment; filename=usuarios_e_vinculos_\d{4}-\d{2}-\d{2}_\d{2}-\d{2}-\d{2}\.xlsx$/', + (string) $response->headers->get('Content-Disposition') + ); + self::assertFileDoesNotExist($response->getFile()->getPathname()); + } + + public function testStandardUserReceivesSameDenialAsUserList(): void + { + $this->client->loginUser($this->createActiveUser(UserRolesEnum::ROLE_USER), 'web'); + + $this->client->request( + Request::METHOD_GET, + $this->router->generate('admin_user_list') + ); + $listStatusCode = $this->client->getResponse()->getStatusCode(); + + $this->client->request( + Request::METHOD_GET, + $this->router->generate('admin_user_export_links') + ); + + $response = $this->client->getResponse(); + + self::assertSame($listStatusCode, $response->getStatusCode()); + self::assertFalse($response->isSuccessful()); + } + + public function testManagerCanExportUserLinks(): void + { + $this->client->loginUser($this->createActiveUser(UserRolesEnum::ROLE_MANAGER), 'web'); + + $this->client->request( + Request::METHOD_GET, + $this->router->generate('admin_user_export_links') + ); + + $response = $this->client->getResponse(); + + self::assertInstanceOf(BinaryFileResponse::class, $response); + self::assertResponseIsSuccessful(); + } + + private function createActiveUser(UserRolesEnum $role): User + { + $id = Uuid::v4(); + $user = new User(); + $user->setId($id); + $user->setFirstname('Usuário'); + $user->setLastname('Exportação'); + $user->setEmail(sprintf('export-web-%s@example.test', $id->toRfc4122())); + $user->setPassword('not-used'); + $user->setStatus(UserStatusEnum::ACTIVE->value); + $user->setRoles([$role->value]); + + $this->entityManager->persist($user); + $this->entityManager->flush(); + + return $user; + } +} From bcf36ad5c21dd2f59d4f6e3f660bfe656a67dd69 Mon Sep 17 00:00:00 2001 From: Victor Ferreira Date: Thu, 23 Jul 2026 23:23:08 -0300 Subject: [PATCH 6/6] =?UTF-8?q?feat:=20adiciona=20bot=C3=A3o=20de=20export?= =?UTF-8?q?a=C3=A7=C3=A3o=20de=20v=C3=ADnculos?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- templates/_admin/user/list.html.twig | 11 ++++++++--- .../Web/Admin/UserExportAdminWebControllerTest.php | 14 ++++++++++++++ translations/messages.en.yaml | 1 + translations/messages.es.yaml | 1 + translations/messages.pt-br.yaml | 1 + translations/messages.regmel.yaml | 1 + 6 files changed, 26 insertions(+), 3 deletions(-) diff --git a/templates/_admin/user/list.html.twig b/templates/_admin/user/list.html.twig index 054078713..f6dfdd567 100644 --- a/templates/_admin/user/list.html.twig +++ b/templates/_admin/user/list.html.twig @@ -16,9 +16,14 @@

diff --git a/tests/Functional/Controller/Web/Admin/UserExportAdminWebControllerTest.php b/tests/Functional/Controller/Web/Admin/UserExportAdminWebControllerTest.php index 1b41a5b4c..1f4476911 100644 --- a/tests/Functional/Controller/Web/Admin/UserExportAdminWebControllerTest.php +++ b/tests/Functional/Controller/Web/Admin/UserExportAdminWebControllerTest.php @@ -83,6 +83,20 @@ public function testManagerCanExportUserLinks(): void self::assertResponseIsSuccessful(); } + public function testUsersListShowsExportUsersAndLinksButton(): void + { + $this->client->request( + Request::METHOD_GET, + $this->router->generate('admin_user_list') + ); + + self::assertResponseIsSuccessful(); + self::assertSelectorTextSame( + sprintf('a[href="%s"]', $this->router->generate('admin_user_export_links')), + 'Exportar usuários e vínculos' + ); + } + private function createActiveUser(UserRolesEnum $role): User { $id = Uuid::v4(); diff --git a/translations/messages.en.yaml b/translations/messages.en.yaml index 29ce0316d..50290c872 100644 --- a/translations/messages.en.yaml +++ b/translations/messages.en.yaml @@ -423,6 +423,7 @@ total_value: Total Value user: User user_name: User Name users: Users +export_users_and_links: Export users and links updated_at: Updated at updated_success: Updated successfully. diff --git a/translations/messages.es.yaml b/translations/messages.es.yaml index 48a610b9a..5b89de39d 100644 --- a/translations/messages.es.yaml +++ b/translations/messages.es.yaml @@ -421,6 +421,7 @@ total_value: Valor total user: Usuario user_name: Nombre de usuario users: Usuarios +export_users_and_links: Exportar usuarios y vínculos updated_at: Actualizado en updated_success: Updated successfully diff --git a/translations/messages.pt-br.yaml b/translations/messages.pt-br.yaml index fb20d5464..7f7470fd7 100644 --- a/translations/messages.pt-br.yaml +++ b/translations/messages.pt-br.yaml @@ -422,6 +422,7 @@ total_value: Valor total user: Usuário user_name: Nome do Usuário users: Usuários +export_users_and_links: Exportar usuários e vínculos updated_at: Atualizado em updated_success: Atualizado com sucesso. diff --git a/translations/messages.regmel.yaml b/translations/messages.regmel.yaml index 7f027d3b1..ed1f987ef 100644 --- a/translations/messages.regmel.yaml +++ b/translations/messages.regmel.yaml @@ -168,6 +168,7 @@ documents: Termos de Adesão does_not_expire: não expira download_all_terms: Baixar todos os termos export_users: Exportar usuários +export_users_and_links: Exportar usuários e vínculos downloadable-files: Arquivos para download download_archive: Baixar arquivo KML organization_duplicate: Este município já foi credenciado. Entre em contato com %s para mais informações.