Deduplicate BOM entry price logic into ProjectBuildHelper

The private getBomEntryUnitPrice() in ProjectBomEntriesDataTable was
identical to the one in ProjectBuildHelper. Replaced it with a new
public getEntryUnitPrice() on ProjectBuildHelper (returns BigDecimal,
never null) and delegate to it from the DataTable.

This eliminates the duplicate code and brings the DataTable lines under
the existing ProjectBuildHelper test coverage. Added three tests for
getEntryUnitPrice() covering the no-pricing, non-part, and part cases.
This commit is contained in:
MayNiklas 2026-04-15 13:11:06 +02:00
parent 5d669da932
commit fa1e5549f0
3 changed files with 45 additions and 20 deletions

View file

@ -36,8 +36,7 @@ use App\Services\ElementTypeNameGenerator;
use App\Services\EntityURLGenerator;
use App\Services\Formatters\AmountFormatter;
use App\Services\Formatters\MoneyFormatter;
use App\Services\Parts\PricedetailHelper;
use Brick\Math\BigDecimal;
use App\Services\ProjectSystem\ProjectBuildHelper;
use Brick\Math\RoundingMode;
use Doctrine\ORM\AbstractQuery;
use Doctrine\ORM\Query;
@ -55,7 +54,7 @@ class ProjectBomEntriesDataTable implements DataTableTypeInterface
protected TranslatorInterface $translator,
protected AmountFormatter $amountFormatter,
protected PartDataTableHelper $partDataTableHelper,
protected PricedetailHelper $pricedetailHelper,
protected ProjectBuildHelper $projectBuildHelper,
protected MoneyFormatter $moneyFormatter,
) {
}
@ -212,7 +211,7 @@ class ProjectBomEntriesDataTable implements DataTableTypeInterface
'label' => 'project.bom.price',
'visible' => false,
'render' => function ($value, ProjectBOMEntry $context) {
$price = $this->getBomEntryUnitPrice($context);
$price = $this->projectBuildHelper->getEntryUnitPrice($context);
return $this->moneyFormatter->format($price->toScale(2, RoundingMode::UP)->toFloat(), null, 2, true);
},
])
@ -220,7 +219,7 @@ class ProjectBomEntriesDataTable implements DataTableTypeInterface
'label' => 'project.bom.ext_price',
'visible' => false,
'render' => function ($value, ProjectBOMEntry $context) {
$price = $this->getBomEntryUnitPrice($context);
$price = $this->projectBuildHelper->getEntryUnitPrice($context);
return $this->moneyFormatter->format(
$price->multipliedBy($context->getQuantity())->toScale(2, RoundingMode::UP)->toFloat(),
null,
@ -258,21 +257,6 @@ class ProjectBomEntriesDataTable implements DataTableTypeInterface
]);
}
private function getBomEntryUnitPrice(ProjectBOMEntry $entry): BigDecimal
{
if ($entry->getPart() instanceof Part) {
$amount = $entry->getQuantity();
// If the BOM quantity is below the minimum order amount, use the minimum order amount
// for the price lookup — otherwise calculateAvgPrice returns null (no price tier matches).
$minOrderAmount = $this->pricedetailHelper->getMinOrderAmount($entry->getPart());
if ($minOrderAmount !== null) {
$amount = max($amount, $minOrderAmount);
}
return $this->pricedetailHelper->calculateAvgPrice($entry->getPart(), $amount) ?? BigDecimal::zero();
}
return $entry->getPrice() ?? BigDecimal::zero();
}
private function getFilterQuery(QueryBuilder $builder, array $options): void
{
$builder

View file

@ -227,6 +227,16 @@ final readonly class ProjectBuildHelper
?->toScale(2, RoundingMode::UP);
}
/**
* Returns the effective unit price for a single piece of the given BOM entry,
* taking bulk pricing and minimum order amounts into account for N builds.
* Returns BigDecimal::zero() when no pricing data is available.
*/
public function getEntryUnitPrice(ProjectBOMEntry $entry, int $number_of_builds = 1, ?Currency $currency = null): BigDecimal
{
return $this->getBomEntryUnitPrice($entry, $number_of_builds, $currency) ?? BigDecimal::zero();
}
/**
* Returns the effective unit price for a single piece of the given BOM entry,
* taking bulk pricing into account for N builds.

View file

@ -264,6 +264,37 @@ final class ProjectBuildHelperTest extends WebTestCase
$this->assertTrue(BigDecimal::of('11.00')->isEqualTo($result));
}
public function testGetEntryUnitPriceReturnsZeroForNoPricingData(): void
{
$entry = new ProjectBOMEntry();
$entry->setPart(new Part()); // part with no orderdetails
$entry->setQuantity(5);
$result = $this->service->getEntryUnitPrice($entry);
$this->assertTrue(BigDecimal::zero()->isEqualTo($result));
}
public function testGetEntryUnitPriceNonPartEntry(): void
{
$entry = new ProjectBOMEntry();
$entry->setName('Wire');
$entry->setQuantity(2);
$entry->setPrice(BigDecimal::of('1.25'));
$result = $this->service->getEntryUnitPrice($entry);
$this->assertTrue(BigDecimal::of('1.25')->isEqualTo($result));
}
public function testGetEntryUnitPriceWithPart(): void
{
$entry = new ProjectBOMEntry();
$entry->setPart($this->makePartWithPrice(2.00));
$entry->setQuantity(3);
$result = $this->service->getEntryUnitPrice($entry);
$this->assertTrue(BigDecimal::of('2.00')->isEqualTo($result));
}
public function testCalculateTotalBuildPriceRespectsMinOrderAmount(): void
{
$project = new Project();