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 %} +
+
+ {% 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/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