From 1611b6cd4190296352b3bcbd303ca4599e732cc9 Mon Sep 17 00:00:00 2001
From: MayNiklas
Date: Wed, 15 Apr 2026 11:01:12 +0200
Subject: [PATCH 1/6] Add unit price and extended price columns to project BOM
table
Adds two optional columns to the project BOM datatable (hidden by
default, toggleable via column visibility):
- **Price**: unit price for the BOM entry in the base currency,
looked up via PricedetailHelper. For parts whose BOM quantity falls
below the minimum order amount the minimum order amount is used for
the price tier lookup so that a price is always returned.
- **Extended Price**: unit price multiplied by the BOM quantity.
Prices are rendered via MoneyFormatter (locale-aware, with currency
symbol). Both columns round up to 2 decimal places to avoid displaying
0.00 for very small prices.
---
src/DataTables/ProjectBomEntriesDataTable.php | 46 ++++++++++++++++++-
1 file changed, 44 insertions(+), 2 deletions(-)
diff --git a/src/DataTables/ProjectBomEntriesDataTable.php b/src/DataTables/ProjectBomEntriesDataTable.php
index 04d8206bd..6ee726e89 100644
--- a/src/DataTables/ProjectBomEntriesDataTable.php
+++ b/src/DataTables/ProjectBomEntriesDataTable.php
@@ -29,12 +29,16 @@
use App\DataTables\Column\MarkdownColumn;
use App\DataTables\Helpers\PartDataTableHelper;
use App\Doctrine\Helpers\FieldHelper;
-use App\Entity\Parts\Part;
use App\Entity\Parts\ManufacturingStatus;
+use App\Entity\Parts\Part;
use App\Entity\ProjectSystem\ProjectBOMEntry;
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 Brick\Math\RoundingMode;
use Doctrine\ORM\AbstractQuery;
use Doctrine\ORM\Query;
use Doctrine\ORM\QueryBuilder;
@@ -50,7 +54,9 @@ public function __construct(
protected EntityURLGenerator $entityURLGenerator,
protected TranslatorInterface $translator,
protected AmountFormatter $amountFormatter,
- protected PartDataTableHelper $partDataTableHelper
+ protected PartDataTableHelper $partDataTableHelper,
+ protected PricedetailHelper $pricedetailHelper,
+ protected MoneyFormatter $moneyFormatter,
) {
}
@@ -202,6 +208,27 @@ public function configure(DataTable $dataTable, array $options): void
return '';
}
])
+ ->add('price', TextColumn::class, [
+ 'label' => 'project.bom.price',
+ 'visible' => false,
+ 'render' => function ($value, ProjectBOMEntry $context) {
+ $price = $this->getBomEntryUnitPrice($context);
+ return $this->moneyFormatter->format($price->toScale(2, RoundingMode::UP)->toFloat(), null, 2, true);
+ },
+ ])
+ ->add('ext_price', TextColumn::class, [
+ 'label' => 'project.bom.ext_price',
+ 'visible' => false,
+ 'render' => function ($value, ProjectBOMEntry $context) {
+ $price = $this->getBomEntryUnitPrice($context);
+ return $this->moneyFormatter->format(
+ $price->multipliedBy($context->getQuantity())->toScale(2, RoundingMode::UP)->toFloat(),
+ null,
+ 2,
+ true
+ );
+ },
+ ])
->add('addedDate', LocaleDateTimeColumn::class, [
'label' => $this->translator->trans('part.table.addedDate'),
@@ -231,6 +258,21 @@ function (QueryBuilder $builder) use ($options): void {
]);
}
+ 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
From e9fb0dba51e7da646065b53e78d9de6e9898f89d Mon Sep 17 00:00:00 2001
From: MayNiklas
Date: Wed, 15 Apr 2026 11:01:20 +0200
Subject: [PATCH 2/6] Add translation key for project.bom.ext_price
Adds the English translation "Extended Price" for the new BOM extended
price column. Other languages are marked needs-translation and will be
picked up by Crowdin.
---
translations/messages.cs.xlf | 6 ++++++
translations/messages.da.xlf | 6 ++++++
translations/messages.de.xlf | 6 ++++++
translations/messages.en.xlf | 6 ++++++
translations/messages.es.xlf | 6 ++++++
translations/messages.hu.xlf | 6 ++++++
translations/messages.it.xlf | 6 ++++++
translations/messages.pl.xlf | 6 ++++++
translations/messages.ru.xlf | 6 ++++++
translations/messages.zh.xlf | 6 ++++++
10 files changed, 60 insertions(+)
diff --git a/translations/messages.cs.xlf b/translations/messages.cs.xlf
index 74ca2a26f..0f4cf2c8a 100644
--- a/translations/messages.cs.xlf
+++ b/translations/messages.cs.xlf
@@ -7241,6 +7241,12 @@ Element 3
Cena
+
+
+ project.bom.ext_price
+ Extended Price
+
+
part.info.withdraw_modal.title.withdraw
diff --git a/translations/messages.da.xlf b/translations/messages.da.xlf
index 9878a09e8..85faf9c2a 100644
--- a/translations/messages.da.xlf
+++ b/translations/messages.da.xlf
@@ -7184,6 +7184,12 @@ Element 3
Pris
+
+
+ project.bom.ext_price
+ Extended Price
+
+
part.info.withdraw_modal.title.withdraw
diff --git a/translations/messages.de.xlf b/translations/messages.de.xlf
index db5951368..680d4b7fd 100644
--- a/translations/messages.de.xlf
+++ b/translations/messages.de.xlf
@@ -7235,6 +7235,12 @@ Element 1 -> Element 1.2
Preis
+
+
+ project.bom.ext_price
+ Extended Price
+
+
part.info.withdraw_modal.title.withdraw
diff --git a/translations/messages.en.xlf b/translations/messages.en.xlf
index 176c66504..589dc2383 100644
--- a/translations/messages.en.xlf
+++ b/translations/messages.en.xlf
@@ -7236,6 +7236,12 @@ Element 1 -> Element 1.2
Price
+
+
+ project.bom.ext_price
+ Extended Price
+
+
part.info.withdraw_modal.title.withdraw
diff --git a/translations/messages.es.xlf b/translations/messages.es.xlf
index 17b2156bd..c580a4916 100644
--- a/translations/messages.es.xlf
+++ b/translations/messages.es.xlf
@@ -7259,6 +7259,12 @@ Elemento 3
Precio
+
+
+ project.bom.ext_price
+ Extended Price
+
+
part.info.withdraw_modal.title.withdraw
diff --git a/translations/messages.hu.xlf b/translations/messages.hu.xlf
index ba47c2e2f..86dd9f6cf 100644
--- a/translations/messages.hu.xlf
+++ b/translations/messages.hu.xlf
@@ -7198,6 +7198,12 @@
Ár
+
+
+ project.bom.ext_price
+ Extended Price
+
+
part.info.withdraw_modal.title.withdraw
diff --git a/translations/messages.it.xlf b/translations/messages.it.xlf
index cfaee7a25..70fdbdfa8 100644
--- a/translations/messages.it.xlf
+++ b/translations/messages.it.xlf
@@ -7186,6 +7186,12 @@ Element 3
Prezzo
+
+
+ project.bom.ext_price
+ Extended Price
+
+
part.info.withdraw_modal.title.withdraw
diff --git a/translations/messages.pl.xlf b/translations/messages.pl.xlf
index a4eb1cda0..0237d46e9 100644
--- a/translations/messages.pl.xlf
+++ b/translations/messages.pl.xlf
@@ -7256,6 +7256,12 @@ Element 3
Cena
+
+
+ project.bom.ext_price
+ Extended Price
+
+
part.info.withdraw_modal.title.withdraw
diff --git a/translations/messages.ru.xlf b/translations/messages.ru.xlf
index 4fd2aa823..f0d185589 100644
--- a/translations/messages.ru.xlf
+++ b/translations/messages.ru.xlf
@@ -7260,6 +7260,12 @@
Цена
+
+
+ project.bom.ext_price
+ Extended Price
+
+
part.info.withdraw_modal.title.withdraw
diff --git a/translations/messages.zh.xlf b/translations/messages.zh.xlf
index 9455240cf..fde08cfec 100644
--- a/translations/messages.zh.xlf
+++ b/translations/messages.zh.xlf
@@ -7259,6 +7259,12 @@ Element 3
价格
+
+
+ project.bom.ext_price
+ Extended Price
+
+
part.info.withdraw_modal.title.withdraw
From 82e55bc83d3752f6cd5e356a671c8052a456f399 Mon Sep 17 00:00:00 2001
From: MayNiklas
Date: Wed, 15 Apr 2026 12:38:06 +0200
Subject: [PATCH 3/6] Add build price summary to project info tab
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Displays the total BOM price for N builds on the project info page,
using the existing price-tier logic from PricedetailHelper. The user
can adjust the number of builds via a small form; the unit price is
also shown when N > 1.
New backend:
- ProjectBuildHelper gains calculateTotalBuildPrice(),
calculateUnitBuildPrice(), roundedTotalBuildPrice(), and
roundedUnitBuildPrice() — bulk-order quantities are factored in so
that price tiers apply correctly across N builds.
- ProjectController::info() now reads ?n= and passes number_of_builds
to the template.
Template (_info.html.twig):
- Adds price badge (hidden when no pricing data is available).
- Adds number-of-builds form that reloads the info page.
---
src/Controller/ProjectController.php | 3 +
.../ProjectSystem/ProjectBuildHelper.php | 77 ++++++++++++++++++-
templates/projects/info/_info.html.twig | 36 +++++++--
translations/messages.en.xlf | 12 +++
4 files changed, 121 insertions(+), 7 deletions(-)
diff --git a/src/Controller/ProjectController.php b/src/Controller/ProjectController.php
index d2c35efd4..531deb3f4 100644
--- a/src/Controller/ProjectController.php
+++ b/src/Controller/ProjectController.php
@@ -69,10 +69,13 @@ public function info(Project $project, Request $request, ProjectBuildHelper $bui
return $table->getResponse();
}
+ $number_of_builds = max(1, $request->query->getInt('n', 1));
+
return $this->render('projects/info/info.html.twig', [
'buildHelper' => $buildHelper,
'datatable' => $table,
'project' => $project,
+ 'number_of_builds' => $number_of_builds,
]);
}
diff --git a/src/Services/ProjectSystem/ProjectBuildHelper.php b/src/Services/ProjectSystem/ProjectBuildHelper.php
index a541c29d1..9d643a872 100644
--- a/src/Services/ProjectSystem/ProjectBuildHelper.php
+++ b/src/Services/ProjectSystem/ProjectBuildHelper.php
@@ -25,16 +25,22 @@
use App\Entity\Parts\Part;
use App\Entity\ProjectSystem\Project;
use App\Entity\ProjectSystem\ProjectBOMEntry;
+use App\Entity\PriceInformations\Currency;
use App\Helpers\Projects\ProjectBuildRequest;
use App\Services\Parts\PartLotWithdrawAddHelper;
+use App\Services\Parts\PricedetailHelper;
+use Brick\Math\BigDecimal;
+use Brick\Math\RoundingMode;
/**
* @see \App\Tests\Services\ProjectSystem\ProjectBuildHelperTest
*/
final readonly class ProjectBuildHelper
{
- public function __construct(private PartLotWithdrawAddHelper $withdraw_add_helper)
- {
+ public function __construct(
+ private PartLotWithdrawAddHelper $withdraw_add_helper,
+ private PricedetailHelper $pricedetailHelper,
+ ) {
}
/**
@@ -168,4 +174,71 @@ public function doBuild(ProjectBuildRequest $buildRequest): void
$this->withdraw_add_helper->add($buildRequest->getBuildsPartLot(), $buildRequest->getNumberOfBuilds(), $message);
}
}
+
+ /**
+ * Calculates the total price to build the given project N times, taking bulk pricing into account.
+ * Returns null if no BOM entry has any pricing information.
+ */
+ public function calculateTotalBuildPrice(Project $project, int $number_of_builds = 1, ?Currency $currency = null): ?BigDecimal
+ {
+ $total = BigDecimal::zero();
+ $has_price = false;
+
+ foreach ($project->getBomEntries() as $entry) {
+ $unit_price = $this->getBomEntryUnitPrice($entry, $number_of_builds, $currency);
+ if ($unit_price === null) {
+ continue;
+ }
+ $has_price = true;
+ $total = $total->plus($unit_price->multipliedBy($entry->getQuantity())->multipliedBy($number_of_builds));
+ }
+
+ return $has_price ? $total : null;
+ }
+
+ /**
+ * Calculates the price to build one unit of the given project when ordering for N builds in total.
+ * Returns null if no BOM entry has any pricing information.
+ */
+ public function calculateUnitBuildPrice(Project $project, int $number_of_builds = 1, ?Currency $currency = null): ?BigDecimal
+ {
+ $total = $this->calculateTotalBuildPrice($project, $number_of_builds, $currency);
+ if ($total === null) {
+ return null;
+ }
+ return $total->dividedBy($number_of_builds, 10, RoundingMode::HALF_UP);
+ }
+
+ /**
+ * Returns the total build price rounded up to 2 decimal places, ready for display.
+ */
+ public function roundedTotalBuildPrice(Project $project, int $number_of_builds = 1, ?Currency $currency = null): ?BigDecimal
+ {
+ return $this->calculateTotalBuildPrice($project, $number_of_builds, $currency)
+ ?->toScale(2, RoundingMode::UP);
+ }
+
+ /**
+ * Returns the unit build price rounded up to 2 decimal places, ready for display.
+ */
+ public function roundedUnitBuildPrice(Project $project, int $number_of_builds = 1, ?Currency $currency = null): ?BigDecimal
+ {
+ return $this->calculateUnitBuildPrice($project, $number_of_builds, $currency)
+ ?->toScale(2, RoundingMode::UP);
+ }
+
+ /**
+ * Returns the effective unit price for a single piece of the given BOM entry,
+ * taking bulk pricing into account for N builds.
+ */
+ private function getBomEntryUnitPrice(ProjectBOMEntry $entry, int $number_of_builds, ?Currency $currency): ?BigDecimal
+ {
+ if ($entry->getPart() instanceof Part) {
+ $total_qty = $entry->getQuantity() * $number_of_builds;
+ $min_order = $this->pricedetailHelper->getMinOrderAmount($entry->getPart());
+ $effective_qty = ($min_order !== null) ? max($total_qty, $min_order) : $total_qty;
+ return $this->pricedetailHelper->calculateAvgPrice($entry->getPart(), $effective_qty, $currency);
+ }
+ return $entry->getPrice();
+ }
}
diff --git a/templates/projects/info/_info.html.twig b/templates/projects/info/_info.html.twig
index b95be2537..c3a8e86d8 100644
--- a/templates/projects/info/_info.html.twig
+++ b/templates/projects/info/_info.html.twig
@@ -55,6 +55,32 @@
+ {% set n = number_of_builds ?? 1 %}
+ {% set total_build_price = buildHelper.roundedTotalBuildPrice(project, n, app.user.currency ?? null) %}
+ {% set unit_build_price = buildHelper.roundedUnitBuildPrice(project, n, app.user.currency ?? null) %}
+ {% if total_build_price is not null %}
+
+
+
+
+ {% trans %}project.info.total_build_price{% endtrans %}:
+ {{ total_build_price | format_money(app.user.currency ?? null, 2) }}
+ {% if n > 1 and unit_build_price is not null %}
+
+ ({% trans %}project.info.per_unit_price{% endtrans %}: {{ unit_build_price | format_money(app.user.currency ?? null, 2) }})
+
+ {% endif %}
+
+
+
+ {% endif %}
+
{% if project.children is not empty %}
@@ -69,9 +95,9 @@
{% if project.comment is not empty %}
-
-
{% trans %}comment.label{% endtrans %}:
- {{ project.comment|format_markdown }}
-
+
+
{% trans %}comment.label{% endtrans %}:
+ {{ project.comment|format_markdown }}
+
{% endif %}
-
\ No newline at end of file
+
diff --git a/translations/messages.en.xlf b/translations/messages.en.xlf
index 589dc2383..9e5ed157f 100644
--- a/translations/messages.en.xlf
+++ b/translations/messages.en.xlf
@@ -7212,6 +7212,18 @@ Element 1 -> Element 1.2
Subprojects
+
+
+ project.info.total_build_price
+ Total build price
+
+
+
+
+ project.info.per_unit_price
+ per unit
+
+
project.info.bom_add_parts
From 5d669da93210b7d773d116692566cfaf293520b1 Mon Sep 17 00:00:00 2001
From: MayNiklas
Date: Wed, 15 Apr 2026 13:04:15 +0200
Subject: [PATCH 4/6] Add tests for build price calculation in
ProjectBuildHelper
Covers calculateTotalBuildPrice(), calculateUnitBuildPrice(),
roundedTotalBuildPrice(), and the private getBomEntryUnitPrice()
helper. Scenarios tested: empty project, no pricing data, non-part BOM
entries with manual prices, part entries with pricedetails, mixed
entries, rounding-up of sub-cent prices, and minimum order amount
floor for price tier lookup.
---
.../ProjectSystem/ProjectBuildHelperTest.php | 146 ++++++++++++++++++
1 file changed, 146 insertions(+)
diff --git a/tests/Services/ProjectSystem/ProjectBuildHelperTest.php b/tests/Services/ProjectSystem/ProjectBuildHelperTest.php
index fb31b51e6..cf36b0301 100644
--- a/tests/Services/ProjectSystem/ProjectBuildHelperTest.php
+++ b/tests/Services/ProjectSystem/ProjectBuildHelperTest.php
@@ -26,7 +26,10 @@
use App\Entity\Parts\PartLot;
use App\Entity\ProjectSystem\Project;
use App\Entity\ProjectSystem\ProjectBOMEntry;
+use App\Entity\PriceInformations\Orderdetail;
+use App\Entity\PriceInformations\Pricedetail;
use App\Services\ProjectSystem\ProjectBuildHelper;
+use Brick\Math\BigDecimal;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
final class ProjectBuildHelperTest extends WebTestCase
@@ -130,6 +133,149 @@ public function testGetMaximumBuildableCountAsString(): void
$project->addBomEntry($bom_entry1);
$this->assertSame('∞', $this->service->getMaximumBuildableCountAsString($project));
+ }
+
+ // --- Build price tests ---
+
+ private function makePartWithPrice(float $pricePerPiece, float $minQty = 1.0): Part
+ {
+ $part = new Part();
+ $orderdetail = new Orderdetail();
+ $pricedetail = (new Pricedetail())
+ ->setMinDiscountQuantity($minQty)
+ ->setPrice(BigDecimal::of((string) $pricePerPiece));
+ $orderdetail->addPricedetail($pricedetail);
+ $part->addOrderdetail($orderdetail);
+ return $part;
+ }
+
+ public function testCalculateTotalBuildPriceEmptyProject(): void
+ {
+ $project = new Project();
+ $this->assertNull($this->service->calculateTotalBuildPrice($project));
+ }
+
+ public function testCalculateTotalBuildPriceNoPricingData(): void
+ {
+ $project = new Project();
+ // Part with no orderdetails — no pricing
+ $entry = (new ProjectBOMEntry())->setPart(new Part())->setQuantity(2);
+ $project->addBomEntry($entry);
+
+ $this->assertNull($this->service->calculateTotalBuildPrice($project));
+ }
+
+ public function testCalculateTotalBuildPriceNonPartEntry(): void
+ {
+ $project = new Project();
+ $entry = new ProjectBOMEntry();
+ $entry->setName('Custom wire');
+ $entry->setQuantity(3);
+ $entry->setPrice(BigDecimal::of('2.00'));
+ $project->addBomEntry($entry);
+
+ // 3 × 2.00 = 6.00 for 1 build
+ $result = $this->service->calculateTotalBuildPrice($project, 1);
+ $this->assertNotNull($result);
+ $this->assertTrue(BigDecimal::of('6.00')->isEqualTo($result));
+ }
+
+ public function testCalculateTotalBuildPriceNonPartEntryMultipleBuilds(): void
+ {
+ $project = new Project();
+ $entry = new ProjectBOMEntry();
+ $entry->setName('Custom wire');
+ $entry->setQuantity(3);
+ $entry->setPrice(BigDecimal::of('2.00'));
+ $project->addBomEntry($entry);
+
+ // 3 × 2.00 × 5 = 30.00 for 5 builds
+ $result = $this->service->calculateTotalBuildPrice($project, 5);
+ $this->assertNotNull($result);
+ $this->assertTrue(BigDecimal::of('30.00')->isEqualTo($result));
+ }
+
+ public function testCalculateTotalBuildPriceWithPart(): void
+ {
+ $project = new Project();
+ $entry = new ProjectBOMEntry();
+ $entry->setPart($this->makePartWithPrice(1.50));
+ $entry->setQuantity(4);
+ $project->addBomEntry($entry);
+
+ // 4 × 1.50 = 6.00 for 1 build
+ $result = $this->service->calculateTotalBuildPrice($project, 1);
+ $this->assertNotNull($result);
+ $this->assertTrue(BigDecimal::of('6.00')->isEqualTo($result));
+ }
+
+ public function testCalculateUnitBuildPriceEqualsTotal(): void
+ {
+ $project = new Project();
+ $entry = new ProjectBOMEntry();
+ $entry->setName('Screw');
+ $entry->setQuantity(10);
+ $entry->setPrice(BigDecimal::of('0.10'));
+ $project->addBomEntry($entry);
+
+ // unit = 10 × 0.10 = 1.00; total for 3 builds = 3.00
+ $unit = $this->service->calculateUnitBuildPrice($project, 3);
+ $total = $this->service->calculateTotalBuildPrice($project, 3);
+ $this->assertNotNull($unit);
+ $this->assertNotNull($total);
+ $this->assertTrue($total->isEqualTo($unit->multipliedBy(3)));
+ }
+
+ public function testRoundedTotalBuildPriceRoundsUp(): void
+ {
+ $project = new Project();
+ $entry = new ProjectBOMEntry();
+ $entry->setName('Tiny part');
+ $entry->setQuantity(1);
+ $entry->setPrice(BigDecimal::of('0.001'));
+ $project->addBomEntry($entry);
+
+ // 0.001 rounded up to 2dp = 0.01
+ $result = $this->service->roundedTotalBuildPrice($project, 1);
+ $this->assertNotNull($result);
+ $this->assertTrue(BigDecimal::of('0.01')->isEqualTo($result));
+ }
+
+ public function testCalculateTotalBuildPriceMixedEntries(): void
+ {
+ $project = new Project();
+
+ // Part entry: 2 × 3.00 = 6.00
+ $partEntry = new ProjectBOMEntry();
+ $partEntry->setPart($this->makePartWithPrice(3.00));
+ $partEntry->setQuantity(2);
+ $project->addBomEntry($partEntry);
+
+ // Non-part entry with price: 5 × 1.00 = 5.00
+ $nonPartEntry = new ProjectBOMEntry();
+ $nonPartEntry->setName('Solder');
+ $nonPartEntry->setQuantity(5);
+ $nonPartEntry->setPrice(BigDecimal::of('1.00'));
+ $project->addBomEntry($nonPartEntry);
+
+ // Total = 11.00
+ $result = $this->service->calculateTotalBuildPrice($project, 1);
+ $this->assertNotNull($result);
+ $this->assertTrue(BigDecimal::of('11.00')->isEqualTo($result));
+ }
+
+ public function testCalculateTotalBuildPriceRespectsMinOrderAmount(): void
+ {
+ $project = new Project();
+ // Part has a minimum order quantity of 10 at 0.50/piece
+ $entry = new ProjectBOMEntry();
+ $entry->setPart($this->makePartWithPrice(0.50, 10.0));
+ $entry->setQuantity(1); // BOM only needs 1, but MOQ is 10
+ $project->addBomEntry($entry);
+ // Price lookup uses qty=10 (MOQ), returns 0.50. Cost = 1 × 0.50 = 0.50
+ $result = $this->service->calculateTotalBuildPrice($project, 1);
+ $this->assertNotNull($result);
+ $this->assertTrue(BigDecimal::of('0.50')->isEqualTo($result));
}
}
From fa1e5549f0c764107e59b452c69fa0758e039a01 Mon Sep 17 00:00:00 2001
From: MayNiklas
Date: Wed, 15 Apr 2026 13:11:06 +0200
Subject: [PATCH 5/6] 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.
---
src/DataTables/ProjectBomEntriesDataTable.php | 24 +++-----------
.../ProjectSystem/ProjectBuildHelper.php | 10 ++++++
.../ProjectSystem/ProjectBuildHelperTest.php | 31 +++++++++++++++++++
3 files changed, 45 insertions(+), 20 deletions(-)
diff --git a/src/DataTables/ProjectBomEntriesDataTable.php b/src/DataTables/ProjectBomEntriesDataTable.php
index 6ee726e89..2d5c4ebc5 100644
--- a/src/DataTables/ProjectBomEntriesDataTable.php
+++ b/src/DataTables/ProjectBomEntriesDataTable.php
@@ -36,8 +36,7 @@
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 @@ public function __construct(
protected TranslatorInterface $translator,
protected AmountFormatter $amountFormatter,
protected PartDataTableHelper $partDataTableHelper,
- protected PricedetailHelper $pricedetailHelper,
+ protected ProjectBuildHelper $projectBuildHelper,
protected MoneyFormatter $moneyFormatter,
) {
}
@@ -212,7 +211,7 @@ public function configure(DataTable $dataTable, array $options): void
'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 @@ public function configure(DataTable $dataTable, array $options): void
'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 @@ function (QueryBuilder $builder) use ($options): void {
]);
}
- 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
diff --git a/src/Services/ProjectSystem/ProjectBuildHelper.php b/src/Services/ProjectSystem/ProjectBuildHelper.php
index 9d643a872..ee5b8c68b 100644
--- a/src/Services/ProjectSystem/ProjectBuildHelper.php
+++ b/src/Services/ProjectSystem/ProjectBuildHelper.php
@@ -227,6 +227,16 @@ public function roundedUnitBuildPrice(Project $project, int $number_of_builds =
?->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.
diff --git a/tests/Services/ProjectSystem/ProjectBuildHelperTest.php b/tests/Services/ProjectSystem/ProjectBuildHelperTest.php
index cf36b0301..de9f94062 100644
--- a/tests/Services/ProjectSystem/ProjectBuildHelperTest.php
+++ b/tests/Services/ProjectSystem/ProjectBuildHelperTest.php
@@ -264,6 +264,37 @@ public function testCalculateTotalBuildPriceMixedEntries(): void
$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();
From c66390ea4f14c5b1353d9494b40087fd1c2415bb Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Jan=20B=C3=B6hmer?=
Date: Wed, 15 Apr 2026 22:09:22 +0200
Subject: [PATCH 6/6] Added type hint to service
---
tests/Services/ProjectSystem/ProjectBuildHelperTest.php | 3 +--
tests/Services/ProjectSystem/ProjectBuildPartHelperTest.php | 3 +--
2 files changed, 2 insertions(+), 4 deletions(-)
diff --git a/tests/Services/ProjectSystem/ProjectBuildHelperTest.php b/tests/Services/ProjectSystem/ProjectBuildHelperTest.php
index de9f94062..b80adb2f5 100644
--- a/tests/Services/ProjectSystem/ProjectBuildHelperTest.php
+++ b/tests/Services/ProjectSystem/ProjectBuildHelperTest.php
@@ -34,8 +34,7 @@
final class ProjectBuildHelperTest extends WebTestCase
{
- /** @var ProjectBuildHelper */
- protected $service;
+ protected ProjectBuildHelper $service;
protected function setUp(): void
{
diff --git a/tests/Services/ProjectSystem/ProjectBuildPartHelperTest.php b/tests/Services/ProjectSystem/ProjectBuildPartHelperTest.php
index 894f6315b..8126c83d0 100644
--- a/tests/Services/ProjectSystem/ProjectBuildPartHelperTest.php
+++ b/tests/Services/ProjectSystem/ProjectBuildPartHelperTest.php
@@ -28,8 +28,7 @@
final class ProjectBuildPartHelperTest extends WebTestCase
{
- /** @var ProjectBuildPartHelper */
- protected $service;
+ protected ProjectBuildPartHelper $service;
protected function setUp(): void
{