From 82e55bc83d3752f6cd5e356a671c8052a456f399 Mon Sep 17 00:00:00 2001
From: MayNiklas
Date: Wed, 15 Apr 2026 12:38:06 +0200
Subject: [PATCH] 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 d2c35efd..531deb3f 100644
--- a/src/Controller/ProjectController.php
+++ b/src/Controller/ProjectController.php
@@ -69,10 +69,13 @@ class ProjectController extends AbstractController
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 a541c29d..9d643a87 100644
--- a/src/Services/ProjectSystem/ProjectBuildHelper.php
+++ b/src/Services/ProjectSystem/ProjectBuildHelper.php
@@ -25,16 +25,22 @@ namespace App\Services\ProjectSystem;
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 @@ final readonly class ProjectBuildHelper
$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 b95be253..c3a8e86d 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 589dc238..9e5ed157 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