diff --git a/src/Controller/InvoiceController.php b/src/Controller/InvoiceController.php index fc5a4c57..9ecba3de 100644 --- a/src/Controller/InvoiceController.php +++ b/src/Controller/InvoiceController.php @@ -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', diff --git a/src/Entity/Invoice.php b/src/Entity/Invoice.php index e890d91a..f63ce0df 100644 --- a/src/Entity/Invoice.php +++ b/src/Entity/Invoice.php @@ -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") + * @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); + } + } } diff --git a/src/Entity/InvoiceMeta.php b/src/Entity/InvoiceMeta.php new file mode 100644 index 00000000..1e089cab --- /dev/null +++ b/src/Entity/InvoiceMeta.php @@ -0,0 +1,59 @@ +invoice = $entity; + + return $this; + } + + /** + * @return Invoice|null + */ + public function getEntity(): ?EntityWithMetaFields + { + return $this->invoice; + } +} diff --git a/src/Event/InvoiceMetaDefinitionEvent.php b/src/Event/InvoiceMetaDefinitionEvent.php new file mode 100644 index 00000000..9b668b0b --- /dev/null +++ b/src/Event/InvoiceMetaDefinitionEvent.php @@ -0,0 +1,34 @@ +entity = $entity; + } + + public function getEntity(): Invoice + { + return $this->entity; + } +} diff --git a/src/Event/InvoiceMetaDisplayEvent.php b/src/Event/InvoiceMetaDisplayEvent.php new file mode 100644 index 00000000..971e3fe3 --- /dev/null +++ b/src/Event/InvoiceMetaDisplayEvent.php @@ -0,0 +1,27 @@ + 'invoice.payment_date', 'required' => false, ])); + + $builder->add('metaFields', MetaFieldsCollectionType::class); } /** diff --git a/src/Invoice/InvoiceItemWithAmountInterface.php b/src/Invoice/InvoiceItemWithAmountInterface.php index 6c3f3115..d7520550 100644 --- a/src/Invoice/InvoiceItemWithAmountInterface.php +++ b/src/Invoice/InvoiceItemWithAmountInterface.php @@ -9,6 +9,9 @@ namespace App\Invoice; +/** + * Will be removed with 2.0, just here for BC compatibility. + */ interface InvoiceItemWithAmountInterface { public function getAmount(): float; diff --git a/src/Migrations/Version20220101204501.php b/src/Migrations/Version20220101204501.php new file mode 100644 index 00000000..e96d0a69 --- /dev/null +++ b/src/Migrations/Version20220101204501.php @@ -0,0 +1,42 @@ +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'); + } +} diff --git a/src/Repository/Loader/InvoiceIdLoader.php b/src/Repository/Loader/InvoiceIdLoader.php index ec37a3b5..c2ea74d5 100644 --- a/src/Repository/Loader/InvoiceIdLoader.php +++ b/src/Repository/Loader/InvoiceIdLoader.php @@ -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(); } } diff --git a/templates/invoice/listing.html.twig b/templates/invoice/listing.html.twig index ca62ae83..1f256c18 100644 --- a/templates/invoice/listing.html.twig +++ b/templates/invoice/listing.html.twig @@ -10,6 +10,13 @@ 'user': {'class': 'hidden-xs hidden-sm text-nowrap hidden', 'orderBy': false}, 'customer': {'class': 'hidden-xs hidden-sm text-nowrap', 'orderBy': false}, 'comment': {'class': 'hidden-xs hidden-sm', 'title': 'label.description'|trans}, +} %} +{% for field in metaColumns %} + {% set columns = columns|merge({ + ('mf_' ~ field.name): {'title': field.label|trans, 'class': 'hidden-xs hidden-sm', 'orderBy': false} + }) %} +{% endfor %} +{% set columns = columns|merge({ 'invoice_number': {'class': 'hidden-xs hidden-sm w-min', 'title': 'invoice.number'|trans, 'orderBy': false}, 'due_date': {'class': 'hidden-xs w-min', 'title': 'invoice.due_days'|trans, 'orderBy': false}, 'payment_date': {'class': 'hidden-xs hidden w-min', 'title': 'invoice.payment_date'|trans, 'orderBy': false}, @@ -18,7 +25,7 @@ 'tax': {'class': 'hidden-xs text-right w-min hidden', 'title': 'invoice.tax'|trans}, 'total_rate': {'class': 'hidden-xs text-right w-min'}, 'actions': {'class': 'actions alwaysVisible', 'orderBy': false}, -} %} +}) %} {% set tableName = 'invoices' %} @@ -42,6 +49,11 @@ {{ widgets.user_avatar(entry.user) }} {{ widgets.username(entry.user) }} {{ widgets.label_customer(entry.customer) }} {{ entry.comment }} + {% for field in metaColumns %} + + {{ tables.datatable_meta_column(entry, field) }} + + {% endfor %} {{ widgets.label(entry.invoiceNumber, 'default') }} {{ macros.invoice_due_date(entry) }} diff --git a/tests/Controller/InvoiceControllerTest.php b/tests/Controller/InvoiceControllerTest.php index 80af9070..daaabd9c 100644 --- a/tests/Controller/InvoiceControllerTest.php +++ b/tests/Controller/InvoiceControllerTest.php @@ -199,6 +199,7 @@ class InvoiceControllerTest extends ControllerBaseTest $this->assertIsRedirect($client); $this->assertRedirectUrl($client, '/invoice/show?id=', false); $client->followRedirect(); + $this->assertTrue($client->getResponse()->isSuccessful()); $this->assertDataTableRowCount($client, 'datatable_invoices', 1); $em = $this->getEntityManager(); diff --git a/tests/Entity/InvoiceMetaTest.php b/tests/Entity/InvoiceMetaTest.php new file mode 100644 index 00000000..38f608c9 --- /dev/null +++ b/tests/Entity/InvoiceMetaTest.php @@ -0,0 +1,41 @@ +expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Expected instanceof Invoice, received "App\Entity\Customer"'); + + $sut = new InvoiceMeta(); + $sut->setEntity(new Customer()); + } +} diff --git a/tests/Entity/InvoiceTest.php b/tests/Entity/InvoiceTest.php index 07ec2d08..8f105aea 100644 --- a/tests/Entity/InvoiceTest.php +++ b/tests/Entity/InvoiceTest.php @@ -14,6 +14,7 @@ use App\Entity\ActivityMeta; use App\Entity\Customer; use App\Entity\CustomerMeta; use App\Entity\Invoice; +use App\Entity\InvoiceMeta; use App\Entity\InvoiceTemplate; use App\Entity\Project; use App\Entity\ProjectMeta; @@ -221,4 +222,54 @@ class InvoiceTest extends TestCase return new DateNumberGenerator($repository); } + + public function testClone() + { + $sut = new Invoice(); + $sut->setComment('foo kajsdhgf aksjdhfg'); + $sut->setFilename('1234567890'); + + $meta = new InvoiceMeta(); + $meta->setName('blabla'); + $meta->setValue('1234567890'); + $meta->setIsVisible(false); + $meta->setIsRequired(true); + $sut->setMetaField($meta); + + $clone = clone $sut; + + foreach ($sut->getMetaFields() as $metaField) { + $cloneMeta = $clone->getMetaField($metaField->getName()); + self::assertEquals($cloneMeta->getValue(), $metaField->getValue()); + } + self::assertEquals('1234567890', $clone->getInvoiceFilename()); + self::assertEquals('foo kajsdhgf aksjdhfg', $clone->getComment()); + } + + public function testMetaFields() + { + $sut = new Invoice(); + $meta = new InvoiceMeta(); + $meta->setName('foo')->setValue('bar')->setType('test'); + self::assertInstanceOf(Invoice::class, $sut->setMetaField($meta)); + self::assertEquals(1, $sut->getMetaFields()->count()); + $result = $sut->getMetaField('foo'); + self::assertSame($result, $meta); + self::assertEquals('test', $result->getType()); + + $meta2 = new InvoiceMeta(); + $meta2->setName('foo')->setValue('bar')->setType('test2'); + self::assertInstanceOf(Invoice::class, $sut->setMetaField($meta2)); + self::assertEquals(1, $sut->getMetaFields()->count()); + self::assertCount(0, $sut->getVisibleMetaFields()); + + $result = $sut->getMetaField('foo'); + self::assertSame($result, $meta); + self::assertEquals('test2', $result->getType()); + + $sut->setMetaField((new InvoiceMeta())->setName('blub')->setIsVisible(true)); + $sut->setMetaField((new InvoiceMeta())->setName('blab')->setIsVisible(true)); + self::assertEquals(3, $sut->getMetaFields()->count()); + self::assertCount(2, $sut->getVisibleMetaFields()); + } } diff --git a/tests/Repository/Loader/InvoiceLoaderTest.php b/tests/Repository/Loader/InvoiceLoaderTest.php index a1a6296c..da4cd2f2 100644 --- a/tests/Repository/Loader/InvoiceLoaderTest.php +++ b/tests/Repository/Loader/InvoiceLoaderTest.php @@ -20,7 +20,7 @@ class InvoiceLoaderTest extends AbstractLoaderTest { public function testLoadResults() { - $em = $this->getEntityManagerMock(2); + $em = $this->getEntityManagerMock(3); $sut = new InvoiceLoader($em);