-
- ${components.Highlight({hit: item, attribute: 'name'})}
-
+
+
+
+
-
- ${components.Highlight({hit: item, attribute: 'description'})}
- ${item.category ? html`
${components.Highlight({hit: item, attribute: 'category'})}
` : ""}
- ${item.footprint ? html`
${components.Highlight({hit: item, attribute: 'footprint'})}
` : ""}
+
+
+
+ ${components.Highlight({hit: item, attribute: 'name'})}
+
+
+
+ ${components.Highlight({hit: item, attribute: 'description'})}
+ ${item.category ? html`
${components.Highlight({hit: item, attribute: 'category'})}
` : ""}
+ ${item.footprint ? html`
${components.Highlight({hit: item, attribute: 'footprint'})}
` : ""}
+
-
-
- `;
+
+ `;
},
},
},
];
+
+ if (hasAssemblyDetailUrl) {
+ sources.push(
+ // Assemblies source (filtered from the same mixed endpoint results)
+ {
+ sourceId: 'assemblies',
+ getItems() {
+ return fetchMixedItems(query).then((items) =>
+ items.filter((item) => item.type === "assembly")
+ );
+ },
+ getItemUrl({ item }) {
+ return assembly_detail_uri_template.replace('__ID__', item.id);
+ },
+ templates: {
+ header({ html }) {
+ return html`
+ `;
+ },
+ item({ item, components, html }) {
+ const details_url = assembly_detail_uri_template.replace('__ID__', item.id);
+
+ return html`
+
+
+
+

+
+
+
+
+ ${components.Highlight({hit: item, attribute: 'name'})}
+
+
+
+ ${components.Highlight({hit: item, attribute: 'description'})}
+
+
+
+
+ `;
+ },
+ },
+ }
+ );
+ }
+
+ if (hasProjectDetailUrl) {
+ sources.push(
+ // Projects source (filtered from the same mixed endpoint results)
+ {
+ sourceId: 'projects',
+ getItems() {
+ return fetchMixedItems(query).then((items) =>
+ items.filter((item) => item.type === "project")
+ );
+ },
+ getItemUrl({ item }) {
+ return project_detail_uri_template.replace('__ID__', item.id);
+ },
+ templates: {
+ header({ html }) {
+ return html`
+ `;
+ },
+ item({ item, components, html }) {
+ const details_url = project_detail_uri_template.replace('__ID__', item.id);
+
+ return html`
+
+
+
+

+
+
+
+
+ ${components.Highlight({hit: item, attribute: 'name'})}
+
+
+
+ ${components.Highlight({hit: item, attribute: 'description'})}
+
+
+
+
+ `;
+ },
+ },
+ }
+ );
+ }
+
+ return sources;
},
});
@@ -192,6 +316,5 @@ export default class extends Controller {
this._autocomplete.setIsOpen(false);
});
}
-
}
}
diff --git a/assets/controllers/elements/toggle_visibility_controller.js b/assets/controllers/elements/toggle_visibility_controller.js
new file mode 100644
index 00000000..51c9cb33
--- /dev/null
+++ b/assets/controllers/elements/toggle_visibility_controller.js
@@ -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" : "";
+ });
+ });
+ }
+
+}
diff --git a/assets/controllers/pages/dont_check_quantity_checkbox_controller.js b/assets/controllers/pages/dont_check_quantity_checkbox_controller.js
index 2abd3d77..f3e8cb90 100644
--- a/assets/controllers/pages/dont_check_quantity_checkbox_controller.js
+++ b/assets/controllers/pages/dont_check_quantity_checkbox_controller.js
@@ -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() {
diff --git a/assets/controllers/pages/statistics_assembly_controller.js b/assets/controllers/pages/statistics_assembly_controller.js
new file mode 100644
index 00000000..df53e304
--- /dev/null
+++ b/assets/controllers/pages/statistics_assembly_controller.js
@@ -0,0 +1,157 @@
+import { Controller } from '@hotwired/stimulus';
+
+export default class extends Controller {
+ static values = {
+ cleanupBomUrl: String,
+ cleanupPreviewUrl: String
+ }
+
+ static targets = ["bomCount", "previewCount", "bomButton", "previewButton"]
+
+ async cleanup(event) {
+ if (event) {
+ event.preventDefault();
+ event.stopImmediatePropagation();
+ }
+
+ const button = event ? event.currentTarget : null;
+ if (button) button.disabled = true;
+
+ try {
+ const data = await this.fetchWithErrorHandling(this.cleanupBomUrlValue, { method: 'POST' });
+
+ if (data.success) {
+ this.showSuccessMessage(data.message);
+ if (this.hasBomCountTarget) {
+ this.bomCountTarget.textContent = data.new_count;
+ }
+ if (data.new_count === 0 && this.hasBomButtonTarget) {
+ this.bomButtonTarget.remove();
+ }
+ } else {
+ this.showErrorMessage(data.message || 'BOM cleanup failed');
+ }
+ } catch (error) {
+ this.showErrorMessage(error.message || 'An unexpected error occurred during BOM cleanup');
+ } finally {
+ if (button) button.disabled = false;
+ }
+ }
+
+ async cleanupPreview(event) {
+ if (event) {
+ event.preventDefault();
+ event.stopImmediatePropagation();
+ }
+
+ const button = event ? event.currentTarget : null;
+ if (button) button.disabled = true;
+
+ try {
+ const data = await this.fetchWithErrorHandling(this.cleanupPreviewUrlValue, { method: 'POST' });
+
+ if (data.success) {
+ this.showSuccessMessage(data.message);
+ if (this.hasPreviewCountTarget) {
+ this.previewCountTarget.textContent = data.new_count;
+ }
+ if (data.new_count === 0 && this.hasPreviewButtonTarget) {
+ this.previewButtonTarget.remove();
+ }
+ } else {
+ this.showErrorMessage(data.message || 'Preview cleanup failed');
+ }
+ } catch (error) {
+ this.showErrorMessage(error.message || 'An unexpected error occurred during Preview cleanup');
+ } finally {
+ if (button) button.disabled = false;
+ }
+ }
+
+ getHeaders() {
+ return {
+ 'X-Requested-With': 'XMLHttpRequest',
+ 'Accept': 'application/json',
+ }
+ }
+
+ async fetchWithErrorHandling(url, options = {}, timeout = 30000) {
+ const controller = new AbortController()
+ const timeoutId = setTimeout(() => controller.abort(), timeout)
+
+ try {
+ const response = await fetch(url, {
+ ...options,
+ headers: { ...this.getHeaders(), ...options.headers },
+ signal: controller.signal
+ })
+
+ clearTimeout(timeoutId)
+
+ if (!response.ok) {
+ const errorText = await response.text()
+ let errorMessage = `Server error (${response.status})`;
+ try {
+ const errorJson = JSON.parse(errorText);
+ if (errorJson && errorJson.message) {
+ errorMessage = errorJson.message;
+ }
+ } catch (e) {
+ // Not a JSON response, use status text
+ errorMessage = `${errorMessage}: ${errorText}`;
+ }
+ throw new Error(errorMessage)
+ }
+
+ return await response.json()
+ } catch (error) {
+ clearTimeout(timeoutId)
+
+ if (error.name === 'AbortError') {
+ throw new Error('Request timed out. Please try again.')
+ } else if (error.message.includes('Failed to fetch')) {
+ throw new Error('Network error. Please check your connection and try again.')
+ } else {
+ throw error
+ }
+ }
+ }
+
+ showSuccessMessage(message) {
+ this.showToast('success', message)
+ }
+
+ showErrorMessage(message) {
+ this.showToast('error', message)
+ }
+
+ showToast(type, message) {
+ const iconClass = type === 'success' ? 'fa-check-circle' : 'fa-exclamation-triangle';
+ const bgClass = type === 'success' ? 'bg-success' : 'bg-danger';
+ const title = type === 'success' ? 'Success' : 'Error';
+ const timeString = new Date().toLocaleString(undefined, {
+ year: '2-digit',
+ month: 'numeric',
+ day: 'numeric',
+ hour: 'numeric',
+ minute: '2-digit'
+ });
+
+ const toastHTML = `
+
+ `;
+
+ // Add toast to body. The common--toast controller will move it to the container.
+ document.body.insertAdjacentHTML('beforeend', toastHTML);
+ }
+}
diff --git a/assets/css/app/images.css b/assets/css/app/images.css
index 7fa23a9e..05a8b291 100644
--- a/assets/css/app/images.css
+++ b/assets/css/app/images.css
@@ -67,3 +67,8 @@
.object-fit-cover {
object-fit: cover;
}
+
+.assembly-table-image {
+ max-height: 40px;
+ object-fit: contain;
+}
diff --git a/assets/js/lib/datatables.js b/assets/js/lib/datatables.js
index 67bab02d..2f078283 100644
--- a/assets/js/lib/datatables.js
+++ b/assets/js/lib/datatables.js
@@ -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') {
diff --git a/config/permissions.yaml b/config/permissions.yaml
index 39e91b57..fbaca756 100644
--- a/config/permissions.yaml
+++ b/config/permissions.yaml
@@ -124,6 +124,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]]"
diff --git a/config/services.yaml b/config/services.yaml
index 5021c577..030155c5 100644
--- a/config/services.yaml
+++ b/config/services.yaml
@@ -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
diff --git a/docs/configuration.md b/docs/configuration.md
index b4c3d747..df51f71f 100644
--- a/docs/configuration.md
+++ b/docs/configuration.md
@@ -150,6 +150,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
diff --git a/migrations/Version20251016141941.php b/migrations/Version20251016141941.php
new file mode 100644
index 00000000..f5e57b64
--- /dev/null
+++ b/migrations/Version20251016141941.php
@@ -0,0 +1,236 @@
+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');
+ }
+}
diff --git a/src/Command/Migrations/ConvertBBCodeCommand.php b/src/Command/Migrations/ConvertBBCodeCommand.php
index 201263ff..b0c08392 100644
--- a/src/Command/Migrations/ConvertBBCodeCommand.php
+++ b/src/Command/Migrations/ConvertBBCodeCommand.php
@@ -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'],
diff --git a/src/Controller/AdminPages/AssemblyAdminController.php b/src/Controller/AdminPages/AssemblyAdminController.php
new file mode 100644
index 00000000..20f64092
--- /dev/null
+++ b/src/Controller/AdminPages/AssemblyAdminController.php
@@ -0,0 +1,80 @@
+.
+ */
+
+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);
+ }
+}
diff --git a/src/Controller/AdminPages/BaseAdminController.php b/src/Controller/AdminPages/BaseAdminController.php
index 7c109751..44d54354 100644
--- a/src/Controller/AdminPages/BaseAdminController.php
+++ b/src/Controller/AdminPages/BaseAdminController.php
@@ -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);
+ }
+ }
}
diff --git a/src/Controller/AssemblyController.php b/src/Controller/AssemblyController.php
new file mode 100644
index 00000000..5c558d66
--- /dev/null
+++ b/src/Controller/AssemblyController.php
@@ -0,0 +1,319 @@
+.
+ */
+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')]
+ #[Route(path: '/{id}', 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,
+ ]);
+ }
+}
diff --git a/src/Controller/PartListsController.php b/src/Controller/PartListsController.php
index 2210fc18..f2eef604 100644
--- a/src/Controller/PartListsController.php
+++ b/src/Controller/PartListsController.php
@@ -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'));
diff --git a/src/Controller/ProjectController.php b/src/Controller/ProjectController.php
index 2a6d19ee..aa0b3f76 100644
--- a/src/Controller/ProjectController.php
+++ b/src/Controller/ProjectController.php
@@ -46,17 +46,20 @@ 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+'])]
+ #[Route(path: '/{id}/info', name: 'project_info')]
+ #[Route(path: '/{id}', requirements: ['id' => '\d+'])]
public function info(Project $project, Request $request, ProjectBuildHelper $buildHelper, TableSettings $tableSettings): Response
{
$this->denyAccessUnlessGranted('read', $project);
@@ -147,6 +150,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 +194,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 +219,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 +422,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,
diff --git a/src/Controller/StatisticsController.php b/src/Controller/StatisticsController.php
index 67c29781..baec7467 100644
--- a/src/Controller/StatisticsController.php
+++ b/src/Controller/StatisticsController.php
@@ -42,9 +42,14 @@ declare(strict_types=1);
namespace App\Controller;
use App\Services\Tools\StatisticsHelper;
+use App\Entity\AssemblySystem\AssemblyBOMEntry;
+use App\Entity\AssemblySystem\Assembly;
+use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
+use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
+use Symfony\Contracts\Translation\TranslatorInterface;
class StatisticsController extends AbstractController
{
@@ -57,4 +62,97 @@ class StatisticsController extends AbstractController
'helper' => $helper,
]);
}
+
+ #[Route(path: '/statistics/cleanup-assembly-bom-entries', name: 'statistics_cleanup_assembly_bom_entries', methods: ['POST'])]
+ public function cleanupAssemblyBOMEntries(
+ EntityManagerInterface $em,
+ StatisticsHelper $helper,
+ TranslatorInterface $translator
+ ): JsonResponse {
+ $this->denyAccessUnlessGranted('@tools.statistics');
+
+ try {
+ // We fetch the IDs of the entries that have a non-existent part.
+ // We use a raw SQL approach or a more robust DQL to avoid proxy initialization issues.
+ $qb = $em->createQueryBuilder();
+ $qb->select('be.id', 'IDENTITY(be.part) AS part_id')
+ ->from(AssemblyBOMEntry::class, 'be')
+ ->leftJoin('be.part', 'p')
+ ->where('be.part IS NOT NULL')
+ ->andWhere('p.id IS NULL');
+
+ $results = $qb->getQuery()->getResult();
+ $count = count($results);
+
+ foreach ($results as $result) {
+ $entryId = $result['id'];
+ $partId = $result['part_id'] ?? 'unknown';
+
+ $entry = $em->find(AssemblyBOMEntry::class, $entryId);
+ if ($entry instanceof AssemblyBOMEntry) {
+ $entry->setPart(null);
+ $entry->setName(sprintf('part-id=%s not found', $partId));
+ }
+ }
+
+ $em->flush();
+
+ return new JsonResponse([
+ 'success' => true,
+ 'count' => $count,
+ 'message' => $translator->trans('statistics.cleanup_assembly_bom_entries.success', [
+ '%count%' => $count,
+ ]),
+ 'new_count' => $helper->getInvalidPartBOMEntriesCount(),
+ ]);
+ } catch (\Exception $e) {
+ return new JsonResponse([
+ 'success' => false,
+ 'message' => $translator->trans('statistics.cleanup_assembly_bom_entries.error') . ' ' . $e->getMessage(),
+ ], Response::HTTP_INTERNAL_SERVER_ERROR);
+ }
+ }
+
+ #[Route(path: '/statistics/cleanup-assembly-preview-attachments', name: 'statistics_cleanup_assembly_preview_attachments', methods: ['POST'])]
+ public function cleanupAssemblyPreviewAttachments(
+ EntityManagerInterface $em,
+ StatisticsHelper $helper,
+ TranslatorInterface $translator
+ ): JsonResponse {
+ $this->denyAccessUnlessGranted('@tools.statistics');
+
+ try {
+ $qb = $em->createQueryBuilder();
+ $qb->select('a')
+ ->from(Assembly::class, 'a')
+ ->leftJoin('a.master_picture_attachment', 'm')
+ ->where('a.master_picture_attachment IS NOT NULL')
+ ->andWhere('m.id IS NULL');
+
+ $assemblies = $qb->getQuery()->getResult();
+ $count = count($assemblies);
+
+ foreach ($assemblies as $assembly) {
+ if ($assembly instanceof Assembly) {
+ $assembly->setMasterPictureAttachment(null);
+ }
+ }
+
+ $em->flush();
+
+ return new JsonResponse([
+ 'success' => true,
+ 'count' => $count,
+ 'message' => $translator->trans('statistics.cleanup_assembly_preview_attachments.success', [
+ '%count%' => $count,
+ ]),
+ 'new_count' => $helper->getInvalidAssemblyPreviewAttachmentsCount(),
+ ]);
+ } catch (\Exception $e) {
+ return new JsonResponse([
+ 'success' => false,
+ 'message' => $translator->trans('statistics.cleanup_assembly_preview_attachments.error') . ' ' . $e->getMessage(),
+ ], Response::HTTP_INTERNAL_SERVER_ERROR);
+ }
+ }
}
diff --git a/src/Controller/TreeController.php b/src/Controller/TreeController.php
index 71f8ba5c..0ba3a158 100644
--- a/src/Controller/TreeController.php
+++ b/src/Controller/TreeController.php
@@ -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);
+ }
}
diff --git a/src/Controller/TypeaheadController.php b/src/Controller/TypeaheadController.php
index 39821f59..13a57dd7 100644
--- a/src/Controller/TypeaheadController.php
+++ b/src/Controller/TypeaheadController.php
@@ -22,8 +22,12 @@ declare(strict_types=1);
namespace App\Controller;
+use App\Entity\AssemblySystem\Assembly;
use App\Entity\Parameters\AbstractParameter;
+use App\Entity\ProjectSystem\Project;
+use App\Services\Attachments\ProjectPreviewGenerator;
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 +58,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,44 +118,148 @@ 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,
+ ProjectPreviewGenerator $projectPreviewGenerator,
+ AssemblyPreviewGenerator $assemblyPreviewGenerator,
+ AttachmentURLGenerator $attachmentURLGenerator,
+ Request $request,
+ 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);
+ /** @var Part[]|Assembly[] $data */
$data = [];
foreach ($parts as $part) {
//Determine the picture to show:
$preview_attachment = $previewGenerator->getTablePreviewAttachment($part);
if($preview_attachment instanceof Attachment) {
- $preview_url = $attachmentURLGenerator->getThumbnailURL($preview_attachment, 'thumbnail_sm');
+ $preview_url = $attachmentURLGenerator->getThumbnailURL($preview_attachment);
} else {
$preview_url = '';
}
/** @var Part $part */
$data[] = [
+ 'type' => 'part',
'id' => $part->getID(),
'name' => $part->getName(),
'category' => $part->getCategory() instanceof Category ? $part->getCategory()->getName() : 'Unknown',
'footprint' => $part->getFootprint() instanceof Footprint ? $part->getFootprint()->getName() : '',
'description' => mb_strimwidth($part->getDescription(), 0, 127, '...'),
'image' => $preview_url,
- ];
+ ];
+ }
+
+ $multiDataSources = $request->query->getBoolean('multidatasources');
+
+ if ($multiDataSources) {
+ if ($this->isGranted('@projects.read')) {
+ $projectRepository = $entityManager->getRepository(Project::class);
+
+ $projects = $projectRepository->autocompleteSearch($query, 100);
+
+ foreach ($projects as $project) {
+ $preview_attachment = $projectPreviewGenerator->getTablePreviewAttachment($project);
+
+ if ($preview_attachment instanceof Attachment) {
+ $preview_url = $attachmentURLGenerator->getThumbnailURL($preview_attachment);
+ } else {
+ $preview_url = '';
+ }
+
+ /** @var Project $project */
+ $data[] = [
+ 'type' => 'project',
+ 'id' => $project->getID(),
+ 'name' => $project->getName(),
+ 'category' => '',
+ 'footprint' => '',
+ 'description' => mb_strimwidth($project->getDescription(), 0, 127, '...'),
+ 'image' => $preview_url,
+ ];
+ }
+ }
+
+ if ($this->isGranted('@assemblies.read')) {
+ $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);
+ } else {
+ $preview_url = '';
+ }
+
+ /** @var Assembly $assembly */
+ $data[] = [
+ 'type' => 'assembly',
+ 'id' => $assembly->getID(),
+ 'name' => $assembly->getName(),
+ 'category' => '',
+ 'footprint' => '',
+ 'description' => mb_strimwidth($assembly->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
{
diff --git a/src/DataFixtures/DataStructureFixtures.php b/src/DataFixtures/DataStructureFixtures.php
index 9c685338..392bd30f 100644
--- a/src/DataFixtures/DataStructureFixtures.php
+++ b/src/DataFixtures/DataStructureFixtures.php
@@ -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) {
diff --git a/src/DataFixtures/GroupFixtures.php b/src/DataFixtures/GroupFixtures.php
index d8e54b9f..ddb74b1c 100644
--- a/src/DataFixtures/GroupFixtures.php
+++ b/src/DataFixtures/GroupFixtures.php
@@ -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);
+ }
+
}
diff --git a/src/DataTables/AssemblyBomEntriesDataTable.php b/src/DataTables/AssemblyBomEntriesDataTable.php
new file mode 100644
index 00000000..60c67ed2
--- /dev/null
+++ b/src/DataTables/AssemblyBomEntriesDataTable.php
@@ -0,0 +1,247 @@
+.
+ */
+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 .= '
'.htmlspecialchars($context->getName()).'';
+ }
+ } 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 .= '
'.htmlspecialchars($context->getName()).'';
+ }
+ }
+
+ 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('
%s ', 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
+ {
+
+ }
+}
diff --git a/src/DataTables/AssemblyDataTable.php b/src/DataTables/AssemblyDataTable.php
new file mode 100644
index 00000000..aaad2e45
--- /dev/null
+++ b/src/DataTables/AssemblyDataTable.php
@@ -0,0 +1,250 @@
+.
+ */
+
+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);
+ }
+ }
+}
diff --git a/src/DataTables/Filters/AssemblyFilter.php b/src/DataTables/Filters/AssemblyFilter.php
new file mode 100644
index 00000000..d8d07a1e
--- /dev/null
+++ b/src/DataTables/Filters/AssemblyFilter.php
@@ -0,0 +1,68 @@
+.
+ */
+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);
+ }
+}
diff --git a/src/DataTables/Filters/AssemblySearchFilter.php b/src/DataTables/Filters/AssemblySearchFilter.php
new file mode 100644
index 00000000..2ab33c83
--- /dev/null
+++ b/src/DataTables/Filters/AssemblySearchFilter.php
@@ -0,0 +1,172 @@
+.
+ */
+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;
+ }
+
+
+}
diff --git a/src/DataTables/Filters/PartFilter.php b/src/DataTables/Filters/PartFilter.php
index a08293ca..8a41c6d7 100644
--- a/src/DataTables/Filters/PartFilter.php
+++ b/src/DataTables/Filters/PartFilter.php
@@ -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;
@@ -108,6 +109,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
*************************************************/
@@ -182,6 +191,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();
diff --git a/src/DataTables/Filters/PartSearchFilter.php b/src/DataTables/Filters/PartSearchFilter.php
index 9f6734e5..6c94e324 100644
--- a/src/DataTables/Filters/PartSearchFilter.php
+++ b/src/DataTables/Filters/PartSearchFilter.php
@@ -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,10 @@ class PartSearchFilter implements FilterInterface
if ($this->ipn) {
$fields_to_search[] = 'part.ipn';
}
+ if ($this->assembly) {
+ $fields_to_search[] = '_assembly.name';
+ $fields_to_search[] = '_assembly.ipn';
+ }
return $fields_to_search;
}
@@ -337,5 +344,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;
+ }
}
diff --git a/src/DataTables/Helpers/AssemblyDataTableHelper.php b/src/DataTables/Helpers/AssemblyDataTableHelper.php
new file mode 100644
index 00000000..dda563ea
--- /dev/null
+++ b/src/DataTables/Helpers/AssemblyDataTableHelper.php
@@ -0,0 +1,77 @@
+.
+ */
+
+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(
+ '
%s%s',
+ $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(
+ '

',
+ 'Assembly image',
+ $this->attachmentURLGenerator->getThumbnailURL($preview_attachment),
+ $this->attachmentURLGenerator->getThumbnailURL($preview_attachment, 'thumbnail_md'),
+ 'hoverpic assembly-table-image',
+ $title
+ );
+ }
+}
diff --git a/src/DataTables/PartsDataTable.php b/src/DataTables/PartsDataTable.php
index d2faba76..491a1fef 100644
--- a/src/DataTables/PartsDataTable.php
+++ b/src/DataTables/PartsDataTable.php
@@ -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;
@@ -258,6 +259,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('
%s', $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'),
@@ -457,6 +486,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');
diff --git a/src/Entity/AssemblySystem/Assembly.php b/src/Entity/AssemblySystem/Assembly.php
new file mode 100644
index 00000000..20a7aa1b
--- /dev/null
+++ b/src/Entity/AssemblySystem/Assembly.php
@@ -0,0 +1,373 @@
+.
+ */
+
+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
+ */
+#[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
+ */
+ #[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
+ */
+ #[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
+ */
+ #[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;
+ }
+
+}
diff --git a/src/Entity/AssemblySystem/AssemblyBOMEntry.php b/src/Entity/AssemblySystem/AssemblyBOMEntry.php
new file mode 100644
index 00000000..500a4401
--- /dev/null
+++ b/src/Entity/AssemblySystem/AssemblyBOMEntry.php
@@ -0,0 +1,340 @@
+.
+ */
+
+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(),
+ ];
+ }
+}
diff --git a/src/Entity/Attachments/AssemblyAttachment.php b/src/Entity/Attachments/AssemblyAttachment.php
new file mode 100644
index 00000000..c0c75c18
--- /dev/null
+++ b/src/Entity/Attachments/AssemblyAttachment.php
@@ -0,0 +1,47 @@
+.
+ */
+
+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
+ */
+#[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;
+}
diff --git a/src/Entity/Attachments/Attachment.php b/src/Entity/Attachments/Attachment.php
index e0d1bd9d..711b4129 100644
--- a/src/Entity/Attachments/Attachment.php
+++ b/src/Entity/Attachments/Attachment.php
@@ -97,7 +97,7 @@ use function in_array;
#[DiscriminatorMap(typeProperty: '_type', mapping: self::API_DISCRIMINATOR_MAP)]
abstract class Attachment extends AbstractNamedDBElement
{
- final public const ORM_DISCRIMINATOR_MAP = ['Part' => PartAttachment::class, 'PartCustomState' => PartCustomStateAttachment::class, 'Device' => ProjectAttachment::class,
+ final public 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,
diff --git a/src/Entity/Base/AbstractDBElement.php b/src/Entity/Base/AbstractDBElement.php
index a088b3df..757da2d4 100644
--- a/src/Entity/Base/AbstractDBElement.php
+++ b/src/Entity/Base/AbstractDBElement.php
@@ -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,
diff --git a/src/Entity/LogSystem/CollectionElementDeleted.php b/src/Entity/LogSystem/CollectionElementDeleted.php
index 34ab8fba..15e0001e 100644
--- a/src/Entity/LogSystem/CollectionElementDeleted.php
+++ b/src/Entity/LogSystem/CollectionElementDeleted.php
@@ -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,
diff --git a/src/Entity/LogSystem/LogTargetType.php b/src/Entity/LogSystem/LogTargetType.php
index 3b2d8682..0095fd8f 100644
--- a/src/Entity/LogSystem/LogTargetType.php
+++ b/src/Entity/LogSystem/LogTargetType.php
@@ -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,
diff --git a/src/Entity/Parameters/AbstractParameter.php b/src/Entity/Parameters/AbstractParameter.php
index d84e68ad..ed29e6a4 100644
--- a/src/Entity/Parameters/AbstractParameter.php
+++ b/src/Entity/Parameters/AbstractParameter.php
@@ -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];
diff --git a/src/Entity/Parameters/AssemblyParameter.php b/src/Entity/Parameters/AssemblyParameter.php
new file mode 100644
index 00000000..349fa790
--- /dev/null
+++ b/src/Entity/Parameters/AssemblyParameter.php
@@ -0,0 +1,65 @@
+.
+ */
+
+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 .
+ */
+
+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;
+}
diff --git a/src/Entity/Parts/Part.php b/src/Entity/Parts/Part.php
index 5ac81b60..aaad0ace 100644
--- a/src/Entity/Parts/Part.php
+++ b/src/Entity/Parts/Part.php
@@ -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;
@@ -124,6 +125,7 @@ class Part extends AttachmentContainingDBElement
use OrderTrait;
use ParametersTrait;
use ProjectTrait;
+ use AssemblyTrait;
use AssociationTrait;
use EDATrait;
@@ -185,6 +187,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();
diff --git a/src/Entity/Parts/PartTraits/AssemblyTrait.php b/src/Entity/Parts/PartTraits/AssemblyTrait.php
new file mode 100644
index 00000000..2d82c32f
--- /dev/null
+++ b/src/Entity/Parts/PartTraits/AssemblyTrait.php
@@ -0,0 +1,45 @@
+ $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
+ */
+ 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;
+ }
+}
diff --git a/src/Entity/ProjectSystem/ProjectBOMEntry.php b/src/Entity/ProjectSystem/ProjectBOMEntry.php
index 2a7862ec..fd613dc2 100644
--- a/src/Entity/ProjectSystem/ProjectBOMEntry.php
+++ b/src/Entity/ProjectSystem/ProjectBOMEntry.php
@@ -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: [
diff --git a/src/Form/AdminPages/AssemblyAdminForm.php b/src/Form/AdminPages/AssemblyAdminForm.php
new file mode 100644
index 00000000..dd0a8038
--- /dev/null
+++ b/src/Form/AdminPages/AssemblyAdminForm.php
@@ -0,0 +1,82 @@
+.
+ */
+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',
+ ]);
+ }
+}
diff --git a/src/Form/AdminPages/BaseEntityAdminForm.php b/src/Form/AdminPages/BaseEntityAdminForm.php
index f4bf37f8..f0020b25 100644
--- a/src/Form/AdminPages/BaseEntityAdminForm.php
+++ b/src/Form/AdminPages/BaseEntityAdminForm.php
@@ -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',
'autofocus' => $is_new,
@@ -115,7 +121,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',
diff --git a/src/Form/AssemblySystem/AssemblyAddPartsType.php b/src/Form/AssemblySystem/AssemblyAddPartsType.php
new file mode 100644
index 00000000..1fa67126
--- /dev/null
+++ b/src/Form/AssemblySystem/AssemblyAddPartsType.php
@@ -0,0 +1,91 @@
+.
+ */
+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]);
+ }
+}
diff --git a/src/Form/AssemblySystem/AssemblyBOMEntryCollectionType.php b/src/Form/AssemblySystem/AssemblyBOMEntryCollectionType.php
new file mode 100644
index 00000000..04293f4e
--- /dev/null
+++ b/src/Form/AssemblySystem/AssemblyBOMEntryCollectionType.php
@@ -0,0 +1,32 @@
+setDefaults([
+ 'entry_type' => AssemblyBOMEntryType::class,
+ 'entry_options' => [
+ 'label' => false,
+ ],
+ 'allow_add' => true,
+ 'allow_delete' => true,
+ 'by_reference' => false,
+ 'reindex_enable' => true,
+ 'label' => false,
+ ]);
+ }
+}
diff --git a/src/Form/AssemblySystem/AssemblyBOMEntryType.php b/src/Form/AssemblySystem/AssemblyBOMEntryType.php
new file mode 100644
index 00000000..8b56dfeb
--- /dev/null
+++ b/src/Form/AssemblySystem/AssemblyBOMEntryType.php
@@ -0,0 +1,98 @@
+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,
+ ]);
+ }
+}
diff --git a/src/Form/Filters/AssemblyFilterType.php b/src/Form/Filters/AssemblyFilterType.php
new file mode 100644
index 00000000..acfbb1a8
--- /dev/null
+++ b/src/Form/Filters/AssemblyFilterType.php
@@ -0,0 +1,114 @@
+.
+ */
+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',
+ ]);
+ }
+}
diff --git a/src/Form/Filters/AttachmentFilterType.php b/src/Form/Filters/AttachmentFilterType.php
index ff80bd38..a4458895 100644
--- a/src/Form/Filters/AttachmentFilterType.php
+++ b/src/Form/Filters/AttachmentFilterType.php
@@ -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,
diff --git a/src/Form/Filters/LogFilterType.php b/src/Form/Filters/LogFilterType.php
index 30abf723..dd4e1cdf 100644
--- a/src/Form/Filters/LogFilterType.php
+++ b/src/Form/Filters/LogFilterType.php
@@ -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',
diff --git a/src/Form/Filters/PartFilterType.php b/src/Form/Filters/PartFilterType.php
index 25fe70b2..cb4f424f 100644
--- a/src/Form/Filters/PartFilterType.php
+++ b/src/Form/Filters/PartFilterType.php
@@ -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;
@@ -317,6 +318,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
**************************************************************************/
diff --git a/src/Form/Type/AssemblySelectType.php b/src/Form/Type/AssemblySelectType.php
new file mode 100644
index 00000000..282161b1
--- /dev/null
+++ b/src/Form/Type/AssemblySelectType.php
@@ -0,0 +1,122 @@
+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();
+ }
+
+}
diff --git a/src/Form/Type/PartSelectType.php b/src/Form/Type/PartSelectType.php
index 34b8fc7c..8cdd6256 100644
--- a/src/Form/Type/PartSelectType.php
+++ b/src/Form/Type/PartSelectType.php
@@ -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
diff --git a/src/Helpers/Assemblies/AssemblyPartAggregator.php b/src/Helpers/Assemblies/AssemblyPartAggregator.php
new file mode 100644
index 00000000..46495935
--- /dev/null
+++ b/src/Helpers/Assemblies/AssemblyPartAggregator.php
@@ -0,0 +1,273 @@
+.
+ */
+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;
+ }
+}
diff --git a/src/Repository/AssemblyRepository.php b/src/Repository/AssemblyRepository.php
new file mode 100644
index 00000000..d4c57cbb
--- /dev/null
+++ b/src/Repository/AssemblyRepository.php
@@ -0,0 +1,70 @@
+.
+ */
+
+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 .
+ */
+
+namespace App\Repository;
+
+use App\Entity\AssemblySystem\Assembly;
+
+/**
+ * @template TEntityClass of Assembly
+ * @extends StructuralDBElementRepository
+ */
+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')
+ ->orWhere('ILIKE(assembly.ipn, :query) = TRUE');
+
+ $qb->setParameter('query', '%'.$query.'%');
+
+ $qb->setMaxResults($max_limits);
+ $qb->orderBy('NATSORT(assembly.name)', 'ASC');
+
+ return $qb->getQuery()->getResult();
+ }
+}
diff --git a/src/Repository/DBElementRepository.php b/src/Repository/DBElementRepository.php
index f737d91d..5adcad59 100644
--- a/src/Repository/DBElementRepository.php
+++ b/src/Repository/DBElementRepository.php
@@ -161,4 +161,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();
+ }
+ }
}
diff --git a/src/Repository/Parts/DeviceRepository.php b/src/Repository/Parts/DeviceRepository.php
index 442c91e5..3fa93183 100644
--- a/src/Repository/Parts/DeviceRepository.php
+++ b/src/Repository/Parts/DeviceRepository.php
@@ -51,4 +51,18 @@ class DeviceRepository extends StructuralDBElementRepository
//Prevent user from deleting devices, to not accidentally remove filled devices from old versions
return 1;
}
+
+ public function autocompleteSearch(string $query, int $max_limits = 50): array
+ {
+ $qb = $this->createQueryBuilder('p');
+ $qb->select('p')
+ ->where('ILIKE(p.name, :query) = TRUE');
+
+ $qb->setParameter('query', '%'.$query.'%');
+
+ $qb->setMaxResults($max_limits);
+ $qb->orderBy('NATSORT(p.name)', 'ASC');
+
+ return $qb->getQuery()->getResult();
+ }
}
diff --git a/src/Security/Voter/AttachmentVoter.php b/src/Security/Voter/AttachmentVoter.php
index df3d73a7..d9c45bfd 100644
--- a/src/Security/Voter/AttachmentVoter.php
+++ b/src/Security/Voter/AttachmentVoter.php
@@ -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)) {
diff --git a/src/Security/Voter/StructureVoter.php b/src/Security/Voter/StructureVoter.php
index 16d38e05..cb05ffdd 100644
--- a/src/Security/Voter/StructureVoter.php
+++ b/src/Security/Voter/StructureVoter.php
@@ -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',
diff --git a/src/Services/Attachments/AssemblyPreviewGenerator.php b/src/Services/Attachments/AssemblyPreviewGenerator.php
new file mode 100644
index 00000000..9ecbbd07
--- /dev/null
+++ b/src/Services/Attachments/AssemblyPreviewGenerator.php
@@ -0,0 +1,93 @@
+.
+ */
+
+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
+ */
+ 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);
+ }
+}
diff --git a/src/Services/Attachments/AttachmentSubmitHandler.php b/src/Services/Attachments/AttachmentSubmitHandler.php
index 81a83f0c..ea055434 100644
--- a/src/Services/Attachments/AttachmentSubmitHandler.php
+++ b/src/Services/Attachments/AttachmentSubmitHandler.php
@@ -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',
diff --git a/src/Services/Attachments/ProjectPreviewGenerator.php b/src/Services/Attachments/ProjectPreviewGenerator.php
new file mode 100644
index 00000000..9929dbd3
--- /dev/null
+++ b/src/Services/Attachments/ProjectPreviewGenerator.php
@@ -0,0 +1,93 @@
+.
+ */
+
+declare(strict_types=1);
+
+namespace App\Services\Attachments;
+
+use App\Entity\Attachments\Attachment;
+use App\Entity\ProjectSystem\Project;
+
+class ProjectPreviewGenerator
+{
+ public function __construct(protected AttachmentManager $attachmentHelper)
+ {
+ }
+
+ /**
+ * Returns a list of attachments that can be used for previewing the project ordered by priority.
+ *
+ * @param Project $project the project for which the attachments should be determined
+ *
+ * @return (Attachment|null)[]
+ *
+ * @psalm-return list
+ */
+ public function getPreviewAttachments(Project $project): array
+ {
+ $list = [];
+
+ //Master attachment has top priority
+ $attachment = $project->getMasterPictureAttachment();
+ if ($this->isAttachmentValidPicture($attachment)) {
+ $list[] = $attachment;
+ }
+
+ //Then comes the other images of the project
+ foreach ($project->getAttachments() as $attachment) {
+ //Dont show the master attachment twice
+ if ($this->isAttachmentValidPicture($attachment) && $attachment !== $project->getMasterPictureAttachment()) {
+ $list[] = $attachment;
+ }
+ }
+
+ return $list;
+ }
+
+ /**
+ * Determines what attachment should be used for previewing a project (especially in project table).
+ * The returned attachment is guaranteed to be existing and be a picture.
+ *
+ * @param Project $project The project for which the attachment should be determined
+ */
+ public function getTablePreviewAttachment(Project $project): ?Attachment
+ {
+ $attachment = $project->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);
+ }
+}
diff --git a/src/Services/ElementTypeNameGenerator.php b/src/Services/ElementTypeNameGenerator.php
index 19bb19f5..f1c3bcbd 100644
--- a/src/Services/ElementTypeNameGenerator.php
+++ b/src/Services/ElementTypeNameGenerator.php
@@ -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) {
diff --git a/src/Services/ElementTypes.php b/src/Services/ElementTypes.php
index 6ce8f851..adac46f2 100644
--- a/src/Services/ElementTypes.php
+++ b/src/Services/ElementTypes.php
@@ -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',
diff --git a/src/Services/EntityURLGenerator.php b/src/Services/EntityURLGenerator.php
index 91e271cc..bdf26fa9 100644
--- a/src/Services/EntityURLGenerator.php
+++ b/src/Services/EntityURLGenerator.php
@@ -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',
diff --git a/src/Services/ImportExportSystem/BOMImporter.php b/src/Services/ImportExportSystem/BOMImporter.php
index abf72d74..67c91d37 100644
--- a/src/Services/ImportExportSystem/BOMImporter.php
+++ b/src/Services/ImportExportSystem/BOMImporter.php
@@ -24,21 +24,37 @@ namespace App\Services\ImportExportSystem;
use App\Entity\Parts\Supplier;
use App\Entity\PriceInformations\Orderdetail;
+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',
@@ -49,17 +65,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']);
@@ -75,27 +109,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);
}
/**
@@ -117,31 +242,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 object $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[]|object[] An array of imported entries
*/
- public function stringToBOMEntries(string $data, array $options): array
+ public function stringToBOMEntries(object $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 object $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, object $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);
@@ -160,16 +330,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;
}
/**
@@ -229,6 +404,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 $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']) ? '' . $entry['part']['id'] . '' : '-',
+ isset($entry['part']['mpnr']) ? '' . $entry['part']['mpnr'] . '' : '-',
+ isset($entry['part']['ipn']) ? '' . $entry['part']['ipn'] . '' : '-',
+ isset($entry['part']['name']) ? '' . $entry['part']['name'] . '' : '-',
+ );
+
+ $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%' => '' . $entry['part']['name'] . '',
+ '%foundId%' => $part->getID(),
+ '%foundValue%' => '' . $part->getName() . ''
+ ]
+ ));
+ }
+
+ 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%' => '' . $entry['part']['mpnr'] . '',
+ '%foundId%' => $part->getID(),
+ '%foundValue%' => '' . $part->getManufacturerProductNumber() . ''
+ ]
+ ));
+ }
+
+ 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%' => '' . $entry['part']['ipn'] . '',
+ '%foundId%' => $part->getID(),
+ '%foundValue%' => '' . $part->getIpn() . ''
+ ]
+ ));
+ }
+
+ 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 ? '' . $entry['part']['manufacturer']['id'] . '' : '-',
+ isset($entry['part']['manufacturer']['name']) && $entry['part']['manufacturer']['name'] != null ? '' . $entry['part']['manufacturer']['name'] . '' : '-'
+ );
+
+ $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%' => '' . $entry['part']['manufacturer']['name'] . '',
+ '%foundId%' => $manufacturer->getID(),
+ '%foundValue%' => '' . $manufacturer->getName() . ''
+ ]
+ ));
+ }
+
+ $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 ? '' . $entry['part']['category']['id'] . '' : '-',
+ isset($entry['part']['category']['name']) && $entry['part']['category']['name'] != null ? '' . $entry['part']['category']['name'] . '' : '-'
+ );
+
+ $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%' => '' . $entry['part']['category']['name'] . '',
+ '%foundId%' => $category->getID(),
+ '%foundValue%' => '' . $category->getName() . ''
+ ]
+ ));
+ }
+
+ 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
@@ -245,13 +963,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
*/
@@ -779,4 +1512,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
+ );
+ }
}
diff --git a/src/Services/ImportExportSystem/EntityExporter.php b/src/Services/ImportExportSystem/EntityExporter.php
index ab87a905..28022a49 100644
--- a/src/Services/ImportExportSystem/EntityExporter.php
+++ b/src/Services/ImportExportSystem/EntityExporter.php
@@ -22,9 +22,23 @@ declare(strict_types=1);
namespace App\Services\ImportExportSystem;
+use App\Entity\AssemblySystem\Assembly;
+use App\Entity\AssemblySystem\AssemblyBOMEntry;
+use App\Entity\Attachments\AttachmentType;
use PhpOffice\PhpSpreadsheet\Cell\Coordinate;
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;
@@ -49,8 +63,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
@@ -66,6 +82,10 @@ class EntityExporter
$resolver->setDefault('include_children', false);
$resolver->setAllowedTypes('include_children', 'bool');
+
+ $resolver->setDefault('readableSelect', null);
+ $resolver->setAllowedValues('readableSelect', [null, 'readable', 'readable_bom']);
+
}
/**
@@ -223,15 +243,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
@@ -242,6 +314,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);
@@ -268,7 +341,7 @@ class EntityExporter
//Remove percent for fallback
$fallback = str_replace("%", "_", $filename);
-
+
// Create the disposition of the file
$disposition = $response->headers->makeDisposition(
ResponseHeaderBag::DISPOSITION_ATTACHMENT,
@@ -281,4 +354,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);
+ }
}
diff --git a/src/Services/ImportExportSystem/ImporterResult.php b/src/Services/ImportExportSystem/ImporterResult.php
new file mode 100644
index 00000000..4e289d13
--- /dev/null
+++ b/src/Services/ImportExportSystem/ImporterResult.php
@@ -0,0 +1,60 @@
+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;
+ }
+}
\ No newline at end of file
diff --git a/src/Services/Tools/StatisticsHelper.php b/src/Services/Tools/StatisticsHelper.php
index 00bb05c9..653d9635 100644
--- a/src/Services/Tools/StatisticsHelper.php
+++ b/src/Services/Tools/StatisticsHelper.php
@@ -41,8 +41,10 @@ declare(strict_types=1);
namespace App\Services\Tools;
+use App\Entity\AssemblySystem\AssemblyBOMEntry;
use App\Entity\Attachments\Attachment;
use App\Entity\Attachments\AttachmentType;
+use App\Entity\AssemblySystem\Assembly;
use App\Entity\ProjectSystem\Project;
use App\Entity\Parts\Category;
use App\Entity\Parts\Footprint;
@@ -79,6 +81,14 @@ class StatisticsHelper
return $this->part_repo->count([]);
}
+ /**
+ * Returns the count of distinct projects.
+ */
+ public function getDistinctProjectsCount(): int
+ {
+ return $this->em->getRepository(Project::class)->count([]);
+ }
+
/**
* Returns the summed instocked over all parts (only parts without a measurement unit).
*
@@ -116,6 +126,7 @@ class StatisticsHelper
'storelocation' => StorageLocation::class,
'supplier' => Supplier::class,
'currency' => Currency::class,
+ 'assembly' => Assembly::class,
];
if (!isset($arr[$type])) {
@@ -164,4 +175,34 @@ class StatisticsHelper
{
return $this->attachment_repo->getUserUploadedAttachments();
}
+
+ /**
+ * Returns the count of BOM entries which point to a non-existent part ID.
+ */
+ public function getInvalidPartBOMEntriesCount(): int
+ {
+ $qb = $this->em->createQueryBuilder();
+ $qb->select('COUNT(be.id)')
+ ->from(AssemblyBOMEntry::class, 'be')
+ ->leftJoin('be.part', 'p')
+ ->where('be.part IS NOT NULL')
+ ->andWhere('p.id IS NULL');
+
+ return (int) $qb->getQuery()->getSingleScalarResult();
+ }
+
+ /**
+ * Returns the number of assemblies that have a master_picture_attachment that does not exist anymore.
+ */
+ public function getInvalidAssemblyPreviewAttachmentsCount(): int
+ {
+ $qb = $this->em->createQueryBuilder();
+ $qb->select('COUNT(a.id)')
+ ->from(Assembly::class, 'a')
+ ->leftJoin('a.master_picture_attachment', 'at')
+ ->where('a.master_picture_attachment IS NOT NULL')
+ ->andWhere('at.id IS NULL');
+
+ return (int) $qb->getQuery()->getSingleScalarResult();
+ }
}
diff --git a/src/Services/Trees/ToolsTreeBuilder.php b/src/Services/Trees/ToolsTreeBuilder.php
index 6397e3af..9b0af436 100644
--- a/src/Services/Trees/ToolsTreeBuilder.php
+++ b/src/Services/Trees/ToolsTreeBuilder.php
@@ -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;
@@ -193,6 +194,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),
diff --git a/src/Services/Trees/TreeViewGenerator.php b/src/Services/Trees/TreeViewGenerator.php
index d55c87b7..aeaa4f66 100644
--- a/src/Services/Trees/TreeViewGenerator.php
+++ b/src/Services/Trees/TreeViewGenerator.php
@@ -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,
};
}
diff --git a/src/Services/UserSystem/PermissionPresetsHelper.php b/src/Services/UserSystem/PermissionPresetsHelper.php
index 3d125b27..f6f8036e 100644
--- a/src/Services/UserSystem/PermissionPresetsHelper.php
+++ b/src/Services/UserSystem/PermissionPresetsHelper.php
@@ -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);
@@ -136,6 +137,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);
@@ -183,6 +185,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;
}
diff --git a/src/Settings/BehaviorSettings/AssemblyBomTableColumns.php b/src/Settings/BehaviorSettings/AssemblyBomTableColumns.php
new file mode 100644
index 00000000..2833a3df
--- /dev/null
+++ b/src/Settings/BehaviorSettings/AssemblyBomTableColumns.php
@@ -0,0 +1,54 @@
+.
+ */
+
+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);
+ }
+}
diff --git a/src/Settings/BehaviorSettings/AssemblyTableColumns.php b/src/Settings/BehaviorSettings/AssemblyTableColumns.php
new file mode 100644
index 00000000..02c315b4
--- /dev/null
+++ b/src/Settings/BehaviorSettings/AssemblyTableColumns.php
@@ -0,0 +1,49 @@
+.
+ */
+
+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);
+ }
+}
diff --git a/src/Settings/BehaviorSettings/TableSettings.php b/src/Settings/BehaviorSettings/TableSettings.php
index b3421e41..920f80b4 100644
--- a/src/Settings/BehaviorSettings/TableSettings.php
+++ b/src/Settings/BehaviorSettings/TableSettings.php
@@ -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;
+ }
+
}
diff --git a/src/Settings/MiscSettings/AssemblySettings.php b/src/Settings/MiscSettings/AssemblySettings.php
new file mode 100644
index 00000000..82fb26b6
--- /dev/null
+++ b/src/Settings/MiscSettings/AssemblySettings.php
@@ -0,0 +1,45 @@
+.
+ */
+
+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;
+}
diff --git a/src/Settings/MiscSettings/MiscSettings.php b/src/Settings/MiscSettings/MiscSettings.php
index 050dbcbc..8ccf95cc 100644
--- a/src/Settings/MiscSettings/MiscSettings.php
+++ b/src/Settings/MiscSettings/MiscSettings.php
@@ -38,4 +38,7 @@ class MiscSettings
#[EmbeddedSettings]
public ?IpnSuggestSettings $ipnSuggestSettings = null;
+
+ #[EmbeddedSettings]
+ public ?AssemblySettings $assembly = null;
}
diff --git a/src/Twig/EntityExtension.php b/src/Twig/EntityExtension.php
index bff21eb8..0289134b 100644
--- a/src/Twig/EntityExtension.php
+++ b/src/Twig/EntityExtension.php
@@ -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;
@@ -80,6 +81,7 @@ final readonly class EntityExtension
Manufacturer::class => 'manufacturer',
Category::class => 'category',
Project::class => 'device',
+ Assembly::class => 'assembly',
Attachment::class => 'attachment',
Supplier::class => 'supplier',
User::class => 'user',
diff --git a/src/Validator/Constraints/AssemblySystem/AssemblyCycle.php b/src/Validator/Constraints/AssemblySystem/AssemblyCycle.php
new file mode 100644
index 00000000..9d79b879
--- /dev/null
+++ b/src/Validator/Constraints/AssemblySystem/AssemblyCycle.php
@@ -0,0 +1,39 @@
+.
+ */
+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;
+ }
+}
\ No newline at end of file
diff --git a/src/Validator/Constraints/AssemblySystem/AssemblyCycleValidator.php b/src/Validator/Constraints/AssemblySystem/AssemblyCycleValidator.php
new file mode 100644
index 00000000..c8fd18d3
--- /dev/null
+++ b/src/Validator/Constraints/AssemblySystem/AssemblyCycleValidator.php
@@ -0,0 +1,169 @@
+.
+ */
+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();
+ }
+ }
+}
diff --git a/src/Validator/Constraints/AssemblySystem/AssemblyInvalidBomEntry.php b/src/Validator/Constraints/AssemblySystem/AssemblyInvalidBomEntry.php
new file mode 100644
index 00000000..73234c86
--- /dev/null
+++ b/src/Validator/Constraints/AssemblySystem/AssemblyInvalidBomEntry.php
@@ -0,0 +1,21 @@
+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();
+ }
+ }
+}
diff --git a/src/Validator/Constraints/AssemblySystem/UniqueReferencedAssembly.php b/src/Validator/Constraints/AssemblySystem/UniqueReferencedAssembly.php
new file mode 100644
index 00000000..55a31440
--- /dev/null
+++ b/src/Validator/Constraints/AssemblySystem/UniqueReferencedAssembly.php
@@ -0,0 +1,34 @@
+.
+ */
+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';
+}
\ No newline at end of file
diff --git a/src/Validator/Constraints/AssemblySystem/UniqueReferencedAssemblyValidator.php b/src/Validator/Constraints/AssemblySystem/UniqueReferencedAssemblyValidator.php
new file mode 100644
index 00000000..0b3eb395
--- /dev/null
+++ b/src/Validator/Constraints/AssemblySystem/UniqueReferencedAssemblyValidator.php
@@ -0,0 +1,50 @@
+.
+ */
+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;
+ }
+ }
+}
diff --git a/templates/admin/_export_form.html.twig b/templates/admin/_export_form.html.twig
index 07b00d43..b02d4a8e 100644
--- a/templates/admin/_export_form.html.twig
+++ b/templates/admin/_export_form.html.twig
@@ -1,6 +1,6 @@
-