wip: implement invoice generator for teamers
This commit is contained in:
1 parent
6c608063ad
commit
bbdd887dc2
25 files changed
+1584
-21
No files matched your search
@@ -19,6 +19,7 @@
|
||||
"doctrine/doctrine-bundle": "^2.10",
|
||||
"doctrine/doctrine-migrations-bundle": "^3.2",
|
||||
"doctrine/orm": "^2.15",
|
||||
"dompdf/dompdf": "^2.0",
|
||||
"flagception/flagception-bundle": "^6.0",
|
||||
"knplabs/knp-menu-bundle": "^3.2",
|
||||
"knplabs/knp-paginator-bundle": "^6.2",
|
||||
|
||||
@@ -10,3 +10,6 @@ flagception:
|
||||
datev_email:
|
||||
default: false
|
||||
env: FEATURE_DATEV_EMAIL
|
||||
invoice_editor:
|
||||
default: false
|
||||
env: FEATURE_INVOICE_EDITOR
|
||||
@@ -194,3 +194,7 @@ services:
|
||||
$defaults:
|
||||
from: '%default_email_from%'
|
||||
to: '%default_email_to%'
|
||||
|
||||
App\Service\Invoice\InvoicePdfGeneratorInterface: '@App\Service\Invoice\InvoicePdfGenerator'
|
||||
App\Service\Invoice\InvoiceUploadWriterInterface: '@App\Service\Invoice\InvoiceUploadWriter'
|
||||
App\Service\Invoice\InvoiceSubmissionServiceInterface: '@App\Service\Invoice\InvoiceSubmissionService'
|
||||
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
final class Version20260221101500 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Add digital teamer invoice draft entities';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$this->addSql('CREATE TABLE disposition_invoice (id INT AUTO_INCREMENT NOT NULL, disposition_id INT NOT NULL, submitted_by_id INT DEFAULT NULL, generated_upload_id INT DEFAULT NULL, uuid VARCHAR(36) NOT NULL, status VARCHAR(32) NOT NULL, currency VARCHAR(3) NOT NULL, total_cents INT NOT NULL, submitted_at DATETIME DEFAULT NULL COMMENT \'(DC2Type:datetime_immutable)\', rejected_at DATETIME DEFAULT NULL COMMENT \'(DC2Type:datetime_immutable)\', rejection_comment LONGTEXT DEFAULT NULL, locked_at DATETIME DEFAULT NULL COMMENT \'(DC2Type:datetime_immutable)\', created_by VARCHAR(255) DEFAULT NULL, updated_by VARCHAR(255) DEFAULT NULL, created_at DATETIME NOT NULL COMMENT \'(DC2Type:datetime_immutable)\', updated_at DATETIME DEFAULT NULL COMMENT \'(DC2Type:datetime_immutable)\', UNIQUE INDEX UNIQ_58F5D73FD17F50A6 (uuid), UNIQUE INDEX UNIQ_58F5D73FE743E1FB (disposition_id), UNIQUE INDEX UNIQ_58F5D73FF08B5E1A (generated_upload_id), INDEX IDX_58F5D73F8DB32B4A (submitted_by_id), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB');
|
||||
$this->addSql('CREATE TABLE disposition_invoice_item (id INT AUTO_INCREMENT NOT NULL, invoice_id INT NOT NULL, fee_id INT DEFAULT NULL, position INT NOT NULL, source_type VARCHAR(16) NOT NULL, label_snapshot VARCHAR(255) NOT NULL, unit_price_cents_snapshot INT NOT NULL, quantity INT NOT NULL, line_total_cents INT NOT NULL, created_by VARCHAR(255) DEFAULT NULL, updated_by VARCHAR(255) DEFAULT NULL, created_at DATETIME NOT NULL COMMENT \'(DC2Type:datetime_immutable)\', updated_at DATETIME DEFAULT NULL COMMENT \'(DC2Type:datetime_immutable)\', INDEX IDX_77182CD42989F1FD (invoice_id), INDEX IDX_77182CD4AB45AECA (fee_id), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB');
|
||||
$this->addSql('ALTER TABLE disposition_invoice ADD CONSTRAINT FK_58F5D73FE743E1FB FOREIGN KEY (disposition_id) REFERENCES disposition (id) ON DELETE CASCADE');
|
||||
$this->addSql('ALTER TABLE disposition_invoice ADD CONSTRAINT FK_58F5D73F8DB32B4A FOREIGN KEY (submitted_by_id) REFERENCES user (id)');
|
||||
$this->addSql('ALTER TABLE disposition_invoice ADD CONSTRAINT FK_58F5D73FF08B5E1A FOREIGN KEY (generated_upload_id) REFERENCES upload (id) ON DELETE SET NULL');
|
||||
$this->addSql('ALTER TABLE disposition_invoice_item ADD CONSTRAINT FK_77182CD42989F1FD FOREIGN KEY (invoice_id) REFERENCES disposition_invoice (id) ON DELETE CASCADE');
|
||||
$this->addSql('ALTER TABLE disposition_invoice_item ADD CONSTRAINT FK_77182CD4AB45AECA FOREIGN KEY (fee_id) REFERENCES fee (id)');
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$this->addSql('ALTER TABLE disposition_invoice_item DROP FOREIGN KEY FK_77182CD42989F1FD');
|
||||
$this->addSql('ALTER TABLE disposition_invoice_item DROP FOREIGN KEY FK_77182CD4AB45AECA');
|
||||
$this->addSql('ALTER TABLE disposition_invoice DROP FOREIGN KEY FK_58F5D73FE743E1FB');
|
||||
$this->addSql('ALTER TABLE disposition_invoice DROP FOREIGN KEY FK_58F5D73F8DB32B4A');
|
||||
$this->addSql('ALTER TABLE disposition_invoice DROP FOREIGN KEY FK_58F5D73FF08B5E1A');
|
||||
$this->addSql('DROP TABLE disposition_invoice_item');
|
||||
$this->addSql('DROP TABLE disposition_invoice');
|
||||
}
|
||||
}
|
||||
@@ -73,6 +73,7 @@ class DetailController extends AbstractController
|
||||
'disposition' => $disposition,
|
||||
'form' => $form->createView(),
|
||||
'returnUrl' => $this->getReturnUrl($request, 'app_teamer_index'),
|
||||
'invoice_editor_enabled' => $this->featureManager->isActive('invoice_editor'),
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controller\Teamer\Disposition;
|
||||
|
||||
use App\Entity\Disposition;
|
||||
use App\Entity\DispositionInvoice;
|
||||
use App\Entity\Fee;
|
||||
use App\Entity\User;
|
||||
use App\Form\Invoice\TeamerInvoiceEditorType;
|
||||
use App\Htmx\HxRedirectResponse;
|
||||
use App\Model\Invoice\TeamerInvoiceEditorDto;
|
||||
use App\Model\Invoice\TeamerInvoiceItemDto;
|
||||
use App\Service\Invoice\InvoiceSubmissionServiceInterface;
|
||||
use Flagception\Manager\FeatureManagerInterface;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\DependencyInjection\Attribute\Target;
|
||||
use Symfony\Component\Form\FormError;
|
||||
use Symfony\Component\Form\FormInterface;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
use Symfony\Component\Workflow\WorkflowInterface;
|
||||
|
||||
class InvoiceEditorController extends AbstractController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly InvoiceSubmissionServiceInterface $submissionService,
|
||||
private readonly FeatureManagerInterface $featureManager,
|
||||
#[Target('disposition')]
|
||||
private readonly WorkflowInterface $workflow,
|
||||
) {
|
||||
}
|
||||
|
||||
#[Route('/teamer/disposition/invoice-editor/{uuid}', name: 'app_teamer_disposition_invoice_editor', methods: ['GET'])]
|
||||
#[IsGranted('ROLE_TEAMER')]
|
||||
#[IsGranted('VIEW', subject: 'disposition')]
|
||||
public function index(Disposition $disposition): Response
|
||||
{
|
||||
$this->assertFeatureEnabled();
|
||||
$this->assertInvoiceEditable($disposition);
|
||||
|
||||
$dto = $this->submissionService->createEditorDto($disposition);
|
||||
$form = $this->createEditorForm($disposition, $dto, ['Default']);
|
||||
|
||||
return $this->renderEditorForm($disposition, $form, $dto);
|
||||
}
|
||||
|
||||
#[Route('/teamer/disposition/invoice/{uuid}/refresh', name: 'app_teamer_disposition_invoice_refresh', methods: ['POST'])]
|
||||
#[IsGranted('ROLE_TEAMER')]
|
||||
#[IsGranted('VIEW', subject: 'disposition')]
|
||||
public function refresh(Disposition $disposition, Request $request): Response
|
||||
{
|
||||
$this->assertFeatureEnabled();
|
||||
$this->assertInvoiceEditable($disposition);
|
||||
|
||||
$dto = $this->submissionService->createEditorDto($disposition);
|
||||
$form = $this->createEditorForm($disposition, $dto, false);
|
||||
$form->handleRequest($request);
|
||||
|
||||
$normalized = $this->submissionService->refreshDraft($disposition, $dto);
|
||||
$normalizedDto = $this->createDtoFromInvoice($normalized);
|
||||
$normalizedForm = $this->createEditorForm($disposition, $normalizedDto, false);
|
||||
|
||||
return $this->renderEditorForm($disposition, $normalizedForm, $normalizedDto);
|
||||
}
|
||||
|
||||
#[Route('/teamer/disposition/invoice/{uuid}/save', name: 'app_teamer_disposition_invoice_save', methods: ['POST'])]
|
||||
#[IsGranted('ROLE_TEAMER')]
|
||||
#[IsGranted('VIEW', subject: 'disposition')]
|
||||
public function save(Disposition $disposition, Request $request): Response
|
||||
{
|
||||
$this->assertFeatureEnabled();
|
||||
|
||||
if (false === $this->isGranted('INVOICE', $disposition)) {
|
||||
throw $this->createAccessDeniedException();
|
||||
}
|
||||
$this->assertInvoiceEditable($disposition);
|
||||
|
||||
$dto = $this->submissionService->createEditorDto($disposition);
|
||||
$form = $this->createEditorForm($disposition, $dto, ['Default']);
|
||||
$form->handleRequest($request);
|
||||
|
||||
if ($form->isSubmitted() && $form->isValid()) {
|
||||
/** @var User $user */
|
||||
$user = $this->getUser();
|
||||
|
||||
$invoice = $this->submissionService->saveDraft($disposition, $dto, $user);
|
||||
$updatedDto = $this->createDtoFromInvoice($invoice);
|
||||
$updatedForm = $this->createEditorForm($disposition, $updatedDto, ['Default']);
|
||||
|
||||
$this->addFlash('success', 'Dein Entwurf wurde gespeichert');
|
||||
|
||||
return $this->renderEditorForm($disposition, $updatedForm, $updatedDto);
|
||||
}
|
||||
|
||||
return $this->renderEditorForm($disposition, $form, $dto);
|
||||
}
|
||||
|
||||
#[Route('/teamer/disposition/invoice/{uuid}/submit', name: 'app_teamer_disposition_invoice_submit', methods: ['POST'])]
|
||||
#[IsGranted('ROLE_TEAMER')]
|
||||
#[IsGranted('VIEW', subject: 'disposition')]
|
||||
public function submit(Disposition $disposition, Request $request): Response
|
||||
{
|
||||
$this->assertFeatureEnabled();
|
||||
|
||||
if (false === $this->isGranted('INVOICE', $disposition)) {
|
||||
throw $this->createAccessDeniedException();
|
||||
}
|
||||
$this->assertInvoiceEditable($disposition);
|
||||
|
||||
$dto = $this->submissionService->createEditorDto($disposition);
|
||||
$form = $this->createEditorForm($disposition, $dto, ['Default', 'submit']);
|
||||
$form->handleRequest($request);
|
||||
|
||||
if ($form->isSubmitted() && $form->isValid()) {
|
||||
/** @var User $user */
|
||||
$user = $this->getUser();
|
||||
|
||||
try {
|
||||
$this->submissionService->submitFinal($disposition, $dto, $user);
|
||||
} catch (\RuntimeException) {
|
||||
$form->addError(new FormError('Die Honorarnote konnte nicht eingereicht werden. Bitte versuche es erneut.'));
|
||||
|
||||
return $this->renderEditorForm($disposition, $form, $dto);
|
||||
}
|
||||
|
||||
$this->addFlash('success', 'Deine Honorarnote wurde eingereicht');
|
||||
|
||||
return new HxRedirectResponse($this->generateUrl('app_teamer_disposition_detail', [
|
||||
'uuid' => $disposition->getUuid(),
|
||||
]));
|
||||
}
|
||||
|
||||
return $this->renderEditorForm($disposition, $form, $dto);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string>|false $validationGroups
|
||||
*/
|
||||
private function createEditorForm(Disposition $disposition, TeamerInvoiceEditorDto $dto, array|false $validationGroups): FormInterface
|
||||
{
|
||||
return $this->createForm(TeamerInvoiceEditorType::class, $dto, [
|
||||
'validation_groups' => $validationGroups,
|
||||
'fee_choices' => $this->getFeeChoices($disposition),
|
||||
]);
|
||||
}
|
||||
|
||||
private function renderEditorForm(
|
||||
Disposition $disposition,
|
||||
FormInterface $form,
|
||||
TeamerInvoiceEditorDto $dto,
|
||||
): Response {
|
||||
return $this->render('teamer/disposition/_invoice_editor_form.html.twig', [
|
||||
'disposition' => $disposition,
|
||||
'form' => $form->createView(),
|
||||
'dto' => $dto,
|
||||
'allowSubmit' => true,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, int>
|
||||
*/
|
||||
private function getFeeChoices(Disposition $disposition): array
|
||||
{
|
||||
$choices = [];
|
||||
|
||||
foreach ($disposition->getAssignment()->getFees() as $fee) {
|
||||
if (!$fee instanceof Fee) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$choices[sprintf('%s (%s)', $fee->getName(), number_format($fee->getValue() / 100, 2, ',', '.').' EUR')] = $fee->getId();
|
||||
}
|
||||
|
||||
return $choices;
|
||||
}
|
||||
|
||||
private function createDtoFromInvoice(DispositionInvoice $invoice): TeamerInvoiceEditorDto
|
||||
{
|
||||
$dto = new TeamerInvoiceEditorDto();
|
||||
|
||||
foreach ($invoice->getItems() as $invoiceItem) {
|
||||
$item = new TeamerInvoiceItemDto();
|
||||
$item
|
||||
->setFeeId($invoiceItem->getFee()?->getId())
|
||||
->setLabel($invoiceItem->getLabelSnapshot())
|
||||
->setUnitPriceCents($invoiceItem->getUnitPriceCentsSnapshot())
|
||||
->setQuantity($invoiceItem->getQuantity())
|
||||
;
|
||||
$dto->addItem($item);
|
||||
}
|
||||
|
||||
if (0 === count($dto->getItems())) {
|
||||
$dto->addItem(new TeamerInvoiceItemDto());
|
||||
}
|
||||
|
||||
return $dto;
|
||||
}
|
||||
|
||||
private function assertFeatureEnabled(): void
|
||||
{
|
||||
if (false === $this->featureManager->isActive('invoice_editor')) {
|
||||
throw $this->createNotFoundException();
|
||||
}
|
||||
}
|
||||
|
||||
private function assertInvoiceEditable(Disposition $disposition): void
|
||||
{
|
||||
if (false === $this->workflow->can($disposition, 'upload_invoice')) {
|
||||
throw $this->createAccessDeniedException();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -72,6 +72,9 @@ class Disposition implements BlameableEntityInterface, TimestampableEntityInterf
|
||||
#[ORM\Column(type: Types::BOOLEAN)]
|
||||
private bool $syncedWithBusProNet = false;
|
||||
|
||||
#[ORM\OneToOne(mappedBy: 'disposition', targetEntity: DispositionInvoice::class, cascade: ['persist', 'remove'])]
|
||||
private ?DispositionInvoice $invoiceDraft = null;
|
||||
|
||||
public function __construct(Application $application)
|
||||
{
|
||||
$this->uuid = Uuid::v4();
|
||||
@@ -292,4 +295,20 @@ class Disposition implements BlameableEntityInterface, TimestampableEntityInterf
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getInvoiceDraft(): ?DispositionInvoice
|
||||
{
|
||||
return $this->invoiceDraft;
|
||||
}
|
||||
|
||||
public function setInvoiceDraft(?DispositionInvoice $invoiceDraft): static
|
||||
{
|
||||
$this->invoiceDraft = $invoiceDraft;
|
||||
|
||||
if (null !== $invoiceDraft && $invoiceDraft->getDisposition() !== $this) {
|
||||
$invoiceDraft->setDisposition($this);
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Entity;
|
||||
|
||||
use App\Entity\Traits\BlameableEntity;
|
||||
use App\Entity\Traits\TimestampableEntity;
|
||||
use App\Repository\DispositionInvoiceRepository;
|
||||
use Doctrine\Common\Collections\ArrayCollection;
|
||||
use Doctrine\Common\Collections\Collection;
|
||||
use Doctrine\DBAL\Types\Types;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Symfony\Component\Uid\Uuid;
|
||||
|
||||
#[ORM\Entity(repositoryClass: DispositionInvoiceRepository::class)]
|
||||
class DispositionInvoice implements BlameableEntityInterface, TimestampableEntityInterface
|
||||
{
|
||||
use BlameableEntity;
|
||||
use TimestampableEntity;
|
||||
|
||||
public const STATUS_DRAFT = 'draft';
|
||||
public const STATUS_SUBMITTED = 'submitted';
|
||||
public const STATUS_REJECTED = 'rejected';
|
||||
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column]
|
||||
private ?int $id = null;
|
||||
|
||||
#[ORM\Column(type: 'string', length: 36, unique: true)]
|
||||
private string $uuid;
|
||||
|
||||
#[ORM\OneToOne(inversedBy: 'invoiceDraft')]
|
||||
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
|
||||
private ?Disposition $disposition = null;
|
||||
|
||||
#[ORM\Column(length: 32)]
|
||||
private string $status = self::STATUS_DRAFT;
|
||||
|
||||
#[ORM\Column(length: 3)]
|
||||
private string $currency = 'EUR';
|
||||
|
||||
#[ORM\Column]
|
||||
private int $totalCents = 0;
|
||||
|
||||
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, nullable: true)]
|
||||
private ?\DateTimeImmutable $submittedAt = null;
|
||||
|
||||
#[ORM\ManyToOne]
|
||||
private ?User $submittedBy = null;
|
||||
|
||||
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, nullable: true)]
|
||||
private ?\DateTimeImmutable $rejectedAt = null;
|
||||
|
||||
#[ORM\Column(type: Types::TEXT, nullable: true)]
|
||||
private ?string $rejectionComment = null;
|
||||
|
||||
#[ORM\OneToOne]
|
||||
#[ORM\JoinColumn(onDelete: 'SET NULL')]
|
||||
private ?Upload $generatedUpload = null;
|
||||
|
||||
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, nullable: true)]
|
||||
private ?\DateTimeImmutable $lockedAt = null;
|
||||
|
||||
/**
|
||||
* @var Collection<int, DispositionInvoiceItem>
|
||||
*/
|
||||
#[ORM\OneToMany(mappedBy: 'invoice', targetEntity: DispositionInvoiceItem::class, cascade: ['persist', 'remove'], orphanRemoval: true)]
|
||||
#[ORM\OrderBy(['position' => 'ASC'])]
|
||||
private Collection $items;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->uuid = (string) Uuid::v4();
|
||||
$this->items = new ArrayCollection();
|
||||
}
|
||||
|
||||
public function getId(): ?int
|
||||
{
|
||||
return $this->id;
|
||||
}
|
||||
|
||||
public function getUuid(): string
|
||||
{
|
||||
return $this->uuid;
|
||||
}
|
||||
|
||||
public function getDisposition(): ?Disposition
|
||||
{
|
||||
return $this->disposition;
|
||||
}
|
||||
|
||||
public function setDisposition(?Disposition $disposition): static
|
||||
{
|
||||
$this->disposition = $disposition;
|
||||
|
||||
if (null !== $disposition && $disposition->getInvoiceDraft() !== $this) {
|
||||
$disposition->setInvoiceDraft($this);
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getStatus(): string
|
||||
{
|
||||
return $this->status;
|
||||
}
|
||||
|
||||
public function setStatus(string $status): static
|
||||
{
|
||||
$this->status = $status;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getCurrency(): string
|
||||
{
|
||||
return $this->currency;
|
||||
}
|
||||
|
||||
public function setCurrency(string $currency): static
|
||||
{
|
||||
$this->currency = $currency;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getTotalCents(): int
|
||||
{
|
||||
return $this->totalCents;
|
||||
}
|
||||
|
||||
public function setTotalCents(int $totalCents): static
|
||||
{
|
||||
$this->totalCents = $totalCents;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getSubmittedAt(): ?\DateTimeImmutable
|
||||
{
|
||||
return $this->submittedAt;
|
||||
}
|
||||
|
||||
public function setSubmittedAt(?\DateTimeImmutable $submittedAt): static
|
||||
{
|
||||
$this->submittedAt = $submittedAt;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getSubmittedBy(): ?User
|
||||
{
|
||||
return $this->submittedBy;
|
||||
}
|
||||
|
||||
public function setSubmittedBy(?User $submittedBy): static
|
||||
{
|
||||
$this->submittedBy = $submittedBy;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getRejectedAt(): ?\DateTimeImmutable
|
||||
{
|
||||
return $this->rejectedAt;
|
||||
}
|
||||
|
||||
public function setRejectedAt(?\DateTimeImmutable $rejectedAt): static
|
||||
{
|
||||
$this->rejectedAt = $rejectedAt;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getRejectionComment(): ?string
|
||||
{
|
||||
return $this->rejectionComment;
|
||||
}
|
||||
|
||||
public function setRejectionComment(?string $rejectionComment): static
|
||||
{
|
||||
$this->rejectionComment = $rejectionComment;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getGeneratedUpload(): ?Upload
|
||||
{
|
||||
return $this->generatedUpload;
|
||||
}
|
||||
|
||||
public function setGeneratedUpload(?Upload $generatedUpload): static
|
||||
{
|
||||
$this->generatedUpload = $generatedUpload;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getLockedAt(): ?\DateTimeImmutable
|
||||
{
|
||||
return $this->lockedAt;
|
||||
}
|
||||
|
||||
public function setLockedAt(?\DateTimeImmutable $lockedAt): static
|
||||
{
|
||||
$this->lockedAt = $lockedAt;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function isLocked(): bool
|
||||
{
|
||||
return null !== $this->lockedAt;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Collection<int, DispositionInvoiceItem>
|
||||
*/
|
||||
public function getItems(): Collection
|
||||
{
|
||||
return $this->items;
|
||||
}
|
||||
|
||||
public function addItem(DispositionInvoiceItem $item): static
|
||||
{
|
||||
if (!$this->items->contains($item)) {
|
||||
$this->items->add($item);
|
||||
$item->setInvoice($this);
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function removeItem(DispositionInvoiceItem $item): static
|
||||
{
|
||||
if ($this->items->removeElement($item)) {
|
||||
if ($item->getInvoice() === $this) {
|
||||
$item->setInvoice(null);
|
||||
}
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function clearItems(): static
|
||||
{
|
||||
foreach ($this->items as $item) {
|
||||
$this->removeItem($item);
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Entity;
|
||||
|
||||
use App\Entity\Traits\BlameableEntity;
|
||||
use App\Entity\Traits\TimestampableEntity;
|
||||
use App\Repository\DispositionInvoiceItemRepository;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
|
||||
#[ORM\Entity(repositoryClass: DispositionInvoiceItemRepository::class)]
|
||||
class DispositionInvoiceItem implements BlameableEntityInterface, TimestampableEntityInterface
|
||||
{
|
||||
use BlameableEntity;
|
||||
use TimestampableEntity;
|
||||
|
||||
public const SOURCE_ASSIGNMENT_FEE = 'assignment_fee';
|
||||
public const SOURCE_CUSTOM = 'custom';
|
||||
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column]
|
||||
private ?int $id = null;
|
||||
|
||||
#[ORM\ManyToOne(inversedBy: 'items')]
|
||||
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
|
||||
private ?DispositionInvoice $invoice = null;
|
||||
|
||||
#[ORM\Column]
|
||||
private int $position = 0;
|
||||
|
||||
#[ORM\ManyToOne]
|
||||
private ?Fee $fee = null;
|
||||
|
||||
#[ORM\Column(length: 16)]
|
||||
private string $sourceType = self::SOURCE_CUSTOM;
|
||||
|
||||
#[ORM\Column(length: 255)]
|
||||
private string $labelSnapshot;
|
||||
|
||||
#[ORM\Column]
|
||||
private int $unitPriceCentsSnapshot = 0;
|
||||
|
||||
#[ORM\Column]
|
||||
private int $quantity = 1;
|
||||
|
||||
#[ORM\Column]
|
||||
private int $lineTotalCents = 0;
|
||||
|
||||
public function getId(): ?int
|
||||
{
|
||||
return $this->id;
|
||||
}
|
||||
|
||||
public function getInvoice(): ?DispositionInvoice
|
||||
{
|
||||
return $this->invoice;
|
||||
}
|
||||
|
||||
public function setInvoice(?DispositionInvoice $invoice): static
|
||||
{
|
||||
$this->invoice = $invoice;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getPosition(): int
|
||||
{
|
||||
return $this->position;
|
||||
}
|
||||
|
||||
public function setPosition(int $position): static
|
||||
{
|
||||
$this->position = $position;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getFee(): ?Fee
|
||||
{
|
||||
return $this->fee;
|
||||
}
|
||||
|
||||
public function setFee(?Fee $fee): static
|
||||
{
|
||||
$this->fee = $fee;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getSourceType(): string
|
||||
{
|
||||
return $this->sourceType;
|
||||
}
|
||||
|
||||
public function setSourceType(string $sourceType): static
|
||||
{
|
||||
$this->sourceType = $sourceType;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getLabelSnapshot(): string
|
||||
{
|
||||
return $this->labelSnapshot;
|
||||
}
|
||||
|
||||
public function setLabelSnapshot(string $labelSnapshot): static
|
||||
{
|
||||
$this->labelSnapshot = $labelSnapshot;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getUnitPriceCentsSnapshot(): int
|
||||
{
|
||||
return $this->unitPriceCentsSnapshot;
|
||||
}
|
||||
|
||||
public function setUnitPriceCentsSnapshot(int $unitPriceCentsSnapshot): static
|
||||
{
|
||||
$this->unitPriceCentsSnapshot = $unitPriceCentsSnapshot;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getQuantity(): int
|
||||
{
|
||||
return $this->quantity;
|
||||
}
|
||||
|
||||
public function setQuantity(int $quantity): static
|
||||
{
|
||||
$this->quantity = $quantity;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getLineTotalCents(): int
|
||||
{
|
||||
return $this->lineTotalCents;
|
||||
}
|
||||
|
||||
public function setLineTotalCents(int $lineTotalCents): static
|
||||
{
|
||||
$this->lineTotalCents = $lineTotalCents;
|
||||
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\EventListener;
|
||||
|
||||
use App\Entity\DispositionInvoice;
|
||||
use App\Entity\Upload;
|
||||
use App\Event\DocumentRejectedEvent;
|
||||
use App\Repository\DispositionInvoiceRepository;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
|
||||
|
||||
#[AsEventListener(event: DocumentRejectedEvent::NAME, method: 'onDocumentRejected')]
|
||||
class InvoiceDraftRejectedListener
|
||||
{
|
||||
public function __construct(
|
||||
private readonly DispositionInvoiceRepository $invoiceRepository,
|
||||
private readonly EntityManagerInterface $entityManager,
|
||||
) {
|
||||
}
|
||||
|
||||
public function onDocumentRejected(DocumentRejectedEvent $event): void
|
||||
{
|
||||
$document = $event->getDocument();
|
||||
|
||||
if (Upload::TYPE_INVOICE !== $document->getType()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$disposition = $document->getDisposition();
|
||||
|
||||
if (null === $disposition) {
|
||||
return;
|
||||
}
|
||||
|
||||
$invoice = $this->invoiceRepository->findOneByDisposition($disposition);
|
||||
|
||||
if (null === $invoice) {
|
||||
return;
|
||||
}
|
||||
|
||||
$invoice
|
||||
->setStatus(DispositionInvoice::STATUS_REJECTED)
|
||||
->setRejectedAt(new \DateTimeImmutable())
|
||||
->setRejectionComment($event->getComment())
|
||||
->setLockedAt(null)
|
||||
;
|
||||
|
||||
$this->entityManager->flush();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Form\Invoice;
|
||||
|
||||
use App\Model\Invoice\TeamerInvoiceEditorDto;
|
||||
use Symfony\Component\Form\AbstractType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\CollectionType;
|
||||
use Symfony\Component\Form\FormBuilderInterface;
|
||||
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||
use Symfony\Component\Validator\Constraints\Count;
|
||||
use Symfony\Component\Validator\Constraints\IsTrue;
|
||||
|
||||
class TeamerInvoiceEditorType extends AbstractType
|
||||
{
|
||||
public function buildForm(FormBuilderInterface $builder, array $options): void
|
||||
{
|
||||
$builder
|
||||
->add('items', CollectionType::class, [
|
||||
'label' => false,
|
||||
'entry_type' => TeamerInvoiceItemType::class,
|
||||
'entry_options' => [
|
||||
'fee_choices' => $options['fee_choices'],
|
||||
],
|
||||
'allow_add' => true,
|
||||
'allow_delete' => true,
|
||||
'by_reference' => false,
|
||||
'error_bubbling' => false,
|
||||
'constraints' => [
|
||||
new Count([
|
||||
'min' => 1,
|
||||
'minMessage' => 'Bitte füge mindestens eine Position hinzu',
|
||||
]),
|
||||
],
|
||||
])
|
||||
->add('confirmFinal', CheckboxType::class, [
|
||||
'label' => 'Ich bestätige die Richtigkeit der Angaben',
|
||||
'required' => false,
|
||||
'constraints' => [
|
||||
new IsTrue([
|
||||
'message' => 'Bitte bestätige die Angaben',
|
||||
'groups' => ['submit'],
|
||||
]),
|
||||
],
|
||||
])
|
||||
;
|
||||
}
|
||||
|
||||
public function configureOptions(OptionsResolver $resolver): void
|
||||
{
|
||||
$resolver
|
||||
->setDefaults([
|
||||
'data_class' => TeamerInvoiceEditorDto::class,
|
||||
'fee_choices' => [],
|
||||
'validation_groups' => ['Default'],
|
||||
'anti_xss' => false,
|
||||
])
|
||||
->setAllowedTypes('fee_choices', 'array')
|
||||
;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Form\Invoice;
|
||||
|
||||
use App\Model\Invoice\TeamerInvoiceItemDto;
|
||||
use Symfony\Component\Form\AbstractType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\IntegerType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\MoneyType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\TextType;
|
||||
use Symfony\Component\Form\FormBuilderInterface;
|
||||
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||
use Symfony\Component\Validator\Constraints\GreaterThanOrEqual;
|
||||
use Symfony\Component\Validator\Constraints\NotBlank;
|
||||
|
||||
class TeamerInvoiceItemType extends AbstractType
|
||||
{
|
||||
public function buildForm(FormBuilderInterface $builder, array $options): void
|
||||
{
|
||||
$builder
|
||||
->add('feeId', ChoiceType::class, [
|
||||
'label' => 'Leistung',
|
||||
'placeholder' => 'Eigene Position',
|
||||
'required' => false,
|
||||
'choices' => $options['fee_choices'],
|
||||
])
|
||||
->add('label', TextType::class, [
|
||||
'label' => 'Bezeichnung',
|
||||
'constraints' => [
|
||||
new NotBlank([
|
||||
'message' => 'Bitte gib eine Bezeichnung an',
|
||||
]),
|
||||
],
|
||||
])
|
||||
->add('unitPriceCents', MoneyType::class, [
|
||||
'label' => 'Betrag',
|
||||
'divisor' => 100,
|
||||
'currency' => 'EUR',
|
||||
'constraints' => [
|
||||
new GreaterThanOrEqual([
|
||||
'value' => 0,
|
||||
'message' => 'Der Betrag muss mindestens 0 sein',
|
||||
]),
|
||||
],
|
||||
])
|
||||
->add('quantity', IntegerType::class, [
|
||||
'label' => 'Anzahl',
|
||||
'constraints' => [
|
||||
new GreaterThanOrEqual([
|
||||
'value' => 1,
|
||||
'message' => 'Die Anzahl muss mindestens 1 sein',
|
||||
]),
|
||||
],
|
||||
])
|
||||
;
|
||||
}
|
||||
|
||||
public function configureOptions(OptionsResolver $resolver): void
|
||||
{
|
||||
$resolver
|
||||
->setDefaults([
|
||||
'data_class' => TeamerInvoiceItemDto::class,
|
||||
'fee_choices' => [],
|
||||
])
|
||||
->setAllowedTypes('fee_choices', 'array')
|
||||
;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Model\Invoice;
|
||||
|
||||
class TeamerInvoiceEditorDto
|
||||
{
|
||||
/**
|
||||
* @var list<TeamerInvoiceItemDto>
|
||||
*/
|
||||
private array $items = [];
|
||||
|
||||
private bool $confirmFinal = false;
|
||||
|
||||
/**
|
||||
* @return list<TeamerInvoiceItemDto>
|
||||
*/
|
||||
public function getItems(): array
|
||||
{
|
||||
return $this->items;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<TeamerInvoiceItemDto> $items
|
||||
*/
|
||||
public function setItems(array $items): static
|
||||
{
|
||||
$this->items = $items;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function addItem(TeamerInvoiceItemDto $item): static
|
||||
{
|
||||
$this->items[] = $item;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function isConfirmFinal(): bool
|
||||
{
|
||||
return $this->confirmFinal;
|
||||
}
|
||||
|
||||
public function setConfirmFinal(bool $confirmFinal): static
|
||||
{
|
||||
$this->confirmFinal = $confirmFinal;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getTotalCents(): int
|
||||
{
|
||||
$total = 0;
|
||||
foreach ($this->items as $item) {
|
||||
$total += $item->getLineTotalCents();
|
||||
}
|
||||
|
||||
return $total;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Model\Invoice;
|
||||
|
||||
class TeamerInvoiceItemDto
|
||||
{
|
||||
private ?int $feeId = null;
|
||||
private string $label = '';
|
||||
private int $unitPriceCents = 0;
|
||||
private int $quantity = 1;
|
||||
|
||||
public function getFeeId(): ?int
|
||||
{
|
||||
return $this->feeId;
|
||||
}
|
||||
|
||||
public function setFeeId(?int $feeId): static
|
||||
{
|
||||
$this->feeId = $feeId;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getLabel(): string
|
||||
{
|
||||
return $this->label;
|
||||
}
|
||||
|
||||
public function setLabel(?string $label): static
|
||||
{
|
||||
$this->label = (string) $label;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getUnitPriceCents(): int
|
||||
{
|
||||
return $this->unitPriceCents;
|
||||
}
|
||||
|
||||
public function setUnitPriceCents(int|float|string|null $unitPriceCents): static
|
||||
{
|
||||
$this->unitPriceCents = (int) $unitPriceCents;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getQuantity(): int
|
||||
{
|
||||
return $this->quantity;
|
||||
}
|
||||
|
||||
public function setQuantity(int|float|string|null $quantity): static
|
||||
{
|
||||
$this->quantity = (int) $quantity;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getLineTotalCents(): int
|
||||
{
|
||||
return $this->unitPriceCents * $this->quantity;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Repository;
|
||||
|
||||
use App\Entity\DispositionInvoiceItem;
|
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
|
||||
/**
|
||||
* @extends ServiceEntityRepository<DispositionInvoiceItem>
|
||||
*/
|
||||
class DispositionInvoiceItemRepository extends ServiceEntityRepository
|
||||
{
|
||||
public function __construct(ManagerRegistry $registry)
|
||||
{
|
||||
parent::__construct($registry, DispositionInvoiceItem::class);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Repository;
|
||||
|
||||
use App\Entity\Disposition;
|
||||
use App\Entity\DispositionInvoice;
|
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
|
||||
/**
|
||||
* @extends ServiceEntityRepository<DispositionInvoice>
|
||||
*/
|
||||
class DispositionInvoiceRepository extends ServiceEntityRepository
|
||||
{
|
||||
public function __construct(ManagerRegistry $registry)
|
||||
{
|
||||
parent::__construct($registry, DispositionInvoice::class);
|
||||
}
|
||||
|
||||
public function findOneByDisposition(Disposition $disposition): ?DispositionInvoice
|
||||
{
|
||||
return $this->findOneBy(['disposition' => $disposition]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Service\Invoice;
|
||||
|
||||
use App\Entity\DispositionInvoice;
|
||||
use Dompdf\Dompdf;
|
||||
use Dompdf\Options;
|
||||
use Twig\Environment;
|
||||
|
||||
class InvoicePdfGenerator implements InvoicePdfGeneratorInterface
|
||||
{
|
||||
public function __construct(private readonly Environment $twig)
|
||||
{
|
||||
}
|
||||
|
||||
public function generatePdfBinary(DispositionInvoice $invoice): string
|
||||
{
|
||||
$options = new Options();
|
||||
$options->setIsRemoteEnabled(false);
|
||||
$options->setIsHtml5ParserEnabled(true);
|
||||
|
||||
$dompdf = new Dompdf($options);
|
||||
|
||||
$html = $this->twig->render('pdf/invoice_generated.html.twig', [
|
||||
'invoice' => $invoice,
|
||||
'disposition' => $invoice->getDisposition(),
|
||||
'assignment' => $invoice->getDisposition()?->getAssignment(),
|
||||
'teamer' => $invoice->getDisposition()?->getTeamer(),
|
||||
]);
|
||||
|
||||
$dompdf->loadHtml($html, 'UTF-8');
|
||||
$dompdf->setPaper('A4', 'portrait');
|
||||
$dompdf->render();
|
||||
|
||||
return $dompdf->output();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Service\Invoice;
|
||||
|
||||
use App\Entity\DispositionInvoice;
|
||||
|
||||
interface InvoicePdfGeneratorInterface
|
||||
{
|
||||
public function generatePdfBinary(DispositionInvoice $invoice): string;
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Service\Invoice;
|
||||
|
||||
use App\Entity\Disposition;
|
||||
use App\Entity\DispositionInvoice;
|
||||
use App\Entity\DispositionInvoiceItem;
|
||||
use App\Entity\Fee;
|
||||
use App\Entity\Upload;
|
||||
use App\Entity\User;
|
||||
use App\Event\DocumentUploadedEvent;
|
||||
use App\Model\Invoice\TeamerInvoiceEditorDto;
|
||||
use App\Model\Invoice\TeamerInvoiceItemDto;
|
||||
use App\Repository\DispositionInvoiceRepository;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Component\DependencyInjection\Attribute\Target;
|
||||
use Symfony\Component\Workflow\WorkflowInterface;
|
||||
use Symfony\Contracts\EventDispatcher\EventDispatcherInterface;
|
||||
|
||||
class InvoiceSubmissionService implements InvoiceSubmissionServiceInterface
|
||||
{
|
||||
public function __construct(
|
||||
private readonly EntityManagerInterface $entityManager,
|
||||
private readonly DispositionInvoiceRepository $invoiceRepository,
|
||||
private readonly InvoicePdfGeneratorInterface $pdfGenerator,
|
||||
private readonly InvoiceUploadWriterInterface $uploadWriter,
|
||||
private readonly EventDispatcherInterface $eventDispatcher,
|
||||
#[Target('disposition')]
|
||||
private readonly WorkflowInterface $workflow,
|
||||
) {
|
||||
}
|
||||
|
||||
public function createEditorDto(Disposition $disposition): TeamerInvoiceEditorDto
|
||||
{
|
||||
$dto = new TeamerInvoiceEditorDto();
|
||||
$invoice = $this->invoiceRepository->findOneByDisposition($disposition);
|
||||
|
||||
if (null !== $invoice && 0 < $invoice->getItems()->count()) {
|
||||
foreach ($invoice->getItems() as $itemEntity) {
|
||||
$item = new TeamerInvoiceItemDto();
|
||||
$item
|
||||
->setFeeId($itemEntity->getFee()?->getId())
|
||||
->setLabel($itemEntity->getLabelSnapshot())
|
||||
->setUnitPriceCents($itemEntity->getUnitPriceCentsSnapshot())
|
||||
->setQuantity($itemEntity->getQuantity())
|
||||
;
|
||||
$dto->addItem($item);
|
||||
}
|
||||
|
||||
return $dto;
|
||||
}
|
||||
|
||||
$firstFee = $disposition->getAssignment()?->getFees()->first();
|
||||
if ($firstFee instanceof Fee) {
|
||||
$item = new TeamerInvoiceItemDto();
|
||||
$item
|
||||
->setFeeId($firstFee->getId())
|
||||
->setLabel($firstFee->getName())
|
||||
->setUnitPriceCents($firstFee->getValue())
|
||||
->setQuantity(1)
|
||||
;
|
||||
$dto->addItem($item);
|
||||
|
||||
return $dto;
|
||||
}
|
||||
|
||||
$dto->addItem(new TeamerInvoiceItemDto());
|
||||
|
||||
return $dto;
|
||||
}
|
||||
|
||||
public function refreshDraft(Disposition $disposition, TeamerInvoiceEditorDto $dto): DispositionInvoice
|
||||
{
|
||||
$invoice = $this->getOrCreateInvoice($disposition);
|
||||
$this->applyItemsToInvoice($disposition, $invoice, $dto);
|
||||
|
||||
return $invoice;
|
||||
}
|
||||
|
||||
public function saveDraft(Disposition $disposition, TeamerInvoiceEditorDto $dto, User $actor): DispositionInvoice
|
||||
{
|
||||
$invoice = $this->getOrCreateInvoice($disposition);
|
||||
$this->applyItemsToInvoice($disposition, $invoice, $dto);
|
||||
|
||||
$invoice
|
||||
->setStatus(DispositionInvoice::STATUS_DRAFT)
|
||||
->setRejectedAt(null)
|
||||
->setRejectionComment(null)
|
||||
->setLockedAt(null)
|
||||
->setSubmittedAt(null)
|
||||
->setSubmittedBy(null)
|
||||
;
|
||||
|
||||
$this->entityManager->persist($invoice);
|
||||
$this->entityManager->flush();
|
||||
|
||||
return $invoice;
|
||||
}
|
||||
|
||||
public function submitFinal(Disposition $disposition, TeamerInvoiceEditorDto $dto, User $actor): DispositionInvoice
|
||||
{
|
||||
$invoice = $this->getOrCreateInvoice($disposition);
|
||||
$this->applyItemsToInvoice($disposition, $invoice, $dto);
|
||||
|
||||
$now = new \DateTimeImmutable();
|
||||
$invoice
|
||||
->setStatus(DispositionInvoice::STATUS_SUBMITTED)
|
||||
->setSubmittedAt($now)
|
||||
->setSubmittedBy($actor)
|
||||
->setLockedAt($now)
|
||||
->setRejectedAt(null)
|
||||
->setRejectionComment(null)
|
||||
;
|
||||
|
||||
$this->entityManager->persist($invoice);
|
||||
|
||||
$pdfBinary = $this->pdfGenerator->generatePdfBinary($invoice);
|
||||
$upload = $this->uploadWriter->writeFinalInvoiceUpload($disposition, $invoice, $pdfBinary, $actor);
|
||||
|
||||
if (false === $this->workflow->can($disposition, 'upload_invoice')) {
|
||||
throw new \RuntimeException('Invoice upload transition is currently blocked');
|
||||
}
|
||||
|
||||
$this->workflow->apply($disposition, 'upload_invoice');
|
||||
|
||||
$this->entityManager->flush();
|
||||
|
||||
$this->eventDispatcher->dispatch(new DocumentUploadedEvent($upload), DocumentUploadedEvent::NAME);
|
||||
|
||||
return $invoice;
|
||||
}
|
||||
|
||||
private function getOrCreateInvoice(Disposition $disposition): DispositionInvoice
|
||||
{
|
||||
$invoice = $this->invoiceRepository->findOneByDisposition($disposition);
|
||||
|
||||
if (null !== $invoice) {
|
||||
return $invoice;
|
||||
}
|
||||
|
||||
$invoice = new DispositionInvoice();
|
||||
$invoice->setDisposition($disposition);
|
||||
$disposition->setInvoiceDraft($invoice);
|
||||
|
||||
return $invoice;
|
||||
}
|
||||
|
||||
private function applyItemsToInvoice(Disposition $disposition, DispositionInvoice $invoice, TeamerInvoiceEditorDto $dto): void
|
||||
{
|
||||
$assignmentFees = [];
|
||||
foreach ($disposition->getAssignment()->getFees() as $fee) {
|
||||
$assignmentFees[$fee->getId()] = $fee;
|
||||
}
|
||||
|
||||
$invoice->clearItems();
|
||||
|
||||
$position = 1;
|
||||
$total = 0;
|
||||
|
||||
foreach ($dto->getItems() as $itemDto) {
|
||||
$quantity = max(1, $itemDto->getQuantity());
|
||||
$unitPrice = max(0, $itemDto->getUnitPriceCents());
|
||||
$label = trim($itemDto->getLabel());
|
||||
$fee = null;
|
||||
$sourceType = DispositionInvoiceItem::SOURCE_CUSTOM;
|
||||
|
||||
if (null !== $itemDto->getFeeId() && isset($assignmentFees[$itemDto->getFeeId()])) {
|
||||
/** @var Fee $fee */
|
||||
$fee = $assignmentFees[$itemDto->getFeeId()];
|
||||
$label = $fee->getName();
|
||||
$unitPrice = $fee->getValue();
|
||||
$sourceType = DispositionInvoiceItem::SOURCE_ASSIGNMENT_FEE;
|
||||
}
|
||||
|
||||
if ('' === $label) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$lineTotal = $unitPrice * $quantity;
|
||||
|
||||
$item = new DispositionInvoiceItem();
|
||||
$item
|
||||
->setInvoice($invoice)
|
||||
->setPosition($position)
|
||||
->setFee($fee)
|
||||
->setSourceType($sourceType)
|
||||
->setLabelSnapshot($label)
|
||||
->setUnitPriceCentsSnapshot($unitPrice)
|
||||
->setQuantity($quantity)
|
||||
->setLineTotalCents($lineTotal)
|
||||
;
|
||||
|
||||
$invoice->addItem($item);
|
||||
|
||||
$total += $lineTotal;
|
||||
++$position;
|
||||
}
|
||||
|
||||
$invoice->setTotalCents($total);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Service\Invoice;
|
||||
|
||||
use App\Entity\Disposition;
|
||||
use App\Entity\DispositionInvoice;
|
||||
use App\Entity\User;
|
||||
use App\Model\Invoice\TeamerInvoiceEditorDto;
|
||||
|
||||
interface InvoiceSubmissionServiceInterface
|
||||
{
|
||||
public function createEditorDto(Disposition $disposition): TeamerInvoiceEditorDto;
|
||||
|
||||
public function refreshDraft(Disposition $disposition, TeamerInvoiceEditorDto $dto): DispositionInvoice;
|
||||
|
||||
public function saveDraft(Disposition $disposition, TeamerInvoiceEditorDto $dto, User $actor): DispositionInvoice;
|
||||
|
||||
public function submitFinal(Disposition $disposition, TeamerInvoiceEditorDto $dto, User $actor): DispositionInvoice;
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Service\Invoice;
|
||||
|
||||
use App\Entity\Disposition;
|
||||
use App\Entity\DispositionInvoice;
|
||||
use App\Entity\Upload;
|
||||
use App\Entity\User;
|
||||
use App\Service\Upload\UploadHandler;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Component\Filesystem\Filesystem;
|
||||
|
||||
class InvoiceUploadWriter implements InvoiceUploadWriterInterface
|
||||
{
|
||||
public function __construct(
|
||||
private readonly EntityManagerInterface $entityManager,
|
||||
private readonly UploadHandler $uploadHandler,
|
||||
) {
|
||||
}
|
||||
|
||||
public function writeFinalInvoiceUpload(
|
||||
Disposition $disposition,
|
||||
DispositionInvoice $invoice,
|
||||
string $pdfBinary,
|
||||
User $owner,
|
||||
): Upload {
|
||||
if (null !== $existingUpload = $disposition->getDocumentByType(Upload::TYPE_INVOICE)) {
|
||||
$disposition->removeDocument($existingUpload);
|
||||
$this->entityManager->remove($existingUpload);
|
||||
}
|
||||
|
||||
$upload = new Upload();
|
||||
$upload
|
||||
->setType(Upload::TYPE_INVOICE)
|
||||
->setStatus(Upload::STATUS_NEW)
|
||||
->setMimeType('application/pdf')
|
||||
->setOwner($owner)
|
||||
->setOriginalFilename('honorarnote_'.$disposition->getUuid().'.pdf')
|
||||
->setFilename($this->createFilename())
|
||||
->setSize(strlen($pdfBinary))
|
||||
;
|
||||
|
||||
$disposition->addDocument($upload);
|
||||
$invoice->setGeneratedUpload($upload);
|
||||
|
||||
$path = $this->uploadHandler->getUploadFilepath($upload);
|
||||
$directory = \dirname($path);
|
||||
$filesystem = new Filesystem();
|
||||
|
||||
if (false === $filesystem->exists($directory)) {
|
||||
$filesystem->mkdir($directory);
|
||||
}
|
||||
|
||||
$bytesWritten = file_put_contents($path, $pdfBinary);
|
||||
if (false === $bytesWritten) {
|
||||
throw new \RuntimeException(sprintf('Failed to write generated invoice PDF to "%s".', $path));
|
||||
}
|
||||
|
||||
return $upload;
|
||||
}
|
||||
|
||||
private function createFilename(): string
|
||||
{
|
||||
return bin2hex(random_bytes(16)).'.pdf';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Service\Invoice;
|
||||
|
||||
use App\Entity\Disposition;
|
||||
use App\Entity\DispositionInvoice;
|
||||
use App\Entity\Upload;
|
||||
use App\Entity\User;
|
||||
|
||||
interface InvoiceUploadWriterInterface
|
||||
{
|
||||
public function writeFinalInvoiceUpload(
|
||||
Disposition $disposition,
|
||||
DispositionInvoice $invoice,
|
||||
string $pdfBinary,
|
||||
User $owner,
|
||||
): Upload;
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
<!doctype html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<style>
|
||||
body { font-family: DejaVu Sans, sans-serif; font-size: 12px; color: #111827; }
|
||||
h1 { font-size: 20px; margin: 0 0 10px; }
|
||||
.muted { color: #6b7280; }
|
||||
.section { margin-top: 16px; }
|
||||
table { width: 100%; border-collapse: collapse; margin-top: 8px; }
|
||||
th, td { border: 1px solid #d1d5db; padding: 6px; text-align: left; }
|
||||
th { background: #f3f4f6; }
|
||||
.right { text-align: right; }
|
||||
.total-row td { font-weight: bold; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Honorarnote</h1>
|
||||
<div class="muted">Einsatz: {{ assignment.destination }}</div>
|
||||
<div class="muted">Teamer: {{ teamer }}</div>
|
||||
<div class="muted">Datum: {{ "now"|date("d.m.Y") }}</div>
|
||||
|
||||
<div class="section">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Bezeichnung</th>
|
||||
<th class="right">Betrag</th>
|
||||
<th class="right">Anzahl</th>
|
||||
<th class="right">Zeilensumme</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for item in invoice.items %}
|
||||
<tr>
|
||||
<td>{{ item.labelSnapshot }}</td>
|
||||
<td class="right">{{ item.unitPriceCentsSnapshot|format_money }}</td>
|
||||
<td class="right">{{ item.quantity }}</td>
|
||||
<td class="right">{{ item.lineTotalCents|format_money }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
<tr class="total-row">
|
||||
<td colspan="3" class="right">Gesamt</td>
|
||||
<td class="right">{{ invoice.totalCents|format_money }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,94 @@
|
||||
{% macro collectionRow(form, disposition, refreshPath) %}
|
||||
{% set inputAttr = {
|
||||
'hx-post': refreshPath,
|
||||
'hx-target': '#invoice-editor-form',
|
||||
'hx-swap': 'outerHTML',
|
||||
'hx-sync': 'closest form:replace',
|
||||
'hx-trigger': 'focusout, change',
|
||||
} %}
|
||||
<div {{ stimulus_target('form-collection', 'field') }} class="grid lg:grid-cols-12 gap-3 items-end border border-gray-200 rounded p-3">
|
||||
<div class="lg:col-span-4">
|
||||
{{ form_row(form.feeId, { attr: inputAttr }) }}
|
||||
</div>
|
||||
<div class="lg:col-span-3">
|
||||
{{ form_row(form.label, { attr: inputAttr }) }}
|
||||
</div>
|
||||
<div class="lg:col-span-2">
|
||||
{{ form_row(form.unitPriceCents, { attr: inputAttr }) }}
|
||||
</div>
|
||||
<div class="lg:col-span-2">
|
||||
{{ form_row(form.quantity, { attr: inputAttr }) }}
|
||||
</div>
|
||||
<div class="lg:col-span-1 flex justify-end pb-2">
|
||||
<button type="button" {{ stimulus_action('form-collection', 'removeItem') }}>
|
||||
{{ icon('delete', 'w-5 h-5 pointer-events-none text-red-500') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{% endmacro %}
|
||||
|
||||
{% set savePath = path('app_teamer_disposition_invoice_save', { uuid: disposition.uuid }) %}
|
||||
{% set submitPath = path('app_teamer_disposition_invoice_submit', { uuid: disposition.uuid }) %}
|
||||
{% set refreshPath = path('app_teamer_disposition_invoice_refresh', { uuid: disposition.uuid }) %}
|
||||
|
||||
<div id="invoice-editor-form">
|
||||
{{ form_start(form, {
|
||||
attr: {
|
||||
'hx-post': savePath,
|
||||
'hx-target': '#invoice-editor-form',
|
||||
'hx-swap': 'outerHTML'
|
||||
}
|
||||
}) }}
|
||||
|
||||
<div class="pb-4">
|
||||
<twig:MessageBox message="Bitte erfasse deine Honorarnote digital. Du kannst Zwischenschritte als Entwurf speichern." type="info"/>
|
||||
</div>
|
||||
|
||||
{% if disposition.invoiceDraft and disposition.invoiceDraft.status == constant('App\\Entity\\DispositionInvoice::STATUS_REJECTED') %}
|
||||
<div class="pb-4">
|
||||
<twig:MessageBox message="Deine Honorarnote wurde abgelehnt. Bitte überarbeite sie." type="error"/>
|
||||
{% if disposition.invoiceDraft.rejectionComment %}
|
||||
<div class="pt-2 text-sm">
|
||||
<strong>Begründung:</strong> {{ disposition.invoiceDraft.rejectionComment|nl2br }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div {{ stimulus_controller('form-collection', { 'prototype': _self.collectionRow(form.items.vars.prototype, disposition, refreshPath)|json_encode }) }}>
|
||||
<div {{ stimulus_target('form-collection', 'fields') }} class="flex flex-col space-y-3 pb-4">
|
||||
{% do form.items.setRendered %}
|
||||
{%- for item in form.items -%}
|
||||
{{- _self.collectionRow(item, disposition, refreshPath) -}}
|
||||
{%- endfor -%}
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end pb-4">
|
||||
<button type="button" class="btn btn--secondary" {{ stimulus_action('form-collection', 'addItem') }}>
|
||||
Position hinzufügen
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{{ form_errors(form.items) }}
|
||||
</div>
|
||||
|
||||
<div class="pb-4 text-right font-bold">
|
||||
Gesamt: {{ dto.totalCents|format_money }}
|
||||
</div>
|
||||
|
||||
<div class="pb-4">
|
||||
{{ form_row(form.confirmFinal) }}
|
||||
</div>
|
||||
|
||||
<div class="flex items-center space-x-2">
|
||||
<button type="submit" class="btn" formaction="{{ savePath }}">
|
||||
Entwurf speichern
|
||||
</button>
|
||||
<button type="submit" class="btn" formaction="{{ submitPath }}">
|
||||
Final einreichen
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{{ form_rest(form) }}
|
||||
{{ form_end(form) }}
|
||||
</div>
|
||||
@@ -95,28 +95,36 @@
|
||||
|
||||
{# New invoice #}
|
||||
{% if workflow_can(disposition, 'upload_invoice') %}
|
||||
{% set formAttr = stimulus_controller('form-upload-guard')|stimulus_action('form-upload-guard', 'guard', 'submit') %}
|
||||
{{ form_start(form, { 'attr': formAttr.toArray() }) }}
|
||||
<div class="flex flex-col space-y-4">
|
||||
<a href="{{ path('app_teamer_disposition_invoicepdf', { 'uuid': disposition.uuid }) }}" target="_blank" class="inline-block">
|
||||
<img src="{{ asset('build/images/invoice_thumb.jpg') }}" alt="Honorarnote" class="block w-32 h-auto shadow-md">
|
||||
</a>
|
||||
<div>
|
||||
Bitte lade sie ausgefüllt und unterschrieben hier wieder hoch:
|
||||
{% if invoice_editor_enabled %}
|
||||
<div
|
||||
hx-get="{{ path('app_teamer_disposition_invoice_editor', { 'uuid': disposition.uuid }) }}"
|
||||
hx-trigger="load"
|
||||
hx-target="this"
|
||||
hx-swap="innerHTML"
|
||||
>
|
||||
{% include '_partials/_spinner.html.twig' with { 'class': 'w-8 h-8' } %}
|
||||
</div>
|
||||
{% include '_partials/_upload_collection_form.html.twig' with {
|
||||
'endpoint_upload': path('_uploader_upload_invoice'),
|
||||
'accepted_files': 'image/jpg,image/jpeg,application/pdf',
|
||||
} %}
|
||||
{% if form.confirmInvoiceData is defined %}
|
||||
{{ form_row(form.confirmInvoiceData) }}
|
||||
{% endif %}
|
||||
<button type="submit" class="btn" {{ stimulus_target('form-upload-guard', 'submit') }}>
|
||||
Aktualisieren
|
||||
</button>
|
||||
</div>
|
||||
{{ form_rest(form) }}
|
||||
{{ form_end(form) }}
|
||||
{% else %}
|
||||
{% set formAttr = stimulus_controller('form-upload-guard')|stimulus_action('form-upload-guard', 'guard', 'submit') %}
|
||||
{{ form_start(form, { 'attr': formAttr.toArray() }) }}
|
||||
<div class="flex flex-col space-y-4">
|
||||
<a href="{{ path('app_teamer_disposition_invoicepdf', { 'uuid': disposition.uuid }) }}" target="_blank" class="inline-block">
|
||||
<img src="{{ asset('build/images/invoice_thumb.jpg') }}" alt="Honorarnote" class="block w-32 h-auto shadow-md">
|
||||
</a>
|
||||
<div>
|
||||
Bitte lade sie ausgefüllt und unterschrieben hier wieder hoch:
|
||||
</div>
|
||||
{% include '_partials/_upload_collection_form.html.twig' with {
|
||||
'endpoint_upload': path('_uploader_upload_invoice'),
|
||||
'accepted_files': 'image/jpg,image/jpeg,application/pdf',
|
||||
} %}
|
||||
<button type="submit" class="btn" {{ stimulus_target('form-upload-guard', 'submit') }}>
|
||||
Aktualisieren
|
||||
</button>
|
||||
</div>
|
||||
{{ form_rest(form) }}
|
||||
{{ form_end(form) }}
|
||||
{% endif %}
|
||||
{% else %}
|
||||
{% for blocker in workflow_transition_blockers(disposition, 'upload_invoice') %}
|
||||
<twig:MessageBox message="{{ blocker.message }}"/>
|
||||
|
||||
Reference in new issue
Block a user