mirror of
https://github.com/Part-DB/Part-DB-server.git
synced 2026-01-13 21:59:34 +00:00
Merge ebb8a2b48b into 3f6a6cc767
This commit is contained in:
commit
30840ac8b2
136 changed files with 23994 additions and 112 deletions
70
assets/controllers/elements/assembly_select_controller.js
Normal file
70
assets/controllers/elements/assembly_select_controller.js
Normal 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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
62
assets/controllers/elements/toggle_visibility_controller.js
Normal file
62
assets/controllers/elements/toggle_visibility_controller.js
Normal 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" : "";
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -61,3 +61,8 @@
|
|||
.object-fit-cover {
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.assembly-table-image {
|
||||
max-height: 40px;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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') {
|
||||
|
|
|
|||
|
|
@ -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]]"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
236
migrations/Version20251016141941.php
Normal file
236
migrations/Version20251016141941.php
Normal 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');
|
||||
}
|
||||
}
|
||||
|
|
@ -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'],
|
||||
|
|
|
|||
80
src/Controller/AdminPages/AssemblyAdminController.php
Normal file
80
src/Controller/AdminPages/AssemblyAdminController.php
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
318
src/Controller/AssemblyController.php
Normal file
318
src/Controller/AssemblyController.php
Normal 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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -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'));
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
247
src/DataTables/AssemblyBomEntriesDataTable.php
Normal file
247
src/DataTables/AssemblyBomEntriesDataTable.php
Normal 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
|
||||
{
|
||||
|
||||
}
|
||||
}
|
||||
250
src/DataTables/AssemblyDataTable.php
Normal file
250
src/DataTables/AssemblyDataTable.php
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
68
src/DataTables/Filters/AssemblyFilter.php
Normal file
68
src/DataTables/Filters/AssemblyFilter.php
Normal 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);
|
||||
}
|
||||
}
|
||||
172
src/DataTables/Filters/AssemblySearchFilter.php
Normal file
172
src/DataTables/Filters/AssemblySearchFilter.php
Normal 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;
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
77
src/DataTables/Helpers/AssemblyDataTableHelper.php
Normal file
77
src/DataTables/Helpers/AssemblyDataTableHelper.php
Normal 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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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');
|
||||
|
|
|
|||
373
src/Entity/AssemblySystem/Assembly.php
Normal file
373
src/Entity/AssemblySystem/Assembly.php
Normal 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;
|
||||
}
|
||||
|
||||
}
|
||||
340
src/Entity/AssemblySystem/AssemblyBOMEntry.php
Normal file
340
src/Entity/AssemblySystem/AssemblyBOMEntry.php
Normal 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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
48
src/Entity/Attachments/AssemblyAttachment.php
Normal file
48
src/Entity/Attachments/AssemblyAttachment.php
Normal 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;
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
|
||||
|
|
|
|||
65
src/Entity/Parameters/AssemblyParameter.php
Normal file
65
src/Entity/Parameters/AssemblyParameter.php
Normal 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;
|
||||
}
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
45
src/Entity/Parts/PartTraits/AssemblyTrait.php
Normal file
45
src/Entity/Parts/PartTraits/AssemblyTrait.php
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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: [
|
||||
|
|
|
|||
82
src/Form/AdminPages/AssemblyAdminForm.php
Normal file
82
src/Form/AdminPages/AssemblyAdminForm.php
Normal 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',
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
91
src/Form/AssemblySystem/AssemblyAddPartsType.php
Normal file
91
src/Form/AssemblySystem/AssemblyAddPartsType.php
Normal 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]);
|
||||
}
|
||||
}
|
||||
32
src/Form/AssemblySystem/AssemblyBOMEntryCollectionType.php
Normal file
32
src/Form/AssemblySystem/AssemblyBOMEntryCollectionType.php
Normal 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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
98
src/Form/AssemblySystem/AssemblyBOMEntryType.php
Normal file
98
src/Form/AssemblySystem/AssemblyBOMEntryType.php
Normal 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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
114
src/Form/Filters/AssemblyFilterType.php
Normal file
114
src/Form/Filters/AssemblyFilterType.php
Normal 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',
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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
|
||||
**************************************************************************/
|
||||
|
|
|
|||
122
src/Form/Type/AssemblySelectType.php
Normal file
122
src/Form/Type/AssemblySelectType.php
Normal 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();
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
273
src/Helpers/Assemblies/AssemblyPartAggregator.php
Normal file
273
src/Helpers/Assemblies/AssemblyPartAggregator.php
Normal 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;
|
||||
}
|
||||
}
|
||||
69
src/Repository/AssemblyRepository.php
Normal file
69
src/Repository/AssemblyRepository.php
Normal 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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)) {
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
93
src/Services/Attachments/AssemblyPreviewGenerator.php
Normal file
93
src/Services/Attachments/AssemblyPreviewGenerator.php
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
60
src/Services/ImportExportSystem/ImporterResult.php
Normal file
60
src/Services/ImportExportSystem/ImporterResult.php
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
54
src/Settings/BehaviorSettings/AssemblyBomTableColumns.php
Normal file
54
src/Settings/BehaviorSettings/AssemblyBomTableColumns.php
Normal 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);
|
||||
}
|
||||
}
|
||||
49
src/Settings/BehaviorSettings/AssemblyTableColumns.php
Normal file
49
src/Settings/BehaviorSettings/AssemblyTableColumns.php
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
45
src/Settings/MiscSettings/AssemblySettings.php
Normal file
45
src/Settings/MiscSettings/AssemblySettings.php
Normal 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;
|
||||
}
|
||||
|
|
@ -38,4 +38,7 @@ class MiscSettings
|
|||
|
||||
#[EmbeddedSettings]
|
||||
public ?IpnSuggestSettings $ipnSuggestSettings = null;
|
||||
|
||||
#[EmbeddedSettings]
|
||||
public ?AssemblySettings $assembly = null;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
39
src/Validator/Constraints/AssemblySystem/AssemblyCycle.php
Normal file
39
src/Validator/Constraints/AssemblySystem/AssemblyCycle.php
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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 hasn’t 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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 hasn’t 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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';
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
48
templates/admin/assembly_admin.html.twig
Normal file
48
templates/admin/assembly_admin.html.twig
Normal 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 %}
|
||||
22
templates/assemblies/add_parts.html.twig
Normal file
22
templates/assemblies/add_parts.html.twig
Normal 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 %}
|
||||
103
templates/assemblies/export_bom_pdf.html.twig
Normal file
103
templates/assemblies/export_bom_pdf.html.twig
Normal 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>
|
||||
|
|
@ -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>
|
||||
114
templates/assemblies/import_bom.html.twig
Normal file
114
templates/assemblies/import_bom.html.twig
Normal 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 %}
|
||||
91
templates/assemblies/info/_attachments_info.html.twig
Normal file
91
templates/assemblies/info/_attachments_info.html.twig
Normal 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>
|
||||
72
templates/assemblies/info/_info.html.twig
Normal file
72
templates/assemblies/info/_info.html.twig
Normal 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>
|
||||
118
templates/assemblies/info/_info_card.html.twig
Normal file
118
templates/assemblies/info/_info_card.html.twig
Normal 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 %}: <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>
|
||||
5
templates/assemblies/info/_part.html.twig
Normal file
5
templates/assemblies/info/_part.html.twig
Normal 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') }}
|
||||
28
templates/assemblies/info/_subassemblies.html.twig
Normal file
28
templates/assemblies/info/_subassemblies.html.twig
Normal 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>
|
||||
135
templates/assemblies/info/info.html.twig
Normal file
135
templates/assemblies/info/info.html.twig
Normal 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 %}: <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 %}
|
||||
6
templates/assemblies/lists/_action_bar.html.twig
Normal file
6
templates/assemblies/lists/_action_bar.html.twig
Normal 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>
|
||||
62
templates/assemblies/lists/_filter.html.twig
Normal file
62
templates/assemblies/lists/_filter.html.twig
Normal 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>
|
||||
30
templates/assemblies/lists/all_list.html.twig
Normal file
30
templates/assemblies/lists/all_list.html.twig
Normal 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 %}
|
||||
3
templates/assemblies/lists/data.html.twig
Normal file
3
templates/assemblies/lists/data.html.twig
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
{% import "components/datatables.macro.html.twig" as datatables %}
|
||||
|
||||
{{ datatables.partsDatatableWithForm(datatable) }}
|
||||
|
|
@ -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 %}
|
||||
|
|
|
|||
|
|
@ -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'],
|
||||
] %}
|
||||
|
||||
|
|
|
|||
74
templates/form/collection_types_layout_assembly.html.twig
Normal file
74
templates/form/collection_types_layout_assembly.html.twig
Normal 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 %}
|
||||
|
|
@ -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"],
|
||||
|
|
|
|||
31
templates/parts/info/_assemblies.html.twig
Normal file
31
templates/parts/info/_assemblies.html.twig
Normal 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
Loading…
Add table
Add a link
Reference in a new issue