mirror of
https://github.com/Part-DB/Part-DB-server.git
synced 2026-05-17 17:01:31 +00: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>
This commit is contained in:
parent
5b86d6f652
commit
c17cf5e83c
16 changed files with 398 additions and 13 deletions
|
|
@ -69,10 +69,13 @@ class ProjectController extends AbstractController
|
||||||
return $table->getResponse();
|
return $table->getResponse();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$number_of_builds = max(1, $request->query->getInt('n', 1));
|
||||||
|
|
||||||
return $this->render('projects/info/info.html.twig', [
|
return $this->render('projects/info/info.html.twig', [
|
||||||
'buildHelper' => $buildHelper,
|
'buildHelper' => $buildHelper,
|
||||||
'datatable' => $table,
|
'datatable' => $table,
|
||||||
'project' => $project,
|
'project' => $project,
|
||||||
|
'number_of_builds' => $number_of_builds,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -29,12 +29,15 @@ use App\DataTables\Column\LocaleDateTimeColumn;
|
||||||
use App\DataTables\Column\MarkdownColumn;
|
use App\DataTables\Column\MarkdownColumn;
|
||||||
use App\DataTables\Helpers\PartDataTableHelper;
|
use App\DataTables\Helpers\PartDataTableHelper;
|
||||||
use App\Doctrine\Helpers\FieldHelper;
|
use App\Doctrine\Helpers\FieldHelper;
|
||||||
use App\Entity\Parts\Part;
|
|
||||||
use App\Entity\Parts\ManufacturingStatus;
|
use App\Entity\Parts\ManufacturingStatus;
|
||||||
|
use App\Entity\Parts\Part;
|
||||||
use App\Entity\ProjectSystem\ProjectBOMEntry;
|
use App\Entity\ProjectSystem\ProjectBOMEntry;
|
||||||
use App\Services\ElementTypeNameGenerator;
|
use App\Services\ElementTypeNameGenerator;
|
||||||
use App\Services\EntityURLGenerator;
|
use App\Services\EntityURLGenerator;
|
||||||
use App\Services\Formatters\AmountFormatter;
|
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\AbstractQuery;
|
||||||
use Doctrine\ORM\Query;
|
use Doctrine\ORM\Query;
|
||||||
use Doctrine\ORM\QueryBuilder;
|
use Doctrine\ORM\QueryBuilder;
|
||||||
|
|
@ -50,7 +53,9 @@ class ProjectBomEntriesDataTable implements DataTableTypeInterface
|
||||||
protected EntityURLGenerator $entityURLGenerator,
|
protected EntityURLGenerator $entityURLGenerator,
|
||||||
protected TranslatorInterface $translator,
|
protected TranslatorInterface $translator,
|
||||||
protected AmountFormatter $amountFormatter,
|
protected AmountFormatter $amountFormatter,
|
||||||
protected PartDataTableHelper $partDataTableHelper
|
protected PartDataTableHelper $partDataTableHelper,
|
||||||
|
protected ProjectBuildHelper $projectBuildHelper,
|
||||||
|
protected MoneyFormatter $moneyFormatter,
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -202,6 +207,27 @@ class ProjectBomEntriesDataTable implements DataTableTypeInterface
|
||||||
return '';
|
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, [
|
->add('addedDate', LocaleDateTimeColumn::class, [
|
||||||
'label' => $this->translator->trans('part.table.addedDate'),
|
'label' => $this->translator->trans('part.table.addedDate'),
|
||||||
|
|
|
||||||
|
|
@ -25,16 +25,22 @@ namespace App\Services\ProjectSystem;
|
||||||
use App\Entity\Parts\Part;
|
use App\Entity\Parts\Part;
|
||||||
use App\Entity\ProjectSystem\Project;
|
use App\Entity\ProjectSystem\Project;
|
||||||
use App\Entity\ProjectSystem\ProjectBOMEntry;
|
use App\Entity\ProjectSystem\ProjectBOMEntry;
|
||||||
|
use App\Entity\PriceInformations\Currency;
|
||||||
use App\Helpers\Projects\ProjectBuildRequest;
|
use App\Helpers\Projects\ProjectBuildRequest;
|
||||||
use App\Services\Parts\PartLotWithdrawAddHelper;
|
use App\Services\Parts\PartLotWithdrawAddHelper;
|
||||||
|
use App\Services\Parts\PricedetailHelper;
|
||||||
|
use Brick\Math\BigDecimal;
|
||||||
|
use Brick\Math\RoundingMode;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @see \App\Tests\Services\ProjectSystem\ProjectBuildHelperTest
|
* @see \App\Tests\Services\ProjectSystem\ProjectBuildHelperTest
|
||||||
*/
|
*/
|
||||||
final readonly class ProjectBuildHelper
|
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 @@ final readonly class ProjectBuildHelper
|
||||||
$this->withdraw_add_helper->add($buildRequest->getBuildsPartLot(), $buildRequest->getNumberOfBuilds(), $message);
|
$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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -55,6 +55,32 @@
|
||||||
</span>
|
</span>
|
||||||
</h6>
|
</h6>
|
||||||
</div>
|
</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 %}
|
{% if project.children is not empty %}
|
||||||
<div class="mt-1">
|
<div class="mt-1">
|
||||||
<h6>
|
<h6>
|
||||||
|
|
@ -69,9 +95,9 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% if project.comment is not empty %}
|
{% if project.comment is not empty %}
|
||||||
<p>
|
<div class="col-12 mt-2">
|
||||||
<h5>{% trans %}comment.label{% endtrans %}:</h5>
|
<h5>{% trans %}comment.label{% endtrans %}:</h5>
|
||||||
{{ project.comment|format_markdown }}
|
{{ project.comment|format_markdown }}
|
||||||
</p>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -26,13 +26,15 @@ use App\Entity\Parts\Part;
|
||||||
use App\Entity\Parts\PartLot;
|
use App\Entity\Parts\PartLot;
|
||||||
use App\Entity\ProjectSystem\Project;
|
use App\Entity\ProjectSystem\Project;
|
||||||
use App\Entity\ProjectSystem\ProjectBOMEntry;
|
use App\Entity\ProjectSystem\ProjectBOMEntry;
|
||||||
|
use App\Entity\PriceInformations\Orderdetail;
|
||||||
|
use App\Entity\PriceInformations\Pricedetail;
|
||||||
use App\Services\ProjectSystem\ProjectBuildHelper;
|
use App\Services\ProjectSystem\ProjectBuildHelper;
|
||||||
|
use Brick\Math\BigDecimal;
|
||||||
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
|
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
|
||||||
|
|
||||||
final class ProjectBuildHelperTest extends WebTestCase
|
final class ProjectBuildHelperTest extends WebTestCase
|
||||||
{
|
{
|
||||||
/** @var ProjectBuildHelper */
|
protected ProjectBuildHelper $service;
|
||||||
protected $service;
|
|
||||||
|
|
||||||
protected function setUp(): void
|
protected function setUp(): void
|
||||||
{
|
{
|
||||||
|
|
@ -130,6 +132,180 @@ final class ProjectBuildHelperTest extends WebTestCase
|
||||||
$project->addBomEntry($bom_entry1);
|
$project->addBomEntry($bom_entry1);
|
||||||
|
|
||||||
$this->assertSame('∞', $this->service->getMaximumBuildableCountAsString($project));
|
$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));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -28,8 +28,7 @@ use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
|
||||||
|
|
||||||
final class ProjectBuildPartHelperTest extends WebTestCase
|
final class ProjectBuildPartHelperTest extends WebTestCase
|
||||||
{
|
{
|
||||||
/** @var ProjectBuildPartHelper */
|
protected ProjectBuildPartHelper $service;
|
||||||
protected $service;
|
|
||||||
|
|
||||||
protected function setUp(): void
|
protected function setUp(): void
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -7241,6 +7241,12 @@ Element 3</target>
|
||||||
<target>Cena</target>
|
<target>Cena</target>
|
||||||
</segment>
|
</segment>
|
||||||
</unit>
|
</unit>
|
||||||
|
<unit id="bomExPrc" name="project.bom.ext_price">
|
||||||
|
<segment state="initial">
|
||||||
|
<source>project.bom.ext_price</source>
|
||||||
|
<target>Extended Price</target>
|
||||||
|
</segment>
|
||||||
|
</unit>
|
||||||
<unit id="8tP3lQI" name="part.info.withdraw_modal.title.withdraw">
|
<unit id="8tP3lQI" name="part.info.withdraw_modal.title.withdraw">
|
||||||
<segment state="translated">
|
<segment state="translated">
|
||||||
<source>part.info.withdraw_modal.title.withdraw</source>
|
<source>part.info.withdraw_modal.title.withdraw</source>
|
||||||
|
|
|
||||||
|
|
@ -7184,6 +7184,12 @@ Element 3</target>
|
||||||
<target>Pris</target>
|
<target>Pris</target>
|
||||||
</segment>
|
</segment>
|
||||||
</unit>
|
</unit>
|
||||||
|
<unit id="bomExPrc" name="project.bom.ext_price">
|
||||||
|
<segment state="initial">
|
||||||
|
<source>project.bom.ext_price</source>
|
||||||
|
<target>Extended Price</target>
|
||||||
|
</segment>
|
||||||
|
</unit>
|
||||||
<unit id="8tP3lQI" name="part.info.withdraw_modal.title.withdraw">
|
<unit id="8tP3lQI" name="part.info.withdraw_modal.title.withdraw">
|
||||||
<segment state="translated">
|
<segment state="translated">
|
||||||
<source>part.info.withdraw_modal.title.withdraw</source>
|
<source>part.info.withdraw_modal.title.withdraw</source>
|
||||||
|
|
|
||||||
|
|
@ -7235,6 +7235,12 @@ Element 1 -> Element 1.2</target>
|
||||||
<target>Preis</target>
|
<target>Preis</target>
|
||||||
</segment>
|
</segment>
|
||||||
</unit>
|
</unit>
|
||||||
|
<unit id="bomExPrc" name="project.bom.ext_price">
|
||||||
|
<segment state="initial">
|
||||||
|
<source>project.bom.ext_price</source>
|
||||||
|
<target>Extended Price</target>
|
||||||
|
</segment>
|
||||||
|
</unit>
|
||||||
<unit id="8tP3lQI" name="part.info.withdraw_modal.title.withdraw">
|
<unit id="8tP3lQI" name="part.info.withdraw_modal.title.withdraw">
|
||||||
<segment state="translated">
|
<segment state="translated">
|
||||||
<source>part.info.withdraw_modal.title.withdraw</source>
|
<source>part.info.withdraw_modal.title.withdraw</source>
|
||||||
|
|
|
||||||
|
|
@ -7212,6 +7212,18 @@ Element 1 -> Element 1.2</target>
|
||||||
<target>Subprojects</target>
|
<target>Subprojects</target>
|
||||||
</segment>
|
</segment>
|
||||||
</unit>
|
</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">
|
<unit id="7nV.Cmd" name="project.info.bom_add_parts">
|
||||||
<segment state="translated">
|
<segment state="translated">
|
||||||
<source>project.info.bom_add_parts</source>
|
<source>project.info.bom_add_parts</source>
|
||||||
|
|
@ -7236,6 +7248,12 @@ Element 1 -> Element 1.2</target>
|
||||||
<target>Price</target>
|
<target>Price</target>
|
||||||
</segment>
|
</segment>
|
||||||
</unit>
|
</unit>
|
||||||
|
<unit id="bomExPrc" name="project.bom.ext_price">
|
||||||
|
<segment state="translated">
|
||||||
|
<source>project.bom.ext_price</source>
|
||||||
|
<target>Extended Price</target>
|
||||||
|
</segment>
|
||||||
|
</unit>
|
||||||
<unit id="8tP3lQI" name="part.info.withdraw_modal.title.withdraw">
|
<unit id="8tP3lQI" name="part.info.withdraw_modal.title.withdraw">
|
||||||
<segment state="translated">
|
<segment state="translated">
|
||||||
<source>part.info.withdraw_modal.title.withdraw</source>
|
<source>part.info.withdraw_modal.title.withdraw</source>
|
||||||
|
|
|
||||||
|
|
@ -7259,6 +7259,12 @@ Elemento 3</target>
|
||||||
<target>Precio</target>
|
<target>Precio</target>
|
||||||
</segment>
|
</segment>
|
||||||
</unit>
|
</unit>
|
||||||
|
<unit id="bomExPrc" name="project.bom.ext_price">
|
||||||
|
<segment state="initial">
|
||||||
|
<source>project.bom.ext_price</source>
|
||||||
|
<target>Extended Price</target>
|
||||||
|
</segment>
|
||||||
|
</unit>
|
||||||
<unit id="8tP3lQI" name="part.info.withdraw_modal.title.withdraw">
|
<unit id="8tP3lQI" name="part.info.withdraw_modal.title.withdraw">
|
||||||
<segment state="translated">
|
<segment state="translated">
|
||||||
<source>part.info.withdraw_modal.title.withdraw</source>
|
<source>part.info.withdraw_modal.title.withdraw</source>
|
||||||
|
|
|
||||||
|
|
@ -7198,6 +7198,12 @@
|
||||||
<target>Ár</target>
|
<target>Ár</target>
|
||||||
</segment>
|
</segment>
|
||||||
</unit>
|
</unit>
|
||||||
|
<unit id="bomExPrc" name="project.bom.ext_price">
|
||||||
|
<segment state="initial">
|
||||||
|
<source>project.bom.ext_price</source>
|
||||||
|
<target>Extended Price</target>
|
||||||
|
</segment>
|
||||||
|
</unit>
|
||||||
<unit id="8tP3lQI" name="part.info.withdraw_modal.title.withdraw">
|
<unit id="8tP3lQI" name="part.info.withdraw_modal.title.withdraw">
|
||||||
<segment state="translated">
|
<segment state="translated">
|
||||||
<source>part.info.withdraw_modal.title.withdraw</source>
|
<source>part.info.withdraw_modal.title.withdraw</source>
|
||||||
|
|
|
||||||
|
|
@ -7186,6 +7186,12 @@ Element 3</target>
|
||||||
<target>Prezzo</target>
|
<target>Prezzo</target>
|
||||||
</segment>
|
</segment>
|
||||||
</unit>
|
</unit>
|
||||||
|
<unit id="bomExPrc" name="project.bom.ext_price">
|
||||||
|
<segment state="initial">
|
||||||
|
<source>project.bom.ext_price</source>
|
||||||
|
<target>Extended Price</target>
|
||||||
|
</segment>
|
||||||
|
</unit>
|
||||||
<unit id="8tP3lQI" name="part.info.withdraw_modal.title.withdraw">
|
<unit id="8tP3lQI" name="part.info.withdraw_modal.title.withdraw">
|
||||||
<segment state="translated">
|
<segment state="translated">
|
||||||
<source>part.info.withdraw_modal.title.withdraw</source>
|
<source>part.info.withdraw_modal.title.withdraw</source>
|
||||||
|
|
|
||||||
|
|
@ -7256,6 +7256,12 @@ Element 3</target>
|
||||||
<target>Cena</target>
|
<target>Cena</target>
|
||||||
</segment>
|
</segment>
|
||||||
</unit>
|
</unit>
|
||||||
|
<unit id="bomExPrc" name="project.bom.ext_price">
|
||||||
|
<segment state="initial">
|
||||||
|
<source>project.bom.ext_price</source>
|
||||||
|
<target>Extended Price</target>
|
||||||
|
</segment>
|
||||||
|
</unit>
|
||||||
<unit id="8tP3lQI" name="part.info.withdraw_modal.title.withdraw">
|
<unit id="8tP3lQI" name="part.info.withdraw_modal.title.withdraw">
|
||||||
<segment state="translated">
|
<segment state="translated">
|
||||||
<source>part.info.withdraw_modal.title.withdraw</source>
|
<source>part.info.withdraw_modal.title.withdraw</source>
|
||||||
|
|
|
||||||
|
|
@ -7260,6 +7260,12 @@
|
||||||
<target>Цена</target>
|
<target>Цена</target>
|
||||||
</segment>
|
</segment>
|
||||||
</unit>
|
</unit>
|
||||||
|
<unit id="bomExPrc" name="project.bom.ext_price">
|
||||||
|
<segment state="initial">
|
||||||
|
<source>project.bom.ext_price</source>
|
||||||
|
<target>Extended Price</target>
|
||||||
|
</segment>
|
||||||
|
</unit>
|
||||||
<unit id="8tP3lQI" name="part.info.withdraw_modal.title.withdraw">
|
<unit id="8tP3lQI" name="part.info.withdraw_modal.title.withdraw">
|
||||||
<segment state="translated">
|
<segment state="translated">
|
||||||
<source>part.info.withdraw_modal.title.withdraw</source>
|
<source>part.info.withdraw_modal.title.withdraw</source>
|
||||||
|
|
|
||||||
|
|
@ -7259,6 +7259,12 @@ Element 3</target>
|
||||||
<target>价格</target>
|
<target>价格</target>
|
||||||
</segment>
|
</segment>
|
||||||
</unit>
|
</unit>
|
||||||
|
<unit id="bomExPrc" name="project.bom.ext_price">
|
||||||
|
<segment state="initial">
|
||||||
|
<source>project.bom.ext_price</source>
|
||||||
|
<target>Extended Price</target>
|
||||||
|
</segment>
|
||||||
|
</unit>
|
||||||
<unit id="hO.xnng" name="part.info.withdraw_modal.title.withdraw">
|
<unit id="hO.xnng" name="part.info.withdraw_modal.title.withdraw">
|
||||||
<segment state="translated">
|
<segment state="translated">
|
||||||
<source>part.info.withdraw_modal.title.withdraw</source>
|
<source>part.info.withdraw_modal.title.withdraw</source>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue