This commit is contained in:
web-devinition.de 2026-01-13 01:09:39 +06:00 committed by GitHub
commit 30840ac8b2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
136 changed files with 23994 additions and 112 deletions

View file

@ -0,0 +1,70 @@
import {Controller} from "@hotwired/stimulus";
import "tom-select/dist/css/tom-select.bootstrap5.css";
import '../../css/components/tom-select_extensions.css';
import TomSelect from "tom-select";
import {marked} from "marked";
export default class extends Controller {
_tomSelect;
connect() {
let settings = {
allowEmptyOption: true,
plugins: ['dropdown_input', 'clear_button'],
searchField: ["name", "description", "category", "footprint"],
valueField: "id",
labelField: "name",
preload: "focus",
render: {
item: (data, escape) => {
return '<span>' + (data.image ? "<img style='height: 1.5rem; margin-right: 5px;' ' src='" + data.image + "'/>" : "") + escape(data.name) + '</span>';
},
option: (data, escape) => {
if(data.text) {
return '<span>' + escape(data.text) + '</span>';
}
let tmp = '<div class="row m-0">' +
"<div class='col-2 p-0 d-flex align-items-center' style='max-width: 80px;'>" +
(data.image ? "<img class='typeahead-image' src='" + data.image + "'/>" : "") +
"</div>" +
"<div class='col-10'>" +
'<h6 class="m-0">' + escape(data.name) + '</h6>' +
(data.description ? '<p class="m-0">' + marked.parseInline(data.description) + '</p>' : "") +
(data.category ? '<p class="m-0"><span class="fa-solid fa-tags fa-fw"></span> ' + escape(data.category) : "");
return tmp + '</p>' +
'</div></div>';
}
}
};
if (this.element.dataset.autocomplete) {
const base_url = this.element.dataset.autocomplete;
settings.valueField = "id";
settings.load = (query, callback) => {
const url = base_url.replace('__QUERY__', encodeURIComponent(query));
fetch(url)
.then(response => response.json())
.then(json => {callback(json);})
.catch(() => {
callback()
});
};
this._tomSelect = new TomSelect(this.element, settings);
//this._tomSelect.clearOptions();
}
}
disconnect() {
super.disconnect();
//Destroy the TomSelect instance
this._tomSelect.destroy();
}
}

View file

@ -18,7 +18,7 @@ export default class extends Controller {
let settings = {
allowEmptyOption: true,
plugins: ['dropdown_input'],
plugins: ['dropdown_input', 'clear_button'],
searchField: ["name", "description", "category", "footprint"],
valueField: "id",
labelField: "name",

View file

@ -0,0 +1,62 @@
import { Controller } from "@hotwired/stimulus";
export default class extends Controller {
static values = {
classes: Array
};
connect() {
this.displayCheckbox = this.element.querySelector("#display");
this.displaySelect = this.element.querySelector("select#display");
if (this.displayCheckbox) {
this.toggleContainers(this.displayCheckbox.checked);
this.displayCheckbox.addEventListener("change", (event) => {
this.toggleContainers(event.target.checked);
});
}
if (this.displaySelect) {
this.toggleContainers(this.hasDisplaySelectValue());
this.displaySelect.addEventListener("change", () => {
this.toggleContainers(this.hasDisplaySelectValue());
});
}
}
/**
* Check whether a value was selected in the selectbox
* @returns {boolean} True when a value has not been selected that is not empty
*/
hasDisplaySelectValue() {
return this.displaySelect && this.displaySelect.value !== "";
}
/**
* Hides specified containers if the state is active (checkbox checked or select with value).
*
* @param {boolean} isActive - True when the checkbox is activated or the selectbox has a value.
*/
toggleContainers(isActive) {
if (!Array.isArray(this.classesValue) || this.classesValue.length === 0) {
return;
}
this.classesValue.forEach((cssClass) => {
const elements = document.querySelectorAll(`.${cssClass}`);
if (!elements.length) {
return;
}
elements.forEach((element) => {
element.style.display = isActive ? "none" : "";
});
});
}
}

View file

@ -38,7 +38,7 @@ export default class extends Controller {
connect() {
//Add event listener to the checkbox
this.getCheckbox().addEventListener('change', this.toggleInputLimits.bind(this));
this.getCheckbox()?.addEventListener('change', this.toggleInputLimits.bind(this));
}
toggleInputLimits() {

View file

@ -61,3 +61,8 @@
.object-fit-cover {
object-fit: cover;
}
.assembly-table-image {
max-height: 40px;
object-fit: contain;
}

View file

@ -18,6 +18,12 @@
//CHANGED jbtronics: Preserve the get parameters (needed so we can pass additional params to query)
$.fn.initDataTables.defaults.url = window.location.origin + window.location.pathname + window.location.search;
$.fn.dataTable.ext.errMode = function(settings, helpPage, message) {
if (message.includes('ColReorder')) {
console.warn('ColReorder does not fit the number of columns', message);
}
};
var root = this,
config = $.extend({}, $.fn.initDataTables.defaults, config),
state = ''
@ -105,7 +111,6 @@
}
}
root.html(data.template);
dt = $('table', root).DataTable(dtOpts);
if (config.state !== 'none') {

View file

@ -121,6 +121,10 @@ perms: # Here comes a list with all Permission names (they have a perm_[name] co
<<: *PART_CONTAINING
label: "[[Project]]"
assemblies:
<<: *PART_CONTAINING
label: "perm.assemblies"
attachment_types:
<<: *PART_CONTAINING
label: "[[Attachment_type]]"

View file

@ -170,6 +170,9 @@ services:
arguments:
$saml_enabled: '%partdb.saml.enabled%'
App\Validator\Constraints\AssemblySystem\AssemblyCycleValidator:
tags: [ 'validator.constraint_validator' ]
####################################################################################################################
# Table settings
####################################################################################################################
@ -257,6 +260,8 @@ services:
$enabled: '%env(bool:DATABASE_MYSQL_USE_SSL_CA)%'
$verify: '%env(bool:DATABASE_MYSQL_SSL_VERIFY_CERT)%'
App\Helpers\Assemblies\AssemblyPartAggregator: ~
####################################################################################################################
# Monolog
####################################################################################################################
@ -280,6 +285,10 @@ services:
when@test: &test
services:
_defaults:
autowire: true
autoconfigure: true
# Decorate the doctrine fixtures load command to use our custom purger by default
doctrine.fixtures_load_command.custom:
decorates: doctrine.fixtures_load_command
@ -288,3 +297,6 @@ when@test: &test
- '@doctrine.fixtures.loader'
- '@doctrine'
- { default: '@App\Doctrine\Purger\DoNotUsePurgerFactory' }
App\Services\ImportExportSystem\EntityExporter:
public: true

View file

@ -147,6 +147,14 @@ bundled with Part-DB. Set `DATABASE_MYSQL_SSL_VERIFY_CERT` if you want to accept
time).
Also specify the default order of the columns. This is a comma separated list of column names. Available columns
are: `name`, `id`, `ipn`, `description`, `category`, `footprint`, `manufacturer`, `storage_location`, `amount`, `minamount`, `partUnit`, `partCustomState`, `addedDate`, `lastModified`, `needs_review`, `favorite`, `manufacturing_status`, `manufacturer_product_number`, `mass`, `tags`, `attachments`, `edit`.
* `TABLE_ASSEMBLIES_DEFAULT_COLUMNS`: The columns in assemblies tables, which are visible by default (when loading table for first time).
Also specify the default order of the columns. This is a comma separated list of column names. Available columns
are: `name`, `id`, `ipn`, `description`, `referencedAssemblies`, `edit`, `addedDate`, `lastModified`.
* `TABLE_ASSEMBLIES_BOM_DEFAULT_COLUMNS`: The columns in assemblies bom tables, which are visible by default (when loading table for first time).
Also specify the default order of the columns. This is a comma separated list of column names. Available columns
are: `quantity`, `name`, `id`, `ipn`, `description`, `category`, `footprint`, `manufacturer`, `designator`, `mountnames`, `storage_location`, `amount`, `addedDate`, `lastModified`.
* `CREATE_ASSEMBLY_USE_IPN_PLACEHOLDER_IN_NAME`: Use an %%ipn%% placeholder in the name of a assembly. Placeholder is replaced with the ipn input while saving.
### History/Eventlog-related settings

View file

@ -0,0 +1,236 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use App\Migration\AbstractMultiPlatformMigration;
use Doctrine\DBAL\Schema\Schema;
final class Version20251016141941 extends AbstractMultiPlatformMigration
{
public function getDescription(): string
{
return 'Add assemblies and assembly_bom_entries tables';
}
public function mySQLUp(Schema $schema): void
{
$this->addSql(<<<'SQL'
CREATE TABLE assemblies (
id INT AUTO_INCREMENT NOT NULL,
parent_id INT DEFAULT NULL,
id_preview_attachment INT DEFAULT NULL,
name VARCHAR(255) NOT NULL,
comment LONGTEXT NOT NULL,
not_selectable TINYINT(1) NOT NULL,
alternative_names LONGTEXT DEFAULT NULL,
order_quantity INT NOT NULL,
status VARCHAR(64) DEFAULT NULL,
ipn VARCHAR(100) DEFAULT NULL,
order_only_missing_parts TINYINT(1) NOT NULL,
description LONGTEXT NOT NULL,
last_modified DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL,
datetime_added DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL,
INDEX IDX_5F3832C0727ACA70 (parent_id),
INDEX IDX_5F3832C0EA7100A1 (id_preview_attachment),
UNIQUE INDEX UNIQ_5F3832C03D721C14 (ipn),
INDEX assembly_idx_ipn (ipn),
PRIMARY KEY(id)
) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci`
SQL);
$this->addSql(<<<'SQL'
ALTER TABLE assemblies
ADD CONSTRAINT FK_5F3832C0727ACA70 FOREIGN KEY (parent_id) REFERENCES assemblies (id)
SQL);
$this->addSql(<<<'SQL'
ALTER TABLE assemblies
ADD CONSTRAINT FK_5F3832C0EA7100A1 FOREIGN KEY (id_preview_attachment) REFERENCES `attachments` (id) ON DELETE SET NULL
SQL);
$this->addSql(<<<'SQL'
CREATE TABLE assembly_bom_entries (
id INT AUTO_INCREMENT NOT NULL,
id_assembly INT DEFAULT NULL,
id_part INT DEFAULT NULL,
id_referenced_assembly INT DEFAULT NULL,
quantity DOUBLE PRECISION NOT NULL,
mountnames LONGTEXT NOT NULL,
designator LONGTEXT NOT NULL,
name VARCHAR(255) DEFAULT NULL,
comment LONGTEXT NOT NULL,
price NUMERIC(11, 5) DEFAULT NULL,
price_currency_id INT DEFAULT NULL,
last_modified DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL,
datetime_added DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL,
INDEX IDX_8C74887E4AD2039E (id_assembly),
INDEX IDX_8C74887EC22F6CC4 (id_part),
INDEX IDX_8C74887E22522999 (id_referenced_assembly),
INDEX IDX_8C74887E3FFDCD60 (price_currency_id),
PRIMARY KEY(id)
) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci`
SQL);
$this->addSql(<<<'SQL'
ALTER TABLE assembly_bom_entries
ADD CONSTRAINT FK_8C74887E4AD2039E FOREIGN KEY (id_assembly) REFERENCES assemblies (id)
SQL);
$this->addSql(<<<'SQL'
ALTER TABLE assembly_bom_entries
ADD CONSTRAINT FK_8C74887EC22F6CC4 FOREIGN KEY (id_part) REFERENCES `parts` (id)
SQL);
$this->addSql(<<<'SQL'
ALTER TABLE assembly_bom_entries
ADD CONSTRAINT FK_8C74887E22522999 FOREIGN KEY (id_referenced_assembly) REFERENCES assemblies (id) ON DELETE SET NULL
SQL);
$this->addSql(<<<'SQL'
ALTER TABLE assembly_bom_entries
ADD CONSTRAINT FK_8C74887E3FFDCD60 FOREIGN KEY (price_currency_id) REFERENCES currencies (id)
SQL);
}
public function mySQLDown(Schema $schema): void
{
$this->addSql('DROP TABLE assembly_bom_entries');
$this->addSql('DROP TABLE assemblies');
}
public function sqLiteUp(Schema $schema): void
{
$this->addSql(<<<'SQL'
CREATE TABLE assemblies (
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
parent_id INTEGER DEFAULT NULL,
id_preview_attachment INTEGER DEFAULT NULL,
order_quantity INTEGER NOT NULL,
order_only_missing_parts BOOLEAN NOT NULL,
comment CLOB NOT NULL,
not_selectable BOOLEAN NOT NULL,
name VARCHAR(255) NOT NULL,
last_modified DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL,
datetime_added DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL,
status VARCHAR(64) DEFAULT NULL,
ipn VARCHAR(100) DEFAULT NULL,
description CLOB NOT NULL,
alternative_names CLOB DEFAULT NULL,
CONSTRAINT FK_5F3832C0727ACA70 FOREIGN KEY (parent_id) REFERENCES assemblies (id) NOT DEFERRABLE INITIALLY IMMEDIATE,
CONSTRAINT FK_5F3832C0EA7100A1 FOREIGN KEY (id_preview_attachment) REFERENCES "attachments" (id) ON DELETE SET NULL NOT DEFERRABLE INITIALLY IMMEDIATE
)
SQL);
$this->addSql('CREATE INDEX IDX_5F3832C0727ACA70 ON assemblies (parent_id)');
$this->addSql('CREATE INDEX IDX_5F3832C0EA7100A1 ON assemblies (id_preview_attachment)');
$this->addSql('CREATE UNIQUE INDEX UNIQ_5F3832C03D721C14 ON assemblies (ipn)');
$this->addSql('CREATE INDEX assembly_idx_ipn ON assemblies (ipn)');
$this->addSql(<<<'SQL'
CREATE TABLE assembly_bom_entries (
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
id_assembly INTEGER DEFAULT NULL,
id_part INTEGER DEFAULT NULL,
id_referenced_assembly INTEGER DEFAULT NULL,
price_currency_id INTEGER DEFAULT NULL,
quantity DOUBLE PRECISION NOT NULL,
mountnames CLOB NOT NULL,
designator CLOB NOT NULL,
name VARCHAR(255) DEFAULT NULL,
comment CLOB NOT NULL,
price NUMERIC(11, 5) DEFAULT NULL,
last_modified DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL,
datetime_added DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL,
CONSTRAINT FK_8C74887E4AD2039E FOREIGN KEY (id_assembly) REFERENCES assemblies (id) NOT DEFERRABLE INITIALLY IMMEDIATE,
CONSTRAINT FK_8C74887EC22F6CC4 FOREIGN KEY (id_part) REFERENCES "parts" (id) NOT DEFERRABLE INITIALLY IMMEDIATE,
CONSTRAINT FK_8C74887E22522999 FOREIGN KEY (id_referenced_assembly) REFERENCES assemblies (id) ON UPDATE NO ACTION ON DELETE SET NULL NOT DEFERRABLE INITIALLY IMMEDIATE,
CONSTRAINT FK_8C74887E3FFDCD60 FOREIGN KEY (price_currency_id) REFERENCES currencies (id) NOT DEFERRABLE INITIALLY IMMEDIATE
)
SQL);
$this->addSql('CREATE INDEX IDX_8C74887E4AD2039E ON assembly_bom_entries (id_assembly)');
$this->addSql('CREATE INDEX IDX_8C74887EC22F6CC4 ON assembly_bom_entries (id_part)');
$this->addSql('CREATE INDEX IDX_8C74887E22522999 ON assembly_bom_entries (id_referenced_assembly)');
$this->addSql('CREATE INDEX IDX_8C74887E3FFDCD60 ON assembly_bom_entries (price_currency_id)');
}
public function sqLiteDown(Schema $schema): void
{
$this->addSql('DROP TABLE assembly_bom_entries');
$this->addSql('DROP TABLE assemblies');
}
public function postgreSQLUp(Schema $schema): void
{
$this->addSql(<<<'SQL'
CREATE TABLE assemblies (
id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL,
name VARCHAR(255) NOT NULL,
last_modified TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT CURRENT_TIMESTAMP NOT NULL,
datetime_added TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT CURRENT_TIMESTAMP NOT NULL,
comment TEXT NOT NULL,
not_selectable BOOLEAN NOT NULL,
alternative_names TEXT DEFAULT NULL,
order_quantity INT NOT NULL,
status VARCHAR(64) DEFAULT NULL,
ipn VARCHAR(100) DEFAULT NULL,
order_only_missing_parts BOOLEAN NOT NULL,
description TEXT NOT NULL,
parent_id INT DEFAULT NULL,
id_preview_attachment INT DEFAULT NULL,
PRIMARY KEY(id)
)
SQL);
$this->addSql('CREATE INDEX IDX_5F3832C0727ACA70 ON assemblies (parent_id)');
$this->addSql('CREATE INDEX IDX_5F3832C0EA7100A1 ON assemblies (id_preview_attachment)');
$this->addSql('CREATE UNIQUE INDEX UNIQ_5F3832C03D721C14 ON assemblies (ipn)');
$this->addSql('CREATE INDEX assembly_idx_ipn ON assemblies (ipn)');
$this->addSql(<<<'SQL'
ALTER TABLE assemblies
ADD CONSTRAINT FK_5F3832C0727ACA70 FOREIGN KEY (parent_id) REFERENCES assemblies (id) NOT DEFERRABLE INITIALLY IMMEDIATE
SQL);
$this->addSql(<<<'SQL'
ALTER TABLE assemblies
ADD CONSTRAINT FK_5F3832C0EA7100A1 FOREIGN KEY (id_preview_attachment) REFERENCES "attachments" (id) ON DELETE SET NULL NOT DEFERRABLE INITIALLY IMMEDIATE
SQL);
$this->addSql(<<<'SQL'
CREATE TABLE assembly_bom_entries (
id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL,
id_assembly INT DEFAULT NULL,
id_part INT DEFAULT NULL,
id_referenced_assembly INT DEFAULT NULL,
quantity DOUBLE PRECISION NOT NULL,
mountnames TEXT NOT NULL,
designator TEXT NOT NULL,
name VARCHAR(255) DEFAULT NULL,
comment TEXT NOT NULL,
price NUMERIC(11, 5) DEFAULT NULL,
price_currency_id INT DEFAULT NULL,
last_modified TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT CURRENT_TIMESTAMP NOT NULL,
datetime_added TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT CURRENT_TIMESTAMP NOT NULL,
PRIMARY KEY(id)
)
SQL);
$this->addSql('CREATE INDEX IDX_8C74887E4AD2039E ON assembly_bom_entries (id_assembly)');
$this->addSql('CREATE INDEX IDX_8C74887EC22F6CC4 ON assembly_bom_entries (id_part)');
$this->addSql('CREATE INDEX IDX_8C74887E22522999 ON assembly_bom_entries (id_referenced_assembly)');
$this->addSql('CREATE INDEX IDX_8C74887E3FFDCD60 ON assembly_bom_entries (price_currency_id)');
$this->addSql(<<<'SQL'
ALTER TABLE assembly_bom_entries
ADD CONSTRAINT FK_8C74887E4AD2039E FOREIGN KEY (id_assembly) REFERENCES assemblies (id) NOT DEFERRABLE INITIALLY IMMEDIATE
SQL);
$this->addSql(<<<'SQL'
ALTER TABLE assembly_bom_entries
ADD CONSTRAINT FK_8C74887EC22F6CC4 FOREIGN KEY (id_part) REFERENCES "parts" (id) NOT DEFERRABLE INITIALLY IMMEDIATE
SQL);
$this->addSql(<<<'SQL'
ALTER TABLE assembly_bom_entries
ADD CONSTRAINT FK_8C74887E22522999 FOREIGN KEY (id_referenced_assembly) REFERENCES assemblies (id) ON DELETE SET NULL NOT DEFERRABLE INITIALLY IMMEDIATE
SQL);
$this->addSql(<<<'SQL'
ALTER TABLE assembly_bom_entries
ADD CONSTRAINT FK_8C74887E3FFDCD60 FOREIGN KEY (price_currency_id) REFERENCES currencies (id) NOT DEFERRABLE INITIALLY IMMEDIATE
SQL);
}
public function postgreSQLDown(Schema $schema): void
{
$this->addSql('DROP TABLE assembly_bom_entries');
$this->addSql('DROP TABLE assemblies');
}
}

View file

@ -22,6 +22,7 @@ declare(strict_types=1);
namespace App\Command\Migrations;
use App\Entity\AssemblySystem\Assembly;
use Symfony\Component\Console\Attribute\AsCommand;
use App\Entity\Attachments\AttachmentType;
use App\Entity\Base\AbstractNamedDBElement;
@ -88,6 +89,7 @@ class ConvertBBCodeCommand extends Command
AttachmentType::class => ['comment'],
StorageLocation::class => ['comment'],
Project::class => ['comment'],
Assembly::class => ['comment'],
Category::class => ['comment'],
Manufacturer::class => ['comment'],
MeasurementUnit::class => ['comment'],

View file

@ -0,0 +1,80 @@
<?php
/**
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2022 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/>.
*/
declare(strict_types=1);
namespace App\Controller\AdminPages;
use App\Entity\AssemblySystem\Assembly;
use App\Entity\Attachments\AssemblyAttachment;
use App\Entity\Parameters\AssemblyParameter;
use App\Form\AdminPages\AssemblyAdminForm;
use App\Services\ImportExportSystem\EntityExporter;
use App\Services\ImportExportSystem\EntityImporter;
use App\Services\Trees\StructuralElementRecursionHelper;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
#[Route(path: '/assembly')]
class AssemblyAdminController extends BaseAdminController
{
protected string $entity_class = Assembly::class;
protected string $twig_template = 'admin/assembly_admin.html.twig';
protected string $form_class = AssemblyAdminForm::class;
protected string $route_base = 'assembly';
protected string $attachment_class = AssemblyAttachment::class;
protected ?string $parameter_class = AssemblyParameter::class;
#[Route(path: '/{id}', name: 'assembly_delete', methods: ['DELETE'])]
public function delete(Request $request, Assembly $entity, StructuralElementRecursionHelper $recursionHelper): RedirectResponse
{
return $this->_delete($request, $entity, $recursionHelper);
}
#[Route(path: '/{id}/edit/{timestamp}', name: 'assembly_edit', requirements: ['id' => '\d+'])]
#[Route(path: '/{id}/edit', requirements: ['id' => '\d+'])]
public function edit(Assembly $entity, Request $request, EntityManagerInterface $em, ?string $timestamp = null): Response
{
return $this->_edit($entity, $request, $em, $timestamp);
}
#[Route(path: '/new', name: 'assembly_new')]
#[Route(path: '/{id}/clone', name: 'assembly_clone')]
#[Route(path: '/')]
public function new(Request $request, EntityManagerInterface $em, EntityImporter $importer, ?Assembly $entity = null): Response
{
return $this->_new($request, $em, $importer, $entity);
}
#[Route(path: '/export', name: 'assembly_export_all')]
public function exportAll(EntityManagerInterface $em, EntityExporter $exporter, Request $request): Response
{
return $this->_exportAll($em, $exporter, $request);
}
#[Route(path: '/{id}/export', name: 'assembly_export')]
public function exportEntity(Assembly $entity, EntityExporter $exporter, Request $request): Response
{
return $this->_exportEntity($entity, $exporter, $request);
}
}

View file

@ -23,6 +23,8 @@ declare(strict_types=1);
namespace App\Controller\AdminPages;
use App\DataTables\LogDataTable;
use App\Entity\AssemblySystem\Assembly;
use App\Entity\AssemblySystem\AssemblyBOMEntry;
use App\Entity\Attachments\Attachment;
use App\Entity\Attachments\AttachmentContainingDBElement;
use App\Entity\Attachments\AttachmentUpload;
@ -193,6 +195,15 @@ abstract class BaseAdminController extends AbstractController
$entity->setMasterPictureAttachment(null);
}
if ($entity instanceof Assembly) {
/* Replace ipn placeholder with the IPN information if applicable.
* The '%%ipn%%' placeholder is automatically inserted into the Name property,
* depending on CREATE_ASSEMBLY_USE_IPN_PLACEHOLDER_IN_NAME, when creating a new one,
* to avoid having to insert it manually */
$entity->setName(str_ireplace('%%ipn%%', $entity->getIpn() ?? '', $entity->getName()));
}
$this->commentHelper->setMessage($form['log_comment']->getData());
$em->persist($entity);
@ -287,6 +298,15 @@ abstract class BaseAdminController extends AbstractController
$new_entity->setMasterPictureAttachment(null);
}
if ($new_entity instanceof Assembly) {
/* Replace ipn placeholder with the IPN information if applicable.
* The '%%ipn%%' placeholder is automatically inserted into the Name property,
* depending on CREATE_ASSEMBLY_USE_IPN_PLACEHOLDER_IN_NAME, when creating a new one,
* to avoid having to insert it manually */
$new_entity->setName(str_ireplace('%%ipn%%', $new_entity->getIpn() ?? '', $new_entity->getName()));
}
$this->commentHelper->setMessage($form['log_comment']->getData());
$em->persist($new_entity);
$em->flush();
@ -450,6 +470,10 @@ abstract class BaseAdminController extends AbstractController
return $this->redirectToRoute($this->route_base.'_edit', ['id' => $entity->getID()]);
}
} else {
if ($entity instanceof Assembly) {
$this->markReferencedBomEntry($entity);
}
if ($entity instanceof AbstractStructuralDBElement) {
$parent = $entity->getParent();
@ -497,4 +521,16 @@ abstract class BaseAdminController extends AbstractController
return $exporter->exportEntityFromRequest($entity, $request);
}
private function markReferencedBomEntry(Assembly $referencedAssembly): void
{
$bomEntries = $this->entityManager->getRepository(AssemblyBOMEntry::class)->findBy(['referencedAssembly' => $referencedAssembly]);
foreach ($bomEntries as $entry) {
$entry->setReferencedAssembly(null);
$entry->setName($referencedAssembly->getName(). ' DELETED');
$this->entityManager->persist($entry);
}
}
}

View file

@ -0,0 +1,318 @@
<?php
declare(strict_types=1);
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2022 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\Controller;
use App\DataTables\AssemblyBomEntriesDataTable;
use App\DataTables\AssemblyDataTable;
use App\DataTables\ErrorDataTable;
use App\DataTables\Filters\AssemblyFilter;
use App\Entity\AssemblySystem\Assembly;
use App\Entity\AssemblySystem\AssemblyBOMEntry;
use App\Entity\Parts\Part;
use App\Exceptions\InvalidRegexException;
use App\Form\AssemblySystem\AssemblyAddPartsType;
use App\Form\Filters\AssemblyFilterType;
use App\Services\ImportExportSystem\BOMImporter;
use App\Services\Trees\NodesListBuilder;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\DBAL\Exception\DriverException;
use Doctrine\ORM\EntityManagerInterface;
use League\Csv\SyntaxError;
use Omines\DataTablesBundle\DataTableFactory;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\Extension\Core\Type\FileType;
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Validator\Validator\ValidatorInterface;
use Symfony\Contracts\Translation\TranslatorInterface;
use function Symfony\Component\Translation\t;
#[Route(path: '/assembly')]
class AssemblyController extends AbstractController
{
public function __construct(
private readonly DataTableFactory $dataTableFactory,
private readonly TranslatorInterface $translator,
private readonly NodesListBuilder $nodesListBuilder
) {
}
#[Route(path: '/list', name: 'assemblies_list')]
public function showAll(Request $request): Response
{
return $this->showListWithFilter($request,'assemblies/lists/all_list.html.twig');
}
/**
* Common implementation for the part list pages.
* @param Request $request The request to parse
* @param string $template The template that should be rendered
* @param callable|null $filter_changer A function that is called with the filter object as parameter. This function can be used to customize the filter
* @param callable|null $form_changer A function that is called with the form object as parameter. This function can be used to customize the form
* @param array $additonal_template_vars Any additional template variables that should be passed to the template
* @param array $additional_table_vars Any additional variables that should be passed to the table creation
*/
protected function showListWithFilter(Request $request, string $template, ?callable $filter_changer = null, ?callable $form_changer = null, array $additonal_template_vars = [], array $additional_table_vars = []): Response
{
$this->denyAccessUnlessGranted('@assemblies.read');
$formRequest = clone $request;
$formRequest->setMethod('GET');
$filter = new AssemblyFilter($this->nodesListBuilder);
if($filter_changer !== null){
$filter_changer($filter);
}
$filterForm = $this->createForm(AssemblyFilterType::class, $filter, ['method' => 'GET']);
if($form_changer !== null) {
$form_changer($filterForm);
}
$filterForm->handleRequest($formRequest);
$table = $this->dataTableFactory->createFromType(
AssemblyDataTable::class,
array_merge(['filter' => $filter], $additional_table_vars),
['lengthMenu' => AssemblyDataTable::LENGTH_MENU]
)
->handleRequest($request);
if ($table->isCallback()) {
try {
try {
return $table->getResponse();
} catch (DriverException $driverException) {
if ($driverException->getCode() === 1139) {
//Convert the driver exception to InvalidRegexException so it has the same handler as for SQLite
throw InvalidRegexException::fromDriverException($driverException);
} else {
throw $driverException;
}
}
} catch (InvalidRegexException $exception) {
$errors = $this->translator->trans('assembly.table.invalid_regex').': '.$exception->getReason();
$request->request->set('order', []);
return ErrorDataTable::errorTable($this->dataTableFactory, $request, $errors);
}
}
return $this->render($template, array_merge([
'datatable' => $table,
'filterForm' => $filterForm->createView(),
], $additonal_template_vars));
}
#[Route(path: '/{id}/info', name: 'assembly_info', requirements: ['id' => '\d+'])]
public function info(Assembly $assembly, Request $request): Response
{
$this->denyAccessUnlessGranted('read', $assembly);
$table = $this->dataTableFactory->createFromType(AssemblyBomEntriesDataTable::class, ['assembly' => $assembly])
->handleRequest($request);
if ($table->isCallback()) {
return $table->getResponse();
}
return $this->render('assemblies/info/info.html.twig', [
'datatable' => $table,
'assembly' => $assembly,
]);
}
#[Route(path: '/{id}/import_bom', name: 'assembly_import_bom', requirements: ['id' => '\d+'])]
public function importBOM(Request $request, EntityManagerInterface $entityManager, Assembly $assembly,
BOMImporter $BOMImporter, ValidatorInterface $validator): Response
{
$this->denyAccessUnlessGranted('edit', $assembly);
$builder = $this->createFormBuilder();
$builder->add('file', FileType::class, [
'label' => 'import.file',
'required' => true,
'attr' => [
'accept' => '.csv, .json'
]
]);
$builder->add('type', ChoiceType::class, [
'label' => 'assembly.bom_import.type',
'required' => true,
'choices' => [
'assembly.bom_import.type.json' => 'json',
'assembly.bom_import.type.csv' => 'csv',
'assembly.bom_import.type.kicad_pcbnew' => 'kicad_pcbnew',
'assembly.bom_import.type.kicad_schematic' => 'kicad_schematic',
]
]);
$builder->add('clear_existing_bom', CheckboxType::class, [
'label' => 'assembly.bom_import.clear_existing_bom',
'required' => false,
'data' => false,
'help' => 'assembly.bom_import.clear_existing_bom.help',
]);
$builder->add('submit', SubmitType::class, [
'label' => 'import.btn',
]);
$form = $builder->getForm();
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
// Clear existing entries if requested
if ($form->get('clear_existing_bom')->getData()) {
$assembly->getBomEntries()->clear();
$entityManager->flush();
}
try {
$importerResult = $BOMImporter->importFileIntoAssembly($form->get('file')->getData(), $assembly, [
'type' => $form->get('type')->getData(),
]);
//Validate the assembly entries
$errors = $validator->validateProperty($assembly, 'bom_entries');
//If no validation errors occured, save the changes and redirect to edit page
if (count ($errors) === 0 && $importerResult->getViolations()->count() === 0) {
$entries = $importerResult->getBomEntries();
$this->addFlash('success', t('assembly.bom_import.flash.success', ['%count%' => count($entries)]));
$entityManager->flush();
return $this->redirectToRoute('assembly_edit', ['id' => $assembly->getID()]);
}
//Show validation errors
$this->addFlash('error', t('assembly.bom_import.flash.invalid_entries'));
} catch (\UnexpectedValueException|\RuntimeException|SyntaxError $e) {
$this->addFlash('error', t('assembly.bom_import.flash.invalid_file', ['%message%' => $e->getMessage()]));
}
}
$jsonTemplate = [
[
"quantity" => 1.0,
"name" => $this->translator->trans('assembly.bom_import.template.entry.name'),
"part" => [
"id" => null,
"ipn" => $this->translator->trans('assembly.bom_import.template.entry.part.ipn'),
"mpnr" => $this->translator->trans('assembly.bom_import.template.entry.part.mpnr'),
"name" => $this->translator->trans('assembly.bom_import.template.entry.part.name'),
"description" => null,
"manufacturer" => [
"id" => null,
"name" => $this->translator->trans('assembly.bom_import.template.entry.part.manufacturer.name')
],
"category" => [
"id" => null,
"name" => $this->translator->trans('assembly.bom_import.template.entry.part.category.name')
]
]
]
];
return $this->render('assemblies/import_bom.html.twig', [
'assembly' => $assembly,
'jsonTemplate' => $jsonTemplate,
'form' => $form,
'validationErrors' => $errors ?? null,
'importerErrors' => isset($importerResult) ? $importerResult->getViolations() : null,
]);
}
#[Route(path: '/add_parts', name: 'assembly_add_parts_no_id')]
#[Route(path: '/{id}/add_parts', name: 'assembly_add_parts', requirements: ['id' => '\d+'])]
public function addPart(Request $request, EntityManagerInterface $entityManager, ?Assembly $assembly): Response
{
if($assembly instanceof Assembly) {
$this->denyAccessUnlessGranted('edit', $assembly);
} else {
$this->denyAccessUnlessGranted('@assemblies.edit');
}
$form = $this->createForm(AssemblyAddPartsType::class, null, [
'assembly' => $assembly,
]);
//Preset the BOM entries with the selected parts, when the form was not submitted yet
$preset_data = new ArrayCollection();
foreach (explode(',', (string) $request->get('parts', '')) as $part_id) {
//Skip empty part IDs. Postgres seems to be especially sensitive to empty strings, as it does not allow them in integer columns
if ($part_id === '') {
continue;
}
$part = $entityManager->getRepository(Part::class)->find($part_id);
if (null !== $part) {
//If there is already a BOM entry for this part, we use this one (we edit it then)
$bom_entry = $entityManager->getRepository(AssemblyBOMEntry::class)->findOneBy([
'assembly' => $assembly,
'part' => $part
]);
if ($bom_entry !== null) {
$preset_data->add($bom_entry);
} else { //Otherwise create an empty one
$entry = new AssemblyBOMEntry();
$entry->setAssembly($assembly);
$entry->setPart($part);
$preset_data->add($entry);
}
}
}
$form['bom_entries']->setData($preset_data);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$target_assembly = $assembly ?? $form->get('assembly')->getData();
//Ensure that we really have acces to the selected assembly
$this->denyAccessUnlessGranted('edit', $target_assembly);
$data = $form->getData();
$bom_entries = $data['bom_entries'];
foreach ($bom_entries as $bom_entry){
$target_assembly->addBOMEntry($bom_entry);
}
$entityManager->flush();
//If a redirect query parameter is set, redirect to this page
if ($request->query->get('_redirect')) {
return $this->redirect($request->query->get('_redirect'));
}
//Otherwise just show the assembly info page
return $this->redirectToRoute('assembly_info', ['id' => $target_assembly->getID()]);
}
return $this->render('assemblies/add_parts.html.twig', [
'assembly' => $assembly,
'form' => $form,
]);
}
}

View file

@ -331,7 +331,7 @@ class PartListsController extends AbstractController
$filter->setSupplier($request->query->getBoolean('supplier'));
$filter->setManufacturer($request->query->getBoolean('manufacturer'));
$filter->setFootprint($request->query->getBoolean('footprint'));
$filter->setAssembly($request->query->getBoolean('assembly'));
$filter->setRegex($request->query->getBoolean('regex'));

View file

@ -46,14 +46,16 @@ use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Validator\Validator\ValidatorInterface;
use Symfony\Contracts\Translation\TranslatorInterface;
use function Symfony\Component\Translation\t;
#[Route(path: '/project')]
class ProjectController extends AbstractController
{
public function __construct(private readonly DataTableFactory $dataTableFactory)
{
public function __construct(
private readonly DataTableFactory $dataTableFactory,
private readonly TranslatorInterface $translator,
) {
}
#[Route(path: '/{id}/info', name: 'project_info', requirements: ['id' => '\d+'])]
@ -147,6 +149,8 @@ class ProjectController extends AbstractController
'label' => 'project.bom_import.type',
'required' => true,
'choices' => [
'project.bom_import.type.json' => 'json',
'project.bom_import.type.csv' => 'csv',
'project.bom_import.type.kicad_pcbnew' => 'kicad_pcbnew',
'project.bom_import.type.kicad_schematic' => 'kicad_schematic',
'project.bom_import.type.generic_csv' => 'generic_csv',
@ -189,17 +193,20 @@ class ProjectController extends AbstractController
}
// For PCB imports, proceed directly
$entries = $BOMImporter->importFileIntoProject($form->get('file')->getData(), $project, [
$importerResult = $BOMImporter->importFileIntoProject($form->get('file')->getData(), $project, [
'type' => $import_type,
]);
// Validate the project entries
$errors = $validator->validateProperty($project, 'bom_entries');
// If no validation errors occurred, save the changes and redirect to edit page
if (count($errors) === 0) {
//If no validation errors occurred, save the changes and redirect to edit page
if (count($errors) === 0 && $importerResult->getViolations()->count() === 0) {
$entries = $importerResult->getBomEntries();
$this->addFlash('success', t('project.bom_import.flash.success', ['%count%' => count($entries)]));
$entityManager->flush();
return $this->redirectToRoute('project_edit', ['id' => $project->getID()]);
}
@ -211,10 +218,29 @@ class ProjectController extends AbstractController
}
}
$jsonTemplate = [
[
"quantity" => 1.0,
"name" => $this->translator->trans('project.bom_import.template.entry.name'),
"part" => [
"id" => null,
"ipn" => $this->translator->trans('project.bom_import.template.entry.part.ipn'),
"mpnr" => $this->translator->trans('project.bom_import.template.entry.part.mpnr'),
"name" => $this->translator->trans('project.bom_import.template.entry.part.name'),
"manufacturer" => [
"id" => null,
"name" => $this->translator->trans('project.bom_import.template.entry.part.manufacturer.name')
],
]
]
];
return $this->render('projects/import_bom.html.twig', [
'project' => $project,
'jsonTemplate' => $jsonTemplate,
'form' => $form,
'errors' => $errors ?? null,
'validationErrors' => $errors ?? null,
'importerErrors' => isset($importerResult) ? $importerResult->getViolations() : null,
]);
}
@ -395,7 +421,7 @@ class ProjectController extends AbstractController
}
// Import with field mapping and priorities (validation already passed)
$entries = $BOMImporter->stringToBOMEntries($file_content, [
$entries = $BOMImporter->stringToBOMEntries($project, $file_content, [
'type' => 'kicad_schematic',
'field_mapping' => $field_mapping,
'field_priorities' => $field_priorities,

View file

@ -22,6 +22,7 @@ declare(strict_types=1);
namespace App\Controller;
use App\Entity\AssemblySystem\Assembly;
use Symfony\Component\HttpFoundation\Response;
use App\Entity\ProjectSystem\Project;
use App\Entity\Parts\Category;
@ -129,4 +130,17 @@ class TreeController extends AbstractController
return new JsonResponse($tree);
}
#[Route(path: '/assembly/{id}', name: 'tree_assembly')]
#[Route(path: '/assemblies', name: 'tree_assembly_root')]
public function assemblyTree(?Assembly $assembly = null): JsonResponse
{
if ($this->isGranted('@assemblies.read')) {
$tree = $this->treeGenerator->getTreeView(Assembly::class, $assembly, 'assemblies');
} else {
return new JsonResponse("Access denied", Response::HTTP_FORBIDDEN);
}
return new JsonResponse($tree);
}
}

View file

@ -22,8 +22,10 @@ declare(strict_types=1);
namespace App\Controller;
use App\Entity\AssemblySystem\Assembly;
use App\Entity\Parameters\AbstractParameter;
use App\Settings\MiscSettings\IpnSuggestSettings;
use App\Services\Attachments\AssemblyPreviewGenerator;
use Symfony\Component\HttpFoundation\Response;
use App\Entity\Attachments\Attachment;
use App\Entity\Parts\Category;
@ -54,6 +56,7 @@ use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Serializer\Encoder\JsonEncoder;
use Symfony\Component\Serializer\Normalizer\ObjectNormalizer;
use Symfony\Component\Serializer\Serializer;
use InvalidArgumentException;
/**
* In this controller the endpoints for the typeaheads are collected.
@ -113,19 +116,22 @@ class TypeaheadController extends AbstractController
'group' => GroupParameter::class,
'measurement_unit' => MeasurementUnitParameter::class,
'currency' => Currency::class,
default => throw new \InvalidArgumentException('Invalid parameter type: '.$type),
default => throw new InvalidArgumentException('Invalid parameter type: '.$type),
};
}
#[Route(path: '/parts/search/{query}', name: 'typeahead_parts')]
public function parts(EntityManagerInterface $entityManager, PartPreviewGenerator $previewGenerator,
AttachmentURLGenerator $attachmentURLGenerator, string $query = ""): JsonResponse
{
public function parts(
EntityManagerInterface $entityManager,
PartPreviewGenerator $previewGenerator,
AttachmentURLGenerator $attachmentURLGenerator,
string $query = ""
): JsonResponse {
$this->denyAccessUnlessGranted('@parts.read');
$repo = $entityManager->getRepository(Part::class);
$partRepository = $entityManager->getRepository(Part::class);
$parts = $repo->autocompleteSearch($query, 100);
$parts = $partRepository->autocompleteSearch($query, 100);
$data = [];
foreach ($parts as $part) {
@ -145,12 +151,50 @@ class TypeaheadController extends AbstractController
'footprint' => $part->getFootprint() instanceof Footprint ? $part->getFootprint()->getName() : '',
'description' => mb_strimwidth($part->getDescription(), 0, 127, '...'),
'image' => $preview_url,
];
];
}
return new JsonResponse($data);
}
#[Route(path: '/assemblies/search/{query}', name: 'typeahead_assemblies')]
public function assemblies(
EntityManagerInterface $entityManager,
AssemblyPreviewGenerator $assemblyPreviewGenerator,
AttachmentURLGenerator $attachmentURLGenerator,
string $query = ""
): JsonResponse {
$this->denyAccessUnlessGranted('@assemblies.read');
$result = [];
$assemblyRepository = $entityManager->getRepository(Assembly::class);
$assemblies = $assemblyRepository->autocompleteSearch($query, 100);
foreach ($assemblies as $assembly) {
$preview_attachment = $assemblyPreviewGenerator->getTablePreviewAttachment($assembly);
if($preview_attachment instanceof Attachment) {
$preview_url = $attachmentURLGenerator->getThumbnailURL($preview_attachment, 'thumbnail_sm');
} else {
$preview_url = '';
}
/** @var Assembly $assembly */
$result[] = [
'id' => $assembly->getID(),
'name' => $assembly->getName(),
'category' => '',
'footprint' => '',
'description' => mb_strimwidth($assembly->getDescription(), 0, 127, '...'),
'image' => $preview_url,
];
}
return new JsonResponse($result);
}
#[Route(path: '/parameters/{type}/search/{query}', name: 'typeahead_parameters', requirements: ['type' => '.+'])]
public function parameters(string $type, EntityManagerInterface $entityManager, string $query = ""): JsonResponse
{

View file

@ -22,6 +22,7 @@ declare(strict_types=1);
namespace App\DataFixtures;
use App\Entity\AssemblySystem\Assembly;
use App\Entity\Attachments\AttachmentType;
use App\Entity\Base\AbstractStructuralDBElement;
use App\Entity\Parts\PartCustomState;
@ -50,7 +51,7 @@ class DataStructureFixtures extends Fixture implements DependentFixtureInterface
public function load(ObjectManager $manager): void
{
//Reset autoincrement
$types = [AttachmentType::class, Project::class, Category::class, Footprint::class, Manufacturer::class,
$types = [AttachmentType::class, Project::class, Assembly::class, Category::class, Footprint::class, Manufacturer::class,
MeasurementUnit::class, StorageLocation::class, Supplier::class, PartCustomState::class];
foreach ($types as $type) {

View file

@ -58,6 +58,7 @@ class GroupFixtures extends Fixture
$users->setName('users');
$this->permission_presets->applyPreset($users, PermissionPresetsHelper::PRESET_EDITOR);
$this->addDevicesPermissions($users);
$this->addAssemblyPermissions($users);
$this->setReference(self::USERS, $users);
$manager->persist($users);
@ -69,4 +70,9 @@ class GroupFixtures extends Fixture
$this->permissionManager->setAllOperationsOfPermission($group, 'projects', true);
}
private function addAssemblyPermissions(Group $group): void
{
$this->permissionManager->setAllOperationsOfPermission($group, 'assemblies', true);
}
}

View file

@ -0,0 +1,247 @@
<?php
declare(strict_types=1);
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2022 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\DataTables;
use App\DataTables\Column\EntityColumn;
use App\DataTables\Column\LocaleDateTimeColumn;
use App\DataTables\Column\MarkdownColumn;
use App\DataTables\Helpers\AssemblyDataTableHelper;
use App\DataTables\Helpers\ColumnSortHelper;
use App\DataTables\Helpers\PartDataTableHelper;
use App\Entity\AssemblySystem\Assembly;
use App\Entity\Attachments\Attachment;
use App\Entity\Parts\Part;
use App\Entity\AssemblySystem\AssemblyBOMEntry;
use App\Services\Formatters\AmountFormatter;
use App\Settings\BehaviorSettings\TableSettings;
use Doctrine\ORM\QueryBuilder;
use Omines\DataTablesBundle\Adapter\Doctrine\ORM\SearchCriteriaProvider;
use Omines\DataTablesBundle\Adapter\Doctrine\ORMAdapter;
use Omines\DataTablesBundle\Column\TextColumn;
use Omines\DataTablesBundle\DataTable;
use Omines\DataTablesBundle\DataTableTypeInterface;
use Symfony\Contracts\Translation\TranslatorInterface;
class AssemblyBomEntriesDataTable implements DataTableTypeInterface
{
public function __construct(
private readonly TranslatorInterface $translator,
private readonly PartDataTableHelper $partDataTableHelper,
private readonly AssemblyDataTableHelper $assemblyDataTableHelper,
private readonly AmountFormatter $amountFormatter,
private readonly ColumnSortHelper $csh,
private readonly TableSettings $tableSettings,
) {
}
public function configure(DataTable $dataTable, array $options): void
{
$this->csh
->add('picture', TextColumn::class, [
'label' => '',
'className' => 'no-colvis',
'render' => function ($value, AssemblyBOMEntry $context) {
if(!$context->getPart() instanceof Part) {
return '';
}
return $this->partDataTableHelper->renderPicture($context->getPart());
},
])
->add('id', TextColumn::class, [
'label' => $this->translator->trans('part.table.id'),
])
->add('quantity', TextColumn::class, [
'label' => $this->translator->trans('assembly.bom.quantity'),
'className' => 'text-center',
'orderField' => 'bom_entry.quantity',
'render' => function ($value, AssemblyBOMEntry $context): float|string {
//If we have a non-part entry, only show the rounded quantity
if (!$context->getPart() instanceof Part) {
return round($context->getQuantity());
}
//Otherwise use the unit of the part to format the quantity
return htmlspecialchars($this->amountFormatter->format($context->getQuantity(), $context->getPart()->getPartUnit()));
},
])
->add('name', TextColumn::class, [
'label' => $this->translator->trans('part.table.name'),
'orderField' => 'NATSORT(part.name)',
'render' => function ($value, AssemblyBOMEntry $context) {
if(!$context->getPart() instanceof Part && !$context->getReferencedAssembly() instanceof Assembly) {
return htmlspecialchars((string) $context->getName());
}
$tmp = $context->getName();
if ($context->getPart() !== null) {
$tmp = $this->partDataTableHelper->renderName($context->getPart());
$tmp = $this->translator->trans('part.table.name.value.for_part', ['%value%' => $tmp]);
if($context->getName() !== null && $context->getName() !== '') {
$tmp .= '<br><b>'.htmlspecialchars($context->getName()).'</b>';
}
} elseif ($context->getReferencedAssembly() !== null) {
$tmp = $this->assemblyDataTableHelper->renderName($context->getReferencedAssembly());
$tmp = $this->translator->trans('part.table.name.value.for_assembly', ['%value%' => $tmp]);
if($context->getName() !== null && $context->getName() !== '') {
$tmp .= '<br><b>'.htmlspecialchars($context->getName()).'</b>';
}
}
return $tmp;
},
])
->add('ipn', TextColumn::class, [
'label' => $this->translator->trans('part.table.ipn'),
'orderField' => 'NATSORT(part.ipn)',
'render' => function ($value, AssemblyBOMEntry $context) {
if($context->getPart() instanceof Part) {
return $context->getPart()->getIpn();
} elseif($context->getReferencedAssembly() instanceof Assembly) {
return $context->getReferencedAssembly()->getIpn();
}
return '';
}
])
->add('description', MarkdownColumn::class, [
'label' => $this->translator->trans('part.table.description'),
'orderField' => "CASE
WHEN part.id IS NOT NULL THEN part.description
WHEN referencedAssembly.id IS NOT NULL THEN referencedAssembly.description
ELSE bom_entry.comment
END",
'data' => function (AssemblyBOMEntry $context) {
if ($context->getPart() instanceof Part) {
return $context->getPart()->getDescription();
} elseif ($context->getReferencedAssembly() instanceof Assembly) {
return $context->getReferencedAssembly()->getDescription();
}
//For non-part BOM entries show the comment field
return $context->getComment();
},
])
->add('category', EntityColumn::class, [
'label' => $this->translator->trans('part.table.category'),
'property' => 'part.category',
'orderField' => 'NATSORT(category.name)',
])
->add('footprint', EntityColumn::class, [
'property' => 'part.footprint',
'label' => $this->translator->trans('part.table.footprint'),
'orderField' => 'NATSORT(footprint.name)',
])
->add('manufacturer', EntityColumn::class, [
'property' => 'part.manufacturer',
'label' => $this->translator->trans('part.table.manufacturer'),
'orderField' => 'NATSORT(manufacturer.name)',
])
->add('mountnames', TextColumn::class, [
'label' => 'assembly.bom.mountnames',
'render' => function ($value, AssemblyBOMEntry $context) {
$html = '';
foreach (explode(',', $context->getMountnames()) as $mountname) {
$html .= sprintf('<span class="badge badge-secondary bg-secondary">%s</span> ', htmlspecialchars($mountname));
}
return $html;
},
])
->add('designator', TextColumn::class, [
'label' => 'assembly.bom.designator',
'orderField' => 'bom_entry.designator',
'render' => function ($value, AssemblyBOMEntry $context) {
return htmlspecialchars($context->getDesignator());
},
])
->add('instockAmount', TextColumn::class, [
'label' => 'assembly.bom.instockAmount',
'visible' => false,
'render' => function ($value, AssemblyBOMEntry $context) {
if ($context->getPart() !== null) {
return $this->partDataTableHelper->renderAmount($context->getPart());
}
return '';
}
])
->add('storageLocations', TextColumn::class, [
'label' => 'part.table.storeLocations',
'visible' => false,
'render' => function ($value, AssemblyBOMEntry $context) {
if ($context->getPart() !== null) {
return $this->partDataTableHelper->renderStorageLocations($context->getPart());
}
return '';
}
])
->add('addedDate', LocaleDateTimeColumn::class, [
'label' => $this->translator->trans('part.table.addedDate'),
])
->add('lastModified', LocaleDateTimeColumn::class, [
'label' => $this->translator->trans('part.table.lastModified'),
]);
//Apply the user configured order and visibility and add the columns to the table
$this->csh->applyVisibilityAndConfigureColumns($dataTable, $this->tableSettings->assembliesBomDefaultColumns,
"TABLE_ASSEMBLIES_BOM_DEFAULT_COLUMNS");
$dataTable->addOrderBy('name');
$dataTable->createAdapter(ORMAdapter::class, [
'entity' => Attachment::class,
'query' => function (QueryBuilder $builder) use ($options): void {
$this->getQuery($builder, $options);
},
'criteria' => [
function (QueryBuilder $builder) use ($options): void {
$this->buildCriteria($builder, $options);
},
new SearchCriteriaProvider(),
],
]);
}
private function getQuery(QueryBuilder $builder, array $options): void
{
$builder->select('bom_entry')
->addSelect('part')
->from(AssemblyBOMEntry::class, 'bom_entry')
->leftJoin('bom_entry.part', 'part')
->leftJoin('bom_entry.referencedAssembly', 'referencedAssembly')
->leftJoin('part.category', 'category')
->leftJoin('part.footprint', 'footprint')
->leftJoin('part.manufacturer', 'manufacturer')
->where('bom_entry.assembly = :assembly')
->setParameter('assembly', $options['assembly'])
;
}
private function buildCriteria(QueryBuilder $builder, array $options): void
{
}
}

View file

@ -0,0 +1,250 @@
<?php
/**
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2022 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/>.
*/
declare(strict_types=1);
namespace App\DataTables;
use App\DataTables\Adapters\TwoStepORMAdapter;
use App\DataTables\Column\IconLinkColumn;
use App\DataTables\Column\LocaleDateTimeColumn;
use App\DataTables\Column\MarkdownColumn;
use App\DataTables\Column\SelectColumn;
use App\DataTables\Filters\AssemblyFilter;
use App\DataTables\Filters\AssemblySearchFilter;
use App\DataTables\Helpers\AssemblyDataTableHelper;
use App\DataTables\Helpers\ColumnSortHelper;
use App\Doctrine\Helpers\FieldHelper;
use App\Entity\AssemblySystem\Assembly;
use App\Services\EntityURLGenerator;
use App\Settings\BehaviorSettings\TableSettings;
use Doctrine\ORM\AbstractQuery;
use Doctrine\ORM\QueryBuilder;
use Omines\DataTablesBundle\Adapter\Doctrine\ORM\SearchCriteriaProvider;
use Omines\DataTablesBundle\Column\TextColumn;
use Omines\DataTablesBundle\DataTable;
use Omines\DataTablesBundle\DataTableTypeInterface;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Contracts\Translation\TranslatorInterface;
final class AssemblyDataTable implements DataTableTypeInterface
{
const LENGTH_MENU = [[10, 25, 50, 100, -1], [10, 25, 50, 100, "All"]];
public function __construct(
private readonly EntityURLGenerator $urlGenerator,
private readonly TranslatorInterface $translator,
private readonly AssemblyDataTableHelper $assemblyDataTableHelper,
private readonly Security $security,
private readonly ColumnSortHelper $csh,
private readonly TableSettings $tableSettings,
) {
}
public function configureOptions(OptionsResolver $optionsResolver): void
{
$optionsResolver->setDefaults([
'filter' => null,
'search' => null
]);
$optionsResolver->setAllowedTypes('filter', [AssemblyFilter::class, 'null']);
$optionsResolver->setAllowedTypes('search', [AssemblySearchFilter::class, 'null']);
}
public function configure(DataTable $dataTable, array $options): void
{
$resolver = new OptionsResolver();
$this->configureOptions($resolver);
$options = $resolver->resolve($options);
$this->csh
->add('select', SelectColumn::class, visibility_configurable: false)
->add('picture', TextColumn::class, [
'label' => '',
'className' => 'no-colvis',
'render' => fn($value, Assembly $context) => $this->assemblyDataTableHelper->renderPicture($context),
], visibility_configurable: false)
->add('name', TextColumn::class, [
'label' => $this->translator->trans('assembly.table.name'),
'render' => fn($value, Assembly $context) => $this->assemblyDataTableHelper->renderName($context),
'orderField' => 'NATSORT(assembly.name)'
])
->add('id', TextColumn::class, [
'label' => $this->translator->trans('assembly.table.id'),
])
->add('ipn', TextColumn::class, [
'label' => $this->translator->trans('assembly.table.ipn'),
'orderField' => 'NATSORT(assembly.ipn)'
])
->add('description', MarkdownColumn::class, [
'label' => $this->translator->trans('assembly.table.description'),
])
->add('addedDate', LocaleDateTimeColumn::class, [
'label' => $this->translator->trans('assembly.table.addedDate'),
])
->add('lastModified', LocaleDateTimeColumn::class, [
'label' => $this->translator->trans('assembly.table.lastModified'),
]);
//Add a assembly column to list where the assembly is used as referenced assembly as bom-entry, when the user has the permission to see the assemblies
if ($this->security->isGranted('read', Assembly::class)) {
$this->csh->add('referencedAssemblies', TextColumn::class, [
'label' => $this->translator->trans('assembly.referencedAssembly.labelp'),
'render' => function ($value, Assembly $context): string {
$assemblies = $context->getAllReferencedAssembliesRecursive($context);
$max = 5;
$tmp = "";
for ($i = 0; $i < min($max, count($assemblies)); $i++) {
$tmp .= $this->assemblyDataTableHelper->renderName($assemblies[$i]);
if ($i < count($assemblies) - 1) {
$tmp .= ", ";
}
}
if (count($assemblies) > $max) {
$tmp .= ", + ".(count($assemblies) - $max);
}
return $tmp;
}
]);
}
$this->csh
->add('edit', IconLinkColumn::class, [
'label' => $this->translator->trans('assembly.table.edit'),
'href' => fn($value, Assembly $context) => $this->urlGenerator->editURL($context),
'disabled' => fn($value, Assembly $context) => !$this->security->isGranted('edit', $context),
'title' => $this->translator->trans('assembly.table.edit.title'),
]);
//Apply the user configured order and visibility and add the columns to the table
$this->csh->applyVisibilityAndConfigureColumns($dataTable, $this->tableSettings->assembliesDefaultColumns,
"TABLE_ASSEMBLIES_DEFAULT_COLUMNS");
$dataTable->addOrderBy('name')
->createAdapter(TwoStepORMAdapter::class, [
'filter_query' => $this->getFilterQuery(...),
'detail_query' => $this->getDetailQuery(...),
'entity' => Assembly::class,
'hydrate' => AbstractQuery::HYDRATE_OBJECT,
//Use the simple total query, as we just want to get the total number of assemblies without any conditions
//For this the normal query would be pretty slow
'simple_total_query' => true,
'criteria' => [
function (QueryBuilder $builder) use ($options): void {
$this->buildCriteria($builder, $options);
},
new SearchCriteriaProvider(),
],
'query_modifier' => $this->addJoins(...),
]);
}
private function getFilterQuery(QueryBuilder $builder): void
{
/* In the filter query we only select the IDs. The fetching of the full entities is done in the detail query.
* We only need to join the entities here, so we can filter by them.
* The filter conditions are added to this QB in the buildCriteria method.
*
* The amountSum field and the joins are dynamically added by the addJoins method, if the fields are used in the query.
* This improves the performance, as we do not need to join all tables, if we do not need them.
*/
$builder
->select('assembly.id')
->from(Assembly::class, 'assembly')
//The other group by fields, are dynamically added by the addJoins method
->addGroupBy('assembly');
}
private function getDetailQuery(QueryBuilder $builder, array $filter_results): void
{
$ids = array_map(static fn($row) => $row['id'], $filter_results);
/*
* In this query we take the IDs which were filtered, paginated and sorted in the filter query, and fetch the
* full entities.
* We can do complex fetch joins, as we do not need to filter or sort here (which would kill the performance).
* The only condition should be for the IDs.
* It is important that elements are ordered the same way, as the IDs are passed, or ordering will be wrong.
*
* We do not require the subqueries like amountSum here, as it is not used to render the table (and only for sorting)
*/
$builder
->select('assembly')
->addSelect('master_picture_attachment')
->addSelect('attachments')
->from(Assembly::class, 'assembly')
->leftJoin('assembly.master_picture_attachment', 'master_picture_attachment')
->leftJoin('assembly.attachments', 'attachments')
->where('assembly.id IN (:ids)')
->setParameter('ids', $ids)
->addGroupBy('assembly')
->addGroupBy('master_picture_attachment')
->addGroupBy('attachments');
//Get the results in the same order as the IDs were passed
FieldHelper::addOrderByFieldParam($builder, 'assembly.id', 'ids');
}
/**
* This function is called right before the filter query is executed.
* We use it to dynamically add joins to the query, if the fields are used in the query.
* @param QueryBuilder $builder
* @return QueryBuilder
*/
private function addJoins(QueryBuilder $builder): QueryBuilder
{
//Check if the query contains certain conditions, for which we need to add additional joins
//The join fields get prefixed with an underscore, so we can check if they are used in the query easy without confusing them for a assembly subfield
$dql = $builder->getDQL();
if (str_contains($dql, '_master_picture_attachment')) {
$builder->leftJoin('assembly.master_picture_attachment', '_master_picture_attachment');
$builder->addGroupBy('_master_picture_attachment');
}
if (str_contains($dql, '_attachments')) {
$builder->leftJoin('assembly.attachments', '_attachments');
}
return $builder;
}
private function buildCriteria(QueryBuilder $builder, array $options): void
{
//Apply the search criterias first
if ($options['search'] instanceof AssemblySearchFilter) {
$search = $options['search'];
$search->apply($builder);
}
//We do the most stuff here in the filter class
if ($options['filter'] instanceof AssemblyFilter) {
$filter = $options['filter'];
$filter->apply($builder);
}
}
}

View file

@ -0,0 +1,68 @@
<?php
declare(strict_types=1);
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2022 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\DataTables\Filters;
use App\DataTables\Filters\Constraints\DateTimeConstraint;
use App\DataTables\Filters\Constraints\EntityConstraint;
use App\DataTables\Filters\Constraints\IntConstraint;
use App\DataTables\Filters\Constraints\TextConstraint;
use App\Entity\Attachments\AttachmentType;
use App\Services\Trees\NodesListBuilder;
use Doctrine\ORM\QueryBuilder;
class AssemblyFilter implements FilterInterface
{
use CompoundFilterTrait;
public readonly IntConstraint $dbId;
public readonly TextConstraint $ipn;
public readonly TextConstraint $name;
public readonly TextConstraint $description;
public readonly TextConstraint $comment;
public readonly DateTimeConstraint $lastModified;
public readonly DateTimeConstraint $addedDate;
public readonly IntConstraint $attachmentsCount;
public readonly EntityConstraint $attachmentType;
public readonly TextConstraint $attachmentName;
public function __construct(NodesListBuilder $nodesListBuilder)
{
$this->name = new TextConstraint('assembly.name');
$this->description = new TextConstraint('assembly.description');
$this->comment = new TextConstraint('assembly.comment');
$this->dbId = new IntConstraint('assembly.id');
$this->ipn = new TextConstraint('assembly.ipn');
$this->addedDate = new DateTimeConstraint('assembly.addedDate');
$this->lastModified = new DateTimeConstraint('assembly.lastModified');
$this->attachmentsCount = new IntConstraint('COUNT(_attachments)');
$this->attachmentType = new EntityConstraint($nodesListBuilder, AttachmentType::class, '_attachments.attachment_type');
$this->attachmentName = new TextConstraint('_attachments.name');
}
public function apply(QueryBuilder $queryBuilder): void
{
$this->applyAllChildFilters($queryBuilder);
}
}

View file

@ -0,0 +1,172 @@
<?php
declare(strict_types=1);
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2022 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\DataTables\Filters;
use Doctrine\ORM\QueryBuilder;
class AssemblySearchFilter implements FilterInterface
{
/** @var boolean Whether to use regex for searching */
protected bool $regex = false;
/** @var bool Use name field for searching */
protected bool $name = true;
/** @var bool Use description for searching */
protected bool $description = true;
/** @var bool Use comment field for searching */
protected bool $comment = true;
/** @var bool Use ordernr for searching */
protected bool $ordernr = true;
/** @var bool Use Internal part number for searching */
protected bool $ipn = true;
public function __construct(
/** @var string The string to query for */
protected string $keyword
)
{
}
protected function getFieldsToSearch(): array
{
$fields_to_search = [];
if($this->name) {
$fields_to_search[] = 'assembly.name';
}
if($this->description) {
$fields_to_search[] = 'assembly.description';
}
if ($this->comment) {
$fields_to_search[] = 'assembly.comment';
}
if ($this->ipn) {
$fields_to_search[] = 'assembly.ipn';
}
return $fields_to_search;
}
public function apply(QueryBuilder $queryBuilder): void
{
$fields_to_search = $this->getFieldsToSearch();
//If we have nothing to search for, do nothing
if ($fields_to_search === [] || $this->keyword === '') {
return;
}
//Convert the fields to search to a list of expressions
$expressions = array_map(function (string $field): string {
if ($this->regex) {
return sprintf("REGEXP(%s, :search_query) = TRUE", $field);
}
return sprintf("ILIKE(%s, :search_query) = TRUE", $field);
}, $fields_to_search);
//Add Or concatenation of the expressions to our query
$queryBuilder->andWhere(
$queryBuilder->expr()->orX(...$expressions)
);
//For regex, we pass the query as is, for like we add % to the start and end as wildcards
if ($this->regex) {
$queryBuilder->setParameter('search_query', $this->keyword);
} else {
$queryBuilder->setParameter('search_query', '%' . $this->keyword . '%');
}
}
public function getKeyword(): string
{
return $this->keyword;
}
public function setKeyword(string $keyword): AssemblySearchFilter
{
$this->keyword = $keyword;
return $this;
}
public function isRegex(): bool
{
return $this->regex;
}
public function setRegex(bool $regex): AssemblySearchFilter
{
$this->regex = $regex;
return $this;
}
public function isName(): bool
{
return $this->name;
}
public function setName(bool $name): AssemblySearchFilter
{
$this->name = $name;
return $this;
}
public function isDescription(): bool
{
return $this->description;
}
public function setDescription(bool $description): AssemblySearchFilter
{
$this->description = $description;
return $this;
}
public function isIPN(): bool
{
return $this->ipn;
}
public function setIPN(bool $ipn): AssemblySearchFilter
{
$this->ipn = $ipn;
return $this;
}
public function isComment(): bool
{
return $this->comment;
}
public function setComment(bool $comment): AssemblySearchFilter
{
$this->comment = $comment;
return $this;
}
}

View file

@ -36,6 +36,7 @@ use App\DataTables\Filters\Constraints\Part\LessThanDesiredConstraint;
use App\DataTables\Filters\Constraints\Part\ParameterConstraint;
use App\DataTables\Filters\Constraints\Part\TagsConstraint;
use App\DataTables\Filters\Constraints\TextConstraint;
use App\Entity\AssemblySystem\Assembly;
use App\Entity\Attachments\AttachmentType;
use App\Entity\Parts\Category;
use App\Entity\Parts\Footprint;
@ -107,6 +108,14 @@ class PartFilter implements FilterInterface
public readonly TextConstraint $bomName;
public readonly TextConstraint $bomComment;
/*************************************************
* Assembly
*************************************************/
public readonly EntityConstraint $assembly;
public readonly NumberConstraint $assemblyBomQuantity;
public readonly TextConstraint $assemblyBomName;
/*************************************************
* Bulk Import Job tab
*************************************************/
@ -180,6 +189,10 @@ class PartFilter implements FilterInterface
$this->bomName = new TextConstraint('_projectBomEntries.name');
$this->bomComment = new TextConstraint('_projectBomEntries.comment');
$this->assembly = new EntityConstraint($nodesListBuilder, Assembly::class, '_assemblyBomEntries.assembly');
$this->assemblyBomQuantity = new NumberConstraint('_assemblyBomEntries.quantity');
$this->assemblyBomName = new TextConstraint('_assemblyBomEntries.name');
// Bulk Import Job filters
$this->inBulkImportJob = new BulkImportJobExistsConstraint();
$this->bulkImportJobStatus = new BulkImportJobStatusConstraint();

View file

@ -70,6 +70,9 @@ class PartSearchFilter implements FilterInterface
/** @var bool Use Internal Part number for searching */
protected bool $ipn = true;
/** @var bool Use assembly name for searching */
protected bool $assembly = true;
public function __construct(
/** @var string The string to query for */
protected string $keyword
@ -117,6 +120,9 @@ class PartSearchFilter implements FilterInterface
if ($this->ipn) {
$fields_to_search[] = 'part.ipn';
}
if ($this->assembly) {
$fields_to_search[] = '_assembly.name';
}
return $fields_to_search;
}
@ -337,5 +343,14 @@ class PartSearchFilter implements FilterInterface
return $this;
}
public function isAssembly(): bool
{
return $this->assembly;
}
public function setAssembly(bool $assembly): PartSearchFilter
{
$this->assembly = $assembly;
return $this;
}
}

View file

@ -0,0 +1,77 @@
<?php
declare(strict_types=1);
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2022 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\DataTables\Helpers;
use App\Entity\AssemblySystem\Assembly;
use App\Entity\Attachments\Attachment;
use App\Services\Attachments\AssemblyPreviewGenerator;
use App\Services\Attachments\AttachmentURLGenerator;
use App\Services\EntityURLGenerator;
/**
* A helper service which contains common code to render columns for assembly related tables
*/
class AssemblyDataTableHelper
{
public function __construct(
private readonly EntityURLGenerator $entityURLGenerator,
private readonly AssemblyPreviewGenerator $previewGenerator,
private readonly AttachmentURLGenerator $attachmentURLGenerator
) {
}
public function renderName(Assembly $context): string
{
$icon = '';
return sprintf(
'<a href="%s">%s%s</a>',
$this->entityURLGenerator->infoURL($context),
$icon,
htmlspecialchars($context->getName())
);
}
public function renderPicture(Assembly $context): string
{
$preview_attachment = $this->previewGenerator->getTablePreviewAttachment($context);
if (!$preview_attachment instanceof Attachment) {
return '';
}
$title = htmlspecialchars($preview_attachment->getName());
if ($preview_attachment->getFilename()) {
$title .= ' ('.htmlspecialchars($preview_attachment->getFilename()).')';
}
return sprintf(
'<img alt="%s" src="%s" data-thumbnail="%s" class="%s" data-title="%s" data-controller="elements--hoverpic">',
'Assembly image',
$this->attachmentURLGenerator->getThumbnailURL($preview_attachment),
$this->attachmentURLGenerator->getThumbnailURL($preview_attachment, 'thumbnail_md'),
'hoverpic assembly-table-image',
$title
);
}
}

View file

@ -39,6 +39,7 @@ use App\DataTables\Filters\PartSearchFilter;
use App\DataTables\Helpers\ColumnSortHelper;
use App\DataTables\Helpers\PartDataTableHelper;
use App\Doctrine\Helpers\FieldHelper;
use App\Entity\AssemblySystem\Assembly;
use App\Entity\Parts\ManufacturingStatus;
use App\Entity\Parts\Part;
use App\Entity\Parts\PartLot;
@ -253,6 +254,34 @@ final class PartsDataTable implements DataTableTypeInterface
]);
}
//Add a assembly column to list where the part is used, when the user has the permission to see the assemblies
if ($this->security->isGranted('read', Assembly::class)) {
$this->csh->add('assemblies', TextColumn::class, [
'label' => $this->translator->trans('assembly.labelp'),
'render' => function ($value, Part $context): string {
//Only show the first 5 assembly names
$assemblies = $context->getAssemblies();
$tmp = "";
$max = 5;
for ($i = 0; $i < min($max, count($assemblies)); $i++) {
$url = $this->urlGenerator->infoURL($assemblies[$i]);
$tmp .= sprintf('<a href="%s">%s</a>', $url, htmlspecialchars($assemblies[$i]->getName()));
if ($i < count($assemblies) - 1) {
$tmp .= ", ";
}
}
if (count($assemblies) > $max) {
$tmp .= ", + ".(count($assemblies) - $max);
}
return $tmp;
}
]);
}
$this->csh
->add('edit', IconLinkColumn::class, [
'label' => $this->translator->trans('part.table.edit'),
@ -445,6 +474,13 @@ final class PartsDataTable implements DataTableTypeInterface
//Do not group by many-to-* relations, as it would restrict the COUNT having clauses to be maximum 1
//$builder->addGroupBy('_projectBomEntries');
}
if (str_contains($dql, '_assembly.')) {
$builder->leftJoin('part.assembly_bom_entries', '_assemblyBomEntries');
$builder->leftJoin('_assemblyBomEntries.assembly', '_assembly');
}
if (str_contains($dql, '_assemblyBomEntries')) {
$builder->leftJoin('part.assembly_bom_entries', '_assemblyBomEntries');
}
if (str_contains($dql, '_jobPart')) {
$builder->leftJoin('part.bulkImportJobParts', '_jobPart');
$builder->leftJoin('_jobPart.job', '_bulkImportJob');

View file

@ -0,0 +1,373 @@
<?php
/**
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2022 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/>.
*/
declare(strict_types=1);
namespace App\Entity\AssemblySystem;
use App\Repository\AssemblyRepository;
use App\Validator\Constraints\AssemblySystem\AssemblyCycle;
use App\Validator\Constraints\AssemblySystem\AssemblyInvalidBomEntry;
use Doctrine\Common\Collections\Criteria;
use ApiPlatform\Doctrine\Orm\Filter\OrderFilter;
use ApiPlatform\Metadata\ApiFilter;
use ApiPlatform\Metadata\ApiProperty;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Delete;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Link;
use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post;
use ApiPlatform\OpenApi\Model\Operation;
use ApiPlatform\Serializer\Filter\PropertyFilter;
use App\ApiPlatform\Filter\LikeFilter;
use App\Entity\Attachments\Attachment;
use App\Validator\Constraints\UniqueObjectCollection;
use App\Validator\Constraints\AssemblySystem\UniqueReferencedAssembly;
use Doctrine\DBAL\Types\Types;
use App\Entity\Attachments\AssemblyAttachment;
use App\Entity\Base\AbstractStructuralDBElement;
use App\Entity\Parameters\AssemblyParameter;
use App\Entity\Parts\Part;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
use InvalidArgumentException;
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
use Symfony\Component\Serializer\Annotation\Groups;
use Symfony\Component\Validator\Constraints as Assert;
use Symfony\Component\Validator\Constraints\Length;
use Symfony\Component\Validator\Context\ExecutionContextInterface;
/**
* This class represents a assembly in the database.
*
* @extends AbstractStructuralDBElement<AssemblyAttachment, AssemblyParameter>
*/
#[ORM\Entity(repositoryClass: AssemblyRepository::class)]
#[ORM\Table(name: 'assemblies')]
#[UniqueEntity(fields: ['ipn'], message: 'assembly.ipn.must_be_unique')]
#[ORM\Index(columns: ['ipn'], name: 'assembly_idx_ipn')]
#[ApiResource(
operations: [
new Get(security: 'is_granted("read", object)'),
new GetCollection(security: 'is_granted("@assemblies.read")'),
new Post(securityPostDenormalize: 'is_granted("create", object)'),
new Patch(security: 'is_granted("edit", object)'),
new Delete(security: 'is_granted("delete", object)'),
],
normalizationContext: ['groups' => ['assembly:read', 'api:basic:read'], 'openapi_definition_name' => 'Read'],
denormalizationContext: ['groups' => ['assembly:write', 'api:basic:write', 'attachment:write', 'parameter:write'], 'openapi_definition_name' => 'Write'],
)]
#[ApiResource(
uriTemplate: '/assemblies/{id}/children.{_format}',
operations: [
new GetCollection(
openapi: new Operation(summary: 'Retrieves the children elements of a assembly.'),
security: 'is_granted("@assemblies.read")'
)
],
uriVariables: [
'id' => new Link(fromProperty: 'children', fromClass: Assembly::class)
],
normalizationContext: ['groups' => ['assembly:read', 'api:basic:read'], 'openapi_definition_name' => 'Read']
)]
#[ApiFilter(PropertyFilter::class)]
#[ApiFilter(LikeFilter::class, properties: ["name", "comment", "ipn"])]
#[ApiFilter(OrderFilter::class, properties: ['name', 'id', 'addedDate', 'lastModified'])]
class Assembly extends AbstractStructuralDBElement
{
#[ORM\OneToMany(mappedBy: 'parent', targetEntity: self::class)]
#[ORM\OrderBy(['name' => Criteria::ASC])]
protected Collection $children;
#[ORM\ManyToOne(targetEntity: self::class, inversedBy: 'children')]
#[ORM\JoinColumn(name: 'parent_id')]
#[Groups(['assembly:read', 'assembly:write'])]
#[ApiProperty(readableLink: false, writableLink: false)]
protected ?AbstractStructuralDBElement $parent = null;
#[Groups(['assembly:read', 'assembly:write'])]
protected string $comment = '';
/**
* @var Collection<int, AssemblyBOMEntry>
*/
#[Assert\Valid]
#[AssemblyCycle]
#[AssemblyInvalidBomEntry]
#[UniqueReferencedAssembly]
#[Groups(['extended', 'full', 'import'])]
#[ORM\OneToMany(targetEntity: AssemblyBOMEntry::class, mappedBy: 'assembly', cascade: ['persist', 'remove'], orphanRemoval: true)]
#[UniqueObjectCollection(message: 'assembly.bom_entry.part_already_in_bom', fields: ['part'])]
#[UniqueObjectCollection(message: 'assembly.bom_entry.name_already_in_bom', fields: ['name'])]
protected Collection $bom_entries;
#[ORM\Column(type: Types::INTEGER)]
protected int $order_quantity = 0;
/**
* @var string|null The current status of the assembly
*/
#[Assert\Choice(['draft', 'planning', 'in_production', 'finished', 'archived'])]
#[Groups(['extended', 'full', 'assembly:read', 'assembly:write', 'import'])]
#[ORM\Column(type: Types::STRING, length: 64, nullable: true)]
protected ?string $status = null;
/**
* @var string|null The internal ipn number of the assembly
*/
#[Assert\Length(max: 100)]
#[Groups(['extended', 'full', 'assembly:read', 'assembly:write', 'import'])]
#[ORM\Column(type: Types::STRING, length: 100, unique: true, nullable: true)]
#[Length(max: 100)]
protected ?string $ipn = null;
#[ORM\Column(type: Types::BOOLEAN)]
protected bool $order_only_missing_parts = false;
#[Groups(['simple', 'extended', 'full', 'assembly:read', 'assembly:write'])]
#[ORM\Column(type: Types::TEXT)]
protected string $description = '';
/**
* @var Collection<int, AssemblyAttachment>
*/
#[ORM\OneToMany(mappedBy: 'element', targetEntity: AssemblyAttachment::class, cascade: ['persist', 'remove'], orphanRemoval: true)]
#[ORM\OrderBy(['name' => Criteria::ASC])]
#[Groups(['assembly:read', 'assembly:write'])]
protected Collection $attachments;
#[ORM\ManyToOne(targetEntity: AssemblyAttachment::class)]
#[ORM\JoinColumn(name: 'id_preview_attachment', onDelete: 'SET NULL')]
#[Groups(['assembly:read', 'assembly:write'])]
protected ?Attachment $master_picture_attachment = null;
/** @var Collection<int, AssemblyParameter>
*/
#[ORM\OneToMany(mappedBy: 'element', targetEntity: AssemblyParameter::class, cascade: ['persist', 'remove'], orphanRemoval: true)]
#[ORM\OrderBy(['group' => Criteria::ASC, 'name' => 'ASC'])]
#[Groups(['assembly:read', 'assembly:write'])]
protected Collection $parameters;
#[Groups(['assembly:read'])]
protected ?\DateTimeImmutable $addedDate = null;
#[Groups(['assembly:read'])]
protected ?\DateTimeImmutable $lastModified = null;
/********************************************************************************
*
* Getters
*
*********************************************************************************/
public function __construct()
{
$this->attachments = new ArrayCollection();
$this->parameters = new ArrayCollection();
parent::__construct();
$this->bom_entries = new ArrayCollection();
$this->children = new ArrayCollection();
}
public function __clone()
{
//When cloning this assembly, we have to clone each bom entry too.
if ($this->id) {
$bom_entries = $this->bom_entries;
$this->bom_entries = new ArrayCollection();
//Set master attachment is needed
foreach ($bom_entries as $bom_entry) {
$clone = clone $bom_entry;
$this->addBomEntry($clone);
}
}
//Parent has to be last call, as it resets the ID
parent::__clone();
}
/**
* Get the order quantity of this assembly.
*
* @return int the order quantity
*/
public function getOrderQuantity(): int
{
return $this->order_quantity;
}
/**
* Get the "order_only_missing_parts" attribute.
*
* @return bool the "order_only_missing_parts" attribute
*/
public function getOrderOnlyMissingParts(): bool
{
return $this->order_only_missing_parts;
}
/********************************************************************************
*
* Setters
*
*********************************************************************************/
/**
* Set the order quantity.
*
* @param int $new_order_quantity the new order quantity
*
* @return $this
*/
public function setOrderQuantity(int $new_order_quantity): self
{
if ($new_order_quantity < 0) {
throw new InvalidArgumentException('The new order quantity must not be negative!');
}
$this->order_quantity = $new_order_quantity;
return $this;
}
/**
* Set the "order_only_missing_parts" attribute.
*
* @param bool $new_order_only_missing_parts the new "order_only_missing_parts" attribute
*/
public function setOrderOnlyMissingParts(bool $new_order_only_missing_parts): self
{
$this->order_only_missing_parts = $new_order_only_missing_parts;
return $this;
}
public function getBomEntries(): Collection
{
return $this->bom_entries;
}
/**
* @return $this
*/
public function addBomEntry(AssemblyBOMEntry $entry): self
{
$entry->setAssembly($this);
$this->bom_entries->add($entry);
return $this;
}
/**
* @return $this
*/
public function removeBomEntry(AssemblyBOMEntry $entry): self
{
$this->bom_entries->removeElement($entry);
return $this;
}
public function getDescription(): string
{
return $this->description;
}
public function setDescription(string $description): Assembly
{
$this->description = $description;
return $this;
}
/**
* @return string
*/
public function getStatus(): ?string
{
return $this->status;
}
/**
* @param string $status
*/
public function setStatus(?string $status): void
{
$this->status = $status;
}
/**
* Returns the internal part number of the assembly.
* @return string
*/
public function getIpn(): ?string
{
return $this->ipn;
}
/**
* Sets the internal part number of the assembly.
* @param string $ipn The new IPN of the assembly
*/
public function setIpn(?string $ipn): Assembly
{
$this->ipn = $ipn;
return $this;
}
#[Assert\Callback]
public function validate(ExecutionContextInterface $context, $payload): void
{
}
/**
* Get all assemblies and sub-assemblies recursive that are referenced in the assembly bom entries.
*
* @param Assembly $assembly Assembly, which is to be processed recursively.
* @param array $processedAssemblies (optional) a list of the already edited assemblies to avoid circulatory references.
* @return Assembly[] A flat list of all recursively found assemblies.
*/
public function getAllReferencedAssembliesRecursive(Assembly $assembly, array &$processedAssemblies = []): array
{
$assemblies = [];
// Avoid circular references
if (in_array($assembly, $processedAssemblies, true)) {
return $assemblies;
}
// Add the current assembly to the processed
$processedAssemblies[] = $assembly;
// Iterate by the bom entries of the current assembly
foreach ($assembly->getBomEntries() as $bomEntry) {
if ($bomEntry->getReferencedAssembly() !== null) {
$referencedAssembly = $bomEntry->getReferencedAssembly();
$assemblies[] = $referencedAssembly;
// Continue recursively to process sub-assemblies
$assemblies = array_merge($assemblies, $this->getAllReferencedAssembliesRecursive($referencedAssembly, $processedAssemblies));
}
}
return $assemblies;
}
}

View file

@ -0,0 +1,340 @@
<?php
/**
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2022 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/>.
*/
declare(strict_types=1);
namespace App\Entity\AssemblySystem;
use ApiPlatform\Doctrine\Orm\Filter\OrderFilter;
use ApiPlatform\Doctrine\Orm\Filter\RangeFilter;
use ApiPlatform\Metadata\ApiFilter;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Delete;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Link;
use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post;
use ApiPlatform\OpenApi\Model\Operation;
use ApiPlatform\Serializer\Filter\PropertyFilter;
use App\ApiPlatform\Filter\LikeFilter;
use App\Entity\Contracts\TimeStampableInterface;
use App\Repository\DBElementRepository;
use App\Validator\Constraints\AssemblySystem\AssemblyCycle;
use App\Validator\Constraints\AssemblySystem\AssemblyInvalidBomEntry;
use App\Validator\UniqueValidatableInterface;
use Doctrine\DBAL\Types\Types;
use App\Entity\Base\AbstractDBElement;
use App\Entity\Base\TimestampTrait;
use App\Entity\Parts\Part;
use App\Entity\PriceInformations\Currency;
use App\Validator\Constraints\BigDecimal\BigDecimalPositive;
use App\Validator\Constraints\Selectable;
use Brick\Math\BigDecimal;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Annotation\Groups;
use Symfony\Component\Validator\Constraints as Assert;
use Symfony\Component\Validator\Context\ExecutionContextInterface;
/**
* The AssemblyBOMEntry class represents an entry in a assembly's BOM.
*/
#[ORM\HasLifecycleCallbacks]
#[ORM\Entity(repositoryClass: DBElementRepository::class)]
#[ORM\Table('assembly_bom_entries')]
#[ApiResource(
operations: [
new Get(uriTemplate: '/assembly_bom_entries/{id}.{_format}', security: 'is_granted("read", object)',),
new GetCollection(uriTemplate: '/assembly_bom_entries.{_format}', security: 'is_granted("@assemblies.read")',),
new Post(uriTemplate: '/assembly_bom_entries.{_format}', securityPostDenormalize: 'is_granted("create", object)',),
new Patch(uriTemplate: '/assembly_bom_entries/{id}.{_format}', security: 'is_granted("edit", object)',),
new Delete(uriTemplate: '/assembly_bom_entries/{id}.{_format}', security: 'is_granted("delete", object)',),
],
normalizationContext: ['groups' => ['bom_entry:read', 'api:basic:read'], 'openapi_definition_name' => 'Read'],
denormalizationContext: ['groups' => ['bom_entry:write', 'api:basic:write'], 'openapi_definition_name' => 'Write'],
)]
#[ApiResource(
uriTemplate: '/assemblies/{id}/bom.{_format}',
operations: [
new GetCollection(
openapi: new Operation(summary: 'Retrieves the BOM entries of the given assembly.'),
security: 'is_granted("@assemblies.read")'
)
],
uriVariables: [
'id' => new Link(fromProperty: 'bom_entries', fromClass: Assembly::class)
],
normalizationContext: ['groups' => ['bom_entry:read', 'api:basic:read'], 'openapi_definition_name' => 'Read']
)]
#[ApiFilter(PropertyFilter::class)]
#[ApiFilter(LikeFilter::class, properties: ["name", 'mountnames', 'designator', "comment"])]
#[ApiFilter(RangeFilter::class, properties: ['quantity'])]
#[ApiFilter(OrderFilter::class, properties: ['name', 'id', 'addedDate', 'lastModified', 'quantity'])]
class AssemblyBOMEntry extends AbstractDBElement implements UniqueValidatableInterface, TimeStampableInterface
{
use TimestampTrait;
#[Assert\Positive]
#[ORM\Column(name: 'quantity', type: Types::FLOAT)]
#[Groups(['bom_entry:read', 'bom_entry:write', 'import', 'simple', 'extended', 'full'])]
protected float $quantity = 1.0;
/**
* @var string A comma separated list of the names, where this parts should be placed
*/
#[ORM\Column(name: 'mountnames', type: Types::TEXT)]
#[Groups(['bom_entry:read', 'bom_entry:write', 'import', 'simple', 'extended', 'full'])]
protected string $mountnames = '';
/**
* @var string Reference mark on the circuit diagram/PCB
*/
#[ORM\Column(name: 'designator', type: Types::TEXT)]
#[Groups(['bom_entry:read', 'bom_entry:write', 'import', 'simple', 'extended', 'full'])]
protected string $designator = '';
/**
* @var string|null An optional name describing this BOM entry (useful for non-part entries)
*/
#[Assert\Expression('this.getPart() !== null or this.getReferencedAssembly() !== null or this.getName() !== null', message: 'validator.assembly.bom_entry.name_or_part_needed')]
#[ORM\Column(type: Types::STRING, nullable: true)]
#[Groups(['bom_entry:read', 'bom_entry:write', 'import', 'simple', 'extended', 'full'])]
protected ?string $name = null;
/**
* @var string An optional comment for this BOM entry
*/
#[ORM\Column(type: Types::TEXT)]
#[Groups(['bom_entry:read', 'bom_entry:write', 'import', 'extended', 'full'])]
protected string $comment = '';
/**
* @var Assembly|null
*/
#[ORM\ManyToOne(targetEntity: Assembly::class, inversedBy: 'bom_entries')]
#[ORM\JoinColumn(name: 'id_assembly', nullable: true)]
#[Groups(['bom_entry:read', 'bom_entry:write'])]
protected ?Assembly $assembly = null;
/**
* @var Part|null The part associated with this
*/
#[ORM\ManyToOne(targetEntity: Part::class, inversedBy: 'assembly_bom_entries')]
#[ORM\JoinColumn(name: 'id_part')]
#[Groups(['bom_entry:read', 'bom_entry:write', 'full'])]
protected ?Part $part = null;
/**
* @var Assembly|null The associated assembly
*/
#[Assert\Expression(
'(this.getPart() === null or this.getReferencedAssembly() === null) and (this.getName() === null or (this.getName() != null and this.getName() != ""))',
message: 'validator.assembly.bom_entry.only_part_or_assembly_allowed'
)]
#[AssemblyCycle]
#[AssemblyInvalidBomEntry]
#[ORM\ManyToOne(targetEntity: Assembly::class)]
#[ORM\JoinColumn(name: 'id_referenced_assembly', nullable: true, onDelete: 'SET NULL')]
#[Groups(['bom_entry:read', 'bom_entry:write'])]
protected ?Assembly $referencedAssembly = null;
/**
* @var BigDecimal|null The price of this non-part BOM entry
*/
#[Assert\AtLeastOneOf([new BigDecimalPositive(), new Assert\IsNull()])]
#[ORM\Column(type: 'big_decimal', precision: 11, scale: 5, nullable: true)]
#[Groups(['bom_entry:read', 'bom_entry:write', 'import', 'extended', 'full'])]
protected ?BigDecimal $price = null;
/**
* @var ?Currency The currency for the price of this non-part BOM entry
*/
#[ORM\ManyToOne(targetEntity: Currency::class)]
#[ORM\JoinColumn]
#[Selectable]
protected ?Currency $price_currency = null;
public function __construct()
{
}
public function getQuantity(): float
{
return $this->quantity;
}
public function setQuantity(float $quantity): AssemblyBOMEntry
{
$this->quantity = $quantity;
return $this;
}
public function getMountnames(): string
{
return $this->mountnames;
}
public function setMountnames(string $mountnames): AssemblyBOMEntry
{
$this->mountnames = $mountnames;
return $this;
}
public function getDesignator(): string
{
return $this->designator;
}
public function setDesignator(string $designator): AssemblyBOMEntry
{
$this->designator = $designator;
return $this;
}
/**
* @return string
*/
public function getName(): ?string
{
return trim($this->name ?? '') === '' ? null : $this->name;
}
/**
* @param string $name
*/
public function setName(?string $name): AssemblyBOMEntry
{
$this->name = trim($name ?? '') === '' ? null : $name;
return $this;
}
public function getComment(): string
{
return $this->comment;
}
public function setComment(string $comment): AssemblyBOMEntry
{
$this->comment = $comment;
return $this;
}
public function getAssembly(): ?Assembly
{
return $this->assembly;
}
public function setAssembly(?Assembly $assembly): AssemblyBOMEntry
{
$this->assembly = $assembly;
return $this;
}
public function getPart(): ?Part
{
return $this->part;
}
public function setPart(?Part $part): AssemblyBOMEntry
{
$this->part = $part;
return $this;
}
public function getReferencedAssembly(): ?Assembly
{
return $this->referencedAssembly;
}
public function setReferencedAssembly(?Assembly $referencedAssembly): AssemblyBOMEntry
{
$this->referencedAssembly = $referencedAssembly;
return $this;
}
/**
* Returns the price of this BOM entry, if existing.
* Prices are only valid on non-Part BOM entries.
*/
public function getPrice(): ?BigDecimal
{
return $this->price;
}
/**
* Sets the price of this BOM entry.
* Prices are only valid on non-Part BOM entries.
*/
public function setPrice(?BigDecimal $price): void
{
$this->price = $price;
}
public function getPriceCurrency(): ?Currency
{
return $this->price_currency;
}
public function setPriceCurrency(?Currency $price_currency): void
{
$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 instanceof Part;
}
/**
* Checks whether this BOM entry is a assembly associated BOM entry or not.
* @return bool True if this BOM entry is a assembly associated BOM entry, false otherwise.
*/
public function isAssemblyBomEntry(): bool
{
return $this->referencedAssembly !== null;
}
#[Assert\Callback]
public function validate(ExecutionContextInterface $context, $payload): void
{
//Round quantity to whole numbers, if the part is not a decimal part
if ($this->part instanceof Part && (!$this->part->getPartUnit() || $this->part->getPartUnit()->isInteger())) {
$this->quantity = round($this->quantity);
}
//Non-Part BOM entries are rounded
if (!$this->part instanceof Part) {
$this->quantity = round($this->quantity);
}
}
public function getComparableFields(): array
{
return [
'name' => $this->getName(),
'part' => $this->getPart()?->getID(),
'referencedAssembly' => $this->getReferencedAssembly()?->getID(),
];
}
}

View file

@ -0,0 +1,48 @@
<?php
/**
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2022 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/>.
*/
declare(strict_types=1);
namespace App\Entity\Attachments;
use App\Entity\AssemblySystem\Assembly;
use App\Serializer\APIPlatform\OverrideClassDenormalizer;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
use Symfony\Component\Serializer\Attribute\Context;
/**
* A attachment attached to a device element.
* @extends Attachment<Assembly>
*/
#[UniqueEntity(['name', 'attachment_type', 'element'])]
#[UniqueEntity(['name', 'attachment_type', 'element'])]
#[ORM\Entity]
class AssemblyAttachment extends Attachment
{
final public const ALLOWED_ELEMENT_CLASS = Assembly::class;
/**
* @var Assembly|null the element this attachment is associated with
*/
#[ORM\ManyToOne(targetEntity: Assembly::class, inversedBy: 'attachments')]
#[ORM\JoinColumn(name: 'element_id', nullable: false, onDelete: 'CASCADE')]
#[Context(denormalizationContext: [OverrideClassDenormalizer::CONTEXT_KEY => self::ALLOWED_ELEMENT_CLASS])]
protected ?AttachmentContainingDBElement $element = null;
}

View file

@ -97,7 +97,7 @@ use function in_array;
#[DiscriminatorMap(typeProperty: '_type', mapping: self::API_DISCRIMINATOR_MAP)]
abstract class Attachment extends AbstractNamedDBElement
{
private const ORM_DISCRIMINATOR_MAP = ['Part' => PartAttachment::class, 'PartCustomState' => PartCustomStateAttachment::class, 'Device' => ProjectAttachment::class,
private const ORM_DISCRIMINATOR_MAP = ['Part' => PartAttachment::class, 'PartCustomState' => PartCustomStateAttachment::class, 'Device' => ProjectAttachment::class, 'Assembly' => AssemblyAttachment::class,
'AttachmentType' => AttachmentTypeAttachment::class,
'Category' => CategoryAttachment::class, 'Footprint' => FootprintAttachment::class, 'Manufacturer' => ManufacturerAttachment::class,
'Currency' => CurrencyAttachment::class, 'Group' => GroupAttachment::class, 'MeasurementUnit' => MeasurementUnitAttachment::class,
@ -107,7 +107,7 @@ abstract class Attachment extends AbstractNamedDBElement
/*
* The discriminator map used for API platform. The key should be the same as the api platform short type (the @type JSONLD field).
*/
private const API_DISCRIMINATOR_MAP = ["Part" => PartAttachment::class, "PartCustomState" => PartCustomStateAttachment::class, "Project" => ProjectAttachment::class,
private const API_DISCRIMINATOR_MAP = ["Part" => PartAttachment::class, "PartCustomState" => PartCustomStateAttachment::class, "Project" => ProjectAttachment::class, "Assembly" => AssemblyAttachment::class,
"AttachmentType" => AttachmentTypeAttachment::class,
"Category" => CategoryAttachment::class, "Footprint" => FootprintAttachment::class, "Manufacturer" => ManufacturerAttachment::class,
"Currency" => CurrencyAttachment::class, "Group" => GroupAttachment::class, "MeasurementUnit" => MeasurementUnitAttachment::class,

View file

@ -22,6 +22,9 @@ declare(strict_types=1);
namespace App\Entity\Base;
use App\Entity\AssemblySystem\Assembly;
use App\Entity\AssemblySystem\AssemblyBOMEntry;
use App\Entity\Attachments\AssemblyAttachment;
use App\Entity\Attachments\AttachmentType;
use App\Entity\Attachments\Attachment;
use App\Entity\Attachments\AttachmentTypeAttachment;
@ -84,12 +87,15 @@ use Symfony\Component\Serializer\Annotation\Groups;
'part_attachment' => PartAttachment::class,
'part_custom_state_attachment' => PartCustomStateAttachment::class,
'project_attachment' => ProjectAttachment::class,
'assembly_attachment' => AssemblyAttachment::class,
'storelocation_attachment' => StorageLocationAttachment::class,
'supplier_attachment' => SupplierAttachment::class,
'user_attachment' => UserAttachment::class,
'category' => Category::class,
'project' => Project::class,
'project_bom_entry' => ProjectBOMEntry::class,
'assembly' => Assembly::class,
'assembly_bom_entry' => AssemblyBOMEntry::class,
'footprint' => Footprint::class,
'group' => Group::class,
'manufacturer' => Manufacturer::class,

View file

@ -41,6 +41,8 @@ declare(strict_types=1);
namespace App\Entity\LogSystem;
use App\Entity\AssemblySystem\Assembly;
use App\Entity\Attachments\AssemblyAttachment;
use App\Entity\Attachments\Attachment;
use App\Entity\Attachments\AttachmentType;
use App\Entity\Attachments\AttachmentTypeAttachment;
@ -61,6 +63,7 @@ use App\Entity\Contracts\LogWithEventUndoInterface;
use App\Entity\Contracts\NamedElementInterface;
use App\Entity\Parameters\PartCustomStateParameter;
use App\Entity\Parts\PartCustomState;
use App\Entity\Parameters\AssemblyParameter;
use App\Entity\ProjectSystem\Project;
use App\Entity\Parameters\AbstractParameter;
use App\Entity\Parameters\AttachmentTypeParameter;
@ -150,6 +153,7 @@ class CollectionElementDeleted extends AbstractLogEntry implements LogWithEventU
{
if (is_a($abstract_class, AbstractParameter::class, true)) {
return match ($this->getTargetClass()) {
Assembly::class => AssemblyParameter::class,
AttachmentType::class => AttachmentTypeParameter::class,
Category::class => CategoryParameter::class,
Currency::class => CurrencyParameter::class,
@ -172,6 +176,7 @@ class CollectionElementDeleted extends AbstractLogEntry implements LogWithEventU
Category::class => CategoryAttachment::class,
Currency::class => CurrencyAttachment::class,
Project::class => ProjectAttachment::class,
Assembly::class => AssemblyAttachment::class,
Footprint::class => FootprintAttachment::class,
Group::class => GroupAttachment::class,
Manufacturer::class => ManufacturerAttachment::class,

View file

@ -22,6 +22,8 @@ declare(strict_types=1);
*/
namespace App\Entity\LogSystem;
use App\Entity\AssemblySystem\Assembly;
use App\Entity\AssemblySystem\AssemblyBOMEntry;
use App\Entity\Attachments\Attachment;
use App\Entity\Attachments\AttachmentType;
use App\Entity\InfoProviderSystem\BulkInfoProviderImportJob;
@ -74,6 +76,9 @@ enum LogTargetType: int
case BULK_INFO_PROVIDER_IMPORT_JOB_PART = 22;
case PART_CUSTOM_STATE = 23;
case ASSEMBLY = 24;
case ASSEMBLY_BOM_ENTRY = 25;
/**
* Returns the class name of the target type or null if the target type is NONE.
* @return string|null
@ -88,6 +93,8 @@ enum LogTargetType: int
self::CATEGORY => Category::class,
self::PROJECT => Project::class,
self::BOM_ENTRY => ProjectBOMEntry::class,
self::ASSEMBLY => Assembly::class,
self::ASSEMBLY_BOM_ENTRY => AssemblyBOMEntry::class,
self::FOOTPRINT => Footprint::class,
self::GROUP => Group::class,
self::MANUFACTURER => Manufacturer::class,

View file

@ -74,7 +74,7 @@ use function sprintf;
3 => FootprintParameter::class, 4 => GroupParameter::class, 5 => ManufacturerParameter::class,
6 => MeasurementUnitParameter::class, 7 => PartParameter::class, 8 => StorageLocationParameter::class,
9 => SupplierParameter::class, 10 => AttachmentTypeParameter::class,
12 => PartCustomStateParameter::class])]
11 => AssemblyParameter::class, 12 => PartCustomStateParameter::class])]
#[ORM\Table('parameters')]
#[ORM\Index(columns: ['name'], name: 'parameter_name_idx')]
#[ORM\Index(columns: ['param_group'], name: 'parameter_group_idx')]
@ -104,7 +104,7 @@ abstract class AbstractParameter extends AbstractNamedDBElement implements Uniqu
*/
private const API_DISCRIMINATOR_MAP = ["Part" => PartParameter::class,
"AttachmentType" => AttachmentTypeParameter::class, "Category" => CategoryParameter::class, "Currency" => CurrencyParameter::class,
"Project" => ProjectParameter::class, "Footprint" => FootprintParameter::class, "Group" => GroupParameter::class,
"Project" => ProjectParameter::class, "Assembly" => AssemblyParameter::class, "Footprint" => FootprintParameter::class, "Group" => GroupParameter::class,
"Manufacturer" => ManufacturerParameter::class, "MeasurementUnit" => MeasurementUnitParameter::class,
"StorageLocation" => StorageLocationParameter::class, "Supplier" => SupplierParameter::class, "PartCustomState" => PartCustomStateParameter::class];

View file

@ -0,0 +1,65 @@
<?php
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2022 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/>.
*/
declare(strict_types=1);
/**
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2022 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\Entity\Parameters;
use App\Entity\AssemblySystem\Assembly;
use App\Entity\Base\AbstractDBElement;
use App\Repository\ParameterRepository;
use App\Serializer\APIPlatform\OverrideClassDenormalizer;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
use Symfony\Component\Serializer\Attribute\Context;
#[UniqueEntity(fields: ['name', 'group', 'element'])]
#[ORM\Entity(repositoryClass: ParameterRepository::class)]
class AssemblyParameter extends AbstractParameter
{
final public const ALLOWED_ELEMENT_CLASS = Assembly::class;
/**
* @var Assembly the element this para is associated with
*/
#[ORM\ManyToOne(targetEntity: Assembly::class, inversedBy: 'parameters')]
#[ORM\JoinColumn(name: 'element_id', nullable: false, onDelete: 'CASCADE')]
#[Context(denormalizationContext: [OverrideClassDenormalizer::CONTEXT_KEY => self::ALLOWED_ELEMENT_CLASS])]
protected ?AbstractDBElement $element = null;
}

View file

@ -22,6 +22,7 @@ declare(strict_types=1);
namespace App\Entity\Parts;
use App\Entity\Parts\PartTraits\AssemblyTrait;
use ApiPlatform\Doctrine\Common\Filter\DateFilterInterface;
use ApiPlatform\Doctrine\Orm\Filter\BooleanFilter;
use ApiPlatform\Doctrine\Orm\Filter\DateFilter;
@ -123,6 +124,7 @@ class Part extends AttachmentContainingDBElement
use OrderTrait;
use ParametersTrait;
use ProjectTrait;
use AssemblyTrait;
use AssociationTrait;
use EDATrait;
@ -184,6 +186,7 @@ class Part extends AttachmentContainingDBElement
$this->orderdetails = new ArrayCollection();
$this->parameters = new ArrayCollection();
$this->project_bom_entries = new ArrayCollection();
$this->assembly_bom_entries = new ArrayCollection();
$this->associated_parts_as_owner = new ArrayCollection();
$this->associated_parts_as_other = new ArrayCollection();

View file

@ -0,0 +1,45 @@
<?php
declare(strict_types=1);
namespace App\Entity\Parts\PartTraits;
use App\Entity\AssemblySystem\Assembly;
use App\Entity\AssemblySystem\AssemblyBOMEntry;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
trait AssemblyTrait
{
/**
* @var Collection<AssemblyBOMEntry> $assembly_bom_entries
*/
#[ORM\OneToMany(mappedBy: 'part', targetEntity: AssemblyBOMEntry::class, cascade: ['remove'], orphanRemoval: true)]
protected Collection $assembly_bom_entries;
/**
* Returns all AssemblyBOMEntry that use this part.
*
* @phpstan-return Collection<int, AssemblyBOMEntry>
*/
public function getAssemblyBomEntries(): Collection
{
return $this->assembly_bom_entries;
}
/**
* Get all assemblies which uses this part.
*
* @return Assembly[] all assemblies which uses this part as a one-dimensional array of Assembly objects
*/
public function getAssemblies(): array
{
$assemblies = [];
foreach($this->assembly_bom_entries as $entry) {
$assemblies[] = $entry->getAssembly();
}
return $assemblies;
}
}

View file

@ -36,6 +36,7 @@ use ApiPlatform\OpenApi\Model\Operation;
use ApiPlatform\Serializer\Filter\PropertyFilter;
use App\ApiPlatform\Filter\LikeFilter;
use App\Entity\Contracts\TimeStampableInterface;
use App\Repository\DBElementRepository;
use App\Validator\UniqueValidatableInterface;
use Doctrine\DBAL\Types\Types;
use App\Entity\Base\AbstractDBElement;
@ -54,7 +55,7 @@ use Symfony\Component\Validator\Context\ExecutionContextInterface;
* The ProjectBOMEntry class represents an entry in a project's BOM.
*/
#[ORM\HasLifecycleCallbacks]
#[ORM\Entity]
#[ORM\Entity(repositoryClass: DBElementRepository::class)]
#[ORM\Table('project_bom_entries')]
#[ApiResource(
operations: [

View file

@ -0,0 +1,82 @@
<?php
declare(strict_types=1);
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2022 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\Form\AdminPages;
use App\Entity\Base\AbstractNamedDBElement;
use App\Form\AssemblySystem\AssemblyBOMEntryCollectionType;
use App\Form\Type\RichTextEditorType;
use App\Services\LogSystem\EventCommentNeededHelper;
use App\Settings\MiscSettings\AssemblySettings;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
class AssemblyAdminForm extends BaseEntityAdminForm
{
public function __construct(
protected Security $security,
protected EventCommentNeededHelper $eventCommentNeededHelper,
protected ?AssemblySettings $assemblySettings = null,
) {
parent::__construct($security, $eventCommentNeededHelper, $assemblySettings);
}
protected function additionalFormElements(FormBuilderInterface $builder, array $options, AbstractNamedDBElement $entity): void
{
$builder->add('description', RichTextEditorType::class, [
'required' => false,
'label' => 'part.edit.description',
'mode' => 'markdown-single_line',
'empty_data' => '',
'attr' => [
'placeholder' => 'part.edit.description.placeholder',
'rows' => 2,
],
]);
$builder->add('bom_entries', AssemblyBOMEntryCollectionType::class);
$builder->add('status', ChoiceType::class, [
'attr' => [
'class' => 'form-select',
],
'label' => 'assembly.edit.status',
'required' => false,
'empty_data' => '',
'choices' => [
'assembly.status.draft' => 'draft',
'assembly.status.planning' => 'planning',
'assembly.status.in_production' => 'in_production',
'assembly.status.finished' => 'finished',
'assembly.status.archived' => 'archived',
],
]);
$builder->add('ipn', TextType::class, [
'required' => false,
'empty_data' => null,
'label' => 'assembly.edit.ipn',
]);
}
}

View file

@ -22,10 +22,12 @@ declare(strict_types=1);
namespace App\Form\AdminPages;
use App\Entity\AssemblySystem\Assembly;
use App\Entity\PriceInformations\Currency;
use App\Entity\ProjectSystem\Project;
use App\Entity\UserSystem\Group;
use App\Services\LogSystem\EventCommentType;
use App\Settings\MiscSettings\AssemblySettings;
use Symfony\Bundle\SecurityBundle\Security;
use App\Entity\Base\AbstractNamedDBElement;
use App\Entity\Base\AbstractStructuralDBElement;
@ -47,8 +49,11 @@ use Symfony\Component\OptionsResolver\OptionsResolver;
class BaseEntityAdminForm extends AbstractType
{
public function __construct(protected Security $security, protected EventCommentNeededHelper $eventCommentNeededHelper)
{
public function __construct(
protected Security $security,
protected EventCommentNeededHelper $eventCommentNeededHelper,
protected ?AssemblySettings $assemblySettings = null,
) {
}
public function configureOptions(OptionsResolver $resolver): void
@ -69,6 +74,7 @@ class BaseEntityAdminForm extends AbstractType
->add('name', TextType::class, [
'empty_data' => '',
'label' => 'name.label',
'data' => $is_new && $entity instanceof Assembly && $this->assemblySettings !== null && $this->assemblySettings->useIpnPlaceholderInName ? '%%ipn%%' : $entity->getName(),
'attr' => [
'placeholder' => 'part.name.placeholder',
],
@ -114,7 +120,7 @@ class BaseEntityAdminForm extends AbstractType
);
}
if ($entity instanceof AbstractStructuralDBElement && !($entity instanceof Group || $entity instanceof Project || $entity instanceof Currency)) {
if ($entity instanceof AbstractStructuralDBElement && !($entity instanceof Group || $entity instanceof Project || $entity instanceof Assembly || $entity instanceof Currency)) {
$builder->add('alternative_names', TextType::class, [
'required' => false,
'label' => 'entity.edit.alternative_names.label',

View file

@ -0,0 +1,91 @@
<?php
declare(strict_types=1);
/*
* 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\Form\AssemblySystem;
use App\Entity\AssemblySystem\Assembly;
use App\Entity\AssemblySystem\AssemblyBOMEntry;
use App\Form\Type\StructuralEntityType;
use App\Validator\Constraints\UniqueObjectCollection;
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\Form\FormEvent;
use Symfony\Component\Form\FormEvents;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Validator\Constraints\NotNull;
class AssemblyAddPartsType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder->add('assembly', StructuralEntityType::class, [
'class' => Assembly::class,
'required' => true,
'disabled' => $options['assembly'] instanceof Assembly, //If a assembly is given, disable the field
'data' => $options['assembly'],
'constraints' => [
new NotNull()
]
]);
$builder->add('bom_entries', AssemblyBOMEntryCollectionType::class, [
'entry_options' => [
'constraints' => [
new UniqueEntity(fields: ['part'], message: 'assembly.bom_entry.part_already_in_bom',
entityClass: AssemblyBOMEntry::class),
new UniqueEntity(fields: ['referencedAssembly'], message: 'assembly.bom_entry.assembly_already_in_bom',
entityClass: AssemblyBOMEntry::class),
new UniqueEntity(fields: ['name'], message: 'assembly.bom_entry.name_already_in_bom',
entityClass: AssemblyBOMEntry::class, ignoreNull: true),
]
],
'constraints' => [
new UniqueObjectCollection(message: 'assembly.bom_entry.part_already_in_bom', fields: ['part']),
new UniqueObjectCollection(message: 'assembly.bom_entry.assembly_already_in_bom', fields: ['referencedAssembly']),
new UniqueObjectCollection(message: 'assembly.bom_entry.name_already_in_bom', fields: ['name']),
]
]);
$builder->add('submit', SubmitType::class, ['label' => 'save']);
//After submit set the assembly for all bom entries, so that it can be validated properly
$builder->addEventListener(FormEvents::SUBMIT, function (FormEvent $event) {
$form = $event->getForm();
/** @var Assembly $assembly */
$assembly = $form->get('assembly')->getData();
$bom_entries = $form->get('bom_entries')->getData();
foreach ($bom_entries as $bom_entry) {
$bom_entry->setAssembly($assembly);
}
});
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'assembly' => null,
]);
$resolver->setAllowedTypes('assembly', ['null', Assembly::class]);
}
}

View file

@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
namespace App\Form\AssemblySystem;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\CollectionType;
use Symfony\Component\OptionsResolver\OptionsResolver;
class AssemblyBOMEntryCollectionType extends AbstractType
{
public function getParent(): string
{
return CollectionType::class;
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'entry_type' => AssemblyBOMEntryType::class,
'entry_options' => [
'label' => false,
],
'allow_add' => true,
'allow_delete' => true,
'by_reference' => false,
'reindex_enable' => true,
'label' => false,
]);
}
}

View file

@ -0,0 +1,98 @@
<?php
declare(strict_types=1);
namespace App\Form\AssemblySystem;
use App\Entity\AssemblySystem\AssemblyBOMEntry;
use App\Form\Type\AssemblySelectType;
use App\Form\Type\BigDecimalNumberType;
use App\Form\Type\CurrencyEntityType;
use App\Form\Type\PartSelectType;
use App\Form\Type\RichTextEditorType;
use App\Form\Type\SIUnitType;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Event\PreSetDataEvent;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\Form\FormEvents;
use Symfony\Component\OptionsResolver\OptionsResolver;
class AssemblyBOMEntryType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder->addEventListener(FormEvents::PRE_SET_DATA, function (PreSetDataEvent $event) {
$form = $event->getForm();
/** @var AssemblyBOMEntry $data */
$data = $event->getData();
$form->add('quantity', SIUnitType::class, [
'label' => 'assembly.bom.quantity',
'measurement_unit' => $data && $data->getPart() ? $data->getPart()->getPartUnit() : null,
]);
});
$builder
->add('part', PartSelectType::class, [
'required' => false,
])
->add('referencedAssembly', AssemblySelectType::class, [
'label' => 'assembly.bom.referencedAssembly',
'required' => false,
])
->add('name', TextType::class, [
'label' => 'assembly.bom.name',
'help' => 'assembly.bom.name.help',
'required' => false,
])
->add('designator', TextType::class, [
'label' => 'assembly.bom.designator',
'help' => 'assembly.bom.designator.help',
'empty_data' => '',
'required' => false,
])
->add('mountnames', TextType::class, [
'required' => false,
'label' => 'assembly.bom.mountnames',
'empty_data' => '',
'attr' => [
'class' => 'tagsinput',
'data-controller' => 'elements--tagsinput',
],
])
->add('comment', RichTextEditorType::class, [
'required' => false,
'label' => 'assembly.bom.comment',
'empty_data' => '',
'mode' => 'markdown-single_line',
'attr' => [
'rows' => 2,
],
])
->add('price', BigDecimalNumberType::class, [
'label' => false,
'required' => false,
'scale' => 5,
'html5' => true,
'attr' => [
'min' => 0,
'step' => 'any',
],
])
->add('priceCurrency', CurrencyEntityType::class, [
'required' => false,
'label' => false,
'short' => true,
]
);
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'data_class' => AssemblyBOMEntry::class,
]);
}
}

View file

@ -0,0 +1,114 @@
<?php
declare(strict_types=1);
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2022 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\Form\Filters;
use App\DataTables\Filters\AssemblyFilter;
use App\Entity\Attachments\AttachmentType;
use App\Form\Filters\Constraints\DateTimeConstraintType;
use App\Form\Filters\Constraints\NumberConstraintType;
use App\Form\Filters\Constraints\StructuralEntityConstraintType;
use App\Form\Filters\Constraints\TextConstraintType;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\ResetType;
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
class AssemblyFilterType extends AbstractType
{
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'compound' => true,
'data_class' => AssemblyFilter::class,
'csrf_protection' => false,
]);
}
public function buildForm(FormBuilderInterface $builder, array $options): void
{
/*
* Common tab
*/
$builder->add('name', TextConstraintType::class, [
'label' => 'assembly.filter.name',
]);
$builder->add('description', TextConstraintType::class, [
'label' => 'assembly.filter.description',
]);
$builder->add('comment', TextConstraintType::class, [
'label' => 'assembly.filter.comment'
]);
/*
* Advanced tab
*/
$builder->add('dbId', NumberConstraintType::class, [
'label' => 'assembly.filter.dbId',
'min' => 1,
'step' => 1,
]);
$builder->add('ipn', TextConstraintType::class, [
'label' => 'assembly.filter.ipn',
]);
$builder->add('lastModified', DateTimeConstraintType::class, [
'label' => 'lastModified'
]);
$builder->add('addedDate', DateTimeConstraintType::class, [
'label' => 'createdAt'
]);
/**
* Attachments count
*/
$builder->add('attachmentsCount', NumberConstraintType::class, [
'label' => 'assembly.filter.attachments_count',
'step' => 1,
'min' => 0,
]);
$builder->add('attachmentType', StructuralEntityConstraintType::class, [
'label' => 'attachment.attachment_type',
'entity_class' => AttachmentType::class
]);
$builder->add('attachmentName', TextConstraintType::class, [
'label' => 'assembly.filter.attachmentName',
]);
$builder->add('submit', SubmitType::class, [
'label' => 'filter.submit',
]);
$builder->add('discard', ResetType::class, [
'label' => 'filter.discard',
]);
}
}

View file

@ -23,6 +23,7 @@ declare(strict_types=1);
namespace App\Form\Filters;
use App\DataTables\Filters\AttachmentFilter;
use App\Entity\Attachments\AssemblyAttachment;
use App\Entity\Attachments\AttachmentType;
use App\Entity\Attachments\AttachmentTypeAttachment;
use App\Entity\Attachments\CategoryAttachment;
@ -80,6 +81,7 @@ class AttachmentFilterType extends AbstractType
'category.label' => CategoryAttachment::class,
'currency.label' => CurrencyAttachment::class,
'project.label' => ProjectAttachment::class,
'assembly.label' => AssemblyAttachment::class,
'footprint.label' => FootprintAttachment::class,
'group.label' => GroupAttachment::class,
'label_profile.label' => LabelAttachment::class,

View file

@ -114,6 +114,8 @@ class LogFilterType extends AbstractType
LogTargetType::CATEGORY => 'category.label',
LogTargetType::PROJECT => 'project.label',
LogTargetType::BOM_ENTRY => 'project_bom_entry.label',
LogTargetType::ASSEMBLY => 'assembly.label',
LogTargetType::ASSEMBLY_BOM_ENTRY => 'assembly_bom_entry.label',
LogTargetType::FOOTPRINT => 'footprint.label',
LogTargetType::GROUP => 'group.label',
LogTargetType::MANUFACTURER => 'manufacturer.label',

View file

@ -25,6 +25,7 @@ namespace App\Form\Filters;
use App\DataTables\Filters\Constraints\Part\BulkImportPartStatusConstraint;
use App\DataTables\Filters\Constraints\Part\ParameterConstraint;
use App\DataTables\Filters\PartFilter;
use App\Entity\AssemblySystem\Assembly;
use App\Entity\Attachments\AttachmentType;
use App\Entity\InfoProviderSystem\BulkImportJobStatus;
use App\Entity\InfoProviderSystem\BulkImportPartStatus;
@ -313,6 +314,26 @@ class PartFilterType extends AbstractType
}
/**************************************************************************
* Assembly tab
**************************************************************************/
if ($this->security->isGranted('read', Assembly::class)) {
$builder
->add('assembly', StructuralEntityConstraintType::class, [
'label' => 'assembly.label',
'entity_class' => Assembly::class
])
->add('assemblyBomQuantity', NumberConstraintType::class, [
'label' => 'assembly.bom.quantity',
'min' => 0,
'step' => "any",
])
->add('assemblyBomName', TextConstraintType::class, [
'label' => 'assembly.bom.name',
])
;
}
/**************************************************************************
* Bulk Import Job tab
**************************************************************************/

View file

@ -0,0 +1,122 @@
<?php
declare(strict_types=1);
namespace App\Form\Type;
use App\Entity\AssemblySystem\Assembly;
use App\Entity\Attachments\Attachment;
use App\Services\Attachments\AssemblyPreviewGenerator;
use App\Services\Attachments\AttachmentURLGenerator;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bridge\Doctrine\Form\Type\EntityType;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\ChoiceList\ChoiceList;
use Symfony\Component\Form\DataMapperInterface;
use Symfony\Component\Form\Event\PreSetDataEvent;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\Form\FormEvent;
use Symfony\Component\Form\FormEvents;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
class AssemblySelectType extends AbstractType implements DataMapperInterface
{
public function __construct(private readonly UrlGeneratorInterface $urlGenerator, private readonly EntityManagerInterface $em, private readonly AssemblyPreviewGenerator $previewGenerator, private readonly AttachmentURLGenerator $attachmentURLGenerator)
{
}
public function buildForm(FormBuilderInterface $builder, array $options): void
{
//At initialization, we have to fill the form element with our selected data, so the user can see it
$builder->addEventListener(FormEvents::PRE_SET_DATA, function (PreSetDataEvent $event) {
$form = $event->getForm();
$config = $form->getConfig()->getOptions();
$data = $event->getData() ?? [];
$config['compound'] = false;
$config['choices'] = is_iterable($data) ? $data : [$data];
$config['error_bubbling'] = true;
$form->add('autocomplete', EntityType::class, $config);
});
//After form submit, we have to add the selected element as choice, otherwise the form will not accept this element
$builder->addEventListener(FormEvents::PRE_SUBMIT, function(FormEvent $event) {
$data = $event->getData();
$form = $event->getForm();
$options = $form->get('autocomplete')->getConfig()->getOptions();
if (!isset($data['autocomplete']) || '' === $data['autocomplete'] || empty($data['autocomplete'])) {
$options['choices'] = [];
} else {
//Extract the ID from the submitted data
$id = $data['autocomplete'];
//Find the element in the database
$element = $this->em->find($options['class'], $id);
//Add the element as choice
$options['choices'] = [$element];
$options['error_bubbling'] = true;
$form->add('autocomplete', EntityType::class, $options);
}
});
$builder->setDataMapper($this);
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'class' => Assembly::class,
'choice_label' => 'name',
'compound' => true,
'error_bubbling' => false,
]);
$resolver->setDefaults([
'attr' => [
'data-controller' => 'elements--assembly-select',
'data-autocomplete' => $this->urlGenerator->generate('typeahead_assemblies', ['query' => '__QUERY__']),
'autocomplete' => 'off',
],
]);
$resolver->setDefaults([
//Prefill the selected choice with the needed data, so the user can see it without an additional Ajax request
'choice_attr' => ChoiceList::attr($this, function (?Assembly $assembly) {
if($assembly instanceof Assembly) {
//Determine the picture to show:
$preview_attachment = $this->previewGenerator->getTablePreviewAttachment($assembly);
if ($preview_attachment instanceof Attachment) {
$preview_url = $this->attachmentURLGenerator->getThumbnailURL($preview_attachment,
'thumbnail_sm');
} else {
$preview_url = '';
}
}
return $assembly instanceof Assembly ? [
'data-description' => $assembly->getDescription() ? mb_strimwidth($assembly->getDescription(), 0, 127, '...') : '',
'data-category' => '',
'data-footprint' => '',
'data-image' => $preview_url,
] : [];
})
]);
}
public function mapDataToForms($data, \Traversable $forms): void
{
$form = current(iterator_to_array($forms, false));
$form->setData($data);
}
public function mapFormsToData(\Traversable $forms, &$data): void
{
$form = current(iterator_to_array($forms, false));
$data = $form->getData();
}
}

View file

@ -50,7 +50,7 @@ class PartSelectType extends AbstractType implements DataMapperInterface
$options = $form->get('autocomplete')->getConfig()->getOptions();
if (!isset($data['autocomplete']) || '' === $data['autocomplete']) {
if (!isset($data['autocomplete']) || '' === $data['autocomplete'] || empty($data['autocomplete'])) {
$options['choices'] = [];
} else {
//Extract the ID from the submitted data

View file

@ -0,0 +1,273 @@
<?php
declare(strict_types=1);
/*
* 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\Helpers\Assemblies;
use App\Entity\AssemblySystem\Assembly;
use App\Entity\AssemblySystem\AssemblyBOMEntry;
use App\Entity\Parts\Part;
use Dompdf\Dompdf;
use Dompdf\Options;
use Twig\Environment;
class AssemblyPartAggregator
{
public function __construct(private readonly Environment $twig)
{
}
/**
* Aggregate the required parts and their total quantities for an assembly.
*
* @param Assembly $assembly The assembly to process.
* @param float $multiplier The quantity multiplier from the parent assembly.
* @return array Array of parts with their aggregated quantities, keyed by Part ID.
*/
public function getAggregatedParts(Assembly $assembly, float $multiplier): array
{
$aggregatedParts = [];
// Start processing the assembly recursively
$this->processAssembly($assembly, $multiplier, $aggregatedParts);
// Return the final aggregated list of parts
return $aggregatedParts;
}
/**
* Recursive helper to process an assembly and all its BOM entries.
*
* @param Assembly $assembly The current assembly to process.
* @param float $multiplier The quantity multiplier from the parent assembly.
* @param array &$aggregatedParts The array to accumulate parts and their quantities.
*/
private function processAssembly(Assembly $assembly, float $multiplier, array &$aggregatedParts): void
{
/** @var AssemblyBOMEntry $bomEntry */
foreach ($assembly->getBomEntries() as $bomEntry) {
// If the BOM entry refers to a part, add its quantity
if ($bomEntry->getPart() instanceof Part) {
$part = $bomEntry->getPart();
if (!isset($aggregatedParts[$part->getId()])) {
$aggregatedParts[$part->getId()] = [
'part' => $part,
'assembly' => $assembly,
'name' => $bomEntry->getName(),
'designator' => $bomEntry->getDesignator(),
'quantity' => $bomEntry->getQuantity(),
'multiplier' => $multiplier,
];
}
} elseif ($bomEntry->getReferencedAssembly() instanceof Assembly) {
// If the BOM entry refers to another assembly, process it recursively
$this->processAssembly($bomEntry->getReferencedAssembly(), $bomEntry->getQuantity(), $aggregatedParts);
} else {
$aggregatedParts[] = [
'part' => null,
'assembly' => $assembly,
'name' => $bomEntry->getName(),
'designator' => $bomEntry->getDesignator(),
'quantity' => $bomEntry->getQuantity(),
'multiplier' => $multiplier,
];
}
}
}
/**
* Exports a hierarchical Bill of Materials (BOM) for assemblies and parts in a readable format,
* including the multiplier for each part and assembly.
*
* @param Assembly $assembly The root assembly to export.
* @param string $indentationSymbol The symbol used for indentation (e.g., ' ').
* @param int $initialDepth The starting depth for formatting (default: 0).
* @return string Human-readable hierarchical BOM list.
*/
public function exportReadableHierarchy(Assembly $assembly, string $indentationSymbol = ' ', int $initialDepth = 0): string
{
// Start building the hierarchy
$output = '';
$this->processAssemblyHierarchy($assembly, $initialDepth, 1, $indentationSymbol, $output);
return $output;
}
public function exportReadableHierarchyForPdf(array $assemblyHierarchies): string
{
$html = $this->twig->render('assemblies/export_bom_pdf.html.twig', [
'assemblies' => $assemblyHierarchies,
]);
$options = new Options();
$options->set('isHtml5ParserEnabled', true);
$options->set('isPhpEnabled', true);
$dompdf = new Dompdf($options);
$dompdf->loadHtml($html);
$dompdf->setPaper('A4');
$dompdf->render();
$canvas = $dompdf->getCanvas();
$font = $dompdf->getFontMetrics()->getFont('Arial', 'normal');
return $dompdf->output();
}
/**
* Recursive method to process assemblies and their parts.
*
* @param Assembly $assembly The current assembly to process.
* @param int $depth The current depth in the hierarchy.
* @param float $parentMultiplier The multiplier inherited from the parent (default is 1 for root).
* @param string $indentationSymbol The symbol used for indentation.
* @param string &$output The cumulative output string.
*/
private function processAssemblyHierarchy(Assembly $assembly, int $depth, float $parentMultiplier, string $indentationSymbol, string &$output): void
{
// Add the current assembly to the output
if ($depth === 0) {
$output .= sprintf(
"%sAssembly: %s [IPN: %s]\n\n",
str_repeat($indentationSymbol, $depth),
$assembly->getName(),
$assembly->getIpn(),
);
} else {
$output .= sprintf(
"%sAssembly: %s [IPN: %s, Multiplier: %.2f]\n\n",
str_repeat($indentationSymbol, $depth),
$assembly->getName(),
$assembly->getIpn(),
$parentMultiplier
);
}
// Gruppiere BOM-Einträge in Kategorien
$parts = [];
$referencedAssemblies = [];
$others = [];
foreach ($assembly->getBomEntries() as $bomEntry) {
if ($bomEntry->getPart() instanceof Part) {
$parts[] = $bomEntry;
} elseif ($bomEntry->getReferencedAssembly() instanceof Assembly) {
$referencedAssemblies[] = $bomEntry;
} else {
$others[] = $bomEntry;
}
}
if (!empty($parts)) {
// Process each BOM entry for the current assembly
foreach ($parts as $bomEntry) {
$effectiveQuantity = $bomEntry->getQuantity() * $parentMultiplier;
$output .= sprintf(
"%sPart: %s [IPN: %s, MPNR: %s, Quantity: %.2f%s, EffectiveQuantity: %.2f]\n",
str_repeat($indentationSymbol, $depth + 1),
$bomEntry->getPart()?->getName(),
$bomEntry->getPart()?->getIpn() ?? '-',
$bomEntry->getPart()?->getManufacturerProductNumber() ?? '-',
$bomEntry->getQuantity(),
$parentMultiplier > 1 ? sprintf(", Multiplier: %.2f", $parentMultiplier) : '',
$effectiveQuantity,
);
}
$output .= "\n";
}
foreach ($referencedAssemblies as $bomEntry) {
// Add referenced assembly details
$referencedQuantity = $bomEntry->getQuantity() * $parentMultiplier;
$output .= sprintf(
"%sReferenced Assembly: %s [IPN: %s, Quantity: %.2f%s, EffectiveQuantity: %.2f]\n",
str_repeat($indentationSymbol, $depth + 1),
$bomEntry->getReferencedAssembly()->getName(),
$bomEntry->getReferencedAssembly()->getIpn() ?? '-',
$bomEntry->getQuantity(),
$parentMultiplier > 1 ? sprintf(", Multiplier: %.2f", $parentMultiplier) : '',
$referencedQuantity,
);
// Recurse into the referenced assembly
$this->processAssemblyHierarchy(
$bomEntry->getReferencedAssembly(),
$depth + 2, // Increase depth for nested assemblies
$referencedQuantity, // Pass the calculated multiplier
$indentationSymbol,
$output
);
}
foreach ($others as $bomEntry) {
$output .= sprintf(
"%sOther: %s [Quantity: %.2f, Multiplier: %.2f]\n",
str_repeat($indentationSymbol, $depth + 1),
$bomEntry->getName(),
$bomEntry->getQuantity(),
$parentMultiplier,
);
}
}
public function processAssemblyHierarchyForPdf(Assembly $assembly, int $depth, float $quantity, float $parentMultiplier): array
{
$result = [
'name' => $assembly->getName(),
'ipn' => $assembly->getIpn(),
'quantity' => $quantity,
'multiplier' => $depth === 0 ? null : $parentMultiplier,
'parts' => [],
'referencedAssemblies' => [],
'others' => [],
];
foreach ($assembly->getBomEntries() as $bomEntry) {
if ($bomEntry->getPart() instanceof Part) {
$result['parts'][] = [
'name' => $bomEntry->getPart()->getName(),
'ipn' => $bomEntry->getPart()->getIpn(),
'quantity' => $bomEntry->getQuantity(),
'effectiveQuantity' => $bomEntry->getQuantity() * $parentMultiplier,
];
} elseif ($bomEntry->getReferencedAssembly() instanceof Assembly) {
$result['referencedAssemblies'][] = $this->processAssemblyHierarchyForPdf(
$bomEntry->getReferencedAssembly(),
$depth + 1,
$bomEntry->getQuantity(),
$parentMultiplier * $bomEntry->getQuantity()
);
} else {
$result['others'][] = [
'name' => $bomEntry->getName(),
'quantity' => $bomEntry->getQuantity(),
'multiplier' => $parentMultiplier,
];
}
}
return $result;
}
}

View file

@ -0,0 +1,69 @@
<?php
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2022 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/>.
*/
declare(strict_types=1);
/**
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2022 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\Repository;
use App\Entity\AssemblySystem\Assembly;
/**
* @template TEntityClass of Assembly
* @extends StructuralDBElementRepository<TEntityClass>
*/
class AssemblyRepository extends StructuralDBElementRepository
{
/**
* @return Assembly[]
*/
public function autocompleteSearch(string $query, int $max_limits = 50): array
{
$qb = $this->createQueryBuilder('assembly');
$qb->select('assembly')
->where('ILIKE(assembly.name, :query) = TRUE')
->orWhere('ILIKE(assembly.description, :query) = TRUE');
$qb->setParameter('query', '%'.$query.'%');
$qb->setMaxResults($max_limits);
$qb->orderBy('NATSORT(assembly.name)', 'ASC');
return $qb->getQuery()->getResult();
}
}

View file

@ -154,4 +154,14 @@ class DBElementRepository extends EntityRepository
$property->setAccessible(true);
$property->setValue($element, $new_value);
}
protected function save(AbstractDBElement $entity, bool $flush = true): void
{
$manager = $this->getEntityManager();
$manager->persist($entity);
if ($flush) {
$manager->flush();
}
}
}

View file

@ -23,6 +23,7 @@ declare(strict_types=1);
namespace App\Security\Voter;
use App\Entity\Attachments\PartCustomStateAttachment;
use App\Entity\Attachments\AssemblyAttachment;
use App\Services\UserSystem\VoterHelper;
use Symfony\Bundle\SecurityBundle\Security;
use App\Entity\Attachments\AttachmentContainingDBElement;
@ -90,6 +91,8 @@ final class AttachmentVoter extends Voter
$param = 'currencies';
} elseif (is_a($subject, ProjectAttachment::class, true)) {
$param = 'projects';
} elseif (is_a($subject, AssemblyAttachment::class, true)) {
$param = 'assemblies';
} elseif (is_a($subject, FootprintAttachment::class, true)) {
$param = 'footprints';
} elseif (is_a($subject, GroupAttachment::class, true)) {

View file

@ -22,6 +22,7 @@ declare(strict_types=1);
namespace App\Security\Voter;
use App\Entity\AssemblySystem\Assembly;
use App\Entity\Attachments\AttachmentType;
use App\Entity\Parts\PartCustomState;
use App\Entity\ProjectSystem\Project;
@ -48,6 +49,7 @@ final class StructureVoter extends Voter
AttachmentType::class => 'attachment_types',
Category::class => 'categories',
Project::class => 'projects',
Assembly::class => 'assemblies',
Footprint::class => 'footprints',
Manufacturer::class => 'manufacturers',
StorageLocation::class => 'storelocations',

View file

@ -0,0 +1,93 @@
<?php
/**
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2022 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/>.
*/
declare(strict_types=1);
namespace App\Services\Attachments;
use App\Entity\AssemblySystem\Assembly;
use App\Entity\Attachments\Attachment;
class AssemblyPreviewGenerator
{
public function __construct(protected AttachmentManager $attachmentHelper)
{
}
/**
* Returns a list of attachments that can be used for previewing the assembly ordered by priority.
*
* @param Assembly $assembly the assembly for which the attachments should be determined
*
* @return (Attachment|null)[]
*
* @psalm-return list<Attachment|null>
*/
public function getPreviewAttachments(Assembly $assembly): array
{
$list = [];
//Master attachment has top priority
$attachment = $assembly->getMasterPictureAttachment();
if ($this->isAttachmentValidPicture($attachment)) {
$list[] = $attachment;
}
//Then comes the other images of the assembly
foreach ($assembly->getAttachments() as $attachment) {
//Dont show the master attachment twice
if ($this->isAttachmentValidPicture($attachment) && $attachment !== $assembly->getMasterPictureAttachment()) {
$list[] = $attachment;
}
}
return $list;
}
/**
* Determines what attachment should be used for previewing a assembly (especially in assembly table).
* The returned attachment is guaranteed to be existing and be a picture.
*
* @param Assembly $assembly The assembly for which the attachment should be determined
*/
public function getTablePreviewAttachment(Assembly $assembly): ?Attachment
{
$attachment = $assembly->getMasterPictureAttachment();
if ($this->isAttachmentValidPicture($attachment)) {
return $attachment;
}
return null;
}
/**
* Checks if a attachment is exising and a valid picture.
*
* @param Attachment|null $attachment the attachment that should be checked
*
* @return bool true if the attachment is valid
*/
protected function isAttachmentValidPicture(?Attachment $attachment): bool
{
return $attachment instanceof Attachment
&& $attachment->isPicture()
&& $this->attachmentHelper->isFileExisting($attachment);
}
}

View file

@ -22,6 +22,7 @@ declare(strict_types=1);
namespace App\Services\Attachments;
use App\Entity\Attachments\AssemblyAttachment;
use App\Entity\Attachments\Attachment;
use App\Entity\Attachments\AttachmentContainingDBElement;
use App\Entity\Attachments\AttachmentType;
@ -86,6 +87,7 @@ class AttachmentSubmitHandler
CategoryAttachment::class => 'category',
CurrencyAttachment::class => 'currency',
ProjectAttachment::class => 'project',
AssemblyAttachment::class => 'assembly',
FootprintAttachment::class => 'footprint',
GroupAttachment::class => 'group',
ManufacturerAttachment::class => 'manufacturer',

View file

@ -22,6 +22,8 @@ declare(strict_types=1);
namespace App\Services;
use App\Entity\AssemblySystem\Assembly;
use App\Entity\AssemblySystem\AssemblyBOMEntry;
use App\Entity\Attachments\Attachment;
use App\Entity\Attachments\AttachmentContainingDBElement;
use App\Entity\Base\AbstractDBElement;
@ -189,6 +191,8 @@ final readonly class ElementTypeNameGenerator
$on = $entity->getOrderdetail()->getPart();
} elseif ($entity instanceof ProjectBOMEntry && $entity->getProject() instanceof Project) {
$on = $entity->getProject();
} elseif ($entity instanceof AssemblyBOMEntry && $entity->getAssembly() instanceof Assembly) {
$on = $entity->getAssembly();
}
if (isset($on) && $on instanceof NamedElementInterface) {

View file

@ -23,6 +23,8 @@ declare(strict_types=1);
namespace App\Services;
use App\Entity\AssemblySystem\Assembly;
use App\Entity\AssemblySystem\AssemblyBOMEntry;
use App\Entity\Attachments\Attachment;
use App\Entity\Attachments\AttachmentType;
use App\Entity\InfoProviderSystem\BulkInfoProviderImportJob;
@ -57,6 +59,8 @@ enum ElementTypes: string implements TranslatableInterface
case ATTACHMENT_TYPE = "attachment_type";
case PROJECT = "project";
case PROJECT_BOM_ENTRY = "project_bom_entry";
case ASSEMBLY = "assembly";
case ASSEMBLY_BOM_ENTRY = "assembly_bom_entry";
case FOOTPRINT = "footprint";
case MANUFACTURER = "manufacturer";
case MEASUREMENT_UNIT = "measurement_unit";
@ -83,6 +87,8 @@ enum ElementTypes: string implements TranslatableInterface
AttachmentType::class => self::ATTACHMENT_TYPE,
Project::class => self::PROJECT,
ProjectBOMEntry::class => self::PROJECT_BOM_ENTRY,
Assembly::class => self::ASSEMBLY,
AssemblyBOMEntry::class => self::ASSEMBLY_BOM_ENTRY,
Footprint::class => self::FOOTPRINT,
Manufacturer::class => self::MANUFACTURER,
MeasurementUnit::class => self::MEASUREMENT_UNIT,
@ -114,6 +120,8 @@ enum ElementTypes: string implements TranslatableInterface
self::ATTACHMENT_TYPE => 'attachment_type.label',
self::PROJECT => 'project.label',
self::PROJECT_BOM_ENTRY => 'project_bom_entry.label',
self::ASSEMBLY => 'assembly.label',
self::ASSEMBLY_BOM_ENTRY => 'assembly_bom_entry.label',
self::FOOTPRINT => 'footprint.label',
self::MANUFACTURER => 'manufacturer.label',
self::MEASUREMENT_UNIT => 'measurement_unit.label',
@ -143,6 +151,8 @@ enum ElementTypes: string implements TranslatableInterface
self::ATTACHMENT_TYPE => 'attachment_type.labelp',
self::PROJECT => 'project.labelp',
self::PROJECT_BOM_ENTRY => 'project_bom_entry.labelp',
self::ASSEMBLY => 'assembly.labelp',
self::ASSEMBLY_BOM_ENTRY => 'assembly_bom_entry.labelp',
self::FOOTPRINT => 'footprint.labelp',
self::MANUFACTURER => 'manufacturer.labelp',
self::MEASUREMENT_UNIT => 'measurement_unit.labelp',

View file

@ -22,6 +22,7 @@ declare(strict_types=1);
namespace App\Services;
use App\Entity\AssemblySystem\Assembly;
use App\Entity\Attachments\Attachment;
use App\Entity\Attachments\AttachmentType;
use App\Entity\Attachments\PartAttachment;
@ -99,6 +100,7 @@ class EntityURLGenerator
AttachmentType::class => 'attachment_type_edit',
Category::class => 'category_edit',
Project::class => 'project_edit',
Assembly::class => 'assembly_edit',
Supplier::class => 'supplier_edit',
Manufacturer::class => 'manufacturer_edit',
StorageLocation::class => 'store_location_edit',
@ -206,6 +208,7 @@ class EntityURLGenerator
AttachmentType::class => 'attachment_type_edit',
Category::class => 'category_edit',
Project::class => 'project_info',
Assembly::class => 'assembly_info',
Supplier::class => 'supplier_edit',
Manufacturer::class => 'manufacturer_edit',
StorageLocation::class => 'store_location_edit',
@ -237,6 +240,7 @@ class EntityURLGenerator
AttachmentType::class => 'attachment_type_edit',
Category::class => 'category_edit',
Project::class => 'project_edit',
Assembly::class => 'assembly_edit',
Supplier::class => 'supplier_edit',
Manufacturer::class => 'manufacturer_edit',
StorageLocation::class => 'store_location_edit',
@ -269,6 +273,7 @@ class EntityURLGenerator
AttachmentType::class => 'attachment_type_new',
Category::class => 'category_new',
Project::class => 'project_new',
Assembly::class => 'assembly_new',
Supplier::class => 'supplier_new',
Manufacturer::class => 'manufacturer_new',
StorageLocation::class => 'store_location_new',
@ -301,6 +306,7 @@ class EntityURLGenerator
AttachmentType::class => 'attachment_type_clone',
Category::class => 'category_clone',
Project::class => 'device_clone',
Assembly::class => 'assembly_clone',
Supplier::class => 'supplier_clone',
Manufacturer::class => 'manufacturer_clone',
StorageLocation::class => 'store_location_clone',
@ -329,6 +335,7 @@ class EntityURLGenerator
{
$map = [
Project::class => 'project_info',
Assembly::class => 'assembly_info',
Category::class => 'part_list_category',
Footprint::class => 'part_list_footprint',
@ -347,6 +354,7 @@ class EntityURLGenerator
AttachmentType::class => 'attachment_type_delete',
Category::class => 'category_delete',
Project::class => 'project_delete',
Assembly::class => 'assembly_delete',
Supplier::class => 'supplier_delete',
Manufacturer::class => 'manufacturer_delete',
StorageLocation::class => 'store_location_delete',

View file

@ -22,21 +22,37 @@ declare(strict_types=1);
*/
namespace App\Services\ImportExportSystem;
use App\Entity\AssemblySystem\Assembly;
use App\Entity\AssemblySystem\AssemblyBOMEntry;
use App\Entity\Parts\Category;
use App\Entity\Parts\Manufacturer;
use App\Entity\Parts\Part;
use App\Entity\ProjectSystem\Project;
use App\Entity\ProjectSystem\ProjectBOMEntry;
use App\Repository\DBElementRepository;
use App\Repository\PartRepository;
use App\Repository\Parts\CategoryRepository;
use App\Repository\Parts\ManufacturerRepository;
use Doctrine\ORM\EntityManagerInterface;
use InvalidArgumentException;
use League\Csv\Reader;
use Psr\Log\LoggerInterface;
use Symfony\Component\HttpFoundation\File\File;
use Symfony\Component\HttpFoundation\File\UploadedFile;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Contracts\Translation\TranslatorInterface;
use UnexpectedValueException;
use Symfony\Component\Validator\ConstraintViolation;
/**
* @see \App\Tests\Services\ImportExportSystem\BOMImporterTest
*/
class BOMImporter
{
private const IMPORT_TYPE_JSON = 'json';
private const IMPORT_TYPE_CSV = 'csv';
private const IMPORT_TYPE_KICAD_PCB = 'kicad_pcbnew';
private const IMPORT_TYPE_KICAD_SCHEMATIC = 'kicad_schematic';
private const MAP_KICAD_PCB_FIELDS = [
0 => 'Id',
@ -47,17 +63,35 @@ class BOMImporter
5 => 'Supplier and ref',
];
private readonly PartRepository $partRepository;
private readonly ManufacturerRepository $manufacturerRepository;
private readonly CategoryRepository $categoryRepository;
private readonly DBElementRepository $projectBomEntryRepository;
private readonly DBElementRepository $assemblyBomEntryRepository;
private string $jsonRoot = '';
public function __construct(
private readonly EntityManagerInterface $entityManager,
private readonly LoggerInterface $logger,
private readonly BOMValidationService $validationService
private readonly BOMValidationService $validationService,
private readonly TranslatorInterface $translator
) {
$this->partRepository = $this->entityManager->getRepository(Part::class);
$this->manufacturerRepository = $this->entityManager->getRepository(Manufacturer::class);
$this->categoryRepository = $this->entityManager->getRepository(Category::class);
$this->projectBomEntryRepository = $this->entityManager->getRepository(ProjectBOMEntry::class);
$this->assemblyBomEntryRepository = $this->entityManager->getRepository(AssemblyBOMEntry::class);
}
protected function configureOptions(OptionsResolver $resolver): OptionsResolver
{
$resolver->setRequired('type');
$resolver->setAllowedValues('type', ['kicad_pcbnew', 'kicad_schematic']);
$resolver->setAllowedValues('type', [self::IMPORT_TYPE_KICAD_PCB, self::IMPORT_TYPE_KICAD_SCHEMATIC, self::IMPORT_TYPE_JSON, self::IMPORT_TYPE_CSV]);
// For flexible schematic import with field mapping
$resolver->setDefined(['field_mapping', 'field_priorities', 'delimiter']);
@ -73,27 +107,118 @@ class BOMImporter
/**
* Converts the given file into an array of BOM entries using the given options and save them into the given project.
* The changes are not saved into the database yet.
* @return ProjectBOMEntry[]
*/
public function importFileIntoProject(File $file, Project $project, array $options): array
public function importFileIntoProject(UploadedFile $file, Project $project, array $options): ImporterResult
{
$bom_entries = $this->fileToBOMEntries($file, $options);
$importerResult = $this->fileToImporterResult($project, $file, $options);
//Assign the bom_entries to the project
foreach ($bom_entries as $bom_entry) {
$project->addBomEntry($bom_entry);
if ($importerResult->getViolations()->count() === 0) {
//Assign the bom_entries to the project
foreach ($importerResult->getBomEntries() as $bomEntry) {
$project->addBomEntry($bomEntry);
}
}
return $bom_entries;
return $importerResult;
}
/**
* Converts the given file into an array of BOM entries using the given options.
* @return ProjectBOMEntry[]
* Imports a file into an Assembly object and processes its contents.
*
* This method converts the provided file into an ImporterResult object that contains BOM entries and potential
* validation violations. If no violations are found, the BOM entries extracted from the file are added to the
* provided Assembly object.
*
* @param UploadedFile $file The file to be imported and processed.
* @param Assembly $assembly The target Assembly object to which the BOM entries are added.
* @param array $options Options or configurations related to the import process.
*
* @return ImporterResult An object containing the result of the import process, including BOM entries and any violations.
*/
public function fileToBOMEntries(File $file, array $options): array
public function importFileIntoAssembly(UploadedFile $file, Assembly $assembly, array $options): ImporterResult
{
return $this->stringToBOMEntries($file->getContent(), $options);
$importerResult = $this->fileToImporterResult($assembly, $file, $options);
if ($importerResult->getViolations()->count() === 0) {
//Assign the bom_entries to the assembly
foreach ($importerResult->getBomEntries() as $bomEntry) {
$assembly->addBomEntry($bomEntry);
}
}
return $importerResult;
}
/**
* Converts the content of a file into an array of BOM (Bill of Materials) entries.
*
* This method processes the content of the provided file and delegates the conversion
* to a helper method that generates BOM entries based on the provided import object and options.
*
* @param Project|Assembly $importObject The object determining the context of the BOM entries (either a Project or Assembly).
* @param File $file The file whose content will be converted into BOM entries.
* @param array $options Additional options or configurations to be applied during the conversion process.
*
* @return array An array of BOM entries created from the file content.
*/
public function fileToBOMEntries(Project|Assembly $importObject, File $file, array $options): array
{
return $this->stringToBOMEntries($importObject, $file->getContent(), $options);
}
/**
* Handles the conversion of an uploaded file into an ImporterResult for a given project or assembly.
*
* This method processes the uploaded file by validating its file extension based on the provided import type
* options and then proceeds to convert the file content into an ImporterResult. If the file extension is
* invalid or unsupported, the result will contain a corresponding violation.
*
* @param Project|Assembly $importObject The context of the import operation (either a Project or Assembly).
* @param UploadedFile $file The uploaded file to be processed.
* @param array $options An array of options, expected to include an 'type' key to determine valid file types.
*
* @return ImporterResult An object containing the results of the import process, including any detected violations.
*/
public function fileToImporterResult(Project|Assembly $importObject, UploadedFile $file, array $options): ImporterResult
{
$result = new ImporterResult();
//Available file endings depending on the import type
$validExtensions = match ($options['type']) {
self::IMPORT_TYPE_KICAD_PCB => ['kicad_pcb'],
self::IMPORT_TYPE_JSON => ['json'],
self::IMPORT_TYPE_CSV => ['csv'],
default => [],
};
//Get the file extension of the uploaded file
$fileExtension = pathinfo($file->getClientOriginalName(), PATHINFO_EXTENSION);
//Check whether the file extension is valid
if ($validExtensions === []) {
$result->addViolation($this->buildJsonViolation(
'validator.bom_importer.invalid_import_type',
'import.type'
));
return $result;
} else if (!in_array(strtolower($fileExtension), $validExtensions, true)) {
$result->addViolation($this->buildJsonViolation(
'validator.bom_importer.invalid_file_extension',
'file.extension',
$fileExtension,
[
'%extension%' => $fileExtension,
'%importType%' => $this->translator->trans($importObject instanceof Project ? 'project.bom_import.type.'.$options['type'] : 'assembly.bom_import.type.'.$options['type']),
'%allowedExtensions%' => implode(', ', $validExtensions),
]
));
return $result;
}
return $this->stringToImporterResult($importObject, $file->getContent(), $options);
}
/**
@ -115,31 +240,76 @@ class BOMImporter
/**
* Import string data into an array of BOM entries, which are not yet assigned to a project.
* @param string $data The data to import
* @param array $options An array of options
* @return ProjectBOMEntry[] An array of imported entries
*
* @param Project|Assembly $importObject The object determining the context of the BOM entry (either a Project or Assembly).
* @param string $data The data to import
* @param array $options An array of options
*
* @return ProjectBOMEntry[]|AssemblyBOMEntry[] An array of imported entries
*/
public function stringToBOMEntries(string $data, array $options): array
public function stringToBOMEntries(Project|Assembly $importObject, string $data, array $options): array
{
$resolver = new OptionsResolver();
$resolver = $this->configureOptions($resolver);
$options = $resolver->resolve($options);
return match ($options['type']) {
'kicad_pcbnew' => $this->parseKiCADPCB($data),
'kicad_schematic' => $this->parseKiCADSchematic($data, $options),
default => throw new InvalidArgumentException('Invalid import type!'),
self::IMPORT_TYPE_KICAD_PCB => $this->parseKiCADPCB($data, $importObject)->getBomEntries(),
self::IMPORT_TYPE_KICAD_SCHEMATIC => $this->parseKiCADSchematic($data, $options),
default => throw new InvalidArgumentException($this->translator->trans('validator.bom_importer.invalid_import_type', [], 'validators')),
};
}
private function parseKiCADPCB(string $data): array
/**
* Import string data into an array of BOM entries, which are not yet assigned to a project.
*
* @param Project|Assembly $importObject The object determining the context of the BOM entry (either a Project or Assembly).
* @param string $data The data to import
* @param array $options An array of options
*
* @return ImporterResult An result of imported entries or a violation list
*/
public function stringToImporterResult(Project|Assembly $importObject, string $data, array $options): ImporterResult
{
$resolver = new OptionsResolver();
$resolver = $this->configureOptions($resolver);
$options = $resolver->resolve($options);
$defaultImporterResult = new ImporterResult();
$defaultImporterResult->addViolation($this->buildJsonViolation(
'validator.bom_importer.invalid_import_type',
'import.type'
));
return match ($options['type']) {
self::IMPORT_TYPE_KICAD_PCB => $this->parseKiCADPCB($data, $importObject),
self::IMPORT_TYPE_JSON => $this->parseJson($importObject, $data),
self::IMPORT_TYPE_CSV => $this->parseCsv($importObject, $data),
default => $defaultImporterResult,
};
}
/**
* Parses a KiCAD PCB file and imports its BOM (Bill of Materials) entries into the given Project or Assembly context.
*
* This method processes a semicolon-delimited CSV data string, normalizes column names,
* validates the required fields, and creates BOM entries for each record in the data.
* The BOM entries are added to the provided Project or Assembly, depending on the context.
*
* @param string $data The semicolon- or comma-delimited CSV data to be parsed.
* @param Project|Assembly $importObject The object determining the context of the BOM entry (either a Project or Assembly).
* @return ImporterResult The result of the import process, containing the created BOM entries.
*
* @throws UnexpectedValueException If required fields are missing in the provided data.
*/
private function parseKiCADPCB(string $data, Project|Assembly $importObject): ImporterResult
{
$result = new ImporterResult();
$csv = Reader::fromString($data);
$csv->setDelimiter(';');
$csv->setHeaderOffset(0);
$bom_entries = [];
foreach ($csv->getRecords() as $offset => $entry) {
//Translate the german field names to english
$entry = $this->normalizeColumnNames($entry);
@ -158,16 +328,21 @@ class BOMImporter
throw new \UnexpectedValueException('Quantity missing at line ' . ($offset + 1) . '!');
}
$bom_entry = new ProjectBOMEntry();
$bom_entry->setName($entry['Designation'] . ' (' . $entry['Package'] . ')');
$bom_entry->setMountnames($entry['Designator'] ?? '');
$bom_entry = $importObject instanceof Project ? new ProjectBOMEntry() : new AssemblyBOMEntry();
if ($bom_entry instanceof ProjectBOMEntry) {
$bom_entry->setName($entry['Designation'] . ' (' . $entry['Package'] . ')');
} else {
$bom_entry->setName($entry['Designation']);
}
$bom_entry->setMountnames($entry['Designator']);
$bom_entry->setComment($entry['Supplier and ref'] ?? '');
$bom_entry->setQuantity((float) ($entry['Quantity'] ?? 1));
$bom_entries[] = $bom_entry;
$result->addBomEntry($bom_entry);
}
return $bom_entries;
return $result;
}
/**
@ -227,6 +402,549 @@ class BOMImporter
return $this->validationService->validateBOMEntries($mapped_entries, $options);
}
/**
* Parses the given JSON data into an ImporterResult while validating and transforming entries according to the
* specified options and object type. If violations are encountered during parsing, they are added to the result.
*
* The structure of each entry in the JSON data is validated to ensure that required fields (e.g., quantity, and name)
* are present, and optional composite fields, like `part` and its sub-properties, meet specific criteria. Various
* conditions are checked, including whether the provided values are the correct types, and if relationships (like
* matching parts or manufacturers) are resolved successfully.
*
* Violations are added for:
* - Missing or invalid `quantity` values.
* - Non-string `name` values.
* - Invalid structure or missing sub-properties in `part`.
* - Incorrect or unresolved references to parts and their information, such as `id`, `name`, `manufacturer_product_number`
* (mpnr), `internal_part_number` (ipn), or `description`.
* - Inconsistent or absent manufacturer information.
*
* If a match for a part or manufacturer cannot be resolved, a violation is added alongside an indication of the
* imported value and any partially matched information. Warnings for no exact matches are also added for parts
* using specific identifying properties like name, manufacturer product number, or internal part numbers.
*
* Additional validations include:
* - Checking for empty or invalid descriptions.
* - Ensuring manufacturers, if specified, have valid `name` or `id` values.
*
* @param Project|Assembly $importObject The object determining the context of the BOM entry (either a Project or Assembly).
* @param string $data JSON encoded string containing BOM entries data.
*
* @return ImporterResult The result containing parsed data and any violations encountered during the parsing process.
*/
private function parseJson(Project|Assembly $importObject, string $data): ImporterResult
{
$result = new ImporterResult();
$this->jsonRoot = 'JSON Import for '.($importObject instanceof Project ? 'Project' : 'Assembly');
$data = json_decode($data, true);
foreach ($data as $key => $entry) {
if (!isset($entry['quantity'])) {
$result->addViolation($this->buildJsonViolation(
'validator.bom_importer.json_csv.quantity.required',
"entry[$key].quantity"
));
}
if (isset($entry['quantity']) && (!is_float($entry['quantity']) || $entry['quantity'] <= 0)) {
$result->addViolation($this->buildJsonViolation(
'validator.bom_importer.json_csv.quantity.float',
"entry[$key].quantity",
$entry['quantity']
));
}
if (isset($entry['name']) && !is_string($entry['name'])) {
$result->addViolation($this->buildJsonViolation(
'validator.bom_importer.json_csv.parameter.string.notEmpty',
"entry[$key].name",
$entry['name']
));
}
if (isset($entry['part'])) {
$this->processPart($importObject, $entry, $result, $key, self::IMPORT_TYPE_JSON);
} else {
$bomEntry = $this->getOrCreateBomEntry($importObject, $entry['name'] ?? null);
$bomEntry->setQuantity((float) $entry['quantity']);
$result->addBomEntry($bomEntry);
}
}
return $result;
}
/**
* Parses a CSV string and processes its rows into hierarchical data structures,
* performing validations and converting data based on the provided headers.
* Handles potential violations and manages the creation of BOM entries based on the given type.
*
* @param Project|Assembly $importObject The object determining the context of the BOM entry (either a Project or Assembly).
* @param string $csvData The raw CSV data to parse, with rows separated by newlines.
*
* @return ImporterResult Returns an ImporterResult instance containing BOM entries and any validation violations encountered.
*/
function parseCsv(Project|Assembly $importObject, string $csvData): ImporterResult
{
$result = new ImporterResult();
$rows = explode("\r\n", trim($csvData));
$headers = str_getcsv(array_shift($rows));
if (count($headers) === 1 && isset($headers[0])) {
//If only one column was recognized, try fallback with semicolon as a separator
$headers = str_getcsv($headers[0], ';');
}
foreach ($rows as $key => $row) {
$entry = [];
$values = str_getcsv($row);
if (count($values) === 1 || count($values) !== count($headers)) {
//If only one column was recognized, try fallback with semicolon as a separator
$values = str_getcsv($row, ';');
}
if (trim($row) === '' || count($values) === 1) {
continue;
}
foreach ($headers as $index => $column) {
//Change the column names in small letters
$column = strtolower($column);
//Convert column name into hierarchy
$path = explode('_', $column);
/** @var array<string, mixed> $temp */
$temp = &$entry;
/** @var lowercase-string $step */
foreach ($path as $step) {
if (!isset($temp[$step])) {
$temp[$step] = [];
}
$temp = &$temp[$step];
}
//If there is no value, skip
if (isset($values[$index]) && $values[$index] !== '') {
//Check whether the value is numerical
if (is_numeric($values[$index]) && !in_array($column, ['name','description','manufacturer','designator'], true)) {
//Convert to integer or float
$temp = (str_contains($values[$index], '.'))
? floatval($values[$index])
: intval($values[$index]);
} else {
//Leave other data types untouched
$temp = $values[$index];
}
}
}
$entry = $this->removeEmptyProperties($entry);
if (!isset($entry['quantity'])) {
$result->addViolation($this->buildJsonViolation(
'validator.bom_importer.csv.quantity.required',
"row[$key].quantity"
));
}
if (isset($entry['quantity']) && (!is_numeric($entry['quantity']) || $entry['quantity'] <= 0)) {
$result->addViolation($this->buildJsonViolation(
'validator.bom_importer.csv.quantity.float',
"row[$key].quantity",
$entry['quantity']
));
}
if (isset($entry['name']) && !is_string($entry['name'])) {
$result->addViolation($this->buildJsonViolation(
'validator.bom_importer.json_csv.parameter.string.notEmpty',
"row[$key].name",
$entry['name']
));
}
if (isset($entry['id']) && is_numeric($entry['id'])) {
//Use id column as a fallback for the expected part_id column
$entry['part']['id'] = (int) $entry['id'];
}
if (isset($entry['part'])) {
$this->processPart($importObject, $entry, $result, $key, self::IMPORT_TYPE_CSV);
} else {
$bomEntry = $this->getOrCreateBomEntry($importObject, $entry['name'] ?? null);
if (isset($entry['designator'])) {
$bomEntry->setMountnames(trim($entry['designator']) === '' ? '' : trim($entry['designator']));
}
$bomEntry->setQuantity((float) $entry['quantity']);
$result->addBomEntry($bomEntry);
}
}
return $result;
}
/**
* Processes an individual part entry in the import data.
*
* This method validates the structure and content of the provided part entry and uses the findings
* to identify corresponding objects in the database. The result is recorded, and violations are
* logged if issues or discrepancies exist in the validation or database matching process.
*
* @param Project|Assembly $importObject The object determining the context of the BOM entry (either a Project or Assembly).
* @param array $entry The array representation of the part entry.
* @param ImporterResult $result The result object used for recording validation violations.
* @param int $key The index of the entry in the data array.
* @param string $importType The type of import being performed.
*
* @return void
*/
private function processPart(Project|Assembly $importObject, array $entry, ImporterResult $result, int $key, string $importType): void
{
$prefix = $importType === self::IMPORT_TYPE_JSON ? 'entry' : 'row';
if (!is_array($entry['part'])) {
$result->addViolation($this->buildJsonViolation(
'validator.bom_importer.json_csv.parameter.array',
$prefix."[$key].part",
$entry['part']
));
}
$partIdValid = isset($entry['part']['id']) && is_int($entry['part']['id']) && $entry['part']['id'] > 0;
$partMpnrValid = isset($entry['part']['mpnr']) && is_string($entry['part']['mpnr']) && trim($entry['part']['mpnr']) !== '';
$partIpnValid = isset($entry['part']['ipn']) && is_string($entry['part']['ipn']) && trim($entry['part']['ipn']) !== '';
$partNameValid = isset($entry['part']['name']) && is_string($entry['part']['name']) && trim($entry['part']['name']) !== '';
if (!$partIdValid && !$partNameValid && !$partMpnrValid && !$partIpnValid) {
$result->addViolation($this->buildJsonViolation(
'validator.bom_importer.json_csv.parameter.subproperties',
$prefix."[$key].part",
$entry['part'],
['%propertyString%' => '"id", "name", "mpnr", or "ipn"']
));
}
$part = $partIdValid ? $this->partRepository->findOneBy(['id' => $entry['part']['id']]) : null;
$part = $part ?? ($partMpnrValid ? $this->partRepository->findOneBy(['manufacturer_product_number' => trim($entry['part']['mpnr'])]) : null);
$part = $part ?? ($partIpnValid ? $this->partRepository->findOneBy(['ipn' => trim($entry['part']['ipn'])]) : null);
$part = $part ?? ($partNameValid ? $this->partRepository->findOneBy(['name' => trim($entry['part']['name'])]) : null);
if ($part === null) {
$value = sprintf('part.id: %s, part.mpnr: %s, part.ipn: %s, part.name: %s',
isset($entry['part']['id']) ? '<strong>' . $entry['part']['id'] . '</strong>' : '-',
isset($entry['part']['mpnr']) ? '<strong>' . $entry['part']['mpnr'] . '</strong>' : '-',
isset($entry['part']['ipn']) ? '<strong>' . $entry['part']['ipn'] . '</strong>' : '-',
isset($entry['part']['name']) ? '<strong>' . $entry['part']['name'] . '</strong>' : '-',
);
$result->addViolation($this->buildJsonViolation(
'validator.bom_importer.json_csv.parameter.notFoundFor',
$prefix."[$key].part",
$entry['part'],
['%value%' => $value]
));
}
if ($partNameValid && $part !== null && isset($entry['part']['name']) && $part->getName() !== trim($entry['part']['name'])) {
$result->addViolation($this->buildJsonViolation(
'validator.bom_importer.json_csv.parameter.noExactMatch',
$prefix."[$key].part.name",
$entry['part']['name'],
[
'%importValue%' => '<strong>' . $entry['part']['name'] . '</strong>',
'%foundId%' => $part->getID(),
'%foundValue%' => '<strong>' . $part->getName() . '</strong>'
]
));
}
if ($partMpnrValid && $part !== null && isset($entry['part']['mpnr']) && $part->getManufacturerProductNumber() !== trim($entry['part']['mpnr'])) {
$result->addViolation($this->buildJsonViolation(
'validator.bom_importer.json_csv.parameter.noExactMatch',
$prefix."[$key].part.mpnr",
$entry['part']['mpnr'],
[
'%importValue%' => '<strong>' . $entry['part']['mpnr'] . '</strong>',
'%foundId%' => $part->getID(),
'%foundValue%' => '<strong>' . $part->getManufacturerProductNumber() . '</strong>'
]
));
}
if ($partIpnValid && $part !== null && isset($entry['part']['ipn']) && $part->getIpn() !== trim($entry['part']['ipn'])) {
$result->addViolation($this->buildJsonViolation(
'validator.bom_importer.json_csv.parameter.noExactMatch',
$prefix."[$key].part.ipn",
$entry['part']['ipn'],
[
'%importValue%' => '<strong>' . $entry['part']['ipn'] . '</strong>',
'%foundId%' => $part->getID(),
'%foundValue%' => '<strong>' . $part->getIpn() . '</strong>'
]
));
}
if (isset($entry['part']['description'])) {
if (!is_string($entry['part']['description']) || trim($entry['part']['description']) === '') {
$result->addViolation($this->buildJsonViolation(
'validator.bom_importer.json_csv.parameter.string.notEmpty',
'entry[$key].part.description',
$entry['part']['description']
));
}
}
$partDescription = $entry['part']['description'] ?? '';
$manufacturerIdValid = false;
$manufacturerNameValid = false;
if (array_key_exists('manufacturer', $entry['part'])) {
if (!is_array($entry['part']['manufacturer'])) {
$result->addViolation($this->buildJsonViolation(
'validator.bom_importer.json_csv.parameter.array',
'entry[$key].part.manufacturer',
$entry['part']['manufacturer'])
);
}
$manufacturerIdValid = isset($entry['part']['manufacturer']['id']) && is_int($entry['part']['manufacturer']['id']) && $entry['part']['manufacturer']['id'] > 0;
$manufacturerNameValid = isset($entry['part']['manufacturer']['name']) && is_string($entry['part']['manufacturer']['name']) && trim($entry['part']['manufacturer']['name']) !== '';
if (!$manufacturerIdValid && !$manufacturerNameValid) {
$result->addViolation($this->buildJsonViolation(
'validator.bom_importer.json_csv.parameter.manufacturerOrCategoryWithSubProperties',
$prefix."[$key].part.manufacturer",
$entry['part']['manufacturer'],
));
}
}
$manufacturer = $manufacturerIdValid ? $this->manufacturerRepository->findOneBy(['id' => $entry['part']['manufacturer']['id']]) : null;
$manufacturer = $manufacturer ?? ($manufacturerNameValid ? $this->manufacturerRepository->findOneBy(['name' => trim($entry['part']['manufacturer']['name'])]) : null);
if (($manufacturerIdValid || $manufacturerNameValid) && $manufacturer === null) {
$value = sprintf(
'manufacturer.id: %s, manufacturer.name: %s',
isset($entry['part']['manufacturer']['id']) && $entry['part']['manufacturer']['id'] != null ? '<strong>' . $entry['part']['manufacturer']['id'] . '</strong>' : '-',
isset($entry['part']['manufacturer']['name']) && $entry['part']['manufacturer']['name'] != null ? '<strong>' . $entry['part']['manufacturer']['name'] . '</strong>' : '-'
);
$result->addViolation($this->buildJsonViolation(
'validator.bom_importer.json_csv.parameter.notFoundFor',
$prefix."[$key].part.manufacturer",
$entry['part']['manufacturer'],
['%value%' => $value]
));
}
if ($manufacturerNameValid && $manufacturer !== null && isset($entry['part']['manufacturer']['name']) && $manufacturer->getName() !== trim($entry['part']['manufacturer']['name'])) {
$result->addViolation($this->buildJsonViolation(
'validator.bom_importer.json_csv.parameter.noExactMatch',
$prefix."[$key].part.manufacturer.name",
$entry['part']['manufacturer']['name'],
[
'%importValue%' => '<strong>' . $entry['part']['manufacturer']['name'] . '</strong>',
'%foundId%' => $manufacturer->getID(),
'%foundValue%' => '<strong>' . $manufacturer->getName() . '</strong>'
]
));
}
$categoryIdValid = false;
$categoryNameValid = false;
if (array_key_exists('category', $entry['part'])) {
if (!is_array($entry['part']['category'])) {
$result->addViolation($this->buildJsonViolation(
'validator.bom_importer.json_csv.parameter.array',
'entry[$key].part.category',
$entry['part']['category'])
);
}
$categoryIdValid = isset($entry['part']['category']['id']) && is_int($entry['part']['category']['id']) && $entry['part']['category']['id'] > 0;
$categoryNameValid = isset($entry['part']['category']['name']) && is_string($entry['part']['category']['name']) && trim($entry['part']['category']['name']) !== '';
if (!$categoryIdValid && !$categoryNameValid) {
$result->addViolation($this->buildJsonViolation(
'validator.bom_importer.json_csv.parameter.manufacturerOrCategoryWithSubProperties',
$prefix."[$key].part.category",
$entry['part']['category']
));
}
}
$category = $categoryIdValid ? $this->categoryRepository->findOneBy(['id' => $entry['part']['category']['id']]) : null;
$category = $category ?? ($categoryNameValid ? $this->categoryRepository->findOneBy(['name' => trim($entry['part']['category']['name'])]) : null);
if (($categoryIdValid || $categoryNameValid)) {
$value = sprintf(
'category.id: %s, category.name: %s',
isset($entry['part']['category']['id']) && $entry['part']['category']['id'] != null ? '<strong>' . $entry['part']['category']['id'] . '</strong>' : '-',
isset($entry['part']['category']['name']) && $entry['part']['category']['name'] != null ? '<strong>' . $entry['part']['category']['name'] . '</strong>' : '-'
);
$result->addViolation($this->buildJsonViolation(
'validator.bom_importer.json_csv.parameter.notFoundFor',
$prefix."[$key].part.category",
$entry['part']['category'],
['%value%' => $value]
));
}
if ($categoryNameValid && $category !== null && isset($entry['part']['category']['name']) && $category->getName() !== trim($entry['part']['category']['name'])) {
$result->addViolation($this->buildJsonViolation(
'validator.bom_importer.json_csv.parameter.noExactMatch',
$prefix."[$key].part.category.name",
$entry['part']['category']['name'],
[
'%importValue%' => '<strong>' . $entry['part']['category']['name'] . '</strong>',
'%foundId%' => $category->getID(),
'%foundValue%' => '<strong>' . $category->getName() . '</strong>'
]
));
}
if ($result->getViolations()->count() > 0) {
return;
}
if ($partDescription !== '') {
//When updating the associated parts to a assembly, take over the description of the part.
$part->setDescription($partDescription);
}
/** @var Manufacturer|null $manufacturer */
if ($manufacturer !== null && $manufacturer->getID() !== $part->getManufacturer()->getID()) {
//When updating the associated parts, take over to a assembly of the manufacturer of the part.
$part->setManufacturer($manufacturer);
}
/** @var Category|null $category */
if ($category !== null && $category->getID() !== $part->getCategory()->getID()) {
//When updating the associated parts to a assembly, take over the category of the part.
$part->setCategory($category);
}
if ($importObject instanceof Assembly) {
$bomEntry = $this->assemblyBomEntryRepository->findOneBy(['assembly' => $importObject, 'part' => $part]);
if ($bomEntry === null) {
if (isset($entry['name']) && $entry['name'] !== '') {
$bomEntry = $this->assemblyBomEntryRepository->findOneBy(['assembly' => $importObject, 'name' => $entry['name']]);
}
if ($bomEntry === null) {
$bomEntry = new AssemblyBOMEntry();
}
}
} else {
$bomEntry = $this->projectBomEntryRepository->findOneBy(['project' => $importObject, 'part' => $part]);
if ($bomEntry === null) {
if (isset($entry['name']) && $entry['name'] !== '') {
$bomEntry = $this->projectBomEntryRepository->findOneBy(['project' => $importObject, 'name' => $entry['name']]);
}
if ($bomEntry === null) {
$bomEntry = new ProjectBOMEntry();
}
}
}
$bomEntry->setQuantity((float) $entry['quantity']);
if (isset($entry['name'])) {
$givenName = trim($entry['name']) === '' ? null : trim ($entry['name']);
if ($givenName !== null && $part !== null && $part->getName() !== $givenName) {
//Apply different names for parts list entry
$bomEntry->setName(trim($entry['name']) === '' ? null : trim ($entry['name']));
}
} else {
$bomEntry->setName(null);
}
if (isset($entry['designator'])) {
if ($bomEntry instanceof ProjectBOMEntry) {
$bomEntry->setMountnames(trim($entry['designator']) === '' ? '' : trim($entry['designator']));
} elseif ($bomEntry instanceof AssemblyBOMEntry) {
$bomEntry->setDesignator(trim($entry['designator']) === '' ? '' : trim($entry['designator']));
}
}
$bomEntry->setPart($part);
$result->addBomEntry($bomEntry);
}
private function removeEmptyProperties(array $data): array
{
foreach ($data as $key => &$value) {
//Recursive check when the value is an array
if (is_array($value)) {
$value = $this->removeEmptyProperties($value);
//Remove the array when it is empty after cleaning
if (empty($value)) {
unset($data[$key]);
}
} elseif ($value === null || $value === '') {
//Remove values that are explicitly zero or empty
unset($data[$key]);
}
}
return $data;
}
/**
* Retrieves an existing BOM (Bill of Materials) entry by name or creates a new one if not found.
*
* Depending on whether the provided import object is a Project or Assembly, this method attempts to locate
* a corresponding BOM entry in the appropriate repository. If no entry is located, a new BOM entry object
* is instantiated according to the type of the import object.
*
* @param Project|Assembly $importObject The object determining the context of the BOM entry (either a Project or Assembly).
* @param string|null $name The name of the BOM entry to search for or assign to a new entry.
*
* @return ProjectBOMEntry|AssemblyBOMEntry An existing or newly created BOM entry.
*/
private function getOrCreateBomEntry(Project|Assembly $importObject, ?string $name): ProjectBOMEntry|AssemblyBOMEntry
{
$bomEntry = null;
//Check whether there is a name
if (!empty($name)) {
if ($importObject instanceof Project) {
$bomEntry = $this->projectBomEntryRepository->findOneBy(['name' => $name]);
} else {
$bomEntry = $this->assemblyBomEntryRepository->findOneBy(['name' => $name]);
}
}
//If no bom entry was found, a new object create
if ($bomEntry === null) {
if ($importObject instanceof Project) {
$bomEntry = new ProjectBOMEntry();
} else {
$bomEntry = new AssemblyBOMEntry();
}
}
$bomEntry->setName($name);
return $bomEntry;
}
/**
* This function uses the order of the fields in the CSV files to make them locale independent.
* @param array $entry
@ -243,13 +961,28 @@ class BOMImporter
}
//@phpstan-ignore-next-line We want to keep this check just to be safe when something changes
$new_index = self::MAP_KICAD_PCB_FIELDS[$index] ?? throw new \UnexpectedValueException('Invalid field index!');
$new_index = self::MAP_KICAD_PCB_FIELDS[$index] ?? throw new UnexpectedValueException('Invalid field index!');
$out[$new_index] = $field;
}
return $out;
}
/**
* Builds a JSON-based constraint violation.
*
* This method creates a `ConstraintViolation` object that represents a validation error.
* The violation includes a message, property path, invalid value, and other contextual information.
* Translations for the violation message can be applied through the translator service.
*
* @param string $message The translation key for the validation message.
* @param string $propertyPath The property path where the violation occurred.
* @param mixed|null $invalidValue The value that caused the violation (optional).
* @param array $parameters Additional parameters for message placeholders (default is an empty array).
*
* @return ConstraintViolation The created constraint violation object.
*/
/**
* Parse KiCad schematic BOM with flexible field mapping
*/
@ -727,4 +1460,30 @@ class BOMImporter
return array_values($headers);
}
/**
* Builds a JSON-based constraint violation.
*
* This method creates a `ConstraintViolation` object that represents a validation error.
* The violation includes a message, property path, invalid value, and other contextual information.
* Translations for the violation message can be applied through the translator service.
*
* @param string $message The translation key for the validation message.
* @param string $propertyPath The property path where the violation occurred.
* @param mixed|null $invalidValue The value that caused the violation (optional).
* @param array $parameters Additional parameters for message placeholders (default is an empty array).
*
* @return ConstraintViolation The created constraint violation object.
*/
private function buildJsonViolation(string $message, string $propertyPath, mixed $invalidValue = null, array $parameters = []): ConstraintViolation
{
return new ConstraintViolation(
message: $this->translator->trans($message, $parameters, 'validators'),
messageTemplate: $message,
parameters: $parameters,
root: $this->jsonRoot,
propertyPath: $propertyPath,
invalidValue: $invalidValue
);
}
}

View file

@ -22,8 +22,22 @@ declare(strict_types=1);
namespace App\Services\ImportExportSystem;
use App\Entity\AssemblySystem\Assembly;
use App\Entity\AssemblySystem\AssemblyBOMEntry;
use App\Entity\Attachments\AttachmentType;
use App\Entity\Base\AbstractNamedDBElement;
use App\Entity\Base\AbstractStructuralDBElement;
use App\Entity\LabelSystem\LabelProfile;
use App\Entity\Parts\Category;
use App\Entity\Parts\Footprint;
use App\Entity\Parts\Manufacturer;
use App\Entity\Parts\MeasurementUnit;
use App\Entity\Parts\StorageLocation;
use App\Entity\Parts\Supplier;
use App\Entity\PriceInformations\Currency;
use App\Entity\ProjectSystem\Project;
use App\Entity\ProjectSystem\ProjectBOMEntry;
use App\Helpers\Assemblies\AssemblyPartAggregator;
use App\Helpers\FilenameSanatizer;
use App\Serializer\APIPlatform\SkippableItemNormalizer;
use Symfony\Component\OptionsResolver\OptionsResolver;
@ -48,8 +62,10 @@ use PhpOffice\PhpSpreadsheet\Writer\Xls;
*/
class EntityExporter
{
public function __construct(protected SerializerInterface $serializer)
{
public function __construct(
protected SerializerInterface $serializer,
protected AssemblyPartAggregator $assemblyPartAggregator,
) {
}
protected function configureOptions(OptionsResolver $resolver): void
@ -65,6 +81,10 @@ class EntityExporter
$resolver->setDefault('include_children', false);
$resolver->setAllowedTypes('include_children', 'bool');
$resolver->setDefault('readableSelect', null);
$resolver->setAllowedValues('readableSelect', [null, 'readable', 'readable_bom']);
}
/**
@ -222,15 +242,67 @@ class EntityExporter
$entities = [$entities];
}
//Do the serialization with the given options
$serialized_data = $this->exportEntities($entities, $options);
if ($request->get('readableSelect', false) === 'readable') {
// Map entity classes to export functions
$entityExportMap = [
AttachmentType::class => fn($entities) => $this->exportReadable($entities, AttachmentType::class),
Category::class => fn($entities) => $this->exportReadable($entities, Category::class),
Project::class => fn($entities) => $this->exportReadable($entities, Project::class),
Assembly::class => fn($entities) => $this->exportReadable($entities, Assembly::class),
Supplier::class => fn($entities) => $this->exportReadable($entities, Supplier::class),
Manufacturer::class => fn($entities) => $this->exportReadable($entities, Manufacturer::class),
StorageLocation::class => fn($entities) => $this->exportReadable($entities, StorageLocation::class),
Footprint::class => fn($entities) => $this->exportReadable($entities, Footprint::class),
Currency::class => fn($entities) => $this->exportReadable($entities, Currency::class),
MeasurementUnit::class => fn($entities) => $this->exportReadable($entities, MeasurementUnit::class),
LabelProfile::class => fn($entities) => $this->exportReadable($entities, LabelProfile::class, false),
];
$response = new Response($serialized_data);
// Determine the type of the entity
$type = null;
foreach ($entities as $entity) {
$entityClass = get_class($entity);
if (isset($entityExportMap[$entityClass])) {
$type = $entityClass;
break;
}
}
//Resolve the format
$optionsResolver = new OptionsResolver();
$this->configureOptions($optionsResolver);
$options = $optionsResolver->resolve($options);
// Generate the response
$response = isset($entityExportMap[$type])
? new Response($entityExportMap[$type]($entities))
: new Response('');
$options['format'] = 'csv';
$options['level'] = 'readable';
} elseif ($request->get('readableSelect', false) === 'readable_bom') {
$hierarchies = [];
foreach ($entities as $entity) {
if (!$entity instanceof Assembly) {
throw new InvalidArgumentException('Only assemblies can be exported in readable BOM format');
}
$hierarchies[] = $this->assemblyPartAggregator->processAssemblyHierarchyForPdf($entity, 0, 1, 1);
}
$pdfContent = $this->assemblyPartAggregator->exportReadableHierarchyForPdf($hierarchies);
$response = new Response($pdfContent);
$options['format'] = 'pdf';
$options['level'] = 'readable_bom';
} else {
//Do the serialization with the given options
$serialized_data = $this->exportEntities($entities, $options);
$response = new Response($serialized_data);
//Resolve the format
$optionsResolver = new OptionsResolver();
$this->configureOptions($optionsResolver);
$options = $optionsResolver->resolve($options);
}
//Determine the content type for the response
@ -241,6 +313,7 @@ class EntityExporter
'json' => 'application/json',
'xlsx' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'xls' => 'application/vnd.ms-excel',
'pdf' => 'application/pdf',
default => 'text/plain',
};
$response->headers->set('Content-Type', $content_type);
@ -277,4 +350,311 @@ class EntityExporter
return $response;
}
/**
* Exports data for multiple entity types in a readable CSV format.
*
* @param array $entities The entities to export.
* @param string $type The type of entities ('category', 'project', 'assembly', 'attachmentType', 'supplier').
* @return string The generated CSV content as a string.
*/
public function exportReadable(array $entities, string $type, bool $isHierarchical = true): string
{
//Define headers and entity-specific processing logic
$defaultProcessEntity = fn($entity, $depth) => [
'Id' => $entity->getId(),
'ParentId' => $entity->getParent()?->getId() ?? '',
'NameHierarchical' => str_repeat('--', $depth) . ' ' . $entity->getName(),
'Name' => $entity->getName(),
'FullName' => $this->getFullName($entity),
];
$config = [
AttachmentType::class => [
'header' => ['Id', 'ParentId', 'NameHierarchical', 'Name', 'FullName'],
'processEntity' => $defaultProcessEntity,
],
Category::class => [
'header' => ['Id', 'ParentId', 'NameHierarchical', 'Name', 'FullName'],
'processEntity' => $defaultProcessEntity,
],
Project::class => [
'header' => [
'Id', 'ParentId', 'Type', 'ProjectNameHierarchical', 'ProjectName', 'ProjectFullName',
//BOM relevant attributes
'Quantity', 'PartId', 'PartName', 'Ipn', 'Manufacturer', 'Mpn', 'Name', 'Mountnames',
'Description',
],
'processEntity' => fn($entity, $depth) => [
'Id' => $entity->getId(),
'ParentId' => $entity->getParent()?->getId() ?? '',
'Type' => 'project',
'ProjectNameHierarchical' => str_repeat('--', $depth) . ' ' . $entity->getName(),
'ProjectName' => $entity->getName(),
'ProjectFullName' => $this->getFullName($entity),
//BOM relevant attributes
'Quantity' => '-',
'PartId' => '-',
'PartName' => '-',
'Ipn' => '-',
'Manufacturer' => '-',
'Mpn' => '-',
'Name' => '-',
'Mountnames' => '-',
'Description' => '-',
],
'processBomEntries' => fn($entity, $depth) => array_map(fn(ProjectBOMEntry $bomEntry) => [
'Id' => $entity->getId(),
'ParentId' => '',
'Type' => 'project_bom_entry',
'ProjectNameHierarchical' => str_repeat('--', $depth) . '> ' . $entity->getName(),
'ProjectName' => $entity->getName(),
'ProjectFullName' => $this->getFullName($entity),
//BOM relevant attributes
'Quantity' => $bomEntry->getQuantity(),
'PartId' => $bomEntry->getPart()?->getId() ?? '',
'PartName' => $bomEntry->getPart()?->getName() ?? '',
'Ipn' => $bomEntry->getPart()?->getIpn() ?? '',
'Manufacturer' => $bomEntry->getPart()?->getManufacturer()?->getName() ?? '',
'Mpn' => $bomEntry->getPart()?->getManufacturerProductNumber() ?? '',
'Name' => $bomEntry->getPart()?->getName() ?? '',
'Mountnames' => $bomEntry->getMountnames(),
'Description' => $bomEntry->getPart()?->getDescription() ?? '',
], $entity->getBomEntries()->toArray()),
],
Assembly::class => [
'header' => [
'Id', 'ParentId', 'Type', 'AssemblyIpn', 'AssemblyNameHierarchical', 'AssemblyName',
'AssemblyFullName',
//BOM relevant attributes
'Quantity', 'PartId', 'PartName', 'Ipn', 'Manufacturer', 'Mpn', 'Name', 'Designator',
'Description', 'ReferencedAssemblyId', 'ReferencedAssemblyIpn',
'ReferencedAssemblyFullName',
],
'processEntity' => fn($entity, $depth) => [
'Id' => $entity->getId(),
'ParentId' => $entity->getParent()?->getId() ?? '',
'Type' => 'assembly',
'AssemblyIpn' => $entity->getIpn(),
'AssemblyNameHierarchical' => str_repeat('--', $depth) . ' ' . $entity->getName(),
'AssemblyName' => $entity->getName(),
'AssemblyFullName' => $this->getFullName($entity),
//BOM relevant attributes
'Quantity' => '-',
'PartId' => '-',
'PartName' => '-',
'Ipn' => '-',
'Manufacturer' => '-',
'Mpn' => '-',
'Name' => '-',
'Designator' => '-',
'Description' => '-',
'ReferencedAssemblyId' => '-',
'ReferencedAssemblyIpn' => '-',
'ReferencedAssemblyFullName' => '-',
],
'processBomEntries' => fn($entity, $depth) => $this->processBomEntriesWithAggregatedParts($entity, $depth),
],
Supplier::class => [
'header' => ['Id', 'ParentId', 'NameHierarchical', 'Name', 'FullName'],
'processEntity' => $defaultProcessEntity,
],
Manufacturer::class => [
'header' => ['Id', 'ParentId', 'NameHierarchical', 'Name', 'FullName'],
'processEntity' => $defaultProcessEntity,
],
StorageLocation::class => [
'header' => ['Id', 'ParentId', 'NameHierarchical', 'Name', 'FullName'],
'processEntity' => $defaultProcessEntity,
],
Footprint::class => [
'header' => ['Id', 'ParentId', 'NameHierarchical', 'Name', 'FullName'],
'processEntity' => $defaultProcessEntity,
],
Currency::class => [
'header' => ['Id', 'ParentId', 'NameHierarchical', 'Name', 'FullName'],
'processEntity' => $defaultProcessEntity,
],
MeasurementUnit::class => [
'header' => ['Id', 'ParentId', 'NameHierarchical', 'Name', 'FullName'],
'processEntity' => $defaultProcessEntity,
],
LabelProfile::class => [
'header' => ['Id', 'SupportedElement', 'Name'],
'processEntity' => fn(LabelProfile $entity, $depth) => [
'Id' => $entity->getId(),
'SupportedElement' => $entity->getOptions()->getSupportedElement()->name,
'Name' => $entity->getName(),
],
],
];
//Get configuration for the entity type
$entityConfig = $config[$type] ?? null;
if (!$entityConfig) {
return '';
}
//Initialize CSV data with the header
$csvData = [];
$csvData[] = $entityConfig['header'];
$relevantEntities = $entities;
if ($isHierarchical) {
//Filter root entities (those without parents)
$relevantEntities = array_filter($entities, fn($entity) => $entity->getParent() === null);
if (count($relevantEntities) === 0 && count($entities) > 0) {
//If no root entities are found, then we need to add all entities
$relevantEntities = $entities;
}
}
//Sort root entities alphabetically by `name`
usort($relevantEntities, fn($a, $b) => strnatcasecmp($a->getName(), $b->getName()));
//Recursive function to process an entity and its children
$processEntity = function ($entity, &$csvData, $depth = 0) use (&$processEntity, $entityConfig, $isHierarchical) {
//Add main entity data to CSV
$csvData[] = $entityConfig['processEntity']($entity, $depth);
//Process BOM entries if applicable
if (isset($entityConfig['processBomEntries'])) {
$bomRows = $entityConfig['processBomEntries']($entity, $depth);
foreach ($bomRows as $bomRow) {
$csvData[] = $bomRow;
}
}
if ($isHierarchical) {
//Retrieve children, sort alphabetically, then process them
$children = $entity->getChildren()->toArray();
usort($children, fn($a, $b) => strnatcasecmp($a->getName(), $b->getName()));
foreach ($children as $childEntity) {
$processEntity($childEntity, $csvData, $depth + 1);
}
}
};
//Start processing with root entities
foreach ($relevantEntities as $rootEntity) {
$processEntity($rootEntity, $csvData);
}
//Generate CSV string
$output = '';
foreach ($csvData as $line) {
$output .= implode(';', $line) . "\n"; // Use a semicolon as the delimiter
}
return $output;
}
/**
* Process BOM entries and include aggregated parts as "complete_part_list".
*
* @param Assembly $assembly The assembly being processed.
* @param int $depth The current depth in the hierarchy.
* @return array Processed BOM entries and aggregated parts rows.
*/
private function processBomEntriesWithAggregatedParts(Assembly $assembly, int $depth): array
{
$rows = [];
/** @var AssemblyBOMEntry $bomEntry */
foreach ($assembly->getBomEntries() as $bomEntry) {
// Add the BOM entry itself
$rows[] = [
'Id' => $assembly->getId(),
'ParentId' => '',
'Type' => 'assembly_bom_entry',
'AssemblyIpn' => $assembly->getIpn(),
'AssemblyNameHierarchical' => str_repeat('--', $depth) . '> ' . $assembly->getName(),
'AssemblyName' => $assembly->getName(),
'AssemblyFullName' => $this->getFullName($assembly),
//BOM relevant attributes
'Quantity' => $bomEntry->getQuantity(),
'PartId' => $bomEntry->getPart()?->getId() ?? '-',
'PartName' => $bomEntry->getPart()?->getName() ?? '-',
'Ipn' => $bomEntry->getPart()?->getIpn() ?? '-',
'Manufacturer' => $bomEntry->getPart()?->getManufacturer()?->getName() ?? '-',
'Mpn' => $bomEntry->getPart()?->getManufacturerProductNumber() ?? '-',
'Name' => $bomEntry->getName() ?? '-',
'Designator' => $bomEntry->getDesignator(),
'Description' => $bomEntry->getPart()?->getDescription() ?? '-',
'ReferencedAssemblyId' => $bomEntry->getReferencedAssembly()?->getId() ?? '-',
'ReferencedAssemblyIpn' => $bomEntry->getReferencedAssembly()?->getIpn() ?? '-',
'ReferencedAssemblyFullName' => $this->getFullName($bomEntry->getReferencedAssembly() ?? null),
];
// If a referenced assembly exists, add aggregated parts
if ($bomEntry->getReferencedAssembly() instanceof Assembly) {
$referencedAssembly = $bomEntry->getReferencedAssembly();
// Get aggregated parts for the referenced assembly
$aggregatedParts = $this->assemblyPartAggregator->getAggregatedParts($referencedAssembly, $bomEntry->getQuantity());;
foreach ($aggregatedParts as $partData) {
$partAssembly = $partData['assembly'] ?? null;
$rows[] = [
'Id' => $assembly->getId(),
'ParentId' => '',
'Type' => 'subassembly_part_list',
'AssemblyIpn' => $partAssembly ? $partAssembly->getIpn() : '',
'AssemblyNameHierarchical' => '',
'AssemblyName' => $partAssembly ? $partAssembly->getName() : '',
'AssemblyFullName' => $this->getFullName($partAssembly),
//BOM relevant attributes
'Quantity' => $partData['quantity'],
'PartId' => $partData['part']?->getId(),
'PartName' => $partData['part']?->getName(),
'Ipn' => $partData['part']?->getIpn(),
'Manufacturer' => $partData['part']?->getManufacturer()?->getName(),
'Mpn' => $partData['part']?->getManufacturerProductNumber(),
'Name' => $partData['name'] ?? '',
'Designator' => $partData['designator'],
'Description' => $partData['part']?->getDescription(),
'ReferencedAssemblyId' => '-',
'ReferencedAssemblyIpn' => '-',
'ReferencedAssemblyFullName' => '-',
];
}
}
}
return $rows;
}
/**
* Constructs the full hierarchical name of an object by traversing
* through its parent objects and concatenating their names using
* a specified separator.
*
* @param AttachmentType|Category|Project|Assembly|Supplier|Manufacturer|StorageLocation|Footprint|Currency|MeasurementUnit|LabelProfile|null $object The object whose full name is to be constructed. If null, the result will be an empty string.
* @param string $separator The string used to separate the names of the objects in the full hierarchy.
*
* @return string The full hierarchical name constructed by concatenating the names of the object and its parents.
*/
private function getFullName(AttachmentType|Category|Project|Assembly|Supplier|Manufacturer|StorageLocation|Footprint|Currency|MeasurementUnit|LabelProfile|null $object, string $separator = '->'): string
{
$fullNameParts = [];
while ($object !== null) {
array_unshift($fullNameParts, $object->getName());
$object = $object->getParent();
}
return implode($separator, $fullNameParts);
}
}

View file

@ -0,0 +1,60 @@
<?php
declare(strict_types=1);
namespace App\Services\ImportExportSystem;
use Symfony\Component\Validator\ConstraintViolation;
use Symfony\Component\Validator\ConstraintViolationList;
class ImporterResult
{
private array $bomEntries = [];
private ConstraintViolationList $violations;
public function __construct(array $bomEntries = [])
{
$this->bomEntries = $bomEntries;
$this->violations = new ConstraintViolationList();
}
/**
* Fügt einen neuen BOM-Eintrag hinzu.
*/
public function addBomEntry(object $bomEntry): void
{
$this->bomEntries[] = $bomEntry;
}
/**
* Gibt alle BOM-Einträge zurück.
*/
public function getBomEntries(): array
{
return $this->bomEntries;
}
/**
* Gibt die Liste der Violation zurück.
*/
public function getViolations(): ConstraintViolationList
{
return $this->violations;
}
/**
* Fügt eine neue `ConstraintViolation` zur Liste hinzu.
*/
public function addViolation(ConstraintViolation $violation): void
{
$this->violations->add($violation);
}
/**
* Prüft, ob die Liste der Violationen leer ist.
*/
public function hasViolations(): bool
{
return count($this->violations) > 0;
}
}

View file

@ -22,6 +22,7 @@ declare(strict_types=1);
namespace App\Services\Trees;
use App\Entity\AssemblySystem\Assembly;
use App\Entity\Attachments\AttachmentType;
use App\Entity\LabelSystem\LabelProfile;
use App\Entity\Parts\Category;
@ -183,6 +184,12 @@ class ToolsTreeBuilder
$this->urlGenerator->generate('project_new')
))->setIcon('fa-fw fa-treeview fa-solid fa-archive');
}
if ($this->security->isGranted('read', new Assembly())) {
$nodes[] = (new TreeViewNode(
$this->translator->trans('tree.tools.edit.assemblies'),
$this->urlGenerator->generate('assembly_new')
))->setIcon('fa-fw fa-treeview fa-solid fa-list');
}
if ($this->security->isGranted('read', new Supplier())) {
$nodes[] = (new TreeViewNode(
$this->elementTypeNameGenerator->typeLabelPlural(Supplier::class),

View file

@ -22,6 +22,7 @@ declare(strict_types=1);
namespace App\Services\Trees;
use App\Entity\AssemblySystem\Assembly;
use App\Entity\Base\AbstractDBElement;
use App\Entity\Base\AbstractNamedDBElement;
use App\Entity\Base\AbstractStructuralDBElement;
@ -155,6 +156,10 @@ class TreeViewGenerator
$href_type = 'list_parts';
}
if ($mode === 'assemblies') {
$href_type = 'list_parts';
}
$generic = $this->getGenericTree($class, $parent);
$treeIterator = new TreeViewNodeIterator($generic);
$recursiveIterator = new RecursiveIteratorIterator($treeIterator, RecursiveIteratorIterator::SELF_FIRST);
@ -184,6 +189,15 @@ class TreeViewGenerator
$root_node->setExpanded($this->rootNodeExpandedByDefault);
$root_node->setIcon($this->entityClassToRootNodeIcon($class));
$generic = [$root_node];
} elseif ($mode === 'assemblies' && $this->rootNodeEnabled) {
//We show the root node as a link to the list of all assemblies
$show_all_parts_url = $this->router->generate('assemblies_list');
$root_node = new TreeViewNode($this->entityClassToRootNodeString($class), $show_all_parts_url, $generic);
$root_node->setExpanded($this->rootNodeExpandedByDefault);
$root_node->setIcon($this->entityClassToRootNodeIcon($class));
$generic = [$root_node];
}
@ -226,6 +240,7 @@ class TreeViewGenerator
Manufacturer::class => $icon.'fa-industry',
Supplier::class => $icon.'fa-truck',
Project::class => $icon.'fa-archive',
Assembly::class => $icon.'fa-list',
default => null,
};
}

View file

@ -105,6 +105,7 @@ class PermissionPresetsHelper
$this->permissionResolver->setAllOperationsOfPermission($perm_holder, 'part_custom_states', PermissionData::ALLOW);
$this->permissionResolver->setAllOperationsOfPermission($perm_holder, 'suppliers', PermissionData::ALLOW);
$this->permissionResolver->setAllOperationsOfPermission($perm_holder, 'projects', PermissionData::ALLOW);
$this->permissionResolver->setAllOperationsOfPermission($perm_holder, 'assemblies', PermissionData::ALLOW);
//Allow to change system settings
$this->permissionResolver->setPermission($perm_holder, 'config', 'change_system_settings', PermissionData::ALLOW);
@ -135,6 +136,7 @@ class PermissionPresetsHelper
$this->permissionResolver->setAllOperationsOfPermissionExcept($permHolder, 'part_custom_states', PermissionData::ALLOW, ['import']);
$this->permissionResolver->setAllOperationsOfPermissionExcept($permHolder, 'suppliers', PermissionData::ALLOW, ['import']);
$this->permissionResolver->setAllOperationsOfPermissionExcept($permHolder, 'projects', PermissionData::ALLOW, ['import']);
$this->permissionResolver->setAllOperationsOfPermissionExcept($permHolder, 'assemblies', PermissionData::ALLOW, ['import']);
//Attachments permissions
$this->permissionResolver->setPermission($permHolder, 'attachments', 'show_private', PermissionData::ALLOW);
@ -182,6 +184,9 @@ class PermissionPresetsHelper
//Set projects permissions
$this->permissionResolver->setPermission($perm_holder, 'projects', 'read', PermissionData::ALLOW);
//Set assemblies permissions
$this->permissionResolver->setPermission($perm_holder, 'assemblies', 'read', PermissionData::ALLOW);
return $perm_holder;
}

View file

@ -0,0 +1,54 @@
<?php
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2024 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/>.
*/
declare(strict_types=1);
namespace App\Settings\BehaviorSettings;
use Symfony\Contracts\Translation\TranslatableInterface;
use Symfony\Contracts\Translation\TranslatorInterface;
enum AssemblyBomTableColumns : string implements TranslatableInterface
{
case NAME = "name";
case ID = "id";
case QUANTITY = "quantity";
case IPN = "ipn";
case DESCRIPTION = "description";
case CATEGORY = "category";
case MANUFACTURER = "manufacturer";
case DESIGNATOR = "designator";
case MOUNTNAMES = "mountnames";
case STORAGE_LOCATION = "storage_location";
case AMOUNT = "amount";
case ADDED_DATE = "addedDate";
case LAST_MODIFIED = "lastModified";
case EDIT = "edit";
public function trans(TranslatorInterface $translator, ?string $locale = null): string
{
$key = match($this) {
default => 'assembly.bom.table.' . $this->value,
};
return $translator->trans($key, locale: $locale);
}
}

View file

@ -0,0 +1,49 @@
<?php
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2024 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/>.
*/
declare(strict_types=1);
namespace App\Settings\BehaviorSettings;
use Symfony\Contracts\Translation\TranslatableInterface;
use Symfony\Contracts\Translation\TranslatorInterface;
enum AssemblyTableColumns : string implements TranslatableInterface
{
case NAME = "name";
case ID = "id";
case IPN = "ipn";
case DESCRIPTION = "description";
case REFERENCED_ASSEMBLIES = "referencedAssemblies";
case ADDED_DATE = "addedDate";
case LAST_MODIFIED = "lastModified";
case EDIT = "edit";
public function trans(TranslatorInterface $translator, ?string $locale = null): string
{
$key = match($this) {
default => 'assembly.table.' . $this->value,
};
return $translator->trans($key, locale: $locale);
}
}

View file

@ -70,6 +70,37 @@ class TableSettings
PartTableColumns::CATEGORY, PartTableColumns::FOOTPRINT, PartTableColumns::MANUFACTURER,
PartTableColumns::LOCATION, PartTableColumns::AMOUNT, PartTableColumns::CUSTOM_PART_STATE];
/** @var AssemblyTableColumns[] */
#[SettingsParameter(ArrayType::class,
label: new TM("settings.behavior.table.assemblies_default_columns"),
description: new TM("settings.behavior.table.assemblies_default_columns.help"),
options: ['type' => EnumType::class, 'options' => ['class' => AssemblyTableColumns::class]],
formType: \Symfony\Component\Form\Extension\Core\Type\EnumType::class,
formOptions: ['class' => AssemblyTableColumns::class, 'multiple' => true, 'ordered' => true],
envVar: "TABLE_ASSEMBLIES_DEFAULT_COLUMNS", envVarMode: EnvVarMode::OVERWRITE, envVarMapper: [self::class, 'mapAssembliesDefaultColumnsEnv']
)]
#[Assert\NotBlank()]
#[Assert\Unique()]
#[Assert\All([new Assert\Type(AssemblyTableColumns::class)])]
public array $assembliesDefaultColumns = [AssemblyTableColumns::ID, AssemblyTableColumns::IPN, AssemblyTableColumns::NAME,
AssemblyTableColumns::DESCRIPTION, AssemblyTableColumns::REFERENCED_ASSEMBLIES, AssemblyTableColumns::EDIT];
/** @var AssemblyBomTableColumns[] */
#[SettingsParameter(ArrayType::class,
label: new TM("settings.behavior.table.assemblies_bom_default_columns"),
description: new TM("settings.behavior.table.assemblies_bom_default_columns.help"),
options: ['type' => EnumType::class, 'options' => ['class' => AssemblyBomTableColumns::class]],
formType: \Symfony\Component\Form\Extension\Core\Type\EnumType::class,
formOptions: ['class' => AssemblyBomTableColumns::class, 'multiple' => true, 'ordered' => true],
envVar: "TABLE_ASSEMBLIES_BOM_DEFAULT_COLUMNS", envVarMode: EnvVarMode::OVERWRITE, envVarMapper: [self::class, 'mapAssemblyBomsDefaultColumnsEnv']
)]
#[Assert\NotBlank()]
#[Assert\Unique()]
#[Assert\All([new Assert\Type(AssemblyBomTableColumns::class)])]
public array $assembliesBomDefaultColumns = [AssemblyBomTableColumns::QUANTITY, AssemblyBomTableColumns::ID,
AssemblyBomTableColumns::IPN, AssemblyBomTableColumns::NAME, AssemblyBomTableColumns::DESCRIPTION];
#[SettingsParameter(label: new TM("settings.behavior.table.preview_image_min_width"),
formOptions: ['attr' => ['min' => 1, 'max' => 100]],
envVar: "int:TABLE_IMAGE_PREVIEW_MIN_SIZE", envVarMode: EnvVarMode::OVERWRITE
@ -101,4 +132,36 @@ class TableSettings
return $ret;
}
public static function mapAssembliesDefaultColumnsEnv(string $columns): array
{
$exploded = explode(',', $columns);
$ret = [];
foreach ($exploded as $column) {
$enum = AssemblyTableColumns::tryFrom($column);
if (!$enum) {
throw new \InvalidArgumentException("Invalid column '$column' in TABLE_ASSEMBLIES_DEFAULT_COLUMNS");
}
$ret[] = $enum;
}
return $ret;
}
public static function mapAssemblyBomsDefaultColumnsEnv(string $columns): array
{
$exploded = explode(',', $columns);
$ret = [];
foreach ($exploded as $column) {
$enum = AssemblyBomTableColumns::tryFrom($column);
if (!$enum) {
throw new \InvalidArgumentException("Invalid column '$column' in TABLE_ASSEMBLIES_BOM_DEFAULT_COLUMNS");
}
$ret[] = $enum;
}
return $ret;
}
}

View file

@ -0,0 +1,45 @@
<?php
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2024 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/>.
*/
declare(strict_types=1);
namespace App\Settings\MiscSettings;
use App\Settings\SettingsIcon;
use Jbtronics\SettingsBundle\Metadata\EnvVarMode;
use Jbtronics\SettingsBundle\Settings\Settings;
use Jbtronics\SettingsBundle\Settings\SettingsParameter;
use Jbtronics\SettingsBundle\Settings\SettingsTrait;
use Symfony\Component\Translation\TranslatableMessage as TM;
#[Settings(label: new TM("settings.misc.assembly"))]
#[SettingsIcon("fa-list")]
class AssemblySettings
{
use SettingsTrait;
#[SettingsParameter(
label: new TM("settings.misc.assembly.useIpnPlaceholderInName"),
envVar: "bool:CREATE_ASSEMBLY_USE_IPN_PLACEHOLDER_IN_NAME", envVarMode: EnvVarMode::OVERWRITE,
)]
public bool $useIpnPlaceholderInName = true;
}

View file

@ -38,4 +38,7 @@ class MiscSettings
#[EmbeddedSettings]
public ?IpnSuggestSettings $ipnSuggestSettings = null;
#[EmbeddedSettings]
public ?AssemblySettings $assembly = null;
}

View file

@ -22,6 +22,7 @@ declare(strict_types=1);
*/
namespace App\Twig;
use App\Entity\AssemblySystem\Assembly;
use App\Entity\Attachments\Attachment;
use App\Entity\Base\AbstractDBElement;
use App\Entity\Parts\PartCustomState;
@ -111,6 +112,7 @@ final class EntityExtension extends AbstractExtension
Manufacturer::class => 'manufacturer',
Category::class => 'category',
Project::class => 'device',
Assembly::class => 'assembly',
Attachment::class => 'attachment',
Supplier::class => 'supplier',
User::class => 'user',

View file

@ -0,0 +1,39 @@
<?php
declare(strict_types=1);
/*
* 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\Validator\Constraints\AssemblySystem;
use Symfony\Component\Validator\Constraint;
/**
* This constraint checks that there is no cycle in bom configuration of the assembly
*/
#[\Attribute(\Attribute::TARGET_PROPERTY)]
class AssemblyCycle extends Constraint
{
public string $message = 'assembly.bom_entry.assembly_cycle';
public function validatedBy(): string
{
return AssemblyCycleValidator::class;
}
}

View file

@ -0,0 +1,169 @@
<?php
declare(strict_types=1);
/*
* 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\Validator\Constraints\AssemblySystem;
use App\Entity\AssemblySystem\Assembly;
use Symfony\Component\Form\Form;
use Symfony\Component\Validator\Constraint;
use Symfony\Component\Validator\ConstraintValidator;
use Symfony\Component\Validator\Exception\UnexpectedTypeException;
use Symfony\Component\Validator\Violation\ConstraintViolationBuilder;
use ReflectionClass;
/**
* Validator class to check for cycles in assemblies based on BOM entries.
*
* This validator ensures that the structure of assemblies does not contain circular dependencies
* by validating each entry in the Bill of Materials (BOM) of the given assembly. Additionally,
* it can handle form-submitted BOM entries to include these in the validation process.
*/
class AssemblyCycleValidator extends ConstraintValidator
{
public function validate($value, Constraint $constraint): void
{
if (!$constraint instanceof AssemblyCycle) {
throw new UnexpectedTypeException($constraint, AssemblyCycle::class);
}
if (!$value instanceof Assembly) {
return;
}
$availableViolations = $this->context->getViolations();
if (count($availableViolations) > 0) {
//already violations given, currently no more needed to check
return;
}
$bomEntries = [];
if ($this->context->getRoot() instanceof Form && $this->context->getRoot()->has('bom_entries')) {
$bomEntries = $this->context->getRoot()->get('bom_entries')->getData();
$bomEntries = is_array($bomEntries) ? $bomEntries : iterator_to_array($bomEntries);
} elseif ($this->context->getRoot() instanceof Assembly) {
$bomEntries = $value->getBomEntries()->toArray();
}
$relevantEntries = [];
foreach ($bomEntries as $bomEntry) {
if ($bomEntry->getReferencedAssembly() !== null) {
$relevantEntries[$bomEntry->getId()] = $bomEntry;
}
}
$visitedAssemblies = [];
foreach ($relevantEntries as $bomEntry) {
if ($this->hasCycle($bomEntry->getReferencedAssembly(), $value, $visitedAssemblies)) {
$this->addViolation($value, $constraint);
}
}
}
/**
* Determines if there is a cyclic dependency in the assembly hierarchy.
*
* This method checks if a cycle exists in the hierarchy of referenced assemblies starting
* from a given assembly. It traverses through the Bill of Materials (BOM) entries of each
* assembly recursively and keeps track of visited assemblies to detect cycles.
*
* @param Assembly|null $currentAssembly The current assembly being checked for cycles.
* @param Assembly $originalAssembly The original assembly from where the cycle detection started.
* @param Assembly[] $visitedAssemblies A list of assemblies that have been visited during the current traversal.
*
* @return bool True if a cycle is detected, false otherwise.
*/
private function hasCycle(?Assembly $currentAssembly, Assembly $originalAssembly, array $visitedAssemblies = []): bool
{
//No referenced assembly → no cycle
if ($currentAssembly === null) {
return false;
}
//If the assembly has already been visited, there is a cycle
if (in_array($currentAssembly->getId(), array_map(fn($a) => $a->getId(), $visitedAssemblies), true)) {
return true;
}
//Add the current assembly to the visited
$visitedAssemblies[] = $currentAssembly;
//Go through the bom entries of the current assembly
foreach ($currentAssembly->getBomEntries() as $bomEntry) {
$referencedAssembly = $bomEntry->getReferencedAssembly();
if ($referencedAssembly !== null && $this->hasCycle($referencedAssembly, $originalAssembly, $visitedAssemblies)) {
return true;
}
}
//Remove the current assembly from the list of visit (recursion completed)
array_pop($visitedAssemblies);
return false;
}
/**
* Adds a violation to the current context if it hasnt already been added.
*
* This method checks whether a violation with the same property path as the current violation
* already exists in the context. If such a violation is found, the current violation is not added again.
* The process involves reflection to access private or protected properties of violation objects.
*
* @param mixed $value The value that triggered the violation.
* @param AssemblyCycle $constraint The constraint containing the validation details.
*
*/
private function addViolation(mixed $value, AssemblyCycle $constraint): void
{
/** @var ConstraintViolationBuilder $buildViolation */
$buildViolation = $this->context->buildViolation($constraint->message)
->setParameter('%name%', $value->getName());
$alreadyAdded = false;
try {
$reflectionClass = new ReflectionClass($buildViolation);
$property = $reflectionClass->getProperty('propertyPath');
$propertyPath = $property->getValue($buildViolation);
$availableViolations = $this->context->getViolations();
foreach ($availableViolations as $tmpViolation) {
$tmpReflectionClass = new ReflectionClass($tmpViolation);
$tmpProperty = $tmpReflectionClass->getProperty('propertyPath');
$tmpPropertyPath = $tmpProperty->getValue($tmpViolation);
if ($tmpPropertyPath === $propertyPath) {
$alreadyAdded = true;
}
}
} catch (\ReflectionException) {
}
if (!$alreadyAdded) {
$buildViolation->addViolation();
}
}
}

View file

@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace App\Validator\Constraints\AssemblySystem;
use Symfony\Component\Validator\Constraint;
/**
* This constraint ensures that no BOM entries in the assembly reference its own children.
*/
#[\Attribute(\Attribute::TARGET_PROPERTY)]
class AssemblyInvalidBomEntry extends Constraint
{
public string $message = 'assembly.bom_entry.invalid_child_entry';
public function validatedBy(): string
{
return AssemblyInvalidBomEntryValidator::class;
}
}

View file

@ -0,0 +1,142 @@
<?php
declare(strict_types=1);
namespace App\Validator\Constraints\AssemblySystem;
use App\Entity\AssemblySystem\Assembly;
use Symfony\Component\Form\Form;
use Symfony\Component\Validator\Constraint;
use Symfony\Component\Validator\ConstraintValidator;
use Symfony\Component\Validator\Exception\UnexpectedTypeException;
use Symfony\Component\Validator\Violation\ConstraintViolationBuilder;
use ReflectionClass;
/**
* Validator to check that no child assemblies are referenced in BOM entries.
*/
class AssemblyInvalidBomEntryValidator extends ConstraintValidator
{
public function validate($value, Constraint $constraint): void
{
if (!$constraint instanceof AssemblyInvalidBomEntry) {
throw new UnexpectedTypeException($constraint, AssemblyInvalidBomEntry::class);
}
if (!$value instanceof Assembly) {
return;
}
$availableViolations = $this->context->getViolations();
if (count($availableViolations) > 0) {
//already violations given, currently no more needed to check
return;
}
$bomEntries = [];
if ($this->context->getRoot() instanceof Form && $this->context->getRoot()->has('bom_entries')) {
$bomEntries = $this->context->getRoot()->get('bom_entries')->getData();
$bomEntries = is_array($bomEntries) ? $bomEntries : iterator_to_array($bomEntries);
} elseif ($this->context->getRoot() instanceof Assembly) {
$bomEntries = $value->getBomEntries()->toArray();
}
$relevantEntries = [];
foreach ($bomEntries as $bomEntry) {
if ($bomEntry->getReferencedAssembly() !== null) {
$relevantEntries[$bomEntry->getId()] = $bomEntry;
}
}
foreach ($relevantEntries as $bomEntry) {
$referencedAssembly = $bomEntry->getReferencedAssembly();
if ($bomEntry->getAssembly()->getParent()?->getId() === $referencedAssembly->getParent()?->getId()) {
//Save on the same assembly level
continue;
} elseif ($this->isInvalidBomEntry($referencedAssembly, $bomEntry->getAssembly())) {
$this->addViolation($value, $constraint);
}
}
}
/**
* Determines whether a Bill of Materials (BOM) entry is invalid based on the relationship
* between the current assembly and the parent assembly.
*
* @param Assembly|null $currentAssembly The current assembly being analyzed. Null indicates no assembly is referenced.
* @param Assembly $parentAssembly The parent assembly to check against the current assembly.
*
* @return bool Returns
*/
private function isInvalidBomEntry(?Assembly $currentAssembly, Assembly $parentAssembly): bool
{
//No assembly referenced -> no problems
if ($currentAssembly === null) {
return false;
}
//Check: is the current assembly a descendant of the parent assembly?
if ($currentAssembly->isChildOf($parentAssembly)) {
return true;
}
//Recursive check: Analyze the current assembly list
foreach ($currentAssembly->getBomEntries() as $bomEntry) {
$referencedAssembly = $bomEntry->getReferencedAssembly();
if ($this->isInvalidBomEntry($referencedAssembly, $parentAssembly)) {
return true;
}
}
return false;
}
/**
* Adds a violation to the current context if it hasnt already been added.
*
* This method checks whether a violation with the same property path as the current violation
* already exists in the context. If such a violation is found, the current violation is not added again.
* The process involves reflection to access private or protected properties of violation objects.
*
* @param mixed $value The value that triggered the violation.
* @param AssemblyInvalidBomEntry $constraint The constraint containing the validation details.
*
*/
private function addViolation($value, AssemblyInvalidBomEntry $constraint): void
{
/** @var ConstraintViolationBuilder $buildViolation */
$buildViolation = $this->context->buildViolation($constraint->message)
->setParameter('%name%', $value->getName());
$alreadyAdded = false;
try {
$reflectionClass = new ReflectionClass($buildViolation);
$property = $reflectionClass->getProperty('propertyPath');
$propertyPath = $property->getValue($buildViolation);
$availableViolations = $this->context->getViolations();
foreach ($availableViolations as $tmpViolation) {
$tmpReflectionClass = new ReflectionClass($tmpViolation);
$tmpProperty = $tmpReflectionClass->getProperty('propertyPath');
$tmpPropertyPath = $tmpProperty->getValue($tmpViolation);
if ($tmpPropertyPath === $propertyPath) {
$alreadyAdded = true;
}
}
} catch (\ReflectionException) {
}
if (!$alreadyAdded) {
$buildViolation->addViolation();
}
}
}

View file

@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
/*
* 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\Validator\Constraints\AssemblySystem;
use Symfony\Component\Validator\Constraint;
/**
* This constraint checks that the given UniqueReferencedAssembly is valid.
*/
#[\Attribute(\Attribute::TARGET_PROPERTY)]
class UniqueReferencedAssembly extends Constraint
{
public string $message = 'assembly.bom_entry.assembly_already_in_bom';
}

View file

@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
/*
* 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\Validator\Constraints\AssemblySystem;
use Symfony\Component\Validator\Constraint;
use Symfony\Component\Validator\ConstraintValidator;
class UniqueReferencedAssemblyValidator extends ConstraintValidator
{
public function validate($value, Constraint $constraint)
{
$assemblies = [];
foreach ($value as $entry) {
$referencedAssemblyId = $entry->getReferencedAssembly()?->getId();
if ($referencedAssemblyId === null) {
continue;
}
if (isset($assemblies[$referencedAssemblyId])) {
/** @var UniqueReferencedAssembly $constraint */
$this->context->buildViolation($constraint->message)
->atPath('referencedAssembly')
->addViolation();
return;
}
$assemblies[$referencedAssemblyId] = true;
}
}
}

View file

@ -1,6 +1,6 @@
<form class="form-horizontal" method="post" action="{{ path }}" data-turbo="false" data-turbo-frame="_top">
<form class="form-horizontal" method="post" action="{{ path }}" data-turbo="false" data-turbo-frame="_top" {{ stimulus_controller('elements/toggle_visibility', {classes: ['format', 'level', 'include_children']}) }}>
<div class="row">
<div class="row format">
<label class="col-form-label col-md-3">{% trans %}export.format{% endtrans %}</label>
<div class="col-md-9">
<select class="form-select" name="format">
@ -12,7 +12,7 @@
</div>
</div>
<div class="row mt-2">
<div class="row mt-2 level">
<label class="col-form-label col-md-3">{% trans %}export.level{% endtrans %}</label>
<div class="col-md-9">
<select class="form-select" name="level">
@ -23,7 +23,7 @@
</div>
</div>
<div class="row mt-2">
<div class="row mt-2 include_children">
<div class="offset-md-3 col-sm">
<div class="form-check">
<input class="form-check-input form-check-input" name="include_children" id="include_children" type="checkbox" checked>
@ -34,9 +34,32 @@
</div>
</div>
{% if path is defined and 'assembly' in path %}
<div class="row mt-2">
<label class="col-form-label col-md-3" for="readableSelect" >{% trans %}export.readable.label{% endtrans %}</label>
<div class="col-md-9">
<select id="display" name="readableSelect" class="form-select" data-action="change->action-handler#handleAction">
<option value="" selected></option>
<option value="readable">{% trans %}export.readable{% endtrans %}</option>
<option value="readable_bom">{% trans %}export.readable_bom{% endtrans %}</option>
</select>
</div>
</div>
{% else %}
<div class="row mt-2 align-items-center">
<label class="col-form-label col-md-3" for="readableSelect">{% trans %}export.readable.label{% endtrans %}</label>
<div class="col-md-9 d-flex align-items-center">
<input class="form-check-input me-2" name="readableSelect" value="readable" id="display" type="checkbox" data-action="change->action-handler#handleAction">
<label class="form-check-label" for="display">
{% trans %}export.readable{% endtrans %}
</label>
</div>
</div>
{% endif %}
<div class="row mt-2">
<div class="offset-sm-3 col-sm">
<button type="submit" class="btn btn-primary">{% trans %}export.btn{% endtrans %}</button>
</div>
</div>
</form>
</form>

View file

@ -0,0 +1,48 @@
{% extends "admin/base_admin.html.twig" %}
{# @var entity App\Entity\AssemblySystem\Assembly #}
{% block card_title %}
<i class="fas fa-archive fa-fw"></i> {% trans %}assembly.caption{% endtrans %}
{% endblock %}
{% block edit_title %}
{% trans %}assembly.edit{% endtrans %}: {{ entity.name }}
{% endblock %}
{% block new_title %}
{% trans %}assembly.new{% endtrans %}
{% endblock %}
{% block additional_pills %}
<li class="nav-item"><a data-bs-toggle="tab" class="nav-link link-anchor" href="#part">{% trans %}assembly_bom_entry.labelp{% endtrans %}</a></li>
{% endblock %}
{% block quick_links %}
<div class="btn-toolbar" style="display: inline-block;">
<div class="btn-group">
<a class="btn btn-outline-secondary" href="{{ entity_url(entity) }}"><i class="fas fa-eye fa-fw"></i></a>
</div>
</div>
{% endblock %}
{% block additional_controls %}
{{ form_row(form.description) }}
{{ form_row(form.status) }}
{{ form_row(form.ipn) }}
{% endblock %}
{% block additional_panes %}
<div class="tab-pane" id="part">
{% form_theme form.bom_entries with ['form/collection_types_layout_assembly.html.twig'] %}
{{ form_errors(form.bom_entries) }}
{{ form_widget(form.bom_entries) }}
{% if entity.id %}
<a href="{{ path('assembly_import_bom', {'id': entity.id}) }}" class="btn btn-secondary mb-2"
{% if not is_granted('edit', entity) %}disabled="disabled"{% endif %}>
<i class="fa-solid fa-file-import fa-fw"></i>
{% trans %}assembly.edit.bom.import_bom{% endtrans %}
</a>
{% endif %}
</div>
{% endblock %}

View file

@ -0,0 +1,22 @@
{% extends "main_card.html.twig" %}
{% block title %}{% trans %}assembly.add_parts_to_assembly{% endtrans %}{% endblock %}
{% block card_title %}
<i class="fa-solid fa-magnifying-glass-plus fa-fw"></i>
{% trans %}assembly.add_parts_to_assembly{% endtrans %}{% if assembly %}: <i>{{ assembly.name }}</i>{% endif %}
{% endblock %}
{% block card_content %}
{{ form_start(form) }}
{{ form_row(form.assembly) }}
{% form_theme form.bom_entries with ['form/collection_types_layout_assembly.html.twig'] %}
{{ form_widget(form.bom_entries) }}
{{ form_row(form.submit) }}
{{ form_end(form) }}
{% endblock %}

View file

@ -0,0 +1,103 @@
<!DOCTYPE html>
<html>
<head>
<title>Assembly Hierarchy</title>
<style>
body { font-family: Arial, sans-serif; margin: 20px; }
table { border-collapse: collapse; width: 100%; margin-top: 10px; font-size: 10px;}
th, td { border: 1px solid #ddd; padding: 8px; text-align: left; }
th { background-color: #f2f2f2; }
.assembly-header { font-size: 14px; font-weight: bold; margin-top: 10px; margin-bottom: 10px; }
.toc-table { margin-bottom: 40px; width: auto; font-size: 12px; }
.toc-table th, .toc-table td { border: none; padding: 5px; text-align: left; }
.referenced-assembly-table { margin-left: 20px; margin-top: 10px; }
.page-break { page-break-after: always; }
#footer { position: fixed; right: 0px; bottom: 10px; text-align: center; font-size: 10px; }
#footer .page:after { content: counter(page, decimal); }
@page { margin: 20px 30px 40px 50px; }
</style>
</head>
<body>
<!-- Inhaltsverzeichnis -->
<h1>Table of Contents</h1>
<table class="toc-table">
<thead>
<tr>
<th>#</th>
<th>Assembly Name</th>
<th>IPN</th>
<th>Section</th>
</tr>
</thead>
<tbody>
{% for assembly in assemblies %}
<tr>
<td>{{ loop.index }}</td>
<td>Assembly: {{ assembly.name }}</td>
<td>{% if assembly.ipn != '' %}{{ assembly.ipn }}{% else %}-{% endif %}</td>
<td>{{ loop.index + 1 }}</td>
</tr>
{% endfor %}
</tbody>
</table>
<div class="page-break"></div>
<!-- Assembly Details -->
{% for assembly in assemblies %}
<div class="assembly-header">Assembly: {{ assembly.name }}</div>
<table>
<thead>
<tr>
<th>Name</th>
<th>IPN</th>
<th>Quantity</th>
<th>Multiplier</th>
<th>Effective Quantity</th>
</tr>
</thead>
<tbody>
{% for part in assembly.parts %}
<tr>
<td>{{ part.name }}</td>
<td>{{ part.ipn }}</td>
<td>{{ part.quantity }}</td>
<td>{% if assembly.multiplier %}{{ assembly.multiplier }}{% else %}-{% endif %}</td>
<td>{{ part.effectiveQuantity }}</td>
</tr>
{% endfor %}
{% for other in assembly.others %}
<tr>
<td>{{ other.name }}</td>
<td>{{ other.ipn }}</td>
<td>{{ other.quantity }}</td>
<td>{{ other.multiplier }}</td>
<td>{{ other.effectiveQuantity }}</td>
</tr>
{% endfor %}
{% for referencedAssembly in assembly.referencedAssemblies %}
<tr>
<td>{{ referencedAssembly.name }}</td>
<td>{{ referencedAssembly.ipn }}</td>
<td>{{ referencedAssembly.quantity }}</td>
<td></td>
<td>{{ referencedAssembly.quantity }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% for refAssembly in assembly.referencedAssemblies %}
{% include 'assemblies/export_bom_referenced_assembly_pdf.html.twig' with {'assembly': refAssembly} only %}
{% endfor %}
{% if not loop.last %}
<div style="page-break-after: always;"></div>
{% endif %}
<div id="footer">
<p class="page">Section </p>
</div>
{% endfor %}
</body>
</html>

View file

@ -0,0 +1,55 @@
<div class="referenced-assembly-table">
<div class="assembly-header">Referenced Assembly: {{ assembly.name }} [IPN: {% if assembly.ipn != '' %}{{ assembly.ipn }}{% else %}-{% endif %}, quantity: {{ assembly.quantity }}]</div>
<table>
<thead>
<tr>
<th>Type</th>
<th>Name</th>
<th>IPN</th>
<th>Quantity</th>
<th>Multiplier</th>
<th>Effective Quantity</th>
</tr>
</thead>
<tbody>
{% for part in assembly.parts %}
<tr>
<td>Part</td>
<td>{{ part.name }}</td>
<td>{{ part.ipn }}</td>
<td>{{ part.quantity }}</td>
<td>{% if assembly.multiplier %}{{ assembly.multiplier }}{% else %}-{% endif %}</td>
<td>{{ part.effectiveQuantity }}</td>
</tr>
{% endfor %}
{% for other in assembly.others %}
<tr>
<td>Other</td>
<td>{{ other.name }}</td>
<td>-</td>
<td>{{ other.quantity }}</td>
<td>{{ other.multiplier }}</td>
<td>-</td>
</tr>
{% endfor %}
{% for referencedAssembly in assembly.referencedAssemblies %}
<tr>
<td>Referenced assembly</td>
<td>{{ referencedAssembly.name }}</td>
<td>-</td>
<td>{{ referencedAssembly.quantity }}</td>
<td></td>
<td>{{ referencedAssembly.multiplier }}</td>
</tr>
{% endfor %}
</tbody>
</table>
<!-- Rekursive Auflistung weiterer Assemblies -->
{% for refAssembly in assembly.referencedAssemblies %}
{% include 'assemblies/export_bom_referenced_assembly_pdf.html.twig' with {'assembly': refAssembly} only %}
{% endfor %}
</div>

View file

@ -0,0 +1,114 @@
{% extends "main_card.html.twig" %}
{% block title %}{% trans %}assembly.import_bom{% endtrans %}{% endblock %}
{% block before_card %}
{% if validationErrors or importerErrors %}
<div class="alert alert-danger">
<h4><i class="fa-solid fa-exclamation-triangle fa-fw"></i> {% trans %}parts.import.errors.title{% endtrans %}</h4>
<ul>
{% if validationErrors %}
{% for violation in validationErrors %}
<li>
<b>{{ violation.propertyPath }}: </b>
{{ violation.message|trans(violation.parameters, 'validators') }}
</li>
{% endfor %}
{% endif %}
{% if importerErrors %}
{% for violation in importerErrors %}
<li>
<b>{{ violation.propertyPath }}: </b>
{{ violation.message|trans(violation.parameters, 'validators')|raw }}
</li>
{% endfor %}
{% endif %}
</ul>
</div>
{% endif %}
{% endblock %}
{% block card_title %}
<i class="fa-solid fa-file-import fa-fw"></i>
{% trans %}assembly.import_bom{% endtrans %}{% if assembly %}: <i>{{ assembly.name }}</i>{% endif %}
{% endblock %}
{% block card_content %}
{{ form(form) }}
{% endblock %}
{% block additional_content %}
<div class="container-fluid row d-flex align-items-stretch pe-0 me-0">
<div class="col-md-12 col-lg-6 ps-0 mt-3 d-flex">
<div class="card border-secondary flex-grow-1 overflow-auto">
<div class="card-header bg-secondary text-white">
{% trans %}assembly.import_bom.template.header.json{% endtrans %}
</div>
<div class="card-body">
<pre>{{ jsonTemplate|json_encode(constant('JSON_PRETTY_PRINT') b-or constant('JSON_UNESCAPED_UNICODE')) }}</pre>
{{ 'assembly.bom_import.template.json.table'|trans|raw }}
</div>
</div>
</div>
<div class="col-md-12 col-lg-6 ps-0 mt-3 d-flex">
<div class="card border-secondary flex-grow-1 overflow-auto">
<div class="card-header bg-secondary text-white">
{% trans %}assembly.import_bom.template.header.csv{% endtrans %}
</div>
<div class="card-body">
{{ 'assembly.bom_import.template.csv.exptected_columns'|trans }}
<pre>quantity;name;part_id;part_mpnr;part_ipn;part_name;part_description;part_manufacturer_id;part_manufacturer_name;part_category_id;part_category_name</pre>
<ul>
<li>quantity</li>
<li>name</li>
<li>part_id</li>
<li>part_mpnr</li>
<li>part_ipn</li>
<li>part_name</li>
<li>part_description</li>
<li>part_manufacturer_id</li>
<li>part_manufacturer_name</li>
<li>part_category_id</li>
<li>part_category_name</li>
</ul>
{{ 'assembly.bom_import.template.csv.table'|trans|raw }}
</div>
</div>
</div>
<div class="col-md-12 col-lg-6 ps-0 mt-3 d-flex overflow-auto">
<div class="card border-secondary flex-grow-1 overflow-auto">
<div class="card-header bg-secondary text-white">
{% trans %}assembly.import_bom.template.header.kicad_pcbnew{% endtrans %}
</div>
<div class="card-body">
{{ 'assembly.bom_import.template.kicad_pcbnew.exptected_columns'|trans }}
<pre>Id;Designator;Package;Quantity;Designation;Supplier and ref</pre>
<ul>
<li>Id</li>
<li>Designator</li>
<li>Package</li>
<li>Quantity</li>
<li>Designation</li>
<li>Supplier and ref</li>
<li>Note</li>
<li>Footprint</li>
<li>Value</li>
<li>Footprint</li>
</ul>
{{ 'assembly.bom_import.template.kicad_pcbnew.exptected_columns.note'|trans|raw }}
{{ 'assembly.bom_import.template.kicad_pcbnew.table'|trans|raw }}
{{ 'assembly.bom_import.template.json.table'|trans|raw }}
</div>
</div>
</div>
</div>
{% endblock %}

View file

@ -0,0 +1,91 @@
{% import "helper.twig" as helper %}
<table class="table table-striped table-sm table-hover table-responsive-sm">
<thead>
<tr>
<th></th>
<th>{% trans %}attachment.name{% endtrans %}</th>
<th>{% trans %}attachment.attachment_type{% endtrans %}</th>
<th>{% trans %}attachment.file_name{% endtrans %}</th>
<th>{% trans %}attachment.file_size{% endtrans %}</th>
<th></th>
</tr>
</thead>
<tbody>
{% for attachment in assembly.attachments %}
<tr>
<td>
{% import "components/attachments.macro.html.twig" as attachments %}
{{ attachments.attachment_icon(attachment, attachment_manager) }}
</td>
<td class="align-middle">{{ attachment.name }}</td>
<td class="align-middle">{{ attachment.attachmentType.fullPath }}</td>
<td class="align-middle">
{% if attachment.hasInternal() %}
{{ attachment.filename }}
{% endif %}
</td>
<td class="align-middle h6">
{% if not attachment.hasInternal() %}
<span class="badge bg-primary">
<i class="fas fa-fw fa-globe"></i> {% trans %}attachment.external_only{% endtrans %}
</span>
{% elseif attachment_manager.internalFileExisting(attachment) %}
<span class="badge bg-secondary">
<i class="fas fa-hdd fa-fw"></i> {{ attachment_manager.humanFileSize(attachment) }}
</span>
{% else %}
<span class="badge bg-warning">
<i class="fas fa-exclamation-circle fa-fw"></i> {% trans %}attachment.file_not_found{% endtrans %}
</span>
{% endif %}
{% if attachment.secure %}
<br><span class="badge bg-success">
<i class="fas fa-fw fa-shield-alt"></i> {% trans %}attachment.secure{% endtrans %}
</span>
{% endif %}
{% if attachment == assembly.masterPictureAttachment %}
<br>
<span class="badge bg-primary">
<i class="fas fa-id-card"></i> {% trans %}attachment.preview{% endtrans %}
</span>
{% endif %}
</td>
<td><div class="btn-group" role="group" aria-label="">
<a {% if attachment.hasExternal() %}href="{{ attachment.externalPath }}"{% endif %} target="_blank"
class="btn btn-secondary {% if not attachment.hasExternal() %}disabled{% endif %}"
data-turbo="false" title="{% trans with {"%host%": attachment.host} %}attachment.view_external.view_at{% endtrans %}" rel="noopener">
<i class="fas fa-globe fa-fw"></i>
</a>
<a {% if attachment_manager.isInternalFileExisting(attachment) %}href="{{ entity_url(attachment, 'file_view') }}"{% endif %} target="_blank"
class="btn btn-secondary {% if not attachment_manager.isInternalFileExisting(attachment) or (attachment.secure and not is_granted("show_private", attachment)) %}disabled{% endif %}"
data-turbo="false" title="{% trans %}attachment.view_local{% endtrans %}" rel="noopener">
<i class="fas fa-eye fa-fw"></i>
</a>
<a {% if attachment_manager.isInternalFileExisting(attachment) %}href="{{ entity_url(attachment, 'file_download') }}"{% endif %} data-turbo="false"
class="btn btn-secondary {% if not attachment_manager.isInternalFileExisting(attachment) or (attachment.secure and not is_granted("show_private", attachment)) %}disabled{% endif %}"
title="{% trans %}attachment.download_local{% endtrans %}">
<i class="fas fa-download fa-fw"></i>
</a>
<div class="btn-group" role="group">
<button id="btnGroupDrop1" type="button" class="btn btn-secondary dropdown-toggle" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false" data-boundary="window">
</button>
<div class="dropdown-menu" aria-labelledby="btnGroupDrop1">
<span class="text-muted dropdown-item-text" ><i class="fas fa-lightbulb fa-fw"></i> <b>ID:</b> {{ attachment.iD }}</span>
<span class="text-muted dropdown-item-text" ><i class="fas fa-history fa-fw"></i> <b>{% trans %}lastModified{% endtrans %}:</b> {{ helper.entity_last_modified(attachment) }}</span>
<span class="text-muted dropdown-item-text" ><i class="fas fa-calendar-plus fa-fw"></i> <b>{% trans %}createdAt{% endtrans %}:</b> {{ helper.entity_created_at(attachment) }}</span>
</div>
</div>
</div></td>
</tr>
{% endfor %}
</tbody>
</table>

View file

@ -0,0 +1,72 @@
{% import "helper.twig" as helper %}
<div class="row mt-2">
<div class="col-md-8">
<div class="row">
<div class="col-md-3 col-lg-4 col-4 mt-auto mb-auto">
{% if assembly.masterPictureAttachment %}
<a href="{{ entity_url(assembly.masterPictureAttachment, 'file_view') }}" data-turbo="false" target="_blank" rel="noopener">
<img class="d-block w-100 img-fluid img-thumbnail bg-body-tertiary part-info-image" src="{{ entity_url(assembly.masterPictureAttachment, 'file_view') }}" alt="">
</a>
{% else %}
<img src="{{ asset('img/part_placeholder.svg') }}" class="img-fluid img-thumbnail bg-body-tertiary mb-2 " alt="Part main image" height="300" width="300">
{% endif %}
</div>
<div class="col-md-9 col-lg-8 col-7">
<h3 class="w-fit" title="{% trans %}name.label{% endtrans %}">{{ assembly.name }}
{# You need edit permission to use the edit button #}
{% if is_granted('edit', assembly) %}
<a href="{{ entity_url(assembly, 'edit') }}"><i class="fas fa-fw fa-sm fa-edit"></i></a>
{% endif %}
</h3>
<h6 class="text-muted w-fit" title="{% trans %}description.label{% endtrans %}"><span>{{ assembly.description|format_markdown(true) }}</span></h6>
</div>
</div>
</div>
<div class="col-md-4"> {# Sidebar panel with infos about last creation date, etc. #}
<div class="mb-3">
<span class="text-muted" title="{% trans %}lastModified{% endtrans %}">
<i class="fas fa-history fa-fw"></i> {{ helper.date_user_combination(assembly, true) }}
</span>
<br>
<span class="text-muted mt-1" title="{% trans %}createdAt{% endtrans %}">
<i class="fas fa-calendar-plus fa-fw"></i> {{ helper.date_user_combination(assembly, false) }}
</span>
</div>
<div class="mt-1">
<h6>
{{ helper.assemblies_status_to_badge(assembly.status) }}
</h6>
</div>
<div class="mt-1">
<h6>
<span class="badge badge-primary bg-primary">
<i class="fa-solid fa-list-check fa-fw"></i>
{{ assembly.bomEntries | length }}
{% trans %}assembly.info.bom_entries_count{% endtrans %}
</span>
</h6>
</div>
{% if assembly.children is not empty %}
<div class="mt-1">
<h6>
<span class="badge badge-primary bg-secondary">
<i class="fa-solid fa-folder-tree fa-fw"></i>
{{ assembly.children | length }}
{% trans %}assembly.info.sub_assemblies_count{% endtrans %}
</span>
</h6>
</div>
{% endif %}
</div>
{% if assembly.comment is not empty %}
<p>
<h5>{% trans %}comment.label{% endtrans %}:</h5>
{{ assembly.comment|format_markdown }}
</p>
{% endif %}
</div>

View file

@ -0,0 +1,118 @@
{% import "helper.twig" as helper %}
{% import "label_system/dropdown_macro.html.twig" as dropdown %}
{{ helper.breadcrumb_entity_link(assembly) }}
<div class="accordion mb-4" id="listAccordion">
<div class="accordion-item">
<div class="accordion-header">
<button class="accordion-button collapsed py-2" data-bs-toggle="collapse" data-bs-target="#entityInfo" aria-expanded="true">
{% if assembly.masterPictureAttachment is not null and attachment_manager.isFileExisting(assembly.masterPictureAttachment) %}
<img class="hoverpic ms-0 me-1 d-inline" {{ stimulus_controller('elements/hoverpic') }} data-thumbnail="{{ entity_url(assembly.masterPictureAttachment, 'file_view') }}" src="{{ attachment_thumbnail(assembly.masterPictureAttachment, 'thumbnail_sm') }}">
{% else %}
{{ helper.entity_icon(assembly, "me-1") }}
{% endif %}
{% trans %}assembly.label{% endtrans %}:&nbsp;<b>{{ assembly.name }}</b>
</button>
</div>
<div id="entityInfo" class="accordion-collapse collapse show" data-bs-parent="#listAccordion">
<div class="accordion-body">
{% if assembly.description is not empty %}
{{ assembly.description|format_markdown }}
{% endif %}
<div class="row">
<div class="col-sm-2">
<div class="nav flex-column nav-pills" id="v-pills-tab" role="tablist" aria-orientation="vertical">
<a class="nav-link active" id="v-pills-home-tab" data-bs-toggle="pill" href="#v-pills-home" role="tab" aria-controls="v-pills-home" aria-selected="true">
<i class="fas fa-info-circle fa-fw"></i>
{% trans %}entity.info.common.tab{% endtrans %}
</a>
<a class="nav-link" id="v-pills-statistics-tab" data-bs-toggle="pill" href="#v-pills-statistics" role="tab" aria-controls="v-pills-profile" aria-selected="false">
<i class="fas fa-chart-pie fa-fw"></i>
{% trans %}entity.info.statistics.tab{% endtrans %}
</a>
{% if assembly.attachments is not empty %}
<a class="nav-link" id="v-pills-attachments-tab" data-bs-toggle="pill" href="#v-pills-attachments" role="tab" aria-controls="v-pills-attachments" aria-selected="false">
<i class="fas fa-paperclip fa-fw"></i>
{% trans %}entity.info.attachments.tab{% endtrans %}
</a>
{% endif %}
{% if assembly.comment is not empty %}
<a class="nav-link" id="v-pills-comment-tab" data-bs-toggle="pill" href="#v-pills-comment" role="tab">
<i class="fas fa-comment-alt fa-fw"></i>
{% trans %}comment.label{% endtrans %}
</a>
{% endif %}
</div>
</div>
<div class="col-sm-10">
<div class="tab-content" id="v-pills-tabContent">
<div class="tab-pane fade show active" id="v-pills-home" role="tabpanel" aria-labelledby="v-pills-home-tab">
<div class="row">
<div class="col-sm-9 form-horizontal">
<div class="form-group">
<label class="col-sm-4">{% trans %}entity.info.name{% endtrans %}:</label>
<span class="col-sm form-control-static">{{ assembly.name }}</span>
</div>
<div class="form-group">
<label class="col-sm-4">{% trans %}entity.info.parent{% endtrans %}:</label>
<span class="col-sm form-control-static">
{% if assembly.parent %}
{{ assembly.parent.fullPath }}
{% else %}
-
{% endif %}
</span>
</div>
</div>
<div class="col-sm-3">
{% block quick_links %}{% endblock %}
<a class="btn btn-secondary w-100 mb-2" href="{{ entity_url(assembly, 'edit') }}">
<i class="fas fa-edit"></i> {% trans %}entity.edit.btn{% endtrans %}
</a>
<div class="">
<span class="text-muted" title="{% trans %}lastModified{% endtrans %}">
<i class="fas fa-history fa-fw"></i> {{ assembly.lastModified | format_datetime("short") }}
</span>
<br>
<span class="text-muted mt-1" title="{% trans %}createdAt{% endtrans %}">
<i class="fas fa-calendar-plus fa-fw"></i> {{ assembly.addedDate | format_datetime("short") }}
</span>
</div>
</div>
</div>
</div>
<div class="tab-pane fade" id="v-pills-statistics" role="tabpanel" aria-labelledby="v-pills-statistics-tab">
<div class="form-horizontal">
<div class="form-group">
<label class="col-sm-4">{% trans %}entity.info.children_count{% endtrans %}:</label>
<span class="col-sm form-control-static">{{ assembly.children | length }}</span>
</div>
<div class="form-group">
<label class="col-sm-4">{% trans %}entity.info.parts_count{% endtrans %}:</label>
<span class="col-sm form-control-static">{{ assembly.bomEntries | length }}</span>
</div>
</div>
</div>
{% if assembly.attachments is not empty %}
<div class="tab-pane fade" id="v-pills-attachments" role="tabpanel" aria-labelledby="v-pills-attachments-tab">
{% include "parts/info/_attachments_info.html.twig" with {"part": assembly} %}
</div>
{% endif %}
{% if assembly.comment is not empty %}
<div class="tab-pane fade" id="v-pills-comment" role="tabpanel" aria-labelledby="home-tab">
<div class="container-fluid mt-2 latex" data-controller="common--latex">
{{ assembly.comment|format_markdown }}
</div>
</div>
{% endif %}
</div>
</div>
</div>
</div>
</div>
</div>
</div>

View file

@ -0,0 +1,5 @@
{% import "components/datatables.macro.html.twig" as datatables %}
<div class="mb-2 mt-2"></div>
{{ datatables.datatable(datatable, 'elements/datatables/datatables', 'assemblies') }}

View file

@ -0,0 +1,28 @@
<table class="table table-striped table-hover">
<thead>
<tr>
<th>{% trans %}name.label{% endtrans %}</th>
<th>{% trans %}description.label{% endtrans %}</th>
<th># {% trans %}assembly.info.bom_entries_count{% endtrans %}</th>
<th># {% trans %}assembly.info.sub_assemblies_count{% endtrans %}</th>
</tr>
</thead>
<tbody>
{% for assembly in assembly.children %}
<tr>
<td> {# Name #}
<a href="{{ entity_url(assembly, 'info') }}">{{ assembly.name }}</a>
</td>
<td> {# Description #}
{{ assembly.description | format_markdown }}
</td>
<td>
{{ assembly.bomEntries | length }}
</td>
<td>
{{ assembly.children | length }}
</td>
</tr>
{% endfor %}
</tbody>
</table>

View file

@ -0,0 +1,135 @@
{% extends "main_card.html.twig" %}
{% import "helper.twig" as helper %}
{% block title %}
{% trans %}assembly.info.title{% endtrans %}: {{ assembly.name }}
{% endblock %}
{% block before_card %}
<div class="row mb-3">
<div class="col d-flex align-items-center">
<a href="{{ path('assembly_new') }}"
class="btn btn-success me-2 {% if not is_granted('@assemblies.create') %}disabled{% endif %}">
<i class="fas fa-plus-square fa-fw"></i>
{% trans %}assembly.new{% endtrans %}
</a>
<div class="btn-group">
<a class="btn btn-success" {% if not is_granted('@assemblies.edit') %}disabled{% endif %}
href="{{ path('assembly_add_parts', {"id": assembly.id, "_redirect": uri_without_host(app.request)}) }}">
<i class="fa-solid fa-square-plus fa-fw"></i>
{% trans %}assembly.info.bom_add_parts{% endtrans %}
</a>
<button type="button" class="btn btn-success dropdown-toggle dropdown-toggle-split" data-bs-toggle="dropdown" aria-expanded="false">
<span class="visually-hidden">Toggle Dropdown</span>
</button>
<ul class="dropdown-menu">
<li>
<a class="dropdown-item" href="{{ path('assembly_import_bom', {'id': assembly.id}) }}" {% if not is_granted('edit', assembly) %}disabled="disabled"{% endif %}>
<i class="fa-solid fa-file-import fa-fw"></i>
{% trans %}assembly.edit.bom.import_bom{% endtrans %}
</a>
</li>
</ul>
</div>
<a class="btn btn-secondary ms-2" {% if not is_granted('@assemblies.edit') %}disabled{% endif %}
href="{{ path('assembly_edit', {"id": assembly.id}) }}#part">
<i class="fa-solid fa-square-plus fa-cog"></i>
{% trans %}assembly_bom_entry.labelp{% endtrans %}
</a>
<a class="btn btn-secondary ms-2" {% if not is_granted('@assemblies.edit') %}disabled{% endif %}
href="{{ path('assembly_edit', {"id": assembly.id}) }}">
<i class="fa-solid fa-pencil"></i>
</a>
</div>
</div>
{% endblock %}
{% block content %}
{{ helper.breadcrumb_entity_link(assembly) }}
{{ parent() }}
{% endblock %}
{% block card_title %}
{% if assembly.masterPictureAttachment is not null and attachment_manager.isFileExisting(assembly.masterPictureAttachment) %}
<img class="hoverpic ms-0 me-1 d-inline" {{ stimulus_controller('elements/hoverpic') }} data-thumbnail="{{ entity_url(assembly.masterPictureAttachment, 'file_view') }}" src="{{ attachment_thumbnail(assembly.masterPictureAttachment, 'thumbnail_sm') }}">
{% else %}
{{ helper.entity_icon(assembly, "me-1") }}
{% endif %}
{% trans %}assembly.info.title{% endtrans %}:&nbsp;<b>{{ assembly.name }}</b>
{% endblock %}
{% block card_content %}
<ul class="nav nav-tabs" role="tablist">
<li class="nav-item" role="presentation">
<button class="nav-link active" id="info-tab" data-bs-toggle="tab" data-bs-target="#info-tab-pane"
type="button" role="tab" aria-controls="info-tab-pane" aria-selected="true">
<i class="fa-solid fa-circle-info fa-fw"></i>
{% trans %}assembly.info.info.label{% endtrans %}
</button>
</li>
{% if assembly.children is not empty %}
<li class="nav-item" role="presentation">
<button class="nav-link" id="subassemblies-tab" data-bs-toggle="tab" data-bs-target="#subassemblies-tab-pane"
type="button" role="tab" aria-controls="subassemblies-tab-pane" aria-selected="false">
<i class="fa-solid fa-folder-tree fa-fw"></i>
{% trans %}assembly.info.sub_assemblies.label{% endtrans %}
<span class="badge bg-secondary">{{ assembly.children | length }}</span>
</button>
</li>
{% endif %}
<li class="nav-item" role="presentation">
<button class="nav-link" id="part-tab" data-bs-toggle="tab" data-bs-target="#part-tab-pane"
type="button" role="tab" aria-controls="part-tab-pane" aria-selected="false">
<i class="fa-solid fa-list-check fa-fw"></i>
{% trans %}assembly_bom_entry.labelp{% endtrans %}
<span class="badge bg-secondary">{{ assembly.bomEntries | length }}</span>
</button>
</li>
{% if assembly.attachments is not empty %}
<li class="nav-item">
<a class="nav-link" id="attachments-tab" data-bs-toggle="tab"
data-bs-target="#attachments-tab-pane" role="tab">
<i class="fas fa-paperclip fa-fw"></i>
{% trans %}attachment.labelp{% endtrans %}
<span class="badge bg-secondary">{{ assembly.attachments | length }}</span>
</a>
</li>
{% endif %}
{% if assembly.parameters is not empty %}
<li class="nav-item">
<a class="nav-link" id="parameters-tab" data-bs-toggle="tab"
data-bs-target="#parameters-tab-pane" role="tab">
<i class="fas fa-atlas fa-fw"></i>
{% trans %}entity.info.parameters.tab{% endtrans %}
<span class="badge bg-secondary">{{ assembly.parameters | length }}</span>
</a>
</li>
{% endif %}
</ul>
<div class="tab-content">
<div class="tab-pane fade show active" id="info-tab-pane" role="tabpanel" aria-labelledby="info-tab" tabindex="0">
{% include "assemblies/info/_info.html.twig" %}
</div>
{% if assembly.children is not empty %}
<div class="tab-pane fade" id="subassemblies-tab-pane" role="tabpanel" aria-labelledby="bom-tab" tabindex="0">
{% include "assemblies/info/_subassemblies.html.twig" %}
</div>
{% endif %}
<div class="tab-pane fade" id="part-tab-pane" role="tabpanel" aria-labelledby="part-tab" tabindex="0">
{% include "assemblies/info/_part.html.twig" %}
</div>
<div class="tab-pane fade" id="attachments-tab-pane" role="tabpanel" aria-labelledby="attachments-tab" tabindex="0">
{% include "assemblies/info/_attachments_info.html.twig" with {"assembly": assembly} %}
</div>
<div class="tab-pane fade" id="parameters-tab-pane" role="tabpanel" aria-labelledby="parameters-tab">
{% for name, parameters in assembly.groupedParameters %}
{% if name is not empty %}<h5 class="mt-1">{{ name }}</h5>{% endif %}
{{ helper.parameters_table(assembly.parameters) }}
{% endfor %}
</div>
</div>
{% endblock %}

View file

@ -0,0 +1,6 @@
<div class="row mb-3">
<div class="col">
<a href="{{ path('assembly_new', url_options) }}" class="btn btn-success float-start {% if not is_granted("@assemblies.create") or (entity is defined and entity.notSelectable) %}disabled{% endif %}"><i class="fas fa-plus-square fa-fw"></i>
{% trans%}assemblies.create.btn{% endtrans %}</a>
</div>
</div>

View file

@ -0,0 +1,62 @@
<div class="accordion-item">
<div class="accordion-header">
<button class="accordion-button collapsed py-2" type="button" data-bs-toggle="collapse" data-bs-target="#filterFormCollapse" aria-expanded="false" aria-controls="filterFormCollapse"><i class="fa-solid fa-filter fa-fw"></i> {% trans %}filter.title{% endtrans %}</button>
</div>
<div id="filterFormCollapse" class="accordion-collapse collapse" data-bs-parent="#listAccordion">
<div class="accordion-body">
<ul class="nav nav-tabs" id="filterTabs" role="tablist">
<li class="nav-item" role="presentation">
<button class="nav-link active" id="filter-common-tab" data-bs-toggle="tab" data-bs-target="#filter-common"><i class="fas fa-id-card fa-fw"></i> {% trans %}assembly.edit.tab.common{% endtrans %}</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="filter-advanced-tab" data-bs-toggle="tab" data-bs-target="#filter-advanced"><i class="fas fa-shapes fa-fw"></i> {% trans %}assembly.edit.tab.advanced{% endtrans %}</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="filter-attachments-tab" data-bs-toggle="tab" data-bs-target="#filter-attachments"><i class="fas fa-paperclip fa-fw"></i> {% trans %}assembly.edit.tab.attachments{% endtrans %}</button>
</li>
</ul>
{{ form_start(filterForm, {"attr": {"data-controller": "helpers--form-cleanup", "data-action": "helpers--form-cleanup#submit"}}) }}
<div class="tab-content">
<div class="tab-pane active pt-3" id="filter-common" role="tabpanel" aria-labelledby="filter-common-tab" tabindex="0">
{{ form_row(filterForm.name) }}
{{ form_row(filterForm.description) }}
{{ form_row(filterForm.comment) }}
</div>
<div class="tab-pane pt-3" id="filter-advanced" role="tabpanel" aria-labelledby="filter-advanced-tab" tabindex="0">
{{ form_row(filterForm.dbId) }}
{{ form_row(filterForm.ipn) }}
{{ form_row(filterForm.lastModified) }}
{{ form_row(filterForm.addedDate) }}
</div>
<div class="tab-pane pt-3" id="filter-attachments" role="tabpanel" aria-labelledby="filter-attachments-tab" tabindex="0">
{{ form_row(filterForm.attachmentsCount) }}
{{ form_row(filterForm.attachmentType) }}
{{ form_row(filterForm.attachmentName) }}
</div>
</div>
{{ form_row(filterForm.submit) }}
{{ form_row(filterForm.discard) }}
<div class="row mb-3">
<div class="col-sm-9 offset-sm-3">
<button type="button" class="btn btn-danger" {{ stimulus_action('helpers/form_cleanup', 'clearAll') }}>{% trans %}filter.clear_filters{% endtrans %}</button>
</div>
</div>
{# Retain the query parameters of the search form if it is existing #}
{% if searchFilter is defined %}
{% for property, value in searchFilter|to_array %}
<input type="hidden" name="{{ property }}" data-no-clear="true" value="{{ value }}">
{% endfor %}
{% endif %}
{{ form_end(filterForm) }}
</div>
</div>
</div>

View file

@ -0,0 +1,30 @@
{% extends "base.html.twig" %}
{% block title %}
{% trans %}assembly_list.all.title{% endtrans %}
{% endblock %}
{% block content %}
<div class="accordion mb-3" id="listAccordion">
<div class="accordion-item">
<div class="accordion-header">
<button class="accordion-button collapsed py-2" data-bs-toggle="collapse" data-bs-target="#searchInfo" disabled>
<i class="fa-solid fa-globe fa-fw"></i>
{% trans %}assembly_list.all.title{% endtrans %}
</button>
</div>
<div id="searchInfo" class="accordion-collapse collapse" data-bs-parent="#listAccordion">
<div class="accordion-body">
</div>
</div>
</div>
{% include "assemblies/lists/_filter.html.twig" %}
</div>
{% include "assemblies/lists/_action_bar.html.twig" with {'url_options': {}} %}
{% include "assemblies/lists/data.html.twig" %}
{% endblock %}

View file

@ -0,0 +1,3 @@
{% import "components/datatables.macro.html.twig" as datatables %}
{{ datatables.partsDatatableWithForm(datatable) }}

View file

@ -65,6 +65,10 @@
<label for="search_footprint" class="form-check-label justify-content-start">{% trans %}footprint.label{% endtrans %}</label>
</div>
{% endif %}
<div class="form-check">
<input type="checkbox" class="form-check-input" id="search_assembly" name="assembly" value="1" {{ stimulus_controller('elements/localStorage_checkbox') }}>
<label for="search_assembly" class="form-check-label justify-content-start">{% trans %}assembly.label{% endtrans %}</label>
</div>
<hr>
<div class="form-check">
<input type="checkbox" class="form-check-input" id="regex" name="regex" value="1" {{ stimulus_controller('elements/localStorage_checkbox') }}>
@ -106,4 +110,4 @@
{{ _self.settings_drodown(is_navbar) }}
{% endif %}
</form>
{% endmacro %}
{% endmacro %}

View file

@ -9,6 +9,7 @@
['manufacturers', path('tree_manufacturer_root'), '@manufacturer@@', is_granted('@manufacturers.read') and is_granted('@parts.read'), 'manufacturer'],
['suppliers', path('tree_supplier_root'), '@supplier@@', is_granted('@suppliers.read') and is_granted('@parts.read'), 'supplier'],
['projects', path('tree_device_root'), '@project@@', is_granted('@projects.read'), 'project'],
['assembly', path('tree_assembly_root'), 'assembly.labelp', is_granted('@assemblies.read'), 'assembly'],
['tools', path('tree_tools'), 'tools.label', true, 'tool'],
] %}

View file

@ -0,0 +1,74 @@
{% block assembly_bom_entry_collection_widget %}
{% import 'components/collection_type.macro.html.twig' as collection %}
<div {{ collection.controller(form, 'assembly.bom.delete.confirm', 3) }}>
<table class="table table-striped table-bordered table-sm" {{ collection.target() }}>
<thead>
<tr>
<th></th> {# expand button #}
<th>{% trans %}assembly.bom.quantity{% endtrans %}</th>
<th>{% trans %}assembly.bom.partOrAssembly{% endtrans %}</th>
<th>{% trans %}assembly.bom.identifiers{% endtrans %}</th>
<th></th> {# Remove button #}
</tr>
</thead>
<tbody>
{% for entry in form %}
{{ form_widget(entry) }}
{% endfor %}
</tbody>
</table>
<button type="button" class="btn btn-success mb-2" {{ collection.create_btn() }}>
<i class="fas fa-plus-square fa-fw"></i>
{% trans %}assembly.bom.add_entry{% endtrans %}
</button>
</div>
{% endblock %}
{% block assembly_bom_entry_widget %}
{% set target_id = 'expand_row-' ~ form.vars.name %}
{% import 'components/collection_type.macro.html.twig' as collection %}
<tr>
<td>
<button class="btn btn-sm btn-outline-secondary" type="button" data-bs-toggle="collapse" data-bs-target="#{{ target_id }}">
<i class="fa-solid fa-eye"></i>
</button>
</td>
<td>
{{ form_widget(form.quantity) }}
{{ form_errors(form.quantity) }}
</td>
<td style="min-width: 450px;">
{{ form_row(form.part) }}
{{ form_errors(form.part) }}
<div class="text-center mb-2" style="line-height: 1"></div>
{{ form_widget(form.referencedAssembly) }}
{{ form_errors(form.referencedAssembly) }}
</td>
<td style="min-width: 450px;">
{{ form_row(form.name) }}
{{ form_errors(form.name) }}
<div class="text-center mb-2" style="line-height: 1"></div>
{{ form_row(form.designator) }}
{{ form_errors(form.designator) }}
</td>
<td>
<button type="button" class="btn btn-danger lot_btn_delete position-relative" {{ collection.delete_btn() }}>
<i class="fas fa-trash-alt fa-fw"></i>
{{ collection.new_element_indicator(value) }}
</button>
{{ form_errors(form) }}
</td>
</tr>
<tr class="p-0 d-none"></tr>
<tr class="p-0">
<td colspan="5" class="accordion-body collapse" id="{{ target_id }}">
<div class="">
{{ form_row(form.comment) }}
</div>
</td>
</tr>
{% endblock %}

View file

@ -76,6 +76,21 @@
{% endif %}
{% endmacro %}
{% macro assemblies_status_to_badge(status, class="badge") %}
{% if status is not empty %}
{% set color = " bg-secondary" %}
{% if status == "in_production" %}
{% set color = " bg-success" %}
{% endif %}
<span class="{{ class ~ color}}">
<i class="fa-fw fas fa-info-circle"></i>
{{ ("assembly.status." ~ status) | trans }}
</span>
{% endif %}
{% endmacro %}
{% macro structural_entity_link(entity, link_type = "list_parts") %}
{# @var entity \App\Entity\Base\StructuralDBElement #}
{% if entity %}
@ -101,6 +116,7 @@
"category": ["fa-solid fa-tags", "category.label"],
"currency": ["fa-solid fa-coins", "currency.label"],
"device": ["fa-solid fa-archive", "project.label"],
"assembly": ["fa-solid fa-list", "assembly.label"],
"footprint": ["fa-solid fa-microchip", "footprint.label"],
"group": ["fa-solid fa-users", "group.label"],
"label_profile": ["fa-solid fa-qrcode", "label_profile.label"],

View file

@ -0,0 +1,31 @@
{% import "components/attachments.macro.html.twig" as attachments %}
{% import "helper.twig" as helper %}
<table class="table table-striped table-sm table-hover table-responsive-sm">
<thead>
<tr>
<th></th>
<th>{% trans %}entity.info.name{% endtrans %}</th>
<th>{% trans %}description.label{% endtrans %}</th>
<th>{% trans %}assembly.bom.quantity{% endtrans %}</th>
</tr>
</thead>
<tbody>
{% for bom_entry in part.assemblyBomEntries %}
{# @var bom_entry App\Entity\Assembly\AssemblyBOMEntry #}
<tr>
<td>{% if bom_entry.assembly.masterPictureAttachment is not null %}{{ attachments.attachment_icon(bom_entry.assembly.masterPictureAttachment, attachment_manager) }}{% endif %}</td>
<td><a href="{{ path('assembly_info', {'id': bom_entry.assembly.iD}) }}">{{ bom_entry.assembly.name }}</a></td> {# Name #}
<td>{{ bom_entry.assembly.description|format_markdown }}</td> {# Description #}
<td>{{ bom_entry.quantity | format_amount(part.partUnit) }}</td>
</tr>
{% endfor %}
</tbody>
</table>
<a class="btn btn-success" {% if not is_granted('@assemblies.edit') %}disabled{% endif %}
href="{{ path('assembly_add_parts_no_id', {"parts": part.id, "_redirect": uri_without_host(app.request)}) }}">
<i class="fa-solid fa-magnifying-glass-plus fa-fw"></i>
{% trans %}part.info.add_part_to_assembly{% endtrans %}
</a>

Some files were not shown because too many files have changed in this diff Show more