2023-01-18 23:07:51 +01:00
|
|
|
<?php
|
2023-06-11 18:59:07 +02:00
|
|
|
|
|
|
|
|
declare(strict_types=1);
|
|
|
|
|
|
2023-01-18 23:07:51 +01:00
|
|
|
/*
|
|
|
|
|
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
|
|
|
|
|
*
|
|
|
|
|
* Copyright (C) 2019 - 2023 Jan Böhmer (https://github.com/jbtronics)
|
|
|
|
|
*
|
|
|
|
|
* This program is free software: you can redistribute it and/or modify
|
|
|
|
|
* it under the terms of the GNU Affero General Public License as published
|
|
|
|
|
* by the Free Software Foundation, either version 3 of the License, or
|
|
|
|
|
* (at your option) any later version.
|
|
|
|
|
*
|
|
|
|
|
* This program is distributed in the hope that it will be useful,
|
|
|
|
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
|
|
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
|
|
|
* GNU Affero General Public License for more details.
|
|
|
|
|
*
|
|
|
|
|
* You should have received a copy of the GNU Affero General Public License
|
|
|
|
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
|
|
|
*/
|
|
|
|
|
namespace App\Services\ProjectSystem;
|
|
|
|
|
|
2023-06-11 14:55:06 +02:00
|
|
|
use App\Entity\Parts\Part;
|
2023-01-18 23:07:51 +01:00
|
|
|
use App\Entity\ProjectSystem\Project;
|
|
|
|
|
use App\Entity\ProjectSystem\ProjectBOMEntry;
|
Add price columns to project BOM table and build price summary (#1345)
* 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.
* 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.
* 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.
* 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.
* 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.
* Added type hint to service
---------
Co-authored-by: Jan Böhmer <mail@jan-boehmer.de>
2026-04-15 22:13:07 +02:00
|
|
|
use App\Entity\PriceInformations\Currency;
|
2023-01-22 16:59:58 +01:00
|
|
|
use App\Helpers\Projects\ProjectBuildRequest;
|
|
|
|
|
use App\Services\Parts\PartLotWithdrawAddHelper;
|
Add price columns to project BOM table and build price summary (#1345)
* 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.
* 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.
* 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.
* 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.
* 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.
* Added type hint to service
---------
Co-authored-by: Jan Böhmer <mail@jan-boehmer.de>
2026-04-15 22:13:07 +02:00
|
|
|
use App\Services\Parts\PricedetailHelper;
|
|
|
|
|
use Brick\Math\BigDecimal;
|
|
|
|
|
use Brick\Math\RoundingMode;
|
2023-01-18 23:07:51 +01:00
|
|
|
|
2023-06-11 15:02:59 +02:00
|
|
|
/**
|
|
|
|
|
* @see \App\Tests\Services\ProjectSystem\ProjectBuildHelperTest
|
|
|
|
|
*/
|
2025-10-18 23:32:20 +02:00
|
|
|
final readonly class ProjectBuildHelper
|
2023-01-18 23:07:51 +01:00
|
|
|
{
|
Add price columns to project BOM table and build price summary (#1345)
* 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.
* 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.
* 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.
* 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.
* 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.
* Added type hint to service
---------
Co-authored-by: Jan Böhmer <mail@jan-boehmer.de>
2026-04-15 22:13:07 +02:00
|
|
|
public function __construct(
|
|
|
|
|
private PartLotWithdrawAddHelper $withdraw_add_helper,
|
|
|
|
|
private PricedetailHelper $pricedetailHelper,
|
|
|
|
|
) {
|
2023-01-22 16:59:58 +01:00
|
|
|
}
|
2023-01-18 23:07:51 +01:00
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Returns the maximum buildable amount of the given BOM entry based on the stock of the used parts.
|
|
|
|
|
* This function only works for BOM entries that are associated with a part.
|
|
|
|
|
*/
|
|
|
|
|
public function getMaximumBuildableCountForBOMEntry(ProjectBOMEntry $projectBOMEntry): int
|
|
|
|
|
{
|
|
|
|
|
$part = $projectBOMEntry->getPart();
|
|
|
|
|
|
2023-06-11 14:55:06 +02:00
|
|
|
if (!$part instanceof Part) {
|
2023-01-18 23:07:51 +01:00
|
|
|
throw new \InvalidArgumentException('This function cannot determine the maximum buildable count for a BOM entry without a part!');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if ($projectBOMEntry->getQuantity() <= 0) {
|
|
|
|
|
throw new \RuntimeException('The quantity of the BOM entry must be greater than 0!');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$amount_sum = $part->getAmountSum();
|
|
|
|
|
|
|
|
|
|
return (int) floor($amount_sum / $projectBOMEntry->getQuantity());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Returns the maximum buildable amount of the given project, based on the stock of the used parts in the BOM.
|
|
|
|
|
*/
|
|
|
|
|
public function getMaximumBuildableCount(Project $project): int
|
|
|
|
|
{
|
2025-09-18 11:04:52 +00:00
|
|
|
$bom_entries = $project->getBomEntries();
|
|
|
|
|
if ($bom_entries->isEmpty()) {
|
|
|
|
|
return 0;
|
|
|
|
|
}
|
2023-01-18 23:07:51 +01:00
|
|
|
$maximum_buildable_count = PHP_INT_MAX;
|
2025-09-18 11:04:52 +00:00
|
|
|
foreach ($bom_entries as $bom_entry) {
|
2023-01-18 23:07:51 +01:00
|
|
|
//Skip BOM entries without a part (as we can not determine that)
|
|
|
|
|
if (!$bom_entry->isPartBomEntry()) {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
//The maximum buildable count for the whole project is the minimum of all BOM entries
|
|
|
|
|
$maximum_buildable_count = min($maximum_buildable_count, $this->getMaximumBuildableCountForBOMEntry($bom_entry));
|
|
|
|
|
}
|
|
|
|
|
return $maximum_buildable_count;
|
|
|
|
|
}
|
|
|
|
|
|
2025-10-18 23:32:20 +02:00
|
|
|
/**
|
|
|
|
|
* Returns the maximum buildable amount of the given project as string, based on the stock of the used parts in the BOM.
|
|
|
|
|
* If the maximum buildable count is infinite, the string '∞' is returned.
|
|
|
|
|
* @param Project $project
|
|
|
|
|
* @return string
|
|
|
|
|
*/
|
|
|
|
|
public function getMaximumBuildableCountAsString(Project $project): string
|
|
|
|
|
{
|
|
|
|
|
$max_count = $this->getMaximumBuildableCount($project);
|
|
|
|
|
if ($max_count === PHP_INT_MAX) {
|
|
|
|
|
return '∞';
|
|
|
|
|
}
|
|
|
|
|
return (string) $max_count;
|
|
|
|
|
}
|
|
|
|
|
|
2023-01-18 23:07:51 +01:00
|
|
|
/**
|
2023-04-15 23:14:53 +02:00
|
|
|
* Checks if the given project can be built with the current stock.
|
2023-01-21 21:41:08 +01:00
|
|
|
* This means that the maximum buildable count is greater or equal than the requested $number_of_projects
|
2023-06-11 19:42:02 +02:00
|
|
|
* @param int $number_of_builds
|
2023-01-18 23:07:51 +01:00
|
|
|
*/
|
2023-01-22 00:01:16 +01:00
|
|
|
public function isProjectBuildable(Project $project, int $number_of_builds = 1): bool
|
2023-01-18 23:07:51 +01:00
|
|
|
{
|
2023-01-22 00:01:16 +01:00
|
|
|
return $this->getMaximumBuildableCount($project) >= $number_of_builds;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
2023-04-15 23:14:53 +02:00
|
|
|
* Check if the given BOM entry can be built with the current stock.
|
2023-01-22 00:01:16 +01:00
|
|
|
* This means that the maximum buildable count is greater or equal than the requested $number_of_projects
|
|
|
|
|
*/
|
|
|
|
|
public function isBOMEntryBuildable(ProjectBOMEntry $bom_entry, int $number_of_builds = 1): bool
|
|
|
|
|
{
|
|
|
|
|
return $this->getMaximumBuildableCountForBOMEntry($bom_entry) >= $number_of_builds;
|
2023-01-18 23:07:51 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Returns the project BOM entries for which parts are missing in the stock for the given number of builds
|
|
|
|
|
* @param Project $project The project for which the BOM entries should be checked
|
|
|
|
|
* @param int $number_of_builds How often should the project be build?
|
|
|
|
|
* @return ProjectBOMEntry[]
|
|
|
|
|
*/
|
|
|
|
|
public function getNonBuildableProjectBomEntries(Project $project, int $number_of_builds = 1): array
|
|
|
|
|
{
|
|
|
|
|
if ($number_of_builds < 1) {
|
|
|
|
|
throw new \InvalidArgumentException('The number of builds must be greater than 0!');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$non_buildable_entries = [];
|
|
|
|
|
|
|
|
|
|
foreach ($project->getBomEntries() as $bomEntry) {
|
|
|
|
|
$part = $bomEntry->getPart();
|
|
|
|
|
|
|
|
|
|
//Skip BOM entries without a part (as we can not determine that)
|
2023-06-11 14:55:06 +02:00
|
|
|
if (!$part instanceof Part) {
|
2023-01-18 23:07:51 +01:00
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$amount_sum = $part->getAmountSum();
|
|
|
|
|
|
|
|
|
|
if ($amount_sum < $bomEntry->getQuantity() * $number_of_builds) {
|
|
|
|
|
$non_buildable_entries[] = $bomEntry;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return $non_buildable_entries;
|
|
|
|
|
}
|
2023-01-22 16:59:58 +01:00
|
|
|
|
|
|
|
|
/**
|
2023-01-22 23:27:45 +01:00
|
|
|
* Withdraw the parts from the stock using the given ProjectBuildRequest and create the build parts entries, if needed.
|
2023-01-22 16:59:58 +01:00
|
|
|
* The ProjectBuildRequest has to be validated before!!
|
2023-04-15 23:14:53 +02:00
|
|
|
* You have to flush changes to DB afterward
|
2023-01-22 16:59:58 +01:00
|
|
|
*/
|
2023-01-22 23:27:45 +01:00
|
|
|
public function doBuild(ProjectBuildRequest $buildRequest): void
|
2023-01-22 16:59:58 +01:00
|
|
|
{
|
|
|
|
|
$message = $buildRequest->getComment();
|
|
|
|
|
$message .= ' (Project build: '.$buildRequest->getProject()->getName().')';
|
|
|
|
|
|
2023-01-22 17:37:01 +01:00
|
|
|
foreach ($buildRequest->getPartBomEntries() as $bom_entry) {
|
2023-01-22 16:59:58 +01:00
|
|
|
foreach ($buildRequest->getPartLotsForBOMEntry($bom_entry) as $part_lot) {
|
|
|
|
|
$amount = $buildRequest->getLotWithdrawAmount($part_lot);
|
|
|
|
|
if ($amount > 0) {
|
|
|
|
|
$this->withdraw_add_helper->withdraw($part_lot, $amount, $message);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
2023-01-22 23:27:45 +01:00
|
|
|
|
|
|
|
|
if ($buildRequest->getAddBuildsToBuildsPart()) {
|
|
|
|
|
$this->withdraw_add_helper->add($buildRequest->getBuildsPartLot(), $buildRequest->getNumberOfBuilds(), $message);
|
|
|
|
|
}
|
2023-01-22 16:59:58 +01:00
|
|
|
}
|
Add price columns to project BOM table and build price summary (#1345)
* 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.
* 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.
* 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.
* 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.
* 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.
* Added type hint to service
---------
Co-authored-by: Jan Böhmer <mail@jan-boehmer.de>
2026-04-15 22:13:07 +02:00
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 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;
|
2026-05-19 21:17:45 +02:00
|
|
|
$total = $total->plus($unit_price->multipliedBy(BigDecimal::fromFloatShortest($entry->getQuantity()))->multipliedBy($number_of_builds));
|
Add price columns to project BOM table and build price summary (#1345)
* 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.
* 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.
* 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.
* 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.
* 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.
* Added type hint to service
---------
Co-authored-by: Jan Böhmer <mail@jan-boehmer.de>
2026-04-15 22:13:07 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
}
|
2026-05-19 21:17:45 +02:00
|
|
|
return $total->dividedBy($number_of_builds, 10, RoundingMode::HalfUp);
|
Add price columns to project BOM table and build price summary (#1345)
* 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.
* 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.
* 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.
* 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.
* 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.
* Added type hint to service
---------
Co-authored-by: Jan Böhmer <mail@jan-boehmer.de>
2026-04-15 22:13:07 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 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)
|
2026-05-19 21:17:45 +02:00
|
|
|
?->toScale(2, RoundingMode::Up);
|
Add price columns to project BOM table and build price summary (#1345)
* 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.
* 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.
* 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.
* 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.
* 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.
* Added type hint to service
---------
Co-authored-by: Jan Böhmer <mail@jan-boehmer.de>
2026-04-15 22:13:07 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 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)
|
2026-05-19 21:17:45 +02:00
|
|
|
?->toScale(2, RoundingMode::Up);
|
Add price columns to project BOM table and build price summary (#1345)
* 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.
* 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.
* 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.
* 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.
* 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.
* Added type hint to service
---------
Co-authored-by: Jan Böhmer <mail@jan-boehmer.de>
2026-04-15 22:13:07 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 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();
|
|
|
|
|
}
|
2023-06-11 18:59:07 +02:00
|
|
|
}
|