Feat: Implement contract generation and up-/downloading

This commit is contained in:
Björn Fromme
2023-10-11 17:47:06 +02:00
parent 7ecacd7c78
commit f0911f57aa
13 changed files with 421 additions and 10 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.
+6
View File
@@ -12,6 +12,12 @@ oneup_uploader:
namer: app.upload_namer
storage:
directory: '%kernel.project_dir%/uploads/certificate'
contract:
frontend: dropzone
use_orphanage: true
namer: app.upload_namer
storage:
directory: '%kernel.project_dir%/uploads/contract'
chunks:
maxage: 86400
storage:
+3
View File
@@ -106,3 +106,6 @@ services:
class: App\Service\Upload\UploadNamer
public: true
App\Service\Pdf\ContractRenderer:
arguments:
$options: { 'project_dir': '%kernel.project_dir%' }
@@ -0,0 +1,36 @@
<?php
namespace App\Controller\Teamer\Disposition;
use App\Entity\Disposition;
use App\Service\Pdf\ContractRenderer;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\HeaderUtils;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
class ContractController extends AbstractController
{
public function __construct(private readonly ContractRenderer $renderer)
{}
#[Route('/teamer/disposition/contract/{uuid}', name: 'app_teamer_disposition_contract')]
#[IsGranted('CONTRACT', subject: 'disposition')]
public function index(Disposition $disposition): Response
{
$pdf = $this->renderer->render($disposition);
$teamer = $disposition->getTeamer();
$filename = sprintf('Honorarvertrag_%s_%s.pdf', $teamer, $disposition->getAssignment()->getDestination());
$disposition = HeaderUtils::makeDisposition(HeaderUtils::DISPOSITION_ATTACHMENT, $filename, md5($filename));
$response = new Response($pdf->Output('S'));
$response->setPrivate();
$response->headers->set('Content-type', 'application/pdf');
$response->headers->set('Content-Disposition', $disposition);
return $response;
}
}
@@ -4,6 +4,12 @@ namespace App\Controller\Teamer\Disposition;
use App\Controller\Traits\ReturnUrlTrait;
use App\Entity\Disposition;
use App\Entity\Upload;
use App\Entity\User;
use App\Model\UploadSessionDto;
use App\Service\Upload\UploadHandler;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
@@ -14,13 +20,57 @@ class DetailController extends AbstractController
{
use ReturnUrlTrait;
public function __construct(
private readonly UploadHandler $uploadHandler,
private readonly EntityManagerInterface $entityManager,
private readonly LoggerInterface $logger
) {
}
#[Route('/teamer/disposition/detail/{uuid}', name: 'app_teamer_disposition_detail')]
#[IsGranted('VIEW', subject: 'disposition')]
public function index(Disposition $disposition, Request $request): Response
{
// Handle upload independently from form submission to avoid issues with failing validation
$uploadSession = $this->uploadHandler->getUploadSession();
if (true === $request->isMethod('POST') && 0 < $uploadSession->getCount()) {
$this->updateContract($disposition, $uploadSession);
}
$form = $this->createFormBuilder()->getForm();
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
return $this->redirectToRoute('app_teamer_disposition_detail', [
'uuid' => $disposition->getUuid(),
]);
}
return $this->render('teamer/disposition/detail.html.twig', [
'disposition' => $disposition,
'returnUrl' => $this->getReturnUrl($request, 'app_teamer_index'),
'form' => $form,
]);
}
private function updateContract(Disposition $disposition, UploadSessionDto $uploadSession): void
{
if (null !== $existingUpload = $disposition->getContractPdf()) {
$this->entityManager->remove($existingUpload);
}
/** @var User $user */
$user = $this->getUser();
$upload = Upload::fromUploadDto($uploadSession->getUploads()->first(), $user, Upload::TYPE_CONTRACT);
$disposition->setContractPdf($upload);
$this->entityManager->flush();
$this->uploadHandler->moveUploadSessionFilesFromOrphanage(Upload::TYPE_CONTRACT, $uploadSession);
$this->uploadHandler->destroyUploadSession();
$this->addFlash('success', 'Der Honorarvertrag wurde hochgeladen');
$this->logger->info('Update contract', [
'user' => $user->getUserIdentifier(),
]);
}
}
+5
View File
@@ -52,6 +52,11 @@ class Address
}
}
public function __toString()
{
return sprintf('%s, %s %s', $this->street, $this->postCode, $this->city);
}
public function toPayload(): array
{
return [
+3 -6
View File
@@ -4,7 +4,6 @@ namespace App\Security\Voter;
use App\Entity\Disposition;
use App\Entity\User;
use App\Repository\ApplicationRepository;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
@@ -13,9 +12,7 @@ class DispositionVoter extends Voter
public const VIEW = 'VIEW';
public const EDIT = 'EDIT';
public const DELETE = 'DELETE';
public function __construct(private readonly ApplicationRepository $applicationRepository)
{}
public const CONTRACT = 'CONTRACT';
protected function supports(string $attribute, mixed $subject): bool
{
@@ -23,7 +20,7 @@ class DispositionVoter extends Voter
return false;
}
return in_array($attribute, [static::VIEW, static::EDIT, static::DELETE]);
return in_array($attribute, [static::VIEW, static::EDIT, static::DELETE, static::CONTRACT]);
}
protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token): bool
@@ -34,7 +31,7 @@ class DispositionVoter extends Voter
}
// Teamers may only view or edit their own dispositions
if (in_array('ROLE_TEAMER', $token->getRoleNames()) && in_array($attribute, [static::VIEW, static::EDIT])) {
if (in_array('ROLE_TEAMER', $token->getRoleNames()) && in_array($attribute, [static::VIEW, static::EDIT, static::CONTRACT])) {
/** @var User $user */
$user = $token->getUser();
$teamer = $user->getTeamer();
+32
View File
@@ -0,0 +1,32 @@
<?php
namespace App\Service\Pdf;
use Psr\Cache\CacheItemPoolInterface;
use Symfony\Component\Cache\Adapter\ArrayAdapter;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Contracts\Translation\TranslatorInterface;
abstract class AbstractPdfRenderer
{
protected CacheItemPoolInterface|ArrayAdapter $cache;
protected int $B = 0;
protected int $I = 0;
protected int $U = 0;
protected array $config;
public function __construct(protected readonly TranslatorInterface $translator, array $options)
{
$optionsResolver = new OptionsResolver();
$optionsResolver
->setRequired('project_dir')
->setAllowedTypes('project_dir', 'string')
;
$this->config = $optionsResolver->resolve($options);
// Static cache without serialization
$this->cache = new ArrayAdapter(0, false);
}
}
+54
View File
@@ -0,0 +1,54 @@
<?php
namespace App\Service\Pdf;
use App\Entity\Disposition;
class ContractRenderer extends AbstractPdfRenderer
{
public function render(Disposition $disposition): Pdf
{
$pdf = new Pdf();
$pdf->SetAuthor('Continentale', true);
$pdf->SetCreator('Continentale', true);
// Load template
$pdfTemplate = sprintf('%s/assets/pdf/contract.pdf', $this->config['project_dir']);
$numberOfPages = $pdf->appendPdf($pdfTemplate);
$pdf->setPage(1);
$pdf->SetFont('Arial', '', 10);
$teamer = $disposition->getTeamer();
$assignment = $disposition->getAssignment();
$destination = $assignment->getDestination();
// Current date
$date = (new \DateTimeImmutable())->format('d.m.Y');
$pdf->Text(164,43, utf8_decode($date));
// Name
$pdf->Text(45,67.75, utf8_decode($teamer));
// Address
$pdf->Text(45,77.75, utf8_decode($teamer->getAddress()));
// Jobprofile
$pdf->Text(21,104.5, utf8_decode($assignment->getJobProfile()->getName()));
// Period
$dateFrom = $assignment->getTotalPeriod()->start->format('d.m.Y');
$dateTo = $assignment->getTotalPeriod()->end->format('d.m.Y');
$period = sprintf('%s - %s', $dateFrom, $dateTo);
$pdf->Text(57,113, $period);
// Destination
$pdf->Text(57,121.5, utf8_decode(sprintf('%s, %s', $destination->getProduct(), $destination->getHotel())));
// Set pointer to last page to include all
$pdf->setPage($numberOfPages);
return $pdf;
}
}
+120
View File
@@ -0,0 +1,120 @@
<?php
namespace App\Service\Pdf;
use setasign\Fpdi\Fpdi;
use setasign\Fpdi\PdfParser\PdfParserException;
use setasign\Fpdi\PdfReader\PdfReaderException;
/**
* Functionality to center image on PDF page adapted from
* https://gist.github.com/benshimmin/4088493?permalink_comment_id=2398423#gistcomment-2398423.
*
* Functionality to append pages from external PDF file adapted from
* https://stackoverflow.com/questions/22404601/merging-pdf-files-with-php-fpdi/29552294#29552294
*
* Requires commercial FPDI PDF-Parser addon to parse compressed PDF files.
*/
class Pdf extends Fpdi
{
public const DPI = 300;
public const MM_IN_INCH = 25.4;
public const A4_HEIGHT = 297;
public const A4_WIDTH = 210;
public const MARGIN_X = 10;
public const MARGIN_Y = 10;
public function setPage(int $pageNumber): void
{
$this->page = $pageNumber;
}
public function appendPdf(string $sourceFile): int
{
try {
$pageCount = $this->setSourceFile($sourceFile);
for ($page = 1; $page <= $pageCount; ++$page) {
$templateId = $this->importPage($page);
$size = $this->getTemplateSize($templateId);
$this->AddPage($size['orientation'], [$size['width'], $size['height']]);
$this->useTemplate($templateId);
}
return $pageCount;
} catch (PdfParserException|PdfReaderException $e) {
$this->AddPage();
$this->SetFont('Arial', '', 10);
$this->Text(self::MARGIN_X, self::MARGIN_Y + 10, 'PDF-Fehler: '.$e->getMessage());
return 1;
}
}
public function appendImage(string $sourceFile, string $pageTitle = ''): void
{
$this->AddPage();
$containerWidth = self::A4_WIDTH - self::MARGIN_X * 2;
$containerHeight = self::A4_HEIGHT - self::MARGIN_Y * 3;
$this->centerImage(
$sourceFile,
self::MARGIN_X,
self::MARGIN_Y * 2,
$containerWidth,
$containerHeight
);
if ($pageTitle) {
$this->SetFont('Arial', '', 10);
$this->Text(self::MARGIN_X, self::MARGIN_Y, utf8_decode($pageTitle));
}
}
public function centerImage(
string $imgPath,
int $x = 0,
int $y = 0,
int $containerWidth = self::A4_WIDTH,
int $containerHeight = self::A4_HEIGHT
): void {
[$width, $height] = $this->resizeToFit($imgPath, $containerWidth, $containerHeight);
try {
$this->Image(
$imgPath,
$x + ($containerWidth - $width) / 2,
$y + ($containerHeight - $height) / 2,
$width,
$height
);
} catch (\Exception $e) {
$this->SetFont('Arial', '', 10);
$this->Text(self::MARGIN_X, self::MARGIN_Y + 10, 'PDF-Fehler: '.$e->getMessage());
}
}
protected function pixelsToMm($val): int
{
return (int) (round($val * $this::MM_IN_INCH / self::DPI));
}
protected function mmToPixels($val): int
{
return (int) (round($this::DPI * $val / self::MM_IN_INCH));
}
protected function resizeToFit(
string $imgPath,
int $maxWidth = self::A4_WIDTH,
int $maxHeight = self::A4_HEIGHT
): array {
[$width, $height] = getimagesize($imgPath);
$widthScale = $this->mmToPixels($maxWidth) / $width;
$heightScale = $this->mmToPixels($maxHeight) / $height;
$scale = min($widthScale, $heightScale);
return [
$this->pixelsToMm($scale * $width),
$this->pixelsToMm($scale * $height),
];
}
}
+30 -4
View File
@@ -6,7 +6,7 @@
<h1 class="text-2xl font-bold pb-8">
Einsatzdetails
</h1>
<div class="grid grid-cols-2 gap-8 items-start">
<div class="grid lg:grid-cols-2 gap-8 items-start">
<div class="bg-gray-100 rounded-md p-4">
{% set assignment = disposition.assignment %}
{% include '_partials/_assignment_info.html.twig' %}
@@ -20,9 +20,35 @@
<br>
{{ assignment.contact }}, <a href="mailto:{{ assignment.contact.email }}" class="underline">{{ assignment.contact.email }}</a>
</p>
<a href="{{ returnUrl }}" class="underline">
zurück
</a>
<h3 class="text-xl font-bold pb-4">
Hier findest du deinen Honorarvertrag
</h3>
<div class="pb-4">
<a href="{{ path('app_teamer_disposition_contract', { 'uuid': disposition.uuid }) }}" target="_blank" class="inline-block">
<img src="{{ asset('build/images/contract_thumb.jpg') }}" alt="Honorarvertrag" class="block w-32 h-auto shadow-md">
</a>
</div>
<p class="pb-4">
Bitte lade ihn unterschrieben hier wieder hoch:
</p>
{{ form_start(form) }}
<div class="pb-4">
{% include '_partials/_upload_collection_form.html.twig' with {
'endpoint_upload': path('_uploader_upload_contract'),
'upload_session_params': null,
'accepted_files': 'image/jpg,image/jpeg,application/pdf',
} %}
</div>
<button type="submit" class="btn">
Aktualisieren
</button>
{{ form_rest(form) }}
{{ form_end(form) }}
<div class="pt-4">
<a href="{{ returnUrl }}" class="underline">
zurück
</a>
</div>
</div>
</div>
{% endblock %}
@@ -0,0 +1,82 @@
<?php
namespace App\Tests\Service\Pdf;
use App\Entity\Application;
use App\Entity\Assignment;
use App\Entity\Destination;
use App\Entity\Disposition;
use App\Entity\Embeddable\Address;
use App\Entity\Embeddable\Communication;
use App\Entity\JobProfile;
use App\Entity\Teamer;
use App\Entity\User;
use App\Service\Pdf\ContractRenderer;
use App\Service\Pdf\Pdf;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
use Symfony\Contracts\Translation\TranslatorInterface;
class ContractRendererTest extends WebTestCase
{
public function testRendersCard(): void
{
$projectDir = static::getContainer()->getParameter('kernel.project_dir');
$translator = $this->createMock(TranslatorInterface::class);
$renderer = new ContractRenderer($translator, ['project_dir' => $projectDir]);
$address = new Address();
$address
->setStreet('Testweg 1')
->setCity('Teststadt')
->setPostCode('12345')
;
$communication = new Communication();
$communication
->setEmail('[email protected]')
->setPhone('123456789')
->setMobile('0123-456789')
;
$teamer = new Teamer();
$teamer
->setFirstName('Isolde')
->setLastName('Duschen')
->setAddress($address)
;
$destination = new Destination();
$destination
->setProduct('Skireise')
->setHotel('Unterkunft')
->setCode('ABC123')
->setDateFrom(new \DateTimeImmutable('2023-12-01'))
->setDateTo(new \DateTimeImmutable('2023-12-08'))
;
$jobProfile = new JobProfile();
$jobProfile->setName('Allrounder');
$contact = new User();
$contact
->setFirstName('Carmen')
->setLastName('Bär')
->setEmail('[email protected]')
;
$assignment = new Assignment();
$assignment
->setDestination($destination)
->setJobProfile($jobProfile)
->setContact($contact)
;
$application = new Application($assignment, $teamer);
$disposition = new Disposition($application);
$pdf = $renderer->render($disposition);
$this->assertInstanceOf(Pdf::class, $pdf);
$pdf->Output('F', $projectDir.'/temp/Honorarvertrag.pdf');
}
}