diff --git a/composer.json b/composer.json index 9453fd4..566f867 100644 --- a/composer.json +++ b/composer.json @@ -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", diff --git a/config/packages/flagception.yaml b/config/packages/flagception.yaml index 4623dad..b549383 100644 --- a/config/packages/flagception.yaml +++ b/config/packages/flagception.yaml @@ -10,3 +10,6 @@ flagception: datev_email: default: false env: FEATURE_DATEV_EMAIL + invoice_editor: + default: false + env: FEATURE_INVOICE_EDITOR diff --git a/config/services.yaml b/config/services.yaml index ba4dc97..26b9dfd 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -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' diff --git a/migrations/Version20260221101500.php b/migrations/Version20260221101500.php new file mode 100644 index 0000000..063ac55 --- /dev/null +++ b/migrations/Version20260221101500.php @@ -0,0 +1,38 @@ +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'); + } +} diff --git a/src/Controller/Teamer/Disposition/DetailController.php b/src/Controller/Teamer/Disposition/DetailController.php index 3c28852..57d7f0e 100644 --- a/src/Controller/Teamer/Disposition/DetailController.php +++ b/src/Controller/Teamer/Disposition/DetailController.php @@ -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'), ]); } diff --git a/src/Controller/Teamer/Disposition/InvoiceEditorController.php b/src/Controller/Teamer/Disposition/InvoiceEditorController.php new file mode 100644 index 0000000..66f2fc5 --- /dev/null +++ b/src/Controller/Teamer/Disposition/InvoiceEditorController.php @@ -0,0 +1,217 @@ +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|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 + */ + 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(); + } + } +} diff --git a/src/Entity/Disposition.php b/src/Entity/Disposition.php index 0d30622..c508399 100644 --- a/src/Entity/Disposition.php +++ b/src/Entity/Disposition.php @@ -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; + } } diff --git a/src/Entity/DispositionInvoice.php b/src/Entity/DispositionInvoice.php new file mode 100644 index 0000000..113bff6 --- /dev/null +++ b/src/Entity/DispositionInvoice.php @@ -0,0 +1,255 @@ + + */ + #[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 + */ + 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; + } +} diff --git a/src/Entity/DispositionInvoiceItem.php b/src/Entity/DispositionInvoiceItem.php new file mode 100644 index 0000000..c887d22 --- /dev/null +++ b/src/Entity/DispositionInvoiceItem.php @@ -0,0 +1,151 @@ +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; + } +} diff --git a/src/EventListener/InvoiceDraftRejectedListener.php b/src/EventListener/InvoiceDraftRejectedListener.php new file mode 100644 index 0000000..d1735cc --- /dev/null +++ b/src/EventListener/InvoiceDraftRejectedListener.php @@ -0,0 +1,52 @@ +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(); + } +} diff --git a/src/Form/Invoice/TeamerInvoiceEditorType.php b/src/Form/Invoice/TeamerInvoiceEditorType.php new file mode 100644 index 0000000..cf95c1a --- /dev/null +++ b/src/Form/Invoice/TeamerInvoiceEditorType.php @@ -0,0 +1,63 @@ +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') + ; + } +} diff --git a/src/Form/Invoice/TeamerInvoiceItemType.php b/src/Form/Invoice/TeamerInvoiceItemType.php new file mode 100644 index 0000000..e4c4b74 --- /dev/null +++ b/src/Form/Invoice/TeamerInvoiceItemType.php @@ -0,0 +1,70 @@ +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') + ; + } +} diff --git a/src/Model/Invoice/TeamerInvoiceEditorDto.php b/src/Model/Invoice/TeamerInvoiceEditorDto.php new file mode 100644 index 0000000..1de15ea --- /dev/null +++ b/src/Model/Invoice/TeamerInvoiceEditorDto.php @@ -0,0 +1,62 @@ + + */ + private array $items = []; + + private bool $confirmFinal = false; + + /** + * @return list + */ + public function getItems(): array + { + return $this->items; + } + + /** + * @param list $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; + } +} diff --git a/src/Model/Invoice/TeamerInvoiceItemDto.php b/src/Model/Invoice/TeamerInvoiceItemDto.php new file mode 100644 index 0000000..68eac09 --- /dev/null +++ b/src/Model/Invoice/TeamerInvoiceItemDto.php @@ -0,0 +1,66 @@ +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; + } +} diff --git a/src/Repository/DispositionInvoiceItemRepository.php b/src/Repository/DispositionInvoiceItemRepository.php new file mode 100644 index 0000000..e9d6993 --- /dev/null +++ b/src/Repository/DispositionInvoiceItemRepository.php @@ -0,0 +1,20 @@ + + */ +class DispositionInvoiceItemRepository extends ServiceEntityRepository +{ + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, DispositionInvoiceItem::class); + } +} diff --git a/src/Repository/DispositionInvoiceRepository.php b/src/Repository/DispositionInvoiceRepository.php new file mode 100644 index 0000000..9019c01 --- /dev/null +++ b/src/Repository/DispositionInvoiceRepository.php @@ -0,0 +1,26 @@ + + */ +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]); + } +} diff --git a/src/Service/Invoice/InvoicePdfGenerator.php b/src/Service/Invoice/InvoicePdfGenerator.php new file mode 100644 index 0000000..f927b7b --- /dev/null +++ b/src/Service/Invoice/InvoicePdfGenerator.php @@ -0,0 +1,39 @@ +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(); + } +} diff --git a/src/Service/Invoice/InvoicePdfGeneratorInterface.php b/src/Service/Invoice/InvoicePdfGeneratorInterface.php new file mode 100644 index 0000000..5064e3a --- /dev/null +++ b/src/Service/Invoice/InvoicePdfGeneratorInterface.php @@ -0,0 +1,12 @@ +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); + } +} diff --git a/src/Service/Invoice/InvoiceSubmissionServiceInterface.php b/src/Service/Invoice/InvoiceSubmissionServiceInterface.php new file mode 100644 index 0000000..3fd2fef --- /dev/null +++ b/src/Service/Invoice/InvoiceSubmissionServiceInterface.php @@ -0,0 +1,21 @@ +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'; + } +} diff --git a/src/Service/Invoice/InvoiceUploadWriterInterface.php b/src/Service/Invoice/InvoiceUploadWriterInterface.php new file mode 100644 index 0000000..45ba735 --- /dev/null +++ b/src/Service/Invoice/InvoiceUploadWriterInterface.php @@ -0,0 +1,20 @@ + + + + + + + +

Honorarnote

+
Einsatz: {{ assignment.destination }}
+
Teamer: {{ teamer }}
+
Datum: {{ "now"|date("d.m.Y") }}
+ +
+ + + + + + + + + + + {% for item in invoice.items %} + + + + + + + {% endfor %} + + + + + +
BezeichnungBetragAnzahlZeilensumme
{{ item.labelSnapshot }}{{ item.unitPriceCentsSnapshot|format_money }}{{ item.quantity }}{{ item.lineTotalCents|format_money }}
Gesamt{{ invoice.totalCents|format_money }}
+
+ + diff --git a/templates/teamer/disposition/_invoice_editor_form.html.twig b/templates/teamer/disposition/_invoice_editor_form.html.twig new file mode 100644 index 0000000..df6cf86 --- /dev/null +++ b/templates/teamer/disposition/_invoice_editor_form.html.twig @@ -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', + } %} +
+
+ {{ form_row(form.feeId, { attr: inputAttr }) }} +
+
+ {{ form_row(form.label, { attr: inputAttr }) }} +
+
+ {{ form_row(form.unitPriceCents, { attr: inputAttr }) }} +
+
+ {{ form_row(form.quantity, { attr: inputAttr }) }} +
+
+ +
+
+{% 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 }) %} + +
+ {{ form_start(form, { + attr: { + 'hx-post': savePath, + 'hx-target': '#invoice-editor-form', + 'hx-swap': 'outerHTML' + } + }) }} + +
+ +
+ + {% if disposition.invoiceDraft and disposition.invoiceDraft.status == constant('App\\Entity\\DispositionInvoice::STATUS_REJECTED') %} +
+ + {% if disposition.invoiceDraft.rejectionComment %} +
+ Begründung: {{ disposition.invoiceDraft.rejectionComment|nl2br }} +
+ {% endif %} +
+ {% endif %} + +
+
+ {% do form.items.setRendered %} + {%- for item in form.items -%} + {{- _self.collectionRow(item, disposition, refreshPath) -}} + {%- endfor -%} +
+ +
+ +
+ + {{ form_errors(form.items) }} +
+ +
+ Gesamt: {{ dto.totalCents|format_money }} +
+ +
+ {{ form_row(form.confirmFinal) }} +
+ +
+ + +
+ + {{ form_rest(form) }} + {{ form_end(form) }} +
diff --git a/templates/teamer/disposition/detail.html.twig b/templates/teamer/disposition/detail.html.twig index dec5754..3803afb 100644 --- a/templates/teamer/disposition/detail.html.twig +++ b/templates/teamer/disposition/detail.html.twig @@ -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() }) }} -
- - Honorarnote - -
- Bitte lade sie ausgefüllt und unterschrieben hier wieder hoch: + {% if invoice_editor_enabled %} +
+ {% include '_partials/_spinner.html.twig' with { 'class': 'w-8 h-8' } %}
- {% 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 %} - -
- {{ 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() }) }} +
+ + Honorarnote + +
+ Bitte lade sie ausgefüllt und unterschrieben hier wieder hoch: +
+ {% include '_partials/_upload_collection_form.html.twig' with { + 'endpoint_upload': path('_uploader_upload_invoice'), + 'accepted_files': 'image/jpg,image/jpeg,application/pdf', + } %} + +
+ {{ form_rest(form) }} + {{ form_end(form) }} + {% endif %} {% else %} {% for blocker in workflow_transition_blockers(disposition, 'upload_invoice') %}