feat: download ZIP archive of invoices with status 'paid'

This commit is contained in:
Björn Fromme
2025-01-31 18:08:52 +01:00
parent d39f75d831
commit 3f839f9082
9 changed files with 274 additions and 3 deletions
+1
View File
@@ -22,6 +22,7 @@
"knplabs/knp-menu-bundle": "^3.2", "knplabs/knp-menu-bundle": "^3.2",
"knplabs/knp-paginator-bundle": "^6.2", "knplabs/knp-paginator-bundle": "^6.2",
"liip/imagine-bundle": "^2.11", "liip/imagine-bundle": "^2.11",
"maennchen/zipstream-php": "^3.1",
"nelmio/security-bundle": "^3.0", "nelmio/security-bundle": "^3.0",
"nesbot/carbon": "^2.70", "nesbot/carbon": "^2.70",
"oneup/uploader-bundle": "^4.0", "oneup/uploader-bundle": "^4.0",
Generated
+79 -1
View File
@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically" "This file is @generated automatically"
], ],
"content-hash": "5911cb3f7a5a11524bde5368b3fa7865", "content-hash": "902495c1d53e43f8de93c4f162805e7f",
"packages": [ "packages": [
{ {
"name": "beberlei/doctrineextensions", "name": "beberlei/doctrineextensions",
@@ -2064,6 +2064,84 @@
}, },
"time": "2024-12-12T09:38:23+00:00" "time": "2024-12-12T09:38:23+00:00"
}, },
{
"name": "maennchen/zipstream-php",
"version": "3.1.2",
"source": {
"type": "git",
"url": "https://github.com/maennchen/ZipStream-PHP.git",
"reference": "aeadcf5c412332eb426c0f9b4485f6accba2a99f"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/maennchen/ZipStream-PHP/zipball/aeadcf5c412332eb426c0f9b4485f6accba2a99f",
"reference": "aeadcf5c412332eb426c0f9b4485f6accba2a99f",
"shasum": ""
},
"require": {
"ext-mbstring": "*",
"ext-zlib": "*",
"php-64bit": "^8.2"
},
"require-dev": {
"brianium/paratest": "^7.7",
"ext-zip": "*",
"friendsofphp/php-cs-fixer": "^3.16",
"guzzlehttp/guzzle": "^7.5",
"mikey179/vfsstream": "^1.6",
"php-coveralls/php-coveralls": "^2.5",
"phpunit/phpunit": "^11.0",
"vimeo/psalm": "^6.0"
},
"suggest": {
"guzzlehttp/psr7": "^2.4",
"psr/http-message": "^2.0"
},
"type": "library",
"autoload": {
"psr-4": {
"ZipStream\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Paul Duncan",
"email": "[email protected]"
},
{
"name": "Jonatan Männchen",
"email": "[email protected]"
},
{
"name": "Jesse Donat",
"email": "[email protected]"
},
{
"name": "András Kolesár",
"email": "[email protected]"
}
],
"description": "ZipStream is a library for dynamically streaming dynamic zip files from PHP without writing to the disk at all on the server.",
"keywords": [
"stream",
"zip"
],
"support": {
"issues": "https://github.com/maennchen/ZipStream-PHP/issues",
"source": "https://github.com/maennchen/ZipStream-PHP/tree/3.1.2"
},
"funding": [
{
"url": "https://github.com/maennchen",
"type": "github"
}
],
"time": "2025-01-27T12:07:53+00:00"
},
{ {
"name": "monolog/monolog", "name": "monolog/monolog",
"version": "3.8.1", "version": "3.8.1",
+1 -1
View File
@@ -74,7 +74,7 @@ host('prod')
->setDeployPath('/kunden/tiefschnee.de/myep-team') ->setDeployPath('/kunden/tiefschnee.de/myep-team')
->set('writable_mode', 'chmod') ->set('writable_mode', 'chmod')
->set('http_user', 'ssh-53857-root') ->set('http_user', 'ssh-53857-root')
->set('bin/php', '/usr/bin/php81') ->set('bin/php', '/usr/bin/php82')
->set('rsync_src', __DIR__) ->set('rsync_src', __DIR__)
->set('rsync', $rsyncOptions) ->set('rsync', $rsyncOptions)
->set('cachetool_args', '--web=SymfonyHttpClient --web-path={{release_or_current_path}}/public/ --web-url=https://myep-team.ep-reisen.de') ->set('cachetool_args', '--web=SymfonyHttpClient --web-path={{release_or_current_path}}/public/ --web-url=https://myep-team.ep-reisen.de')
+33
View File
@@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20250131164533 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql('DROP TABLE rememberme_token');
$this->addSql('ALTER TABLE upload ADD downloaded_at DATE DEFAULT NULL COMMENT \'(DC2Type:date_immutable)\', ADD downloaded_by VARCHAR(255) DEFAULT NULL');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('CREATE TABLE rememberme_token (series VARCHAR(88) CHARACTER SET utf8mb4 NOT NULL COLLATE `utf8mb4_unicode_ci`, value VARCHAR(88) CHARACTER SET utf8mb4 NOT NULL COLLATE `utf8mb4_unicode_ci`, lastUsed DATETIME NOT NULL COMMENT \'(DC2Type:datetime_immutable)\', class VARCHAR(100) CHARACTER SET utf8mb4 NOT NULL COLLATE `utf8mb4_unicode_ci`, username VARCHAR(200) CHARACTER SET utf8mb4 NOT NULL COLLATE `utf8mb4_unicode_ci`, PRIMARY KEY(series)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB COMMENT = \'\' ');
$this->addSql('ALTER TABLE upload DROP downloaded_at, DROP downloaded_by');
}
}
@@ -0,0 +1,83 @@
<?php
namespace App\Controller\Administrative\Document;
use App\Entity\Upload;
use App\Repository\UploadRepository;
use App\Service\Upload\UploadHandler;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\HeaderUtils;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\StreamedResponse;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
use ZipStream\Exception\FileNotFoundException;
use ZipStream\Exception\FileNotReadableException;
use ZipStream\ZipStream;
class BatchDownloadController extends AbstractController
{
public function __construct(
private readonly UploadRepository $uploadRepository,
private readonly UploadHandler $uploadHandler,
private readonly EntityManagerInterface $entityManager
) {
}
#[Route(
path: '/administrative/document/batch-download/{type}',
name: 'app_administrative_document_batch_download',
defaults: ['type' => Upload::TYPE_INVOICE]
)]
#[IsGranted('ROLE_ADMINISTRATIVE')]
public function index(string $type, Request $request): Response
{
$status = $request->query->get('status', Upload::STATUS_PAID);
$uploads = $this
->uploadRepository
->getBatchDownloadList($type, $status)
;
if (0 === count($uploads)) {
$this->addFlash('error', 'Keine Dokumente vorhanden');
return $this->redirectToRoute('app_administrative_document_index');
}
$zipFilename = sprintf('Honorarnoten_%s.zip', date('YmdHi'));
$response = new StreamedResponse(function () use ($uploads, $zipFilename) {
$zip = new ZipStream(
defaultEnableZeroHeader: true,
outputName: $zipFilename,
contentType: 'application/octet-stream'
);
foreach ($uploads as $upload) {
/** @var Upload $upload */
$filename = $upload->getOriginalFilename();
$filepath = $this->uploadHandler->getUploadFilepath($upload);
try {
$zip->addFileFromPath(fileName: $filename, path: $filepath);
$upload
->setDownloaded()
->setDownloadedBy($this->getUser()->getUserIdentifier())
;
} catch (FileNotFoundException $e) {
} catch (FileNotReadableException $e) {
}
}
$zip->finish();
$this->entityManager->flush();
});
$disposition = HeaderUtils::makeDisposition('attachment', $zipFilename, md5($zipFilename));
$response->headers->set('Content-Disposition', $disposition);
return $response;
}
}
@@ -4,6 +4,7 @@ namespace App\Controller\Common;
use App\Entity\Upload; use App\Entity\Upload;
use App\Service\Upload\UploadHandler; use App\Service\Upload\UploadHandler;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface; use Psr\Log\LoggerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\File\Exception\FileNotFoundException; use Symfony\Component\HttpFoundation\File\Exception\FileNotFoundException;
@@ -17,6 +18,7 @@ class DownloadController extends AbstractController
{ {
public function __construct( public function __construct(
private readonly UploadHandler $uploadHandler, private readonly UploadHandler $uploadHandler,
private readonly EntityManagerInterface $entityManager,
private readonly LoggerInterface $logger private readonly LoggerInterface $logger
) { ) {
} }
@@ -31,6 +33,13 @@ class DownloadController extends AbstractController
$inline = (bool) $request->get('inline'); $inline = (bool) $request->get('inline');
$disposition = $inline ? ResponseHeaderBag::DISPOSITION_INLINE : ResponseHeaderBag::DISPOSITION_ATTACHMENT; $disposition = $inline ? ResponseHeaderBag::DISPOSITION_INLINE : ResponseHeaderBag::DISPOSITION_ATTACHMENT;
// mark file downloaded
$upload
->setDownloaded()
->setDownloadedBy($this->getUser()->getUserIdentifier())
;
$this->entityManager->flush();
$this->logger->info('Download file', [ $this->logger->info('Download file', [
'file_id' => $upload->getId(), 'file_id' => $upload->getId(),
'file_filename' => $upload->getOriginalFilename(), 'file_filename' => $upload->getOriginalFilename(),
+42
View File
@@ -66,6 +66,12 @@ class Upload implements BlameableEntityInterface, TimestampableEntityInterface
#[ORM\Column(type: Types::TEXT, nullable: true)] #[ORM\Column(type: Types::TEXT, nullable: true)]
private ?string $comment = null; private ?string $comment = null;
#[ORM\Column(type: Types::DATE_IMMUTABLE, nullable: true)]
private ?\DateTimeImmutable $downloadedAt = null;
#[ORM\Column(nullable: true)]
private ?string $downloadedBy = null;
public function __construct() public function __construct()
{ {
$this->uuid = Uuid::v4(); $this->uuid = Uuid::v4();
@@ -242,4 +248,40 @@ class Upload implements BlameableEntityInterface, TimestampableEntityInterface
return $this; return $this;
} }
public function getDownloadedAt(): ?\DateTimeImmutable
{
return $this->downloadedAt;
}
public function setDownloadedAt(?\DateTimeImmutable $downloadedAt): static
{
$this->downloadedAt = $downloadedAt;
return $this;
}
public function isDownloaded(): bool
{
return null !== $this->downloadedAt;
}
public function setDownloaded(): static
{
$this->downloadedAt = new \DateTimeImmutable();
return $this;
}
public function getDownloadedBy(): ?string
{
return $this->downloadedBy;
}
public function setDownloadedBy(?string $downloadedBy): static
{
$this->downloadedBy = $downloadedBy;
return $this;
}
} }
+20
View File
@@ -8,6 +8,7 @@ use App\Repository\Traits\QueryHelperTrait;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository; use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\ORM\Query; use Doctrine\ORM\Query;
use Doctrine\Persistence\ManagerRegistry; use Doctrine\Persistence\ManagerRegistry;
use setasign\Fpdi\PdfParser\CrossReference\AbstractReader;
/** /**
* @extends ServiceEntityRepository<Upload> * @extends ServiceEntityRepository<Upload>
@@ -79,6 +80,25 @@ class UploadRepository extends ServiceEntityRepository
return $data; return $data;
} }
public function getBatchDownloadList(
string $type = Upload::TYPE_INVOICE,
string $status = Upload::STATUS_PAID
): array {
$qb = $this->createQueryBuilder('upload');
return $qb->where(
$qb->expr()->andX(
$qb->expr()->eq('upload.type', ':type'),
$qb->expr()->eq('upload.status', ':status'),
$qb->expr()->isNull('upload.downloadedAt'),
))
->setParameter('type', $type)
->setParameter('status', $status)
->getQuery()
->getResult()
;
}
public function getUploadListQuery(DocumentFilterDto $filterDto): Query public function getUploadListQuery(DocumentFilterDto $filterDto): Query
{ {
$qb = $this->createQueryBuilder('upload'); $qb = $this->createQueryBuilder('upload');
@@ -8,6 +8,10 @@
Dokumentenübersicht Dokumentenübersicht
</h1> </h1>
<div class="flex flex-col items-end space-y-2 md:flex-row md:items-center md:space-x-2 md:space-y-0"> <div class="flex flex-col items-end space-y-2 md:flex-row md:items-center md:space-x-2 md:space-y-0">
<a href="{{ path('app_administrative_document_batch_download') }}" class="btn btn--small btn--secondary">
{{ icon('download', 'w-4 h-4 shrink-0') }}
<span>best. Honorarnoten</span>
</a>
<button type="button" <button type="button"
class="{{ html_classes('btn btn--small', { 'btn--secondary': filterDto.active }) }}" class="{{ html_classes('btn btn--small', { 'btn--secondary': filterDto.active }) }}"
title="Filter" title="Filter"
@@ -22,7 +26,8 @@
{% endif %} {% endif %}
</button> </button>
{% if filterDto.active %} {% if filterDto.active %}
<a href="{{ path('app_common_document_filter_reset', { 'r': return_url() }) }}" class="btn btn--small btn--secondary"> <a href="{{ path('app_common_document_filter_reset', { 'r': return_url() }) }}"
class="btn btn--small btn--secondary">
Reset Reset
</a> </a>
{% endif %} {% endif %}