support custom fields for invoices (#3077)
This commit is contained in:
@@ -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',
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
59
src/Entity/InvoiceMeta.php
Normal file
59
src/Entity/InvoiceMeta.php
Normal 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;
|
||||
}
|
||||
}
|
||||
34
src/Event/InvoiceMetaDefinitionEvent.php
Normal file
34
src/Event/InvoiceMetaDefinitionEvent.php
Normal 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;
|
||||
}
|
||||
}
|
||||
27
src/Event/InvoiceMetaDisplayEvent.php
Normal file
27
src/Event/InvoiceMetaDisplayEvent.php
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
{
|
||||
/**
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -9,6 +9,9 @@
|
||||
|
||||
namespace App\Invoice;
|
||||
|
||||
/**
|
||||
* Will be removed with 2.0, just here for BC compatibility.
|
||||
*/
|
||||
interface InvoiceItemWithAmountInterface
|
||||
{
|
||||
public function getAmount(): float;
|
||||
|
||||
42
src/Migrations/Version20220101204501.php
Normal file
42
src/Migrations/Version20220101204501.php
Normal 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');
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 @@
|
||||
<td class="{{ tables.data_table_column_class(tableName, columns, 'user') }}">{{ widgets.user_avatar(entry.user) }} {{ widgets.username(entry.user) }}</td>
|
||||
<td class="{{ tables.data_table_column_class(tableName, columns, 'customer') }}">{{ widgets.label_customer(entry.customer) }}</td>
|
||||
<td class="{{ tables.data_table_column_class(tableName, columns, 'comment') }}">{{ entry.comment }}</td>
|
||||
{% for field in metaColumns %}
|
||||
<td class="{{ tables.data_table_column_class(tableName, columns, 'mf_' ~ field.name) }}">
|
||||
{{ tables.datatable_meta_column(entry, field) }}
|
||||
</td>
|
||||
{% endfor %}
|
||||
<td class="{{ tables.data_table_column_class(tableName, columns, 'invoice_number') }}">{{ widgets.label(entry.invoiceNumber, 'default') }}</td>
|
||||
<td class="{{ tables.data_table_column_class(tableName, columns, 'due_date') }}">{{ macros.invoice_due_date(entry) }}</td>
|
||||
<td class="{{ tables.data_table_column_class(tableName, columns, 'payment_date') }}">
|
||||
|
||||
@@ -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();
|
||||
|
||||
41
tests/Entity/InvoiceMetaTest.php
Normal file
41
tests/Entity/InvoiceMetaTest.php
Normal file
@@ -0,0 +1,41 @@
|
||||
<?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\Tests\Entity;
|
||||
|
||||
use App\Entity\Customer;
|
||||
use App\Entity\EntityWithMetaFields;
|
||||
use App\Entity\Invoice;
|
||||
use App\Entity\InvoiceMeta;
|
||||
use App\Entity\MetaTableTypeInterface;
|
||||
|
||||
/**
|
||||
* @covers \App\Entity\InvoiceMeta
|
||||
*/
|
||||
class InvoiceMetaTest extends AbstractMetaEntityTest
|
||||
{
|
||||
protected function getEntity(): EntityWithMetaFields
|
||||
{
|
||||
return new Invoice();
|
||||
}
|
||||
|
||||
protected function getMetaEntity(): MetaTableTypeInterface
|
||||
{
|
||||
return new InvoiceMeta();
|
||||
}
|
||||
|
||||
public function testSetEntityThrowsException()
|
||||
{
|
||||
$this->expectException(\InvalidArgumentException::class);
|
||||
$this->expectExceptionMessage('Expected instanceof Invoice, received "App\Entity\Customer"');
|
||||
|
||||
$sut = new InvoiceMeta();
|
||||
$sut->setEntity(new Customer());
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ class InvoiceLoaderTest extends AbstractLoaderTest
|
||||
{
|
||||
public function testLoadResults()
|
||||
{
|
||||
$em = $this->getEntityManagerMock(2);
|
||||
$em = $this->getEntityManagerMock(3);
|
||||
|
||||
$sut = new InvoiceLoader($em);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user