Add build price summary to project info tab

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.
This commit is contained in:
MayNiklas 2026-04-15 12:38:06 +02:00
parent e9fb0dba51
commit 82e55bc83d
4 changed files with 121 additions and 7 deletions

View file

@ -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,
]);
}

View file

@ -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();
}
}

View file

@ -55,6 +55,32 @@
</span>
</h6>
</div>
{% 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 %}
<div class="mt-1">
<h6>
<span class="badge badge-primary bg-success">
<i class="fa-solid fa-money-bill-wave fa-fw"></i>
{% 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 %}
<span class="ms-1">
({% trans %}project.info.per_unit_price{% endtrans %}: {{ unit_build_price | format_money(app.user.currency ?? null, 2) }})
</span>
{% endif %}
</span>
</h6>
</div>
{% endif %}
<form method="get" action="{{ path('project_info', {'id': project.id}) }}" class="mt-2">
<div class="input-group input-group-sm">
<span class="input-group-text">{% trans %}project.builds.number_of_builds{% endtrans %}</span>
<input type="number" min="1" class="form-control" name="n" required value="{{ n }}">
<button class="btn btn-outline-secondary" type="submit">{% trans %}project.build.btn_build{% endtrans %}</button>
</div>
</form>
{% if project.children is not empty %}
<div class="mt-1">
<h6>
@ -69,9 +95,9 @@
</div>
{% if project.comment is not empty %}
<p>
<h5>{% trans %}comment.label{% endtrans %}:</h5>
{{ project.comment|format_markdown }}
</p>
<div class="col-12 mt-2">
<h5>{% trans %}comment.label{% endtrans %}:</h5>
{{ project.comment|format_markdown }}
</div>
{% endif %}
</div>
</div>

View file

@ -7212,6 +7212,18 @@ Element 1 -&gt; Element 1.2</target>
<target>Subprojects</target>
</segment>
</unit>
<unit id="prjTtlBP" name="project.info.total_build_price">
<segment state="translated">
<source>project.info.total_build_price</source>
<target>Total build price</target>
</segment>
</unit>
<unit id="prjUntBP" name="project.info.per_unit_price">
<segment state="translated">
<source>project.info.per_unit_price</source>
<target>per unit</target>
</segment>
</unit>
<unit id="7nV.Cmd" name="project.info.bom_add_parts">
<segment state="translated">
<source>project.info.bom_add_parts</source>