support custom fields for invoices (#3077)

This commit is contained in:
Kevin Papst
2022-01-20 16:59:26 +01:00
committed by GitHub
parent 9731023014
commit 40242b7dcc
15 changed files with 396 additions and 6 deletions

View File

@@ -13,7 +13,10 @@ use App\Configuration\SystemConfiguration;
use App\Entity\Customer;
use App\Entity\Invoice;
use App\Entity\InvoiceTemplate;
use App\Entity\MetaTableTypeInterface;
use App\Event\InvoiceDocumentsEvent;
use App\Event\InvoiceMetaDefinitionEvent;
use App\Event\InvoiceMetaDisplayEvent;
use App\Export\Spreadsheet\AnnotatedObjectExporter;
use App\Export\Spreadsheet\Writer\BinaryFileResponseWriter;
use App\Export\Spreadsheet\Writer\XlsxWriter;
@@ -347,6 +350,7 @@ final class InvoiceController extends AbstractController
'query' => $query,
'toolbarForm' => $form->createView(),
'download' => $invoice,
'metaColumns' => $this->findMetaColumns($query),
]);
}
@@ -695,8 +699,23 @@ final class InvoiceController extends AbstractController
]);
}
/**
* @param InvoiceArchiveQuery $query
* @return MetaTableTypeInterface[]
*/
private function findMetaColumns(InvoiceArchiveQuery $query): array
{
$event = new InvoiceMetaDisplayEvent($query, InvoiceMetaDisplayEvent::INVOICE);
$this->dispatcher->dispatch($event);
return $event->getFields();
}
private function createInvoiceEditForm(Invoice $invoice): FormInterface
{
$event = new InvoiceMetaDefinitionEvent($invoice);
$this->dispatcher->dispatch($event);
return $this->createForm(InvoiceEditForm::class, $invoice, [
'action' => $this->generateUrl('admin_invoice_edit', ['id' => $invoice->getId()]),
'method' => 'POST',

View File

@@ -11,6 +11,8 @@ namespace App\Entity;
use App\Export\Annotation as Exporter;
use App\Invoice\InvoiceModel;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
use JMS\Serializer\Annotation as Serializer;
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
@@ -34,7 +36,7 @@ use Symfony\Component\Validator\Constraints as Assert;
* @Exporter\Expose("user", label="label.username", type="string", exp="object.getUser() === null ? null : object.getUser().getDisplayName()")
* @Exporter\Expose("paymentDate", label="invoice.payment_date", type="date", exp="object.getPaymentDate() === null ? null : object.getPaymentDate()")
*/
class Invoice
class Invoice implements EntityWithMetaFields
{
public const STATUS_PENDING = 'pending';
public const STATUS_PAID = 'paid';
@@ -49,7 +51,6 @@ class Invoice
* @ORM\Column(name="id", type="integer")
* @ORM\Id
* @ORM\GeneratedValue(strategy="IDENTITY")
* @phpstan-ignore-next-line
*/
private $id;
@@ -196,6 +197,28 @@ class Invoice
*/
private $paymentDate;
/**
* Meta fields
*
* All visible meta (custom) fields registered with this invoice
*
* @var InvoiceMeta[]|Collection
*
* @Serializer\Expose()
* @Serializer\Groups({"Invoice"})
* @Serializer\Type(name="array<App\Entity\InvoiceMeta>")
* @Serializer\SerializedName("metaFields")
* @Serializer\Accessor(getter="getVisibleMetaFields")
*
* @ORM\OneToMany(targetEntity="App\Entity\InvoiceMeta", mappedBy="invoice", cascade={"persist"})
*/
private $meta;
public function __construct()
{
$this->meta = new ArrayCollection();
}
public function getId(): ?int
{
return $this->id;
@@ -399,4 +422,68 @@ class Invoice
{
return $this->comment;
}
/**
* @return Collection|MetaTableTypeInterface[]
*/
public function getMetaFields(): Collection
{
return $this->meta;
}
/**
* @return MetaTableTypeInterface[]
*/
public function getVisibleMetaFields(): array
{
$all = [];
foreach ($this->meta as $meta) {
if ($meta->isVisible()) {
$all[] = $meta;
}
}
return $all;
}
public function getMetaField(string $name): ?MetaTableTypeInterface
{
foreach ($this->meta as $field) {
if (strtolower($field->getName()) === strtolower($name)) {
return $field;
}
}
return null;
}
public function setMetaField(MetaTableTypeInterface $meta): EntityWithMetaFields
{
if (null === ($current = $this->getMetaField($meta->getName()))) {
$meta->setEntity($this);
$this->meta->add($meta);
return $this;
}
$current->merge($meta);
return $this;
}
public function __clone()
{
if ($this->id) {
$this->id = null;
}
$currentMeta = $this->meta;
$this->meta = new ArrayCollection();
/** @var InvoiceMeta $meta */
foreach ($currentMeta as $meta) {
$newMeta = clone $meta;
$newMeta->setEntity($this);
$this->setMetaField($newMeta);
}
}
}

View File

@@ -0,0 +1,59 @@
<?php
declare(strict_types=1);
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Entity;
use Doctrine\ORM\Mapping as ORM;
use JMS\Serializer\Annotation as Serializer;
use Symfony\Component\Validator\Constraints as Assert;
/**
* @ORM\Entity()
* @ORM\Table(name="kimai2_invoices_meta",
* uniqueConstraints={
* @ORM\UniqueConstraint(columns={"invoice_id", "name"})
* }
* )
* @Serializer\ExclusionPolicy("all")
*/
class InvoiceMeta implements MetaTableTypeInterface
{
use MetaTableTypeTrait;
/**
* @var Invoice
*
* @ORM\ManyToOne(targetEntity="App\Entity\Invoice", inversedBy="meta")
* @ORM\JoinColumn(onDelete="CASCADE", nullable=false)
* @Assert\NotNull()
*/
private $invoice;
public function setEntity(EntityWithMetaFields $entity): MetaTableTypeInterface
{
if (!($entity instanceof Invoice)) {
throw new \InvalidArgumentException(
sprintf('Expected instanceof Invoice, received "%s"', \get_class($entity))
);
}
$this->invoice = $entity;
return $this;
}
/**
* @return Invoice|null
*/
public function getEntity(): ?EntityWithMetaFields
{
return $this->invoice;
}
}

View File

@@ -0,0 +1,34 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Event;
use App\Entity\Invoice;
use Symfony\Contracts\EventDispatcher\Event;
/**
* This event can be used, to dynamically add meta fields to invoices
*/
final class InvoiceMetaDefinitionEvent extends Event
{
/**
* @var Invoice
*/
private $entity;
public function __construct(Invoice $entity)
{
$this->entity = $entity;
}
public function getEntity(): Invoice
{
return $this->entity;
}
}

View File

@@ -0,0 +1,27 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Event;
use App\Repository\Query\InvoiceArchiveQuery;
/**
* Dynamically find possible meta fields for an invoice-archive query.
*
* @method InvoiceArchiveQuery getQuery()
*/
final class InvoiceMetaDisplayEvent extends AbstractMetaDisplayEvent
{
public const INVOICE = 'invoice';
public function __construct(InvoiceArchiveQuery $query, string $location)
{
parent::__construct($query, $location);
}
}

View File

@@ -12,6 +12,9 @@ namespace App\Export;
use App\Entity\MetaTableTypeInterface;
use App\Invoice\InvoiceItemInterface;
/**
* Will be merged with InvoiceItemInterface in 2.0
*/
interface ExportItemInterface extends InvoiceItemInterface
{
/**

View File

@@ -11,6 +11,7 @@ namespace App\Form;
use App\Entity\Invoice;
use App\Form\Type\DatePickerType;
use App\Form\Type\MetaFieldsCollectionType;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\Extension\Core\Type\TextareaType;
@@ -48,6 +49,8 @@ class InvoiceEditForm extends AbstractType
'label' => 'invoice.payment_date',
'required' => false,
]));
$builder->add('metaFields', MetaFieldsCollectionType::class);
}
/**

View File

@@ -9,6 +9,9 @@
namespace App\Invoice;
/**
* Will be removed with 2.0, just here for BC compatibility.
*/
interface InvoiceItemWithAmountInterface
{
public function getAmount(): float;

View File

@@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace DoctrineMigrations;
use App\Doctrine\AbstractMigration;
use Doctrine\DBAL\Schema\Schema;
final class Version20220101204501 extends AbstractMigration
{
public function getDescription(): string
{
return 'Creates the invoice-meta table';
}
public function up(Schema $schema): void
{
$table = $schema->createTable('kimai2_invoices_meta');
$table->addColumn('id', 'integer', ['autoincrement' => true, 'notnull' => true]);
$table->addColumn('invoice_id', 'integer', ['notnull' => true]);
$table->addColumn('name', 'string', ['notnull' => true, 'length' => 50]);
$table->addColumn('value', 'text', ['notnull' => false, 'length' => 65535]);
$table->addColumn('visible', 'boolean', ['notnull' => true, 'default' => false]);
$table->setPrimaryKey(['id']);
$table->addIndex(['invoice_id'], 'IDX_7EDC37D92989F1FD');
$table->addUniqueIndex(['invoice_id', 'name'], 'UNIQ_7EDC37D92989F1FD5E237E06');
$table->addForeignKeyConstraint('kimai2_invoices', ['invoice_id'], ['id'], ['onDelete' => 'CASCADE'], 'FK_7EDC37D92989F1FD');
}
public function down(Schema $schema): void
{
$schema->dropTable('kimai2_invoices_meta');
}
}

View File

@@ -39,17 +39,25 @@ final class InvoiceIdLoader implements LoaderInterface
$em = $this->entityManager;
$qb = $em->createQueryBuilder();
$customer = $qb->select('PARTIAL i.{id}', 'customer')
$qb->select('PARTIAL i.{id}', 'customer')
->from(Invoice::class, 'i')
->leftJoin('i.customer', 'customer')
->getQuery()
->execute();
$qb = $em->createQueryBuilder();
$user = $qb->select('PARTIAL i.{id}', 'user')
$qb->select('PARTIAL i.{id}', 'user')
->from(Invoice::class, 'i')
->leftJoin('i.user', 'user')
->getQuery()
->execute();
$qb = $em->createQueryBuilder();
$qb->select('PARTIAL i.{id}', 'meta')
->from(Invoice::class, 'i')
->leftJoin('i.meta', 'meta')
->andWhere($qb->expr()->in('i.id', $ids))
->getQuery()
->execute();
}
}