Added a tab "Build" to project info page, where you can see how often you can build this project.

This commit is contained in:
Jan Böhmer 2023-01-18 23:07:51 +01:00
parent 6423e52092
commit 76ec63e760
10 changed files with 386 additions and 2 deletions

View file

@ -26,6 +26,7 @@ use App\Entity\ProjectSystem\Project;
use App\Entity\ProjectSystem\ProjectBOMEntry;
use App\Form\ProjectSystem\ProjectBOMEntryCollectionType;
use App\Form\Type\StructuralEntityType;
use App\Services\ProjectSystem\ProjectBuildHelper;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\ORM\EntityManagerInterface;
use Omines\DataTablesBundle\DataTableFactory;
@ -52,7 +53,7 @@ class ProjectController extends AbstractController
/**
* @Route("/{id}/info", name="project_info", requirements={"id"="\d+"})
*/
public function info(Project $project, Request $request)
public function info(Project $project, Request $request, ProjectBuildHelper $buildHelper)
{
$this->denyAccessUnlessGranted('read', $project);
@ -64,6 +65,7 @@ class ProjectController extends AbstractController
}
return $this->render('Projects/info/info.html.twig', [
'buildHelper' => $buildHelper,
'datatable' => $table,
'project' => $project,
]);

View file

@ -259,7 +259,14 @@ class ProjectBOMEntry extends AbstractDBElement
$this->price_currency = $price_currency;
}
/**
* Checks whether this BOM entry is a part associated BOM entry or not.
* @return bool True if this BOM entry is a part associated BOM entry, false otherwise.
*/
public function isPartBomEntry(): bool
{
return $this->part !== null;
}
/**
* @Assert\Callback

View file

@ -0,0 +1,115 @@
<?php
/*
* 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;
use App\Entity\ProjectSystem\Project;
use App\Entity\ProjectSystem\ProjectBOMEntry;
class ProjectBuildHelper
{
/**
* 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.
* @param ProjectBOMEntry $projectBOMEntry
* @return int
*/
public function getMaximumBuildableCountForBOMEntry(ProjectBOMEntry $projectBOMEntry): int
{
$part = $projectBOMEntry->getPart();
if ($part === null) {
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.
* @param Project $project
* @return int
*/
public function getMaximumBuildableCount(Project $project): int
{
$maximum_buildable_count = PHP_INT_MAX;
foreach ($project->getBOMEntries() as $bom_entry) {
//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;
}
/**
* Checks if the given project can be build with the current stock.
* This means that the maximum buildable count is greater than 0.
* @param Project $project
* @return bool
*/
public function isProjectBuildable(Project $project): bool
{
return $this->getMaximumBuildableCount($project) > 0;
}
/**
* 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)
if ($part === null) {
continue;
}
$amount_sum = $part->getAmountSum();
if ($amount_sum < $bomEntry->getQuantity() * $number_of_builds) {
$non_buildable_entries[] = $bomEntry;
}
}
return $non_buildable_entries;
}
}

View file

@ -29,6 +29,9 @@ class ProjectBuildPartHelper
//Add a tag to the part that indicates that it is a build part
$part->setTags('project-build');
//Associate the part with the project
$project->setBuildPart($part);
return $part;
}
}

View file

@ -0,0 +1,28 @@
{% set can_build = buildHelper.projectBuildable(project) %}
{% import "components/projects.macro.html.twig" as project_macros %}
<div class="alert mt-2 {% if can_build %}alert-success{% else %}alert-danger{% endif %}" role="alert">
{% if not can_build %}
<h5><i class="fa-solid fa-circle-exclamation fa-fw"></i> {% trans %}project.builds.build_not_possible{% endtrans %}</h5>
<b>{% trans %}project.builds.following_bom_entries_miss_instock{% endtrans %}</b>
<ul>
{% for bom_entry in buildHelper.nonBuildableProjectBomEntries(project) %}
<li>{{ project_macros.project_bom_entry_with_missing_instock(bom_entry) }}</li>
{% endfor %}
</ul>
{% else %}
<h5><i class="fa-solid fa-circle-check fa-fw"></i> {% trans %}project.builds.build_possible{% endtrans %}</h5>
<span>{% trans with {"%max_builds%": buildHelper.maximumBuildableCount(project)} %}project.builds.number_of_builds_possible{% endtrans %}</span>
{% endif %}
</div>
<div class="row mt-2">
<div class="col-4">
<div class="input-group mb-3">
<input type="number" min="1" class="form-control" placeholder="Number" aria-describedby="button-addon2" required>
<button class="btn btn-outline-secondary" type="button" id="button-addon2">Build</button>
</div>
</div>
</div>

View file

@ -47,6 +47,13 @@
<span class="badge bg-secondary">{{ project.bomEntries | length }}</span>
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="builds-tab" data-bs-toggle="tab" data-bs-target="#builds-tab-pane"
type="button" role="tab" aria-controls="builds-tab-pane" aria-selected="false">
<i class="fa-solid fa-bolt fa-fw"></i>
{% trans %}project.info.builds.label{% endtrans %}
</button>
</li>
{% if project.attachments is not empty %}
<li class="nav-item">
<a class="nav-link" id="attachments-tab" data-bs-toggle="tab"
@ -81,6 +88,9 @@
<div class="tab-pane fade" id="bom-tab-pane" role="tabpanel" aria-labelledby="bom-tab" tabindex="0">
{% include "Projects/info/_bom.html.twig" %}
</div>
<div class="tab-pane fade" id="builds-tab-pane" role="tabpanel" aria-labelledby="builds-tab" tabindex="0">
{% include "Projects/info/_builds.html.twig" %}
</div>
<div class="tab-pane fade" id="attachments-tab-pane" role="tabpanel" aria-labelledby="attachments-tab" tabindex="0">
{% include "Parts/info/_attachments_info.html.twig" with {"part": project} %}
</div>

View file

@ -0,0 +1,8 @@
{% macro project_bom_entry_with_missing_instock(project_bom_entry, number_of_builds = 1) %}
{# @var \App\Entity\ProjectSystem\ProjectBOMEntry project_bom_entry #}
<b><a href="{{ entity_url(project_bom_entry.part) }}">{{ project_bom_entry.part.name }}</a></b>
{% if project_bom_entry.name %}&nbsp;({{ project_bom_entry.name }}){% endif %}:
<b>{{ project_bom_entry.part.amountSum | format_amount(project_bom_entry.part.partUnit) }}</b> {% trans %}project.builds.stocked{% endtrans %}
/
<b>{{ (project_bom_entry.quantity * number_of_builds) | format_amount(project_bom_entry.part.partUnit) }}</b> {% trans %}project.builds.needed{% endtrans %}
{% endmacro %}

View file

@ -0,0 +1,116 @@
<?php
/*
* 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\Tests\Services\ProjectSystem;
use App\Entity\Parts\Part;
use App\Entity\Parts\PartLot;
use App\Entity\ProjectSystem\Project;
use App\Entity\ProjectSystem\ProjectBOMEntry;
use App\Services\ProjectSystem\ProjectBuildHelper;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
class ProjectBuildHelperTest extends WebTestCase
{
/** @var ProjectBuildHelper */
protected $service;
protected function setUp(): void
{
parent::setUp();
self::bootKernel();
$this->service = self::getContainer()->get(ProjectBuildHelper::class);
}
public function testGetMaximumBuildableCountForBOMEntryNonPartBomEntry()
{
$bom_entry = new ProjectBOMEntry();
$bom_entry->setPart(null);
$bom_entry->setQuantity(10);
$bom_entry->setName('Test');
$this->expectException(\InvalidArgumentException::class);
$this->service->getMaximumBuildableCountForBOMEntry($bom_entry);
}
public function testGetMaximumBuildableCountForBOMEntry()
{
$project_bom_entry = new ProjectBOMEntry();
$project_bom_entry->setQuantity(10);
$part = new Part();
$lot1 = new PartLot();
$lot1->setAmount(120);
$lot2 = new PartLot();
$lot2->setAmount(5);
$part->addPartLot($lot1);
$part->addPartLot($lot2);
$project_bom_entry->setPart($part);
//We have 125 parts in stock, so we can build 12 times the project (125 / 10 = 12.5)
$this->assertEquals(12, $this->service->getMaximumBuildableCountForBOMEntry($project_bom_entry));
$lot1->setAmount(0);
//We have 5 parts in stock, so we can build 0 times the project (5 / 10 = 0.5)
$this->assertEquals(0, $this->service->getMaximumBuildableCountForBOMEntry($project_bom_entry));
}
public function testGetMaximumBuildableCount()
{
$project = new Project();
$project_bom_entry1 = new ProjectBOMEntry();
$project_bom_entry1->setQuantity(10);
$part = new Part();
$lot1 = new PartLot();
$lot1->setAmount(120);
$lot2 = new PartLot();
$lot2->setAmount(5);
$part->addPartLot($lot1);
$part->addPartLot($lot2);
$project_bom_entry1->setPart($part);
$project->addBomEntry($project_bom_entry1);
$project_bom_entry2 = new ProjectBOMEntry();
$project_bom_entry2->setQuantity(5);
$part2 = new Part();
$lot3 = new PartLot();
$lot3->setAmount(10);
$part2->addPartLot($lot3);
$project_bom_entry2->setPart($part2);
$project->addBomEntry($project_bom_entry2);
$project->addBomEntry((new ProjectBOMEntry())->setName('Non part entry')->setQuantity(1));
//Restricted by the few parts in stock of part2
$this->assertEquals(2, $this->service->getMaximumBuildableCount($project));
$lot3->setAmount(1000);
//Now the build count is restricted by the few parts in stock of part1
$this->assertEquals(12, $this->service->getMaximumBuildableCount($project));
$lot3->setAmount(0);
//Now the build count must be 0, as we have no parts in stock
$this->assertEquals(0, $this->service->getMaximumBuildableCount($project));
}
}

View file

@ -0,0 +1,53 @@
<?php
/*
* 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\Tests\Services\ProjectSystem;
use App\Entity\ProjectSystem\Project;
use App\Services\Parts\PricedetailHelper;
use App\Services\ProjectSystem\ProjectBuildPartHelper;
use PHPUnit\Framework\TestCase;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
class ProjectBuildPartHelperTest extends WebTestCase
{
/** @var ProjectBuildPartHelper */
protected $service;
protected function setUp(): void
{
parent::setUp();
self::bootKernel();
$this->service = self::getContainer()->get(ProjectBuildPartHelper::class);
}
public function testGetPartInitialization(): void
{
$project = new Project();
$project->setName('Project 1');
$project->setDescription('Description 1');
$part = $this->service->getPartInitialization($project);
$this->assertSame('Project 1', $part->getName());
$this->assertSame('Description 1', $part->getDescription());
$this->assertSame($project, $part->getBuiltProject());
$this->assertSame($part, $project->getBuildPart());
}
}

View file

@ -10291,5 +10291,47 @@ Element 3</target>
<target>Empty label</target>
</segment>
</unit>
<unit id="YddcwVg" name="project.info.builds.label">
<segment>
<source>project.info.builds.label</source>
<target>Build</target>
</segment>
</unit>
<unit id="6uUxHyg" name="project.builds.build_not_possible">
<segment>
<source>project.builds.build_not_possible</source>
<target>Build not possible: Parts not stocked</target>
</segment>
</unit>
<unit id="HY05vl8" name="project.builds.following_bom_entries_miss_instock">
<segment>
<source>project.builds.following_bom_entries_miss_instock</source>
<target>The following parts have not enough stock to build this project at least once:</target>
</segment>
</unit>
<unit id="VHUY79k" name="project.builds.stocked">
<segment>
<source>project.builds.stocked</source>
<target>stocked</target>
</segment>
</unit>
<unit id="mwL3d70" name="project.builds.needed">
<segment>
<source>project.builds.needed</source>
<target>needed</target>
</segment>
</unit>
<unit id="jZKw98F" name="project.builds.build_possible">
<segment>
<source>project.builds.build_possible</source>
<target>Build possible</target>
</segment>
</unit>
<unit id="rRMnoEh" name="project.builds.number_of_builds_possible">
<segment>
<source>project.builds.number_of_builds_possible</source>
<target><![CDATA[You have enough stocked to build <b>%max_builds%</b> builds of this project.]]></target>
</segment>
</unit>
</file>
</xliff>