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/DataTables/ProjectBomEntriesDataTable.php b/src/DataTables/ProjectBomEntriesDataTable.php
index 04d8206bd..2d5c4ebc5 100644
--- a/src/DataTables/ProjectBomEntriesDataTable.php
+++ b/src/DataTables/ProjectBomEntriesDataTable.php
@@ -29,12 +29,15 @@
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\ProjectSystem\ProjectBuildHelper;
+use Brick\Math\RoundingMode;
use Doctrine\ORM\AbstractQuery;
use Doctrine\ORM\Query;
use Doctrine\ORM\QueryBuilder;
@@ -50,7 +53,9 @@ public function __construct(
protected EntityURLGenerator $entityURLGenerator,
protected TranslatorInterface $translator,
protected AmountFormatter $amountFormatter,
- protected PartDataTableHelper $partDataTableHelper
+ protected PartDataTableHelper $partDataTableHelper,
+ protected ProjectBuildHelper $projectBuildHelper,
+ protected MoneyFormatter $moneyFormatter,
) {
}
@@ -202,6 +207,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->projectBuildHelper->getEntryUnitPrice($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->projectBuildHelper->getEntryUnitPrice($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'),
diff --git a/src/Services/ProjectSystem/ProjectBuildHelper.php b/src/Services/ProjectSystem/ProjectBuildHelper.php
index a541c29d1..ee5b8c68b 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,81 @@ 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 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.
+ */
+ 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/tests/Services/ProjectSystem/ProjectBuildHelperTest.php b/tests/Services/ProjectSystem/ProjectBuildHelperTest.php
index fb31b51e6..b80adb2f5 100644
--- a/tests/Services/ProjectSystem/ProjectBuildHelperTest.php
+++ b/tests/Services/ProjectSystem/ProjectBuildHelperTest.php
@@ -26,13 +26,15 @@
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
{
- /** @var ProjectBuildHelper */
- protected $service;
+ protected ProjectBuildHelper $service;
protected function setUp(): void
{
@@ -130,6 +132,180 @@ 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 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();
+ // 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));
}
}
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
{
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..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
@@ -7236,6 +7248,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