mirror of
https://github.com/Part-DB/Part-DB-server.git
synced 2025-06-20 17:15:51 +02:00
Added a tab "Build" to project info page, where you can see how often you can build this project.
This commit is contained in:
parent
6423e52092
commit
76ec63e760
10 changed files with 386 additions and 2 deletions
|
@ -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,
|
||||
]);
|
||||
|
|
|
@ -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
|
||||
|
|
115
src/Services/ProjectSystem/ProjectBuildHelper.php
Normal file
115
src/Services/ProjectSystem/ProjectBuildHelper.php
Normal 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;
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
28
templates/Projects/info/_builds.html.twig
Normal file
28
templates/Projects/info/_builds.html.twig
Normal 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>
|
|
@ -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>
|
||||
|
|
8
templates/components/projects.macro.html.twig
Normal file
8
templates/components/projects.macro.html.twig
Normal 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 %} ({{ 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 %}
|
116
tests/Services/ProjectSystem/ProjectBuildHelperTest.php
Normal file
116
tests/Services/ProjectSystem/ProjectBuildHelperTest.php
Normal 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));
|
||||
|
||||
}
|
||||
}
|
53
tests/Services/ProjectSystem/ProjectBuildPartHelperTest.php
Normal file
53
tests/Services/ProjectSystem/ProjectBuildPartHelperTest.php
Normal 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());
|
||||
}
|
||||
}
|
|
@ -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>
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue