feat: download ZIP archive of invoices with status 'paid'
This commit is contained in:
@@ -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
@@ -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
@@ -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')
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 %}
|
||||||
|
|||||||
Reference in New Issue
Block a user