Compare commits

...

3 commits

Author SHA1 Message Date
Jan Böhmer
4740b6d19e Show in part info page whether price is inclusive VAT or not
Some checks failed
Build assets artifact / Build assets artifact (push) Has been cancelled
Docker Image Build / docker (push) Has been cancelled
Docker Image Build (FrankenPHP) / docker (push) Has been cancelled
Static analysis / Static analysis (push) Has been cancelled
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.2, mysql) (push) Has been cancelled
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.3, mysql) (push) Has been cancelled
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.4, mysql) (push) Has been cancelled
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.5, mysql) (push) Has been cancelled
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.2, postgres) (push) Has been cancelled
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.3, postgres) (push) Has been cancelled
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.4, postgres) (push) Has been cancelled
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.5, postgres) (push) Has been cancelled
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.2, sqlite) (push) Has been cancelled
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.3, sqlite) (push) Has been cancelled
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.4, sqlite) (push) Has been cancelled
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.5, sqlite) (push) Has been cancelled
2026-02-08 22:09:36 +01:00
Jan Böhmer
5a47b15c97 Use the information from info provider whether prices includes VAT or not 2026-02-08 21:58:14 +01:00
Jan Böhmer
3bff5fa8bd Allow to set if prices contain VAT or not in orderdetail 2026-02-08 21:54:34 +01:00
12 changed files with 215 additions and 51 deletions

View file

@ -74,16 +74,24 @@ export default class extends Controller {
const newElementStr = this.htmlDecode(prototype.replace(regex, this.generateUID())); const newElementStr = this.htmlDecode(prototype.replace(regex, this.generateUID()));
let ret = null;
//Insert new html after the last child element //Insert new html after the last child element
//If the table has a tbody, insert it there //If the table has a tbody, insert it there
//Afterwards return the newly created row //Afterwards return the newly created row
if(targetTable.tBodies[0]) { if(targetTable.tBodies[0]) {
targetTable.tBodies[0].insertAdjacentHTML('beforeend', newElementStr); targetTable.tBodies[0].insertAdjacentHTML('beforeend', newElementStr);
return targetTable.tBodies[0].lastElementChild; ret = targetTable.tBodies[0].lastElementChild;
} else { //Otherwise just insert it } else { //Otherwise just insert it
targetTable.insertAdjacentHTML('beforeend', newElementStr); targetTable.insertAdjacentHTML('beforeend', newElementStr);
return targetTable.lastElementChild; ret = targetTable.lastElementChild;
} }
//Trigger an event to notify other components that a new element has been created, so they can for example initialize select2 on it
targetTable.dispatchEvent(new CustomEvent("collection:elementAdded", {bubbles: true}));
return ret;
} }
/** /**

View file

@ -56,7 +56,8 @@ class TristateHelper {
document.addEventListener("turbo:load", listener); document.addEventListener("turbo:load", listener);
document.addEventListener("turbo:render", listener); document.addEventListener("turbo:render", listener);
document.addEventListener("collection:elementAdded", listener);
} }
} }
export default new TristateHelper(); export default new TristateHelper();

View file

@ -52,6 +52,7 @@ use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM; use Doctrine\ORM\Mapping as ORM;
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity; use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
use Symfony\Component\Serializer\Annotation\Groups; use Symfony\Component\Serializer\Annotation\Groups;
use Symfony\Component\Serializer\Annotation\SerializedName;
use Symfony\Component\Validator\Constraints as Assert; use Symfony\Component\Validator\Constraints as Assert;
use Symfony\Component\Validator\Constraints\Length; use Symfony\Component\Validator\Constraints\Length;
@ -388,6 +389,50 @@ class Orderdetail extends AbstractDBElement implements TimeStampableInterface, N
return $this; return $this;
} }
/**
* Checks if the prices of this orderdetail include VAT. This is determined by checking the pricedetails of this
* orderdetail. If there are no pricedetails or if the pricedetails have conflicting values, null is returned.
* @return bool|null
*/
#[Groups(['orderdetail:read'])]
#[SerializedName('prices_include_vat')]
public function getPricesIncludesVAT(): ?bool
{
$value = null;
//We determine that via the pricedetails
foreach ($this->getPricedetails() as $pricedetail) {
/** @var Pricedetail $pricedetail */
if ($pricedetail->getIncludesVat() === null) {
return null; // If any pricedetail doesn't specify this, we can't determine it
}
if ($value === null) {
$value = $pricedetail->getIncludesVat(); // Set initial value
} elseif ($value !== $pricedetail->getIncludesVat()) {
return null; // If there are conflicting values, we can't determine it
}
}
return $value;
}
/**
* Sets whether the prices of this orderdetail include VAT. This is set for all pricedetails of this orderdetail.
* @param bool|null $includesVat
* @return $this
*/
#[Groups(['orderdetail:write'])]
#[SerializedName('prices_include_vat')]
public function setPricesIncludesVAT(?bool $includesVat): self
{
foreach ($this->getPricedetails() as $pricedetail) {
/** @var Pricedetail $pricedetail */
$pricedetail->setIncludesVat($includesVat);
}
return $this;
}
public function getName(): string public function getName(): string
{ {
return $this->getSupplierPartNr(); return $this->getSupplierPartNr();

View file

@ -124,9 +124,9 @@ class Pricedetail extends AbstractDBElement implements TimeStampableInterface
/** /**
* @var bool|null Whether the price includes VAT or not. Null means, that it is not specified, if the price includes VAT or not. * @var bool|null Whether the price includes VAT or not. Null means, that it is not specified, if the price includes VAT or not.
*/ */
#[ORM\Column(type: Types::BOOLEAN, nullable: true)] #[ORM\Column(name: "include_vat", type: Types::BOOLEAN, nullable: true)]
#[Groups(['extended', 'full', 'import', 'pricedetail:read', 'pricedetail:write'])] #[Groups(['extended', 'full', 'import', 'pricedetail:read', 'pricedetail:write'])]
protected ?bool $include_vat = null; protected ?bool $includes_vat = null;
public function __construct() public function __construct()
{ {
@ -271,6 +271,15 @@ class Pricedetail extends AbstractDBElement implements TimeStampableInterface
return $this->currency?->getIsoCode(); return $this->currency?->getIsoCode();
} }
/**
* Returns whether the price includes VAT or not. Null means, that it is not specified, if the price includes VAT or not.
* @return bool|null
*/
public function getIncludesVat(): ?bool
{
return $this->includes_vat;
}
/******************************************************************************** /********************************************************************************
* *
* Setters * Setters
@ -369,24 +378,13 @@ class Pricedetail extends AbstractDBElement implements TimeStampableInterface
} }
/** /**
* Returns whether the price includes VAT or not. Null means, that it is not specified, if the price includes VAT or not. * Set whether the price includes VAT or not. Null means, that it is not specified, if the price includes VAT or not.
* @return bool|null * @param bool|null $includes_vat
*/
public function getIncludeVat(): ?bool
{
return $this->include_vat;
}
/**
* Sets whether the price includes VAT or not. Null means, that it is not specified, if the price includes VAT or not.
* @param bool|null $include_vat
* @return $this * @return $this
*/ */
public function setIncludeVat(?bool $include_vat): self public function setIncludesVat(?bool $includes_vat): self
{ {
$this->include_vat = $include_vat; $this->includes_vat = $includes_vat;
return $this; return $this;
} }
} }

View file

@ -22,6 +22,7 @@ declare(strict_types=1);
namespace App\Form\Part; namespace App\Form\Part;
use App\Form\Type\TriStateCheckboxType;
use Symfony\Bundle\SecurityBundle\Security; use Symfony\Bundle\SecurityBundle\Security;
use App\Entity\Parts\MeasurementUnit; use App\Entity\Parts\MeasurementUnit;
use App\Entity\Parts\Supplier; use App\Entity\Parts\Supplier;
@ -73,6 +74,11 @@ class OrderdetailType extends AbstractType
'label' => 'orderdetails.edit.obsolete', 'label' => 'orderdetails.edit.obsolete',
]); ]);
$builder->add('pricesIncludesVAT', TriStateCheckboxType::class, [
'required' => false,
'label' => 'orderdetails.edit.prices_includes_vat',
]);
//Add pricedetails after we know the data, so we can set the default currency //Add pricedetails after we know the data, so we can set the default currency
$builder->addEventListener(FormEvents::PRE_SET_DATA, function (FormEvent $event) use ($options): void { $builder->addEventListener(FormEvents::PRE_SET_DATA, function (FormEvent $event) use ($options): void {
/** @var Orderdetail $orderdetail */ /** @var Orderdetail $orderdetail */

View file

@ -94,13 +94,14 @@ final class DTOtoEntityConverter
$entity->setPrice($dto->getPriceAsBigDecimal()); $entity->setPrice($dto->getPriceAsBigDecimal());
$entity->setPriceRelatedQuantity($dto->price_related_quantity); $entity->setPriceRelatedQuantity($dto->price_related_quantity);
//Currency TODO
if ($dto->currency_iso_code !== null) { if ($dto->currency_iso_code !== null) {
$entity->setCurrency($this->getCurrency($dto->currency_iso_code)); $entity->setCurrency($this->getCurrency($dto->currency_iso_code));
} else { } else {
$entity->setCurrency(null); $entity->setCurrency(null);
} }
$entity->setIncludesVat($dto->includes_tax);
return $entity; return $entity;
} }

View file

@ -192,7 +192,7 @@
{% set preview_attach = part_preview_generator.tablePreviewAttachment(part) %} {% set preview_attach = part_preview_generator.tablePreviewAttachment(part) %}
{% if preview_attach %} {% if preview_attach %}
<img src="{{ attachment_thumbnail(preview_attach, 'thumbnail_xs') }}" class="entity-image-xs" alt="Part image" <img src="{{ attachment_thumbnail(preview_attach, 'thumbnail_xs') }}" class="entity-image-xs" alt="Part image"
{{ stimulus_controller('elements/hoverpic') }} data-thumbnail="{{ attachment_thumbnail(preview_attach) }}"> {{ stimulus_controller('elements/hoverpic') }} data-thumbnail="{{ attachment_thumbnail(preview_attach) }}">
{% endif %} {% endif %}
<a href="{{ entity_url(part) }}">{{ part.name }}</a> <a href="{{ entity_url(part) }}">{{ part.name }}</a>
{% endmacro %} {% endmacro %}
@ -241,3 +241,11 @@
{{ datetime|format_datetime }} {{ datetime|format_datetime }}
{% endif %} {% endif %}
{% endmacro %} {% endmacro %}
{% macro vat_text(bool) %}
{% if bool === true %}
({% trans %}prices.incl_vat{% endtrans %})
{% elseif bool === false %}
({% trans %}prices.excl_vat{% endtrans %})
{% endif %}
{% endmacro %}

View file

@ -32,6 +32,7 @@
{{ form_row(form.supplierpartnr, {'attr': {'class': 'form-control-sm'}}) }} {{ form_row(form.supplierpartnr, {'attr': {'class': 'form-control-sm'}}) }}
{{ form_row(form.supplier_product_url, {'attr': {'class': 'form-control-sm'}}) }} {{ form_row(form.supplier_product_url, {'attr': {'class': 'form-control-sm'}}) }}
{{ form_widget(form.obsolete) }} {{ form_widget(form.obsolete) }}
{{ form_row(form.pricesIncludesVAT) }}
</td> </td>
<td> <td>
<div {{ collection.controller(form.pricedetails, 'pricedetails.edit.delete.confirm') }}> <div {{ collection.controller(form.pricedetails, 'pricedetails.edit.delete.confirm') }}>
@ -226,4 +227,4 @@
{{ form_errors(form) }} {{ form_errors(form) }}
</td> </td>
</tr> </tr>
{% endblock %} {% endblock %}

View file

@ -24,8 +24,8 @@
</td> </td>
<td> <td>
{% if order.pricedetails is not empty %} {% if order.pricedetails is not empty %}
<table class="table table-bordered table-sm table-striped table-hover"> <table class="table table-bordered table-sm table-striped table-hover">
<thead class="thead-dark"> <thead class="thead-dark">
<tr> <tr>
<th>{% trans %}part.order.minamount{% endtrans %}</th> <th>{% trans %}part.order.minamount{% endtrans %}</th>
<th>{% trans %}part.order.price{% endtrans %}</th> <th>{% trans %}part.order.price{% endtrans %}</th>
@ -36,32 +36,35 @@
{% endif %} {% endif %}
</th> </th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{% for detail in order.pricedetails %} {% for detail in order.pricedetails %}
<tr> {# @var detail App\Entity\PriceInformations\Pricedetail #}
<tr>
<td> <td>
{{ detail.MinDiscountQuantity | format_amount(part.partUnit) }} {{ detail.MinDiscountQuantity | format_amount(part.partUnit) }}
</td> </td>
<td> <td>
{{ detail.price | format_money(detail.currency) }} / {{ detail.PriceRelatedQuantity | format_amount(part.partUnit) }} {{ detail.price | format_money(detail.currency) }} / {{ detail.PriceRelatedQuantity | format_amount(part.partUnit) }}
{% set tmp = pricedetail_helper.convertMoneyToCurrency(detail.price, detail.currency) %} {% set tmp = pricedetail_helper.convertMoneyToCurrency(detail.price, detail.currency) %}
{% if detail.currency != (app.user.currency ?? null) and tmp is not null and tmp.GreaterThan(0) %} {% if detail.currency != (app.user.currency ?? null) and tmp is not null and tmp.GreaterThan(0) %}
<span class="text-muted">({{ pricedetail_helper.convertMoneyToCurrency(detail.price, detail.currency, app.user.currency ?? null) | format_money(app.user.currency ?? null) }})</span> <span class="text-muted">({{ pricedetail_helper.convertMoneyToCurrency(detail.price, detail.currency, app.user.currency ?? null) | format_money(app.user.currency ?? null) }})</span>
{% endif %} {% endif %}
</td> <small class="text-muted">{{- helper.vat_text(detail.includesVAT) -}}</small>
<td> </td>
{{ detail.PricePerUnit | format_money(detail.currency) }} <td>
{% set tmp = pricedetail_helper.convertMoneyToCurrency(detail.PricePerUnit, detail.currency) %} {{ detail.PricePerUnit | format_money(detail.currency) }}
{% if detail.currency != (app.user.currency ?? null) and tmp is not null and tmp.GreaterThan(0) %} {% set tmp = pricedetail_helper.convertMoneyToCurrency(detail.PricePerUnit, detail.currency) %}
<span class="text-muted">({{ pricedetail_helper.convertMoneyToCurrency(detail.PricePerUnit, detail.currency, app.user.currency ?? null) | format_money(app.user.currency ?? null) }})</span> {% if detail.currency != (app.user.currency ?? null) and tmp is not null and tmp.GreaterThan(0) %}
{% endif %} <span class="text-muted">({{ pricedetail_helper.convertMoneyToCurrency(detail.PricePerUnit, detail.currency, app.user.currency ?? null) | format_money(app.user.currency ?? null) }})</span>
</td> {% endif %}
</tr> <small class="text-muted">{{- helper.vat_text(detail.includesVAT) -}}</small>
{% endfor %} </td>
</tbody> </tr>
</table> {% endfor %}
</tbody>
</table>
{% endif %} {% endif %}
</td> </td>
<td> {# Action for order information #} <td> {# Action for order information #}
@ -80,4 +83,4 @@
{% endfor %} {% endfor %}
</tbody> </tbody>
</table> </table>
</div> </div>

View file

@ -61,4 +61,77 @@ class OrderdetailTest extends TestCase
$this->assertSame($price5, $orderdetail->findPriceForQty(5.3)); $this->assertSame($price5, $orderdetail->findPriceForQty(5.3));
$this->assertSame($price5, $orderdetail->findPriceForQty(10000)); $this->assertSame($price5, $orderdetail->findPriceForQty(10000));
} }
public function testGetPricesIncludesVAT(): void
{
$orderdetail = new Orderdetail();
//By default, the pricesIncludesVAT property should be null for empty orderdetails
$this->assertNull($orderdetail->getPricesIncludesVAT());
$price0 = (new Pricedetail())->setMinDiscountQuantity(0.23);
$price1 = (new Pricedetail())->setMinDiscountQuantity(1);
$price5 = (new Pricedetail())->setMinDiscountQuantity(5.3);
$orderdetail->addPricedetail($price0)->addPricedetail($price1)->addPricedetail($price5);
//With empty pricedetails, the pricesIncludesVAT property should still be null
$this->assertNull($orderdetail->getPricesIncludesVAT());
//If all of the pricedetails have the same value for includesVAT, the pricesIncludesVAT property should return this value
$price0->setIncludesVAT(true);
$price1->setIncludesVAT(true);
$price5->setIncludesVAT(true);
$this->assertTrue($orderdetail->getPricesIncludesVAT());
$price0->setIncludesVAT(false);
$price1->setIncludesVAT(false);
$price5->setIncludesVAT(false);
$this->assertFalse($orderdetail->getPricesIncludesVAT());
//If the pricedetails have different values for includesVAT, the pricesIncludesVAT property should return null
$price0->setIncludesVAT(true);
$price1->setIncludesVAT(false);
$price5->setIncludesVAT(true);
$this->assertNull($orderdetail->getPricesIncludesVAT());
//If the pricedetails have different values for includesVAT, the pricesIncludesVAT property should return null, even if one of them is null
$price0->setIncludesVAT(null);
$price1->setIncludesVAT(false);
$price5->setIncludesVAT(false);
$this->assertNull($orderdetail->getPricesIncludesVAT());
}
public function testSetPricesIncludesVAT(): void
{
$orderdetail = new Orderdetail();
$price0 = (new Pricedetail())->setMinDiscountQuantity(0.23);
$price1 = (new Pricedetail())->setMinDiscountQuantity(1);
$price5 = (new Pricedetail())->setMinDiscountQuantity(5.3);
$orderdetail->addPricedetail($price0)->addPricedetail($price1)->addPricedetail($price5);
$this->assertNull($orderdetail->getPricesIncludesVAT());
$orderdetail->setPricesIncludesVAT(true);
$this->assertTrue($orderdetail->getPricesIncludesVAT());
//Ensure that the pricesIncludesVAT property is correctly propagated to the pricedetails
foreach ($orderdetail->getPricedetails() as $pricedetail) {
$this->assertTrue($pricedetail->getIncludesVAT());
}
$orderdetail->setPricesIncludesVAT(false);
$this->assertFalse($orderdetail->getPricesIncludesVAT());
//Ensure that the pricesIncludesVAT property is correctly propagated to the pricedetails
foreach ($orderdetail->getPricedetails() as $pricedetail) {
$this->assertFalse($pricedetail->getIncludesVAT());
}
$orderdetail->setPricesIncludesVAT(null);
$this->assertNull($orderdetail->getPricesIncludesVAT());
//Ensure that the pricesIncludesVAT property is correctly propagated to the pricedetails
foreach ($orderdetail->getPricedetails() as $pricedetail) {
$this->assertNull($pricedetail->getIncludesVAT());
}
}
} }

View file

@ -101,6 +101,8 @@ class DTOtoEntityConverterTest extends WebTestCase
//For base currencies, the currency field is null //For base currencies, the currency field is null
$this->assertNull($entity->getCurrency()); $this->assertNull($entity->getCurrency());
$this->assertTrue($entity->getIncludesVat());
} }
public function testConvertPurchaseInfo(): void public function testConvertPurchaseInfo(): void

View file

@ -12437,5 +12437,23 @@ Buerklin-API Authentication server:
<target>Make this attachment type only available for certain element classes. Leave empty to show this attachment type for all element classes.</target> <target>Make this attachment type only available for certain element classes. Leave empty to show this attachment type for all element classes.</target>
</segment> </segment>
</unit> </unit>
<unit id="LvlEUjC" name="orderdetails.edit.prices_includes_vat">
<segment>
<source>orderdetails.edit.prices_includes_vat</source>
<target>Prices include VAT</target>
</segment>
</unit>
<unit id="GUsVh5T" name="prices.incl_vat">
<segment>
<source>prices.incl_vat</source>
<target>Incl. VAT</target>
</segment>
</unit>
<unit id="3ipwaVQ" name="prices.excl_vat">
<segment>
<source>prices.excl_vat</source>
<target>Excl. VAT</target>
</segment>
</unit>
</file> </file>
</xliff> </xliff>