Feat: Implement contract generation and up-/downloading
This commit is contained in:
Binary file not shown.
|
After Width: | Height: | Size: 36 KiB |
Binary file not shown.
@@ -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:
|
||||
|
||||
@@ -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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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 [
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
<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');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user