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 %} +
+
+ {% trans %}project.builds.number_of_builds{% endtrans %} + + +
+
{% 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 {