diff --git a/Makefile b/Makefile deleted file mode 100644 index bc4d0bf3e..000000000 --- a/Makefile +++ /dev/null @@ -1,91 +0,0 @@ -# PartDB Makefile for Test Environment Management - -.PHONY: help deps-install lint format format-check test coverage pre-commit all test-typecheck \ -test-setup test-clean test-db-create test-db-migrate test-cache-clear test-fixtures test-run test-reset \ -section-dev dev-setup dev-clean dev-db-create dev-db-migrate dev-cache-clear dev-warmup dev-reset - -# Default target -help: ## Show this help - @awk 'BEGIN {FS = ":.*##"}; /^[a-zA-Z0-9][a-zA-Z0-9_-]+:.*##/ {printf "\033[36m%-20s\033[0m %s\n", $$1, $$2}' $(MAKEFILE_LIST) - -# Dependencies -deps-install: ## Install PHP dependencies with unlimited memory - @echo "📦 Installing PHP dependencies..." - COMPOSER_MEMORY_LIMIT=-1 composer install - yarn install - @echo "✅ Dependencies installed" - -# Complete test environment setup -test-setup: test-clean test-db-create test-db-migrate test-fixtures ## Complete test setup (clean, create DB, migrate, fixtures) - @echo "✅ Test environment setup complete!" - -# Clean test environment -test-clean: ## Clean test cache and database files - @echo "🧹 Cleaning test environment..." - rm -rf var/cache/test - rm -f var/app_test.db - @echo "✅ Test environment cleaned" - -# Create test database -test-db-create: ## Create test database (if not exists) - @echo "🗄️ Creating test database..." - -php bin/console doctrine:database:create --if-not-exists --env test || echo "⚠️ Database creation failed (expected for SQLite) - continuing..." - -# Run database migrations for test environment -test-db-migrate: ## Run database migrations for test environment - @echo "🔄 Running database migrations..." - COMPOSER_MEMORY_LIMIT=-1 php bin/console doctrine:migrations:migrate -n --env test - -# Clear test cache -test-cache-clear: ## Clear test cache - @echo "🗑️ Clearing test cache..." - rm -rf var/cache/test - @echo "✅ Test cache cleared" - -# Load test fixtures -test-fixtures: ## Load test fixtures - @echo "📦 Loading test fixtures..." - php bin/console partdb:fixtures:load -n --env test - -# Run PHPUnit tests -test-run: ## Run PHPUnit tests - @echo "🧪 Running tests..." - php bin/phpunit - -# Quick test reset (clean + migrate + fixtures, skip DB creation) -test-reset: test-cache-clear test-db-migrate test-fixtures - @echo "✅ Test environment reset complete!" - -test-typecheck: ## Run static analysis (PHPStan) - @echo "🧪 Running type checks..." - COMPOSER_MEMORY_LIMIT=-1 composer phpstan - -# Development helpers -dev-setup: dev-clean dev-db-create dev-db-migrate dev-warmup ## Complete development setup (clean, create DB, migrate, warmup) - @echo "✅ Development environment setup complete!" - -dev-clean: ## Clean development cache and database files - @echo "🧹 Cleaning development environment..." - rm -rf var/cache/dev - rm -f var/app_dev.db - @echo "✅ Development environment cleaned" - -dev-db-create: ## Create development database (if not exists) - @echo "🗄️ Creating development database..." - -php bin/console doctrine:database:create --if-not-exists --env dev || echo "⚠️ Database creation failed (expected for SQLite) - continuing..." - -dev-db-migrate: ## Run database migrations for development environment - @echo "🔄 Running database migrations..." - COMPOSER_MEMORY_LIMIT=-1 php bin/console doctrine:migrations:migrate -n --env dev - -dev-cache-clear: ## Clear development cache - @echo "🗑️ Clearing development cache..." - rm -rf var/cache/dev - @echo "✅ Development cache cleared" - -dev-warmup: ## Warm up development cache - @echo "🔥 Warming up development cache..." - COMPOSER_MEMORY_LIMIT=-1 php -d memory_limit=1G bin/console cache:warmup --env dev -n - -dev-reset: dev-cache-clear dev-db-migrate ## Quick development reset (cache clear + migrate) - @echo "✅ Development environment reset complete!" \ No newline at end of file diff --git a/assets/controllers/elements/assembly_select_controller.js b/assets/controllers/elements/assembly_select_controller.js new file mode 100644 index 000000000..1ef117b88 --- /dev/null +++ b/assets/controllers/elements/assembly_select_controller.js @@ -0,0 +1,79 @@ +import {Controller} from "@hotwired/stimulus"; + +import "tom-select/dist/css/tom-select.bootstrap5.css"; +import '../../css/components/tom-select_extensions.css'; +import TomSelect from "tom-select"; +import {marked} from "marked"; + +export default class extends Controller { + _tomSelect; + + connect() { + + //Check if tomselect is inside an modal and do not attach the dropdown to body in that case (as it breaks the modal) + let dropdownParent = "body"; + if (this.element.closest('.modal')) { + dropdownParent = null + } + + let settings = { + allowEmptyOption: true, + plugins: ['dropdown_input', this.element.required ? null : 'clear_button'], + searchField: ["name", "description", "category", "footprint", "ipn"], + valueField: "id", + labelField: "name", + dropdownParent: dropdownParent, + preload: "focus", + render: { + item: (data, escape) => { + return '' + (data.image ? "" : "") + escape(data.name) + ''; + }, + option: (data, escape) => { + if(data.text) { + return '' + escape(data.text) + ''; + } + + let tmp = '
' + + "
" + + (data.image ? "" : "") + + "
" + + "
" + + '
' + escape(data.name) + '
' + + (data.description ? '

' + marked.parseInline(data.description) + '

' : "") + + (data.category ? '

' + escape(data.category) : ""); + + return tmp + '

' + + '
'; + } + } + }; + + + if (this.element.dataset.autocomplete || this.element.querySelector('[data-autocomplete]')) { + const autocompleteElement = this.element.dataset.autocomplete ? this.element : this.element.querySelector('[data-autocomplete]'); + const base_url = autocompleteElement.dataset.autocomplete; + settings.valueField = "id"; + settings.load = (query, callback) => { + const url = base_url.replace('__QUERY__', encodeURIComponent(query)); + + fetch(url) + .then(response => response.json()) + .then(json => {callback(json);}) + .catch(() => { + callback() + }); + }; + + + const targetElement = this.element instanceof HTMLInputElement || this.element instanceof HTMLSelectElement ? this.element : this.element.querySelector('select, input'); + this._tomSelect = new TomSelect(targetElement, settings); + //this._tomSelect.clearOptions(); + } + } + + disconnect() { + super.disconnect(); + //Destroy the TomSelect instance + this._tomSelect.destroy(); + } +} diff --git a/assets/controllers/elements/bom_name_sync_controller.js b/assets/controllers/elements/bom_name_sync_controller.js new file mode 100644 index 000000000..aeffc6074 --- /dev/null +++ b/assets/controllers/elements/bom_name_sync_controller.js @@ -0,0 +1,94 @@ +import {Controller} from "@hotwired/stimulus"; + +export default class extends Controller { + static targets = ["part", "assembly", "name"]; + + connect() { + this.updatePlaceholder(); + // Give TomSelect some time to initialize and set values + setTimeout(() => this.updatePlaceholder(), 100); + setTimeout(() => this.updatePlaceholder(), 500); + } + + updatePlaceholder() { + const partSelect = this.hasPartTarget ? this.partTarget.querySelector('select, input') : null; + const assemblySelect = this.hasAssemblyTarget ? this.assemblyTarget.querySelector('select, input') : null; + const nameInput = this.hasNameTarget ? this.nameTarget : null; + + if (!nameInput) return; + + let selectedName = ""; + + // Helper to get name from tomselect + const getNameFromTS = (el) => { + if (el && el.tomselect) { + const val = el.tomselect.getValue(); + if (val) { + const data = el.tomselect.options[val]; + if (data && data.name) return data.name; + } + } + // Fallback for raw select + if (el && el.value && el.options && el.selectedIndex >= 0) { + return el.options[el.selectedIndex].text; + } + return ""; + }; + + selectedName = getNameFromTS(partSelect); + + if (!selectedName) { + selectedName = getNameFromTS(assemblySelect); + } + + if (selectedName) { + nameInput.placeholder = selectedName; + if (nameInput.value === "") { + nameInput.style.opacity = "0.6"; + } else { + nameInput.style.opacity = "1"; + } + } else { + nameInput.placeholder = nameInput.dataset.originalPlaceholder || ""; + nameInput.style.opacity = "1"; + } + } + + // This method will be called via action when a change occurs + sync(event) { + // Handle mutual exclusion: if a part is selected, clear the assembly (and vice-versa) + // We identify which field was changed by looking at the event target + const changedElement = event.target; + const partSelect = this.hasPartTarget ? this.partTarget.querySelector('select, input') : null; + const assemblySelect = this.hasAssemblyTarget ? this.assemblyTarget.querySelector('select, input') : null; + + // If part was changed and has a value, clear assembly + if (partSelect && (changedElement === partSelect || partSelect.contains(changedElement))) { + const val = partSelect.tomselect ? partSelect.tomselect.getValue() : partSelect.value; + if (val && assemblySelect) { + if (assemblySelect.tomselect) { + assemblySelect.tomselect.clear(true); // true to silent event to avoid loops + } else { + assemblySelect.value = ""; + } + } + } + + // If assembly was changed and has a value, clear part + if (assemblySelect && (changedElement === assemblySelect || assemblySelect.contains(changedElement))) { + const val = assemblySelect.tomselect ? assemblySelect.tomselect.getValue() : assemblySelect.value; + if (val && partSelect) { + if (partSelect.tomselect) { + partSelect.tomselect.clear(true); // true to silent event to avoid loops + } else { + partSelect.value = ""; + } + } + } + + // Delay slightly to allow TomSelect to update its internal state if needed + setTimeout(() => { + this.updatePlaceholder(); + }, 100); + } +} diff --git a/assets/controllers/elements/part_search_controller.js b/assets/controllers/elements/part_search_controller.js index c13969005..d38a3ad60 100644 --- a/assets/controllers/elements/part_search_controller.js +++ b/assets/controllers/elements/part_search_controller.js @@ -54,16 +54,26 @@ export default class extends Controller { } initialize() { - // The endpoint for searching parts + // The endpoint for searching parts or assemblies const base_url = this.element.dataset.autocomplete; // The URL template for the part detail pages const part_detail_uri_template = this.element.dataset.detailUrl; + // The URL template for the assembly detail pages + const assembly_detail_uri_template = this.element.dataset.assemblyDetailUrl; + // The URL template for the project detail pages + const project_detail_uri_template = this.element.dataset.projectDetailUrl; + + const hasAssemblyDetailUrl = + typeof assembly_detail_uri_template === "string" && assembly_detail_uri_template.length > 0; + const hasProjectDetailUrl = + typeof project_detail_uri_template === "string" && project_detail_uri_template.length > 0; //The URL of the placeholder picture const placeholder_image = this.element.dataset.placeholderImage; //If the element is in navbar mode, or not const navbar_mode = this.element.dataset.navbarMode === "true"; + const panel_container_element = document.getElementById("navbar-search-form"); const that = this; @@ -72,10 +82,50 @@ export default class extends Controller { limit: 5, }); + // Cache the last query to avoid fetching the same endpoint twice (parts source + assemblies source) + let lastQuery = null; + let lastFetchPromise = null; + + const fetchMixedItems = (query) => { + if (query === lastQuery && lastFetchPromise) { + return lastFetchPromise; + } + + lastQuery = query; + + const urlString = base_url.replace('__QUERY__', encodeURIComponent(query)); + const url = new URL(urlString, window.location.href); + if (hasAssemblyDetailUrl || hasProjectDetailUrl) { + url.searchParams.set('multidatasources', '1'); + } + + lastFetchPromise = fetch(url.toString()) + .then((response) => response.json()) + .then((items) => { + //Iterate over all fields besides the id and highlight them (if present) + const fields = ["name", "description", "category", "footprint"]; + + items.forEach((item) => { + for (const field of fields) { + if (item[field] !== undefined && item[field] !== null) { + item[field] = that._highlight(item[field], query); + } + } + }); + + return items; + }); + + return lastFetchPromise; + }; + this._autocomplete = autocomplete({ container: this.element, + initialState: { + query: this.element.dataset.initialQuery || that.inputTarget.value || "" + }, //Place the panel in the navbar, if the element is in navbar mode - panelContainer: navbar_mode ? document.getElementById("navbar-search-form") : document.body, + panelContainer: (navbar_mode && panel_container_element) ? panel_container_element : document.body, panelPlacement: this.element.dataset.panelPlacement, plugins: [recentSearchesPlugin], openOnFocus: true, @@ -102,7 +152,12 @@ export default class extends Controller { }, // If the form is submitted, forward the term to the form - onSubmit({state, event, ...setters}) { + onSubmit({ state, event, ...setters }) { + // If the user pressed enter, we want to submit the form + if (event) { + event.preventDefault(); + } + //Put the current text into each target input field const input = that.inputTarget; @@ -116,34 +171,27 @@ export default class extends Controller { } input.value = state.query; - input.form.requestSubmit(); - }, + if (input.form) { + // We prefer requestSubmit() as it is more compatible with Turbo and triggers submit event listeners + // However, we fallback to submit() if requestSubmit() is not available + if (typeof input.form.requestSubmit === 'function') { + input.form.requestSubmit(); + } else { + input.form.submit(); + } + } + }, getSources({ query }) { - return [ - // The parts source + const sources = [ + // Parts source (filtered from mixed endpoint results) { sourceId: 'parts', getItems() { - const url = base_url.replace('__QUERY__', encodeURIComponent(query)); - - const data = fetch(url) - .then((response) => response.json()) - ; - - //Iterate over all fields besides the id and highlight them - const fields = ["name", "description", "category", "footprint"]; - - data.then((items) => { - items.forEach((item) => { - for (const field of fields) { - item[field] = that._highlight(item[field], query); - } - }); - }); - - return data; + return fetchMixedItems(query).then((items) => + items.filter((item) => item.type !== "assembly") + ); }, getItemUrl({ item }) { return part_detail_uri_template.replace('__ID__', item.id); @@ -151,36 +199,130 @@ export default class extends Controller { templates: { header({ html }) { return html`${trans("part.labelp")} -
`; +
`; }, - item({item, components, html}) { + item({ item, components, html }) { const details_url = part_detail_uri_template.replace('__ID__', item.id); return html` - -
-
- ${item.name} -
-
-
- - ${components.Highlight({hit: item, attribute: 'name'})} - + +
+
+ ${item.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`${trans("assembly.labelp")} +
`; + }, + item({ item, components, html }) { + const details_url = assembly_detail_uri_template.replace('__ID__', item.id); + + return html` + +
+
+ ${item.name} +
+
+
+ + ${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`${trans("project.labelp")} +
`; + }, + item({ item, components, html }) { + const details_url = project_detail_uri_template.replace('__ID__', item.id); + + return html` + +
+
+ ${item.name} +
+
+
+ + ${components.Highlight({hit: item, attribute: 'name'})} + +
+
+ ${components.Highlight({hit: item, attribute: 'description'})} +
+
+
+
+ `; + }, + }, + } + ); + } + + return sources; }, }); @@ -192,6 +334,5 @@ export default class extends Controller { this._autocomplete.setIsOpen(false); }); } - } } diff --git a/assets/controllers/elements/part_select_controller.js b/assets/controllers/elements/part_select_controller.js index b69acbbc3..05b7b083a 100644 --- a/assets/controllers/elements/part_select_controller.js +++ b/assets/controllers/elements/part_select_controller.js @@ -19,7 +19,7 @@ export default class extends Controller { let settings = { allowEmptyOption: true, plugins: ['dropdown_input', this.element.required ? null : 'clear_button'], - searchField: ["name", "description", "category", "footprint"], + searchField: ["name", "description", "category", "footprint", "ipn"], valueField: "id", labelField: "name", dropdownParent: dropdownParent, @@ -53,8 +53,9 @@ export default class extends Controller { }; - if (this.element.dataset.autocomplete) { - const base_url = this.element.dataset.autocomplete; + if (this.element.dataset.autocomplete || this.element.querySelector('[data-autocomplete]')) { + const autocompleteElement = this.element.dataset.autocomplete ? this.element : this.element.querySelector('[data-autocomplete]'); + const base_url = autocompleteElement.dataset.autocomplete; settings.valueField = "id"; settings.load = (query, callback) => { const url = base_url.replace('__QUERY__', encodeURIComponent(query)); @@ -68,7 +69,8 @@ export default class extends Controller { }; - this._tomSelect = new TomSelect(this.element, settings); + const targetElement = this.element instanceof HTMLInputElement || this.element instanceof HTMLSelectElement ? this.element : this.element.querySelector('select, input'); + this._tomSelect = new TomSelect(targetElement, settings); //this._tomSelect.clearOptions(); } } diff --git a/assets/controllers/elements/search_options_controller.js b/assets/controllers/elements/search_options_controller.js new file mode 100644 index 000000000..e57011ae5 --- /dev/null +++ b/assets/controllers/elements/search_options_controller.js @@ -0,0 +1,76 @@ +import { Controller } from "@hotwired/stimulus"; + +export default class extends Controller { + static targets = ["datasource", "partOptions", "assemblyOptions", "projectOptions", "divider"]; + static values = { + isSearchList: Boolean + }; + + connect() { + // Delay update slightly to ensure all child controllers are connected and DOM is ready + setTimeout(() => { + this.updateVisibility(); + }, 1000); + } + + onDatasourceChange() { + this.updateVisibility(); + } + + updateVisibility() { + if (!this.hasDatasourceTarget) return; + + const datasource = this.datasourceTarget.value; + const isSearchList = this.isSearchListValue; + const isPart = (datasource === "parts"); + const isAssembly = (datasource === "assemblies"); + const isProject = (datasource === "projects"); + + if (this.hasPartOptionsTarget) { + this.toggleOptions(this.partOptionsTarget, isPart, isSearchList); + } + + if (this.hasAssemblyOptionsTarget) { + this.toggleOptions(this.assemblyOptionsTarget, isAssembly, isSearchList); + } + + if (this.hasProjectOptionsTarget) { + this.toggleOptions(this.projectOptionsTarget, isProject, isSearchList); + } + + if (this.hasDividerTarget) { + this.dividerTarget.classList.toggle("d-none", !isPart && !isAssembly && !isProject); + } + } + + toggleOptions(container, show, isSearchList) { + const wasHidden = container.classList.contains("d-none"); + container.classList.toggle("d-none", !show); + + const checkboxes = container.querySelectorAll('input[type="checkbox"]'); + if (!show) { + // Deselect checkboxes if not in correct mode + checkboxes.forEach(checkbox => { + // Store current state to restore it later if the user switches back + if (checkbox.checked) { + checkbox.dataset.previousState = "true"; + checkbox.checked = false; + // Trigger a change event to update sessionStorage via the sessionStorage_checkbox controller + // We use a CustomEvent to pass the skipStorage flag + checkbox.dispatchEvent(new CustomEvent('change', { bubbles: true, detail: { skipStorage: true } })); + } + }); + } else if (wasHidden) { + // Restore state when switching back + checkboxes.forEach(checkbox => { + // Restore state if NOT on search list + // On search list, we don't restore to avoid overwriting Twig's checked state + if (!isSearchList && checkbox.dataset.previousState === "true") { + checkbox.checked = true; + checkbox.dispatchEvent(new CustomEvent('change', { bubbles: true, detail: { skipStorage: true } })); + } + delete checkbox.dataset.previousState; + }); + } + } +} diff --git a/assets/controllers/elements/sessionStorage_checkbox_controller.js b/assets/controllers/elements/sessionStorage_checkbox_controller.js new file mode 100644 index 000000000..cab3d7e77 --- /dev/null +++ b/assets/controllers/elements/sessionStorage_checkbox_controller.js @@ -0,0 +1,81 @@ +/* + * This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony). + * + * Copyright (C) 2019 - 2023 Jan Böhmer (https://github.com/jbtronics) + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import {Controller} from "@hotwired/stimulus"; + +export default class extends Controller +{ + static values = { + id: String, + isSearchList: Boolean + } + + connect() { + if (this.isSearchListValue) { + // If we are on the search list, we want to update the localStorage with the current (server-side) state + // to ensure consistency. + this.saveState(); + } else { + // Otherwise, we load the state from localStorage. + this.loadState(); + } + this.element.addEventListener('change', (event) => { + // Don't save state if we are currently being toggled by the search_options controller + // to avoid saving "unchecked" states when options are hidden. + // CustomEvent's detail property contains the data we passed. + if (event instanceof CustomEvent && event.detail && event.detail.skipStorage) { + return; + } + this.saveState() + }); + } + + loadState() { + let storageKey = this.getStorageKey(); + let value = localStorage.getItem(storageKey); + if (value === null) { + return; + } + + if (value === 'true') { + this.element.checked = true + } + if (value === 'false') { + this.element.checked = false + } + } + + saveState() { + let storageKey = this.getStorageKey(); + + if (this.element.checked) { + localStorage.setItem(storageKey, 'true'); + } else { + localStorage.setItem(storageKey, 'false'); + } + } + + getStorageKey() { + if (this.hasIdValue) { + return 'persistent_checkbox_' + this.idValue + } + + return 'persistent_checkbox_' + this.element.id; + } +} diff --git a/assets/controllers/elements/sidebar_tree_controller.js b/assets/controllers/elements/sidebar_tree_controller.js index d50cf9004..078e8e05b 100644 --- a/assets/controllers/elements/sidebar_tree_controller.js +++ b/assets/controllers/elements/sidebar_tree_controller.js @@ -60,6 +60,10 @@ export default class extends TreeController { doUpdateIfNeeded() { const info_element = document.getElementById('sidebar-last-time-updated'); + if (!info_element) { + return; + } + const date_str = info_element.dataset.lastUpdate; const server_last_update = new Date(date_str); diff --git a/assets/controllers/elements/toggle_visibility_controller.js b/assets/controllers/elements/toggle_visibility_controller.js new file mode 100644 index 000000000..51c9cb338 --- /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/helpers/form_cleanup_controller.js b/assets/controllers/helpers/form_cleanup_controller.js index d554371d8..b0331b83e 100644 --- a/assets/controllers/helpers/form_cleanup_controller.js +++ b/assets/controllers/helpers/form_cleanup_controller.js @@ -34,6 +34,10 @@ export default class extends Controller { /** @type {HTMLFormElement} */ const form = event.target.closest('form'); + if (!form) { + return; + } + for(const element of form.elements) { if(! element.value) { element.disabled = true; @@ -53,6 +57,11 @@ export default class extends Controller { clearAll(event) { const form = event.target.closest('form'); + + if (!form) { + return; + } + for(const element of form.elements) { // Do not clear elements with data-no-clear attribute if(element.dataset.noClear) { diff --git a/assets/controllers/pages/dont_check_quantity_checkbox_controller.js b/assets/controllers/pages/dont_check_quantity_checkbox_controller.js index 2abd3d77b..f3e8cb900 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/label_download_btn_controller.js b/assets/controllers/pages/label_download_btn_controller.js index 01c0ba103..698d80f4b 100644 --- a/assets/controllers/pages/label_download_btn_controller.js +++ b/assets/controllers/pages/label_download_btn_controller.js @@ -22,6 +22,9 @@ import {Controller} from "@hotwired/stimulus"; export default class extends Controller { download(event) { - this.element.href = document.getElementById('pdf_preview').data + const preview = document.getElementById('pdf_preview'); + if (preview) { + this.element.href = preview.data; + } } -} \ No newline at end of file +} diff --git a/assets/controllers/pages/statistics_assembly_controller.js b/assets/controllers/pages/statistics_assembly_controller.js new file mode 100644 index 000000000..df53e304b --- /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/controllers/turbo/locale_menu_controller.js b/assets/controllers/turbo/locale_menu_controller.js index d55ff8da5..54f7dc0cc 100644 --- a/assets/controllers/turbo/locale_menu_controller.js +++ b/assets/controllers/turbo/locale_menu_controller.js @@ -22,6 +22,8 @@ import { Controller } from '@hotwired/stimulus'; export default class extends Controller { connect() { const menu = document.getElementById('locale-select-menu'); - menu.innerHTML = this.element.innerHTML; + if (menu) { + menu.innerHTML = this.element.innerHTML; + } } -} \ No newline at end of file +} diff --git a/assets/css/app/images.css b/assets/css/app/images.css index 0212a85b7..132cab99b 100644 --- a/assets/css/app/images.css +++ b/assets/css/app/images.css @@ -61,3 +61,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 67bab02db..2f078283f 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/assets/js/register_events.js b/assets/js/register_events.js index 9732c0c11..27c7d626f 100644 --- a/assets/js/register_events.js +++ b/assets/js/register_events.js @@ -35,6 +35,7 @@ class RegisterEventHelper { }); this.registerModalDropRemovalOnFormSubmit(); + this.registerHomepageCleanupOnSearch(); } @@ -63,6 +64,39 @@ class RegisterEventHelper { document.addEventListener('turbo:load', fn); } + registerHomepageCleanupOnSearch() { + const cleanup = () => { + const ids = [ + 'homepage-banner-container', + 'homepage-last-activity-container', + 'homepage-search-container', + 'homepage-first-steps', + 'homepage-license', + 'new-version-alert' + ]; + + const isSearchPage = window.location.pathname.includes('/search') || window.location.search.includes('keyword='); + + ids.forEach(id => { + const elements = document.querySelectorAll('#' + id); + elements.forEach(el => { + if (isSearchPage) { + //We hide it, instead of removing it, to not break Turbo Morphing anchors + el.style.setProperty('display', 'none', 'important'); + } else { + //On non-search pages, we ensure it is visible again if it was hidden by this script + //But only if it's not one of the "anchor" divs from base.html.twig which should stay hidden + if (el.hasAttribute('data-turbo-temporary')) { + el.style.display = ''; + } + } + }); + }); + }; + + this.registerLoadHandler(cleanup); + } + configureDropdowns() { this.registerLoadHandler(() => { //Set the dropdown strategy to fixed, so that the dropdowns are not cut off by the overflow: hidden of the body. @@ -328,4 +362,4 @@ class RegisterEventHelper { } } -export default new RegisterEventHelper(); \ No newline at end of file +export default new RegisterEventHelper(); diff --git a/config/permissions.yaml b/config/permissions.yaml index 39e91b57e..fbaca756f 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 5021c5777..030155c56 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 709c39b38..de48e5b33 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -147,6 +147,17 @@ 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_PROJECTS_DEFAULT_COLUMNS`: The columns in projects 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`, `description`, `notes`, `edit`, `addedDate`, `lastModified`. +* `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 000000000..0c972e830 --- /dev/null +++ b/migrations/Version20251016141941.php @@ -0,0 +1,248 @@ +doesTableExist('assemblies')) { + $this->addSql(<<<'SQL' + CREATE TABLE assemblies ( + id INT AUTO_INCREMENT NOT NULL, + parent_id INT DEFAULT NULL, + id_preview_attachment INT DEFAULT NULL, + name VARCHAR(255) NOT NULL, + comment LONGTEXT NOT NULL, + not_selectable TINYINT(1) NOT NULL, + alternative_names LONGTEXT DEFAULT NULL, + order_quantity INT NOT NULL, + status VARCHAR(64) DEFAULT NULL, + ipn VARCHAR(100) DEFAULT NULL, + order_only_missing_parts TINYINT(1) NOT NULL, + description LONGTEXT NOT NULL, + last_modified DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, + datetime_added DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, + INDEX IDX_5F3832C0727ACA70 (parent_id), + INDEX IDX_5F3832C0EA7100A1 (id_preview_attachment), + UNIQUE INDEX UNIQ_5F3832C03D721C14 (ipn), + INDEX assembly_idx_ipn (ipn), + PRIMARY KEY(id) + ) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` + SQL); + $this->addSql(<<<'SQL' + ALTER TABLE assemblies + ADD CONSTRAINT FK_5F3832C0727ACA70 FOREIGN KEY (parent_id) REFERENCES assemblies (id) + SQL); + $this->addSql(<<<'SQL' + ALTER TABLE assemblies + ADD CONSTRAINT FK_5F3832C0EA7100A1 FOREIGN KEY (id_preview_attachment) REFERENCES `attachments` (id) ON DELETE SET NULL + SQL); + } + + if (!$this->doesTableExist('assembly_bom_entries')) { + $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 + { + if (!$this->doesTableExist('assemblies')) { + $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)'); + } + + if (!$this->doesTableExist('assembly_bom_entries')) { + $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 + { + if (!$this->doesTableExist('assemblies')) { + $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); + } + + if (!$this->doesTableExist('assembly_bom_entries')) { + $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 201263ffd..b0c083921 100644 --- a/src/Command/Migrations/ConvertBBCodeCommand.php +++ b/src/Command/Migrations/ConvertBBCodeCommand.php @@ -22,6 +22,7 @@ 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 @@ protected function getTargetsLists(): array 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 000000000..20f640923 --- /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 7c1097517..44d543541 100644 --- a/src/Controller/AdminPages/BaseAdminController.php +++ b/src/Controller/AdminPages/BaseAdminController.php @@ -23,6 +23,8 @@ 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 @@ protected function _edit(AbstractNamedDBElement $entity, Request $request, Entit $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 @@ protected function _new(Request $request, EntityManagerInterface $em, EntityImpo $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 @@ protected function _delete(Request $request, AbstractNamedDBElement $entity, Str 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 @@ protected function _exportEntity(AbstractNamedDBElement $entity, EntityExporter 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 000000000..5c558d66a --- /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 2210fc186..6cfb9056c 100644 --- a/src/Controller/PartListsController.php +++ b/src/Controller/PartListsController.php @@ -24,8 +24,17 @@ use App\DataTables\ErrorDataTable; use App\DataTables\Filters\PartFilter; +use App\DataTables\Filters\AssemblyFilter; +use App\DataTables\Filters\ProjectFilter; +use App\DataTables\AssemblyDataTable; +use App\DataTables\Filters\AssemblySearchFilter; use App\DataTables\Filters\PartSearchFilter; +use App\DataTables\Filters\ProjectSearchFilter; +use App\Form\Filters\AssemblyFilterType; +use App\Form\Filters\PartFilterType; +use App\Form\Filters\ProjectFilterType; use App\DataTables\PartsDataTable; +use App\DataTables\ProjectSearchDataTable; use App\Entity\Parts\Category; use App\Entity\Parts\Footprint; use App\Entity\Parts\Manufacturer; @@ -33,7 +42,6 @@ use App\Entity\Parts\StorageLocation; use App\Entity\Parts\Supplier; use App\Exceptions\InvalidRegexException; -use App\Form\Filters\PartFilterType; use App\Services\Parts\PartsTableActionHandler; use App\Services\Trees\NodesListBuilder; use App\Settings\BehaviorSettings\SidebarSettings; @@ -160,25 +168,52 @@ protected function showListWithFilter(Request $request, string $template, ?calla $formRequest = clone $request; $formRequest->setMethod('GET'); - $filter = new PartFilter($this->nodesListBuilder); - if($filter_changer !== null){ + + $filterType = $additional_table_vars['filterType'] ?? PartFilter::class; + unset($additional_table_vars['filterType']); + + if ($filterType === PartFilter::class) { + $filter = new PartFilter($this->nodesListBuilder); + } elseif ($filterType === AssemblyFilter::class) { + $filter = new AssemblyFilter($this->nodesListBuilder); + } elseif ($filterType === ProjectFilter::class) { + $filter = new ProjectFilter($this->nodesListBuilder); + } else { + $filter = null; + } + + if ($filter !== null && $filter_changer !== null) { $filter_changer($filter); } //If we are in a post request for the tables, we only have to apply the filter form if the submit query param was set //This saves us some time from creating this complicated term on simple list pages, where no special filter is applied $filterForm = null; - if ($request->getMethod() !== 'POST' || $request->query->has('part_filter')) { - $filterForm = $this->createForm(PartFilterType::class, $filter, ['method' => 'GET']); - if ($form_changer !== null) { - $form_changer($filterForm); - } + if ($request->getMethod() !== 'POST' || $request->query->has('part_filter') || $request->query->has('assembly_filter') || $request->query->has('project_filter') || $request->query->has('submit')) { + $formType = match ($filterType) { + PartFilter::class => PartFilterType::class, + AssemblyFilter::class => AssemblyFilterType::class, + ProjectFilter::class => ProjectFilterType::class, + default => null, + }; + + if ($formType !== null) { + $filterForm = $this->createForm($formType, $filter, ['method' => 'GET']); + if ($form_changer !== null) { + $form_changer($filterForm); + } - $filterForm->handleRequest($formRequest); + $filterForm->handleRequest($formRequest); + } } - $table = $this->dataTableFactory->createFromType(PartsDataTable::class, array_merge( - ['filter' => $filter], $additional_table_vars), + $dataTableType = $additional_table_vars['dataTableType'] ?? PartsDataTable::class; + unset($additional_table_vars['dataTableType']); + + $tableOptions = array_merge( + ['filter' => $filter], $additional_table_vars); + + $table = $this->dataTableFactory->createFromType($dataTableType, $tableOptions, ['pageLength' => $this->tableSettings->fullDefaultPageSize, 'lengthMenu' => PartsDataTable::LENGTH_MENU]) ->handleRequest($request); @@ -313,25 +348,99 @@ function (PartFilter $filter) use ($tag) { ); } - private function searchRequestToFilter(Request $request): PartSearchFilter + /** + * @return PartSearchFilter|AssemblySearchFilter|ProjectSearchFilter + */ + private function searchRequestToFilter(Request $request): object { - $filter = new PartSearchFilter($request->query->get('keyword', '')); + $datasource = $request->query->get('datasource', 'parts'); + $keyword = $request->query->get('keyword', ''); + + if ($datasource === 'assemblies') { + $filter = new AssemblySearchFilter($keyword); + $filter->setDatasource($datasource); + $filter->setRegex($request->query->getBoolean('regex')); + + //As an unchecked checkbox is not set in the query, the default value for all bools have to be false (which is the default argument value)! + //But if we are coming from a simple search (without search options set), we want to search in all fields by default + if ($request->query->has('name') || $request->query->has('description') || $request->query->has('comment') || $request->query->has('ipn') || $request->query->has('category') || $request->query->has('status') || $request->query->has('dbid')) { + $filter->setName($request->query->getBoolean('name')); + $filter->setDescription($request->query->getBoolean('description')); + $filter->setComment($request->query->getBoolean('comment') || $request->query->getBoolean('notes')); + $filter->setIPN($request->query->getBoolean('ipn')); + $filter->setCategory($request->query->getBoolean('category')); + $filter->setStatus($request->query->getBoolean('status')); + $filter->setDbId($request->query->getBoolean('dbid')); + } else { + //Simple search: search in all fields + $filter->setName(true); + $filter->setDescription(true); + $filter->setComment(true); + $filter->setIPN(true); + $filter->setStatus(true); + } - //As an unchecked checkbox is not set in the query, the default value for all bools have to be false (which is the default argument value)! - $filter->setName($request->query->getBoolean('name')); - $filter->setDbId($request->query->getBoolean('dbid')); - $filter->setCategory($request->query->getBoolean('category')); - $filter->setDescription($request->query->getBoolean('description')); - $filter->setMpn($request->query->getBoolean('mpn')); - $filter->setTags($request->query->getBoolean('tags')); - $filter->setStorelocation($request->query->getBoolean('storelocation')); - $filter->setComment($request->query->getBoolean('comment')); - $filter->setIPN($request->query->getBoolean('ipn')); - $filter->setOrdernr($request->query->getBoolean('ordernr')); - $filter->setSupplier($request->query->getBoolean('supplier')); - $filter->setManufacturer($request->query->getBoolean('manufacturer')); - $filter->setFootprint($request->query->getBoolean('footprint')); + return $filter; + } + + if ($datasource === 'projects') { + $filter = new ProjectSearchFilter($keyword); + $filter->setDatasource($datasource); + $filter->setRegex($request->query->getBoolean('regex')); + + //As an unchecked checkbox is not set in the query, the default value for all bools have to be false (which is the default argument value)! + if ($request->query->has('name') || $request->query->has('description') || $request->query->has('comment') || $request->query->has('notes') || $request->query->has('category') || $request->query->has('status') || $request->query->has('dbid')) { + $filter->setName($request->query->getBoolean('name')); + $filter->setDescription($request->query->getBoolean('description')); + $filter->setComment($request->query->getBoolean('comment') || $request->query->getBoolean('notes')); + $filter->setCategory($request->query->getBoolean('category')); + $filter->setStatus($request->query->getBoolean('status')); + $filter->setDbId($request->query->getBoolean('dbid')); + } else { + //Simple search: search in all fields + $filter->setName(true); + $filter->setDescription(true); + $filter->setComment(true); + $filter->setStatus(true); + } + return $filter; + } + + $filter = new PartSearchFilter($keyword); + $filter->setDatasource($datasource); + + //As an unchecked checkbox is not set in the query, the default value for all bools have to be false (which is the default argument value)! + if ($request->query->has('name') || $request->query->has('description') || $request->query->has('comment') || $request->query->has('ipn') || $request->query->has('category') || $request->query->has('dbid') || $request->query->has('mpn') || $request->query->has('tags') || $request->query->has('storelocation') || $request->query->has('ordernr') || $request->query->has('supplier') || $request->query->has('manufacturer') || $request->query->has('footprint') || $request->query->has('assembly') || $request->query->has('manufacturing_status')) { + $filter->setName($request->query->getBoolean('name')); + $filter->setDbId($request->query->getBoolean('dbid')); + $filter->setCategory($request->query->getBoolean('category')); + $filter->setDescription($request->query->getBoolean('description')); + $filter->setMpn($request->query->getBoolean('mpn')); + $filter->setTags($request->query->getBoolean('tags')); + $filter->setStorelocation($request->query->getBoolean('storelocation')); + $filter->setComment($request->query->getBoolean('comment')); + $filter->setManufacturingStatus($request->query->getBoolean('manufacturing_status')); + $filter->setIPN($request->query->getBoolean('ipn')); + $filter->setOrdernr($request->query->getBoolean('ordernr')); + $filter->setSupplier($request->query->getBoolean('supplier')); + $filter->setManufacturer($request->query->getBoolean('manufacturer')); + $filter->setFootprint($request->query->getBoolean('footprint')); + $filter->setAssembly($request->query->getBoolean('assembly')); + } else { + //Simple search: search in all fields + $filter->setName(true); + $filter->setCategory(true); + $filter->setDescription(true); + $filter->setComment(true); + $filter->setManufacturingStatus(true); + $filter->setTags(true); + $filter->setStorelocation(true); + $filter->setOrdernr(true); + $filter->setMpn(true); + $filter->setIPN(true); + $filter->setAssembly(true); + } $filter->setRegex($request->query->getBoolean('regex')); @@ -342,17 +451,34 @@ private function searchRequestToFilter(Request $request): PartSearchFilter public function showSearch(Request $request, DataTableFactory $dataTable): Response { $searchFilter = $this->searchRequestToFilter($request); + $datasource = $request->query->get('datasource', 'parts'); + + $dataTableType = PartsDataTable::class; + $template = 'parts/lists/search_list.html.twig'; + $filterType = PartFilter::class; + + if ($searchFilter instanceof AssemblySearchFilter) { + $dataTableType = AssemblyDataTable::class; + $filterType = AssemblyFilter::class; + } elseif ($searchFilter instanceof ProjectSearchFilter) { + $dataTableType = ProjectSearchDataTable::class; + $filterType = ProjectFilter::class; + } return $this->showListWithFilter($request, - 'parts/lists/search_list.html.twig', + $template, null, null, [ 'keyword' => $searchFilter->getKeyword(), 'searchFilter' => $searchFilter, + 'dataTableType' => $dataTableType, + 'datasource' => $datasource, ], [ 'search' => $searchFilter, + 'dataTableType' => $dataTableType, + 'filterType' => $filterType, ] ); } diff --git a/src/Controller/ProjectController.php b/src/Controller/ProjectController.php index 2a6d19ee2..aa0b3f769 100644 --- a/src/Controller/ProjectController.php +++ b/src/Controller/ProjectController.php @@ -46,17 +46,20 @@ 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 @@ public function importBOM( '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 @@ public function importBOM( } // 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 @@ public function importBOM( } } + $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 @@ public function importBOMMapFields( } // 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 67c297818..baec74677 100644 --- a/src/Controller/StatisticsController.php +++ b/src/Controller/StatisticsController.php @@ -42,9 +42,14 @@ 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 @@ public function showStatistics(StatisticsHelper $helper): Response '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 71f8ba5c6..0ba3a1584 100644 --- a/src/Controller/TreeController.php +++ b/src/Controller/TreeController.php @@ -22,6 +22,7 @@ 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 @@ public function deviceTree(?Project $device = null): JsonResponse 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 39821f598..3557268c2 100644 --- a/src/Controller/TypeaheadController.php +++ b/src/Controller/TypeaheadController.php @@ -22,8 +22,12 @@ 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\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,152 @@ private function typeToParameterClass(string $type): string '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, 10); + /** @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(), + 'ipn' => $part->getIpn(), '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, 10); + + 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, 10); + + 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(), + 'ipn' => $assembly->getIpn(), + '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[] = [ + 'type' => 'assembly', + 'id' => $assembly->getID(), + 'name' => $assembly->getName(), + 'ipn' => $assembly->getIpn(), + '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 9c6853384..392bd30f3 100644 --- a/src/DataFixtures/DataStructureFixtures.php +++ b/src/DataFixtures/DataStructureFixtures.php @@ -22,6 +22,7 @@ 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 @@ public function __construct(protected EntityManagerInterface $em) 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 d8e54b9f5..ddb74b1cc 100644 --- a/src/DataFixtures/GroupFixtures.php +++ b/src/DataFixtures/GroupFixtures.php @@ -58,6 +58,7 @@ public function load(ObjectManager $manager): void $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 @@ private function addDevicesPermissions(Group $group): void $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 000000000..60c67ed27 --- /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 000000000..aa2057122 --- /dev/null +++ b/src/DataTables/AssemblyDataTable.php @@ -0,0 +1,268 @@ +. + */ + +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), + 'orderable' => false, + 'searchable' => false, + ], 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('comment', MarkdownColumn::class, [ + 'label' => $this->translator->trans('assembly.table.comment'), + 'render' => fn($value, Assembly $context) => $context->getComment() + ]) + ->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(); + + //Helper function to check if a join alias is already present in the QueryBuilder + $hasJoin = static function (QueryBuilder $qb, string $alias): bool { + foreach ($qb->getDQLPart('join') as $joins) { + foreach ($joins as $join) { + if ($join->getAlias() === $alias) { + return true; + } + } + } + return false; + }; + + if (str_contains($dql, '_master_picture_attachment') && !$hasJoin($builder, '_master_picture_attachment')) { + $builder->leftJoin('assembly.master_picture_attachment', '_master_picture_attachment'); + $builder->addGroupBy('_master_picture_attachment'); + } + if (str_contains($dql, '_attachments') && !$hasJoin($builder, '_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 000000000..f53305d47 --- /dev/null +++ b/src/DataTables/Filters/AssemblyFilter.php @@ -0,0 +1,74 @@ +. + */ +namespace App\DataTables\Filters; + +use App\DataTables\Filters\Constraints\ChoiceConstraint; +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\AssemblySystem\Assembly; +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 ChoiceConstraint $status; + public readonly EntityConstraint $category; + 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->status = new ChoiceConstraint('assembly.status'); + $this->category = new EntityConstraint($nodesListBuilder, Assembly::class, 'assembly.parent'); + $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 000000000..d41649d87 --- /dev/null +++ b/src/DataTables/Filters/AssemblySearchFilter.php @@ -0,0 +1,353 @@ +. + */ +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 = false; + + /** @var bool Use description for searching */ + protected bool $description = false; + + /** @var bool Use comment field for searching */ + protected bool $comment = false; + + /** @var bool Use Internal part number for searching */ + protected bool $ipn = false; + + /** @var bool Use id field for searching */ + protected bool $dbId = false; + + /** + * If true, we search in the name of the parent assembly (if available). + * This field is named "category" to keep the API consistent with PartSearchFilter, + * although assemblies don't have categories (they only have parents). + */ + protected bool $category = false; + + /** @var bool Use status field for searching */ + protected bool $status = false; + + /** @var string The datasource used for searching */ + protected string $datasource = 'assemblies'; + + protected static int $parameterCounter = 0; + + public function __construct( + /** @var string The string to query for */ + protected string $keyword + ) + { + } + + protected function generateParameterIdentifier(string $property): string + { + //Replace all special characters with underscores + $property = preg_replace('/\W/', '_', $property); + return $property . '_' . (self::$parameterCounter++) . '_'; + } + + 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'; + } + if ($this->status) { + $fields_to_search[] = 'assembly.status'; + } + if ($this->dbId) { + $fields_to_search[] = 'assembly.id'; + } + if ($this->category) { + // We search in the name of the parent assembly. + // This is named category for consistency with PartSearchFilter. + $fields_to_search[] = '_search_parent.name'; + } + + return $fields_to_search; + } + + public function apply(QueryBuilder $queryBuilder): void + { + if ($this->category) { + // We search in the parent assembly. + // Check if the join alias is already present in the QueryBuilder + $hasJoin = false; + foreach ($queryBuilder->getDQLPart('join') as $joins) { + foreach ($joins as $join) { + if ($join->getAlias() === '_search_parent') { + $hasJoin = true; + break 2; + } + } + } + + if (!$hasJoin) { + $queryBuilder->leftJoin('assembly.parent', '_search_parent'); + } + } + + $fields_to_search = $this->getFieldsToSearch(); + + //If we have nothing to search for, do nothing + if ($fields_to_search === [] || $this->keyword === '') { + return; + } + + $parameterIdentifier = $this->generateParameterIdentifier('search_query'); + + //Convert the fields to search to a list of expressions + $expressions = array_map(function (string $field) use ($parameterIdentifier): string { + if ($this->regex) { + return sprintf("REGEXP(%s, :%s) = TRUE", $field, $parameterIdentifier); + } + + return sprintf("ILIKE(%s, :%s) = TRUE", $field, $parameterIdentifier); + }, $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($parameterIdentifier, $this->keyword); + } else { + //Escape % and _ characters in the keyword + $keyword_escaped = str_replace(['%', '_'], ['\%', '\_'], $this->keyword); + $queryBuilder->setParameter($parameterIdentifier, '%' . $keyword_escaped . '%'); + } + } + + 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): self + { + $this->comment = $comment; + return $this; + } + + public function isCategory(): bool + { + return $this->category; + } + + /** + * Set if the parent assembly name should be searched. + * This is named "category" for consistency with PartSearchFilter. + */ + public function setCategory(bool $category): self + { + $this->category = $category; + return $this; + } + + public function isStatus(): bool + { + return $this->status; + } + + public function setStatus(bool $status): self + { + $this->status = $status; + return $this; + } + + public function isMpn(): bool + { + return false; + } + + public function setMpn(bool $mpn): self + { + return $this; + } + + public function isTags(): bool + { + return false; + } + + public function setTags(bool $tags): self + { + return $this; + } + + public function isStorelocation(): bool + { + return false; + } + + public function setStorelocation(bool $storelocation): self + { + return $this; + } + + public function isSupplier(): bool + { + return false; + } + + public function setSupplier(bool $supplier): self + { + return $this; + } + + public function isManufacturer(): bool + { + return false; + } + + public function setManufacturer(bool $manufacturer): self + { + return $this; + } + + public function isFootprint(): bool + { + return false; + } + + public function setFootprint(bool $footprint): self + { + return $this; + } + + public function isDbId(): bool + { + return $this->dbId; + } + + public function setDbId(bool $dbId): self + { + $this->dbId = $dbId; + return $this; + } + + public function isAssembly(): bool + { + return false; + } + + public function setAssembly(bool $assembly): self + { + return $this; + } + + public function isOrdernr(): bool + { + return false; + } + + public function setOrdernr(bool $ordernr): self + { + return $this; + } + + public function getDatasource(): string + { + return $this->datasource; + } + + public function setDatasource(string $datasource): self + { + $this->datasource = $datasource; + return $this; + } +} diff --git a/src/DataTables/Filters/PartFilter.php b/src/DataTables/Filters/PartFilter.php index a08293cac..8a41c6d71 100644 --- a/src/DataTables/Filters/PartFilter.php +++ b/src/DataTables/Filters/PartFilter.php @@ -36,6 +36,7 @@ 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 @@ public function __construct(NodesListBuilder $nodesListBuilder) $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 9f6734e56..084ae2910 100644 --- a/src/DataTables/Filters/PartSearchFilter.php +++ b/src/DataTables/Filters/PartSearchFilter.php @@ -32,31 +32,31 @@ class PartSearchFilter implements FilterInterface protected bool $regex = false; /** @var bool Use name field for searching */ - protected bool $name = true; + protected bool $name = false; /** @var bool Use id field for searching */ protected bool $dbId = false; /** @var bool Use category name for searching */ - protected bool $category = true; + protected bool $category = false; /** @var bool Use description for searching */ - protected bool $description = true; + protected bool $description = false; /** @var bool Use tags for searching */ - protected bool $tags = true; + protected bool $tags = false; /** @var bool Use storelocation name for searching */ - protected bool $storelocation = true; + protected bool $storelocation = false; /** @var bool Use comment field for searching */ - protected bool $comment = true; + protected bool $comment = false; /** @var bool Use ordernr for searching */ - protected bool $ordernr = true; + protected bool $ordernr = false; /** @var bool Use manufacturer product name for searching */ - protected bool $mpn = true; + protected bool $mpn = false; /** @var bool Use supplier name for searching */ protected bool $supplier = false; @@ -67,8 +67,19 @@ class PartSearchFilter implements FilterInterface /** @var bool Use footprint name for searching */ protected bool $footprint = false; + /** @var bool Use manufacturing status for searching */ + protected bool $manufacturingStatus = false; + /** @var bool Use Internal Part number for searching */ - protected bool $ipn = true; + protected bool $ipn = false; + + /** @var bool Use assembly name for searching */ + protected bool $assembly = false; + + /** @var string The datasource used for searching */ + protected string $datasource = 'parts'; + + protected static int $parameterCounter = 0; public function __construct( /** @var string The string to query for */ @@ -77,6 +88,13 @@ public function __construct( { } + protected function generateParameterIdentifier(string $property): string + { + //Replace all special characters with underscores + $property = preg_replace('/\W/', '_', $property); + return $property . '_' . (self::$parameterCounter++) . '_'; + } + protected function getFieldsToSearch(): array { $fields_to_search = []; @@ -85,7 +103,7 @@ protected function getFieldsToSearch(): array $fields_to_search[] = 'part.name'; } if($this->category) { - $fields_to_search[] = '_category.name'; + $fields_to_search[] = '_search_category.name'; } if($this->description) { $fields_to_search[] = 'part.description'; @@ -97,32 +115,66 @@ protected function getFieldsToSearch(): array $fields_to_search[] = 'part.tags'; } if($this->storelocation) { - $fields_to_search[] = '_storelocations.name'; + $fields_to_search[] = '_search_storelocations.name'; } if($this->ordernr) { - $fields_to_search[] = '_orderdetails.supplierpartnr'; + $fields_to_search[] = '_search_orderdetails.supplierpartnr'; } if($this->mpn) { $fields_to_search[] = 'part.manufacturer_product_number'; } if($this->supplier) { - $fields_to_search[] = '_suppliers.name'; + $fields_to_search[] = '_search_suppliers.name'; } if($this->manufacturer) { - $fields_to_search[] = '_manufacturer.name'; + $fields_to_search[] = '_search_manufacturer.name'; } if($this->footprint) { - $fields_to_search[] = '_footprint.name'; + $fields_to_search[] = '_search_footprint.name'; + } + if($this->manufacturingStatus) { + $fields_to_search[] = 'part.manufacturing_status'; } if ($this->ipn) { $fields_to_search[] = 'part.ipn'; } + if ($this->assembly) { + $fields_to_search[] = '_search_assembly.name'; + $fields_to_search[] = '_search_assembly.ipn'; + } return $fields_to_search; } public function apply(QueryBuilder $queryBuilder): void { + if ($this->category) { + $queryBuilder->leftJoin('part.category', '_search_category'); + } + if ($this->storelocation) { + $queryBuilder->leftJoin('part.partLots', '_search_partLots') + ->leftJoin('_search_partLots.storage_location', '_search_storelocations'); + } + if ($this->ordernr) { + $queryBuilder->leftJoin('part.orderdetails', '_search_orderdetails'); + } + if ($this->supplier) { + if (!$this->ordernr) { + $queryBuilder->leftJoin('part.orderdetails', '_search_orderdetails'); + } + $queryBuilder->leftJoin('_search_orderdetails.supplier', '_search_suppliers'); + } + if ($this->manufacturer) { + $queryBuilder->leftJoin('part.manufacturer', '_search_manufacturer'); + } + if ($this->footprint) { + $queryBuilder->leftJoin('part.footprint', '_search_footprint'); + } + if ($this->assembly) { + $queryBuilder->leftJoin('part.assembly_bom_entries', '_search_assemblyBomEntries') + ->leftJoin('_search_assemblyBomEntries.assembly', '_search_assembly'); + } + $fields_to_search = $this->getFieldsToSearch(); $is_numeric = preg_match('/^\d+$/', $this->keyword) === 1; @@ -134,32 +186,34 @@ public function apply(QueryBuilder $queryBuilder): void return; } + $parameterIdentifier = $this->generateParameterIdentifier('search_query'); $expressions = []; - + if($fields_to_search !== []) { //Convert the fields to search to a list of expressions - $expressions = array_map(function (string $field): string { + $expressions = array_map(function (string $field) use ($parameterIdentifier): string { if ($this->regex) { - return sprintf("REGEXP(%s, :search_query) = TRUE", $field); + return sprintf("REGEXP(%s, :%s) = TRUE", $field, $parameterIdentifier); } - return sprintf("ILIKE(%s, :search_query) = TRUE", $field); + return sprintf("ILIKE(%s, :%s) = TRUE", $field, $parameterIdentifier); }, $fields_to_search); - + //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); + $queryBuilder->setParameter($parameterIdentifier, $this->keyword); } else { //Escape % and _ characters in the keyword - $this->keyword = str_replace(['%', '_'], ['\%', '\_'], $this->keyword); - $queryBuilder->setParameter('search_query', '%' . $this->keyword . '%'); + $keyword_escaped = str_replace(['%', '_'], ['\%', '\_'], $this->keyword); + $queryBuilder->setParameter($parameterIdentifier, '%' . $keyword_escaped . '%'); } } //Use equal expression to just search for exact numeric matches if ($search_dbId) { - $expressions[] = $queryBuilder->expr()->eq('part.id', ':id_exact'); - $queryBuilder->setParameter('id_exact', (int) $this->keyword, + $idParameterIdentifier = $this->generateParameterIdentifier('id_exact'); + $expressions[] = $queryBuilder->expr()->eq('part.id', ':' . $idParameterIdentifier); + $queryBuilder->setParameter($idParameterIdentifier, (int) $this->keyword, ParameterType::INTEGER); } @@ -326,6 +380,17 @@ public function setFootprint(bool $footprint): PartSearchFilter return $this; } + public function isManufacturingStatus(): bool + { + return $this->manufacturingStatus; + } + + public function setManufacturingStatus(bool $manufacturingStatus): PartSearchFilter + { + $this->manufacturingStatus = $manufacturingStatus; + return $this; + } + public function isComment(): bool { return $this->comment; @@ -337,5 +402,41 @@ public function setComment(bool $comment): PartSearchFilter return $this; } + public function isAssembly(): bool + { + return $this->assembly; + } + + public function setAssembly(bool $assembly): PartSearchFilter + { + $this->assembly = $assembly; + return $this; + } + /** + * Dummy method for compatibility with assembly/project search options in Twig. + */ + public function isStatus(): bool + { + return false; + } + + /** + * Dummy method for compatibility with assembly/project search options in Twig. + */ + public function setStatus(bool $status): PartSearchFilter + { + return $this; + } + + public function getDatasource(): string + { + return $this->datasource; + } + + public function setDatasource(string $datasource): PartSearchFilter + { + $this->datasource = $datasource; + return $this; + } } diff --git a/src/DataTables/Filters/ProjectFilter.php b/src/DataTables/Filters/ProjectFilter.php new file mode 100644 index 000000000..0444171d7 --- /dev/null +++ b/src/DataTables/Filters/ProjectFilter.php @@ -0,0 +1,73 @@ +. + */ + +namespace App\DataTables\Filters; + +use App\DataTables\Filters\Constraints\ChoiceConstraint; +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\Entity\ProjectSystem\Project; +use App\Services\Trees\NodesListBuilder; +use Doctrine\ORM\QueryBuilder; + +class ProjectFilter implements FilterInterface +{ + use CompoundFilterTrait; + + public readonly IntConstraint $dbId; + public readonly TextConstraint $name; + public readonly TextConstraint $description; + public readonly TextConstraint $comment; + public readonly ChoiceConstraint $status; + public readonly EntityConstraint $category; + 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('project.name'); + $this->description = new TextConstraint('project.description'); + $this->comment = new TextConstraint('project.comment'); + $this->status = new ChoiceConstraint('project.status'); + $this->category = new EntityConstraint($nodesListBuilder, Project::class, 'project.parent'); + $this->dbId = new IntConstraint('project.id'); + $this->addedDate = new DateTimeConstraint('project.addedDate'); + $this->lastModified = new DateTimeConstraint('project.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/ProjectSearchFilter.php b/src/DataTables/Filters/ProjectSearchFilter.php new file mode 100644 index 000000000..860fc55eb --- /dev/null +++ b/src/DataTables/Filters/ProjectSearchFilter.php @@ -0,0 +1,346 @@ +. + */ + +namespace App\DataTables\Filters; + +use Doctrine\ORM\QueryBuilder; + +class ProjectSearchFilter implements FilterInterface +{ + /** @var boolean Whether to use regex for searching */ + protected bool $regex = false; + + /** @var bool Use name field for searching */ + protected bool $name = false; + + /** @var bool Use description for searching */ + protected bool $description = false; + + /** @var bool Use comment field for searching */ + protected bool $comment = false; + + /** @var bool Use status field for searching */ + protected bool $status = false; + + /** + * If true, we search in the name of the parent project (if available). + * This field is named "category" to keep the API consistent with PartSearchFilter and AssemblySearchFilter, + * although projects don't have categories (they only have parents). + */ + protected bool $category = false; + + /** @var bool Use dbId field for searching */ + protected bool $dbId = false; + + /** @var string The datasource used for searching */ + protected string $datasource = 'projects'; + + protected static int $parameterCounter = 0; + + public function __construct( + /** @var string The string to query for */ + protected string $keyword + ) { + } + + protected function generateParameterIdentifier(string $property): string + { + //Replace all special characters with underscores + $property = preg_replace('/\W/', '_', $property); + return $property . '_' . (self::$parameterCounter++) . '_'; + } + + protected function getFieldsToSearch(): array + { + $fields_to_search = []; + + if ($this->name) { + $fields_to_search[] = 'project.name'; + } + if ($this->description) { + $fields_to_search[] = 'project.description'; + } + if ($this->comment) { + $fields_to_search[] = 'project.comment'; + } + if ($this->status) { + $fields_to_search[] = 'project.status'; + } + if ($this->category) { + // We search in the name of the parent project. + // This is named category for consistency with PartSearchFilter and AssemblySearchFilter. + $fields_to_search[] = '_search_parent.name'; + } + if ($this->dbId) { + $fields_to_search[] = 'project.id'; + } + + return $fields_to_search; + } + + public function apply(QueryBuilder $queryBuilder): void + { + if ($this->category) { + // We search in the parent project. + // Check if the join alias is already present in the QueryBuilder + $hasJoin = false; + foreach ($queryBuilder->getDQLPart('join') as $joins) { + foreach ($joins as $join) { + if ($join->getAlias() === '_search_parent') { + $hasJoin = true; + break 2; + } + } + } + + if (!$hasJoin) { + $queryBuilder->leftJoin('project.parent', '_search_parent'); + } + } + + $fields_to_search = $this->getFieldsToSearch(); + + //If we have nothing to search for, do nothing + if ($fields_to_search === [] || $this->keyword === '') { + return; + } + + $parameterIdentifier = $this->generateParameterIdentifier('search_query'); + + //Convert the fields to search to a list of expressions + $expressions = array_map(function (string $field) use ($parameterIdentifier): string { + if ($this->regex) { + return sprintf("REGEXP(%s, :%s) = TRUE", $field, $parameterIdentifier); + } + + return sprintf("ILIKE(%s, :%s) = TRUE", $field, $parameterIdentifier); + }, $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($parameterIdentifier, $this->keyword); + } else { + //Escape % and _ characters in the keyword + $keyword_escaped = str_replace(['%', '_'], ['\%', '\_'], $this->keyword); + $queryBuilder->setParameter($parameterIdentifier, '%' . $keyword_escaped . '%'); + } + } + + public function getKeyword(): string + { + return $this->keyword; + } + + public function setKeyword(string $keyword): self + { + $this->keyword = $keyword; + return $this; + } + + public function isRegex(): bool + { + return $this->regex; + } + + public function setRegex(bool $regex): self + { + $this->regex = $regex; + return $this; + } + + public function isName(): bool + { + return $this->name; + } + + public function setName(bool $name): self + { + $this->name = $name; + return $this; + } + + public function isDescription(): bool + { + return $this->description; + } + + public function setDescription(bool $description): self + { + $this->description = $description; + return $this; + } + + public function isComment(): bool + { + return $this->comment; + } + + public function setComment(bool $comment): self + { + $this->comment = $comment; + return $this; + } + + public function isStatus(): bool + { + return $this->status; + } + + public function setStatus(bool $status): self + { + $this->status = $status; + return $this; + } + + public function isCategory(): bool + { + return $this->category; + } + + /** + * Set if the parent project name should be searched. + * This is named "category" for consistency with PartSearchFilter and AssemblySearchFilter. + */ + public function setCategory(bool $category): self + { + $this->category = $category; + return $this; + } + + public function isMpn(): bool + { + return false; + } + + public function setMpn(bool $mpn): self + { + return $this; + } + + public function isTags(): bool + { + return false; + } + + public function setTags(bool $tags): self + { + return $this; + } + + public function isStorelocation(): bool + { + return false; + } + + public function setStorelocation(bool $storelocation): self + { + return $this; + } + + public function isSupplier(): bool + { + return false; + } + + public function setSupplier(bool $supplier): self + { + return $this; + } + + public function isManufacturer(): bool + { + return false; + } + + public function setManufacturer(bool $manufacturer): self + { + return $this; + } + + public function isFootprint(): bool + { + return false; + } + + public function setFootprint(bool $footprint): self + { + return $this; + } + + public function isDbId(): bool + { + return $this->dbId; + } + + public function setDbId(bool $dbId): self + { + $this->dbId = $dbId; + return $this; + } + + public function isAssembly(): bool + { + return false; + } + + public function setAssembly(bool $assembly): self + { + return $this; + } + + public function isOrdernr(): bool + { + return false; + } + + public function setOrdernr(bool $ordernr): self + { + return $this; + } + + public function isIPN(): bool + { + return false; + } + + public function setIPN(bool $ipn): self + { + return $this; + } + + public function getDatasource(): string + { + return $this->datasource; + } + + public function setDatasource(string $datasource): self + { + $this->datasource = $datasource; + return $this; + } +} diff --git a/src/DataTables/Helpers/AssemblyDataTableHelper.php b/src/DataTables/Helpers/AssemblyDataTableHelper.php new file mode 100644 index 000000000..dda563ea4 --- /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( + '%s', + 'Assembly image', + $this->attachmentURLGenerator->getThumbnailURL($preview_attachment), + $this->attachmentURLGenerator->getThumbnailURL($preview_attachment, 'thumbnail_md'), + 'hoverpic assembly-table-image', + $title + ); + } +} diff --git a/src/DataTables/Helpers/ProjectDataTableHelper.php b/src/DataTables/Helpers/ProjectDataTableHelper.php new file mode 100644 index 000000000..1c7e4d78d --- /dev/null +++ b/src/DataTables/Helpers/ProjectDataTableHelper.php @@ -0,0 +1,74 @@ +. + */ + +namespace App\DataTables\Helpers; + +use App\Entity\Attachments\Attachment; +use App\Entity\ProjectSystem\Project; +use App\Services\Attachments\AttachmentURLGenerator; +use App\Services\Attachments\ProjectPreviewGenerator; +use App\Services\EntityURLGenerator; + +/** + * A helper service which contains common code to render columns for project related tables + */ +class ProjectDataTableHelper +{ + public function __construct( + private readonly EntityURLGenerator $entityURLGenerator, + private readonly ProjectPreviewGenerator $previewGenerator, + private readonly AttachmentURLGenerator $attachmentURLGenerator + ) { + } + + public function renderName(Project $context): string + { + return sprintf( + '%s', + $this->entityURLGenerator->infoURL($context), + htmlspecialchars($context->getName()) + ); + } + + public function renderPicture(Project $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( + '%s', + 'Project image', + $this->attachmentURLGenerator->getThumbnailURL($preview_attachment), + $this->attachmentURLGenerator->getThumbnailURL($preview_attachment, 'thumbnail_md'), + 'hoverpic project-table-image', + $title + ); + } +} diff --git a/src/DataTables/PartsDataTable.php b/src/DataTables/PartsDataTable.php index d2faba766..fa89f3430 100644 --- a/src/DataTables/PartsDataTable.php +++ b/src/DataTables/PartsDataTable.php @@ -39,6 +39,7 @@ 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 @@ public function configure(DataTable $dataTable, array $options): void ]); } + //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'), @@ -390,6 +419,18 @@ private function addJoins(QueryBuilder $builder): QueryBuilder //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 part subfield $dql = $builder->getDQL(); + //Helper function to check if a join alias is already present in the QueryBuilder + $hasJoin = static function (QueryBuilder $qb, string $alias): bool { + foreach ($qb->getDQLPart('join') as $joins) { + foreach ($joins as $join) { + if ($join->getAlias() === $alias) { + return true; + } + } + } + return false; + }; + //Add the amountSum field, if it is used in the query if (str_contains($dql, 'amountSum')) { //Calculate amount sum using a subquery, so we can filter and sort by it @@ -404,62 +445,85 @@ private function addJoins(QueryBuilder $builder): QueryBuilder ); } - if (str_contains($dql, '_category')) { + if (str_contains($dql, '_category') && !$hasJoin($builder, '_category')) { $builder->leftJoin('part.category', '_category'); $builder->addGroupBy('_category'); } - if (str_contains($dql, '_master_picture_attachment')) { + if (str_contains($dql, '_master_picture_attachment') && !$hasJoin($builder, '_master_picture_attachment')) { $builder->leftJoin('part.master_picture_attachment', '_master_picture_attachment'); $builder->addGroupBy('_master_picture_attachment'); } if (str_contains($dql, '_partLots') || str_contains($dql, '_storelocations')) { - $builder->leftJoin('part.partLots', '_partLots'); - $builder->leftJoin('_partLots.storage_location', '_storelocations'); + if (!$hasJoin($builder, '_partLots')) { + $builder->leftJoin('part.partLots', '_partLots'); + } + if (str_contains($dql, '_storelocations') && !$hasJoin($builder, '_storelocations')) { + $builder->leftJoin('_partLots.storage_location', '_storelocations'); + } //Do not group by many-to-* relations, as it would restrict the COUNT having clauses to be maximum 1 //$builder->addGroupBy('_partLots'); //$builder->addGroupBy('_storelocations'); } - if (str_contains($dql, '_footprint')) { + if (str_contains($dql, '_footprint') && !$hasJoin($builder, '_footprint')) { $builder->leftJoin('part.footprint', '_footprint'); $builder->addGroupBy('_footprint'); } - if (str_contains($dql, '_manufacturer')) { + if (str_contains($dql, '_manufacturer') && !$hasJoin($builder, '_manufacturer')) { $builder->leftJoin('part.manufacturer', '_manufacturer'); $builder->addGroupBy('_manufacturer'); } if (str_contains($dql, '_orderdetails') || str_contains($dql, '_suppliers')) { - $builder->leftJoin('part.orderdetails', '_orderdetails'); - $builder->leftJoin('_orderdetails.supplier', '_suppliers'); + if (!$hasJoin($builder, '_orderdetails')) { + $builder->leftJoin('part.orderdetails', '_orderdetails'); + } + if (str_contains($dql, '_suppliers') && !$hasJoin($builder, '_suppliers')) { + $builder->leftJoin('_orderdetails.supplier', '_suppliers'); + } //Do not group by many-to-* relations, as it would restrict the COUNT having clauses to be maximum 1 //$builder->addGroupBy('_orderdetails'); //$builder->addGroupBy('_suppliers'); } - if (str_contains($dql, '_attachments')) { + if (str_contains($dql, '_attachments') && !$hasJoin($builder, '_attachments')) { $builder->leftJoin('part.attachments', '_attachments'); //Do not group by many-to-* relations, as it would restrict the COUNT having clauses to be maximum 1 //$builder->addGroupBy('_attachments'); } - if (str_contains($dql, '_partUnit')) { + if (str_contains($dql, '_partUnit') && !$hasJoin($builder, '_partUnit')) { $builder->leftJoin('part.partUnit', '_partUnit'); $builder->addGroupBy('_partUnit'); } - if (str_contains($dql, '_partCustomState')) { + if (str_contains($dql, '_partCustomState') && !$hasJoin($builder, '_partCustomState')) { $builder->leftJoin('part.partCustomState', '_partCustomState'); $builder->addGroupBy('_partCustomState'); } - if (str_contains($dql, '_parameters')) { + if (str_contains($dql, '_parameters') && !$hasJoin($builder, '_parameters')) { $builder->leftJoin('part.parameters', '_parameters'); //Do not group by many-to-* relations, as it would restrict the COUNT having clauses to be maximum 1 //$builder->addGroupBy('_parameters'); } - if (str_contains($dql, '_projectBomEntries')) { + if (str_contains($dql, '_projectBomEntries') && !$hasJoin($builder, '_projectBomEntries')) { $builder->leftJoin('part.project_bom_entries', '_projectBomEntries'); //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.')) { + if (!$hasJoin($builder, '_assemblyBomEntries')) { + $builder->leftJoin('part.assembly_bom_entries', '_assemblyBomEntries'); + } + if (!$hasJoin($builder, '_assembly')) { + $builder->leftJoin('_assemblyBomEntries.assembly', '_assembly'); + } + } + if (str_contains($dql, '_assemblyBomEntries') && !$hasJoin($builder, '_assemblyBomEntries')) { + $builder->leftJoin('part.assembly_bom_entries', '_assemblyBomEntries'); + } if (str_contains($dql, '_jobPart')) { - $builder->leftJoin('part.bulkImportJobParts', '_jobPart'); - $builder->leftJoin('_jobPart.job', '_bulkImportJob'); + if (!$hasJoin($builder, '_jobPart')) { + $builder->leftJoin('part.bulkImportJobParts', '_jobPart'); + } + if (!$hasJoin($builder, '_bulkImportJob')) { + $builder->leftJoin('_jobPart.job', '_bulkImportJob'); + } //Do not group by many-to-* relations, as it would restrict the COUNT having clauses to be maximum 1 //$builder->addGroupBy('_jobPart'); //$builder->addGroupBy('_bulkImportJob'); diff --git a/src/DataTables/ProjectSearchDataTable.php b/src/DataTables/ProjectSearchDataTable.php new file mode 100644 index 000000000..29a4c4442 --- /dev/null +++ b/src/DataTables/ProjectSearchDataTable.php @@ -0,0 +1,188 @@ +. + */ + +namespace App\DataTables; + +use App\DataTables\Adapters\TwoStepORMAdapter; +use App\DataTables\Column\IconLinkColumn; +use App\DataTables\Column\LocaleDateTimeColumn; +use App\DataTables\Helpers\ColumnSortHelper; +use App\DataTables\Helpers\ProjectDataTableHelper; +use App\DataTables\Filters\ProjectFilter; +use App\DataTables\Filters\ProjectSearchFilter; +use App\DataTables\Column\MarkdownColumn; +use App\Entity\ProjectSystem\Project; +use App\Doctrine\Helpers\FieldHelper; +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; + +class ProjectSearchDataTable implements DataTableTypeInterface +{ + public function __construct( + private readonly ProjectDataTableHelper $projectDataTableHelper, + private readonly TranslatorInterface $translator, + private readonly EntityURLGenerator $urlGenerator, + 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', [ProjectFilter::class, 'null']); + $optionsResolver->setAllowedTypes('search', [ProjectSearchFilter::class, 'null']); + } + + public function configure(DataTable $dataTable, array $options): void + { + $resolver = new OptionsResolver(); + $this->configureOptions($resolver); + $options = $resolver->resolve($options); + + $this->csh + ->add('picture', TextColumn::class, [ + 'label' => '', + 'className' => 'no-colvis', + 'render' => function ($value, Project $context): string { + return $this->projectDataTableHelper->renderPicture($context); + }, + 'orderable' => false, + 'searchable' => false, + ], visibility_configurable: false) + ->add('name', TextColumn::class, [ + 'label' => $this->translator->trans('project.table.name'), + 'render' => function ($value, Project $context): string { + return $this->projectDataTableHelper->renderName($context); + }, + 'orderField' => 'NATSORT(project.name)' + ]) + ->add('id', TextColumn::class, [ + 'label' => $this->translator->trans('project.table.id'), + ]) + ->add('description', MarkdownColumn::class, [ + 'label' => $this->translator->trans('project.table.description'), + ]) + ->add('comment', MarkdownColumn::class, [ + 'label' => $this->translator->trans('project.table.comment'), + ]) + ->add('addedDate', LocaleDateTimeColumn::class, [ + 'label' => $this->translator->trans('project.table.addedDate'), + ]) + ->add('lastModified', LocaleDateTimeColumn::class, [ + 'label' => $this->translator->trans('project.table.lastModified'), + ]) + ->add('edit', IconLinkColumn::class, [ + 'label' => $this->translator->trans('project.table.edit'), + 'href' => fn($value, Project $context) => $this->urlGenerator->editURL($context), + 'disabled' => fn($value, Project $context) => !$this->security->isGranted('edit', $context), + 'title' => $this->translator->trans('project.table.edit.title'), + ]); + + //Apply the user configured order and visibility and add the columns to the table + $this->csh->applyVisibilityAndConfigureColumns($dataTable, $this->tableSettings->projectsDefaultColumns, + "TABLE_PROJECTS_DEFAULT_COLUMNS"); + + $dataTable->addOrderBy('name') + ->createAdapter(TwoStepORMAdapter::class, [ + 'filter_query' => $this->getFilterQuery(...), + 'detail_query' => $this->getDetailQuery(...), + 'entity' => Project::class, + 'hydrate' => AbstractQuery::HYDRATE_OBJECT, + 'simple_total_query' => true, + 'criteria' => [ + function (QueryBuilder $builder) use ($options): void { + if ($options['search'] instanceof ProjectSearchFilter) { + $options['search']->apply($builder); + } + + if ($options['filter'] instanceof ProjectFilter) { + $options['filter']->apply($builder); + } + }, + new SearchCriteriaProvider(), + ], + 'query_modifier' => $this->addJoins(...), + ]); + } + + public function getFilterQuery(QueryBuilder $builder): void + { + $builder + ->select('project.id') + ->from(Project::class, 'project'); + + $this->addJoins($builder); + } + + private function addJoins(QueryBuilder $builder): QueryBuilder + { + $dql = $builder->getDQL(); + + //Helper function to check if a join alias is already present in the QueryBuilder + $hasJoin = static function (QueryBuilder $qb, string $alias): bool { + foreach ($qb->getDQLPart('join') as $joins) { + foreach ($joins as $join) { + if ($join->getAlias() === $alias) { + return true; + } + } + } + return false; + }; + + if (str_contains($dql, '_master_picture_attachment') && !$hasJoin($builder, '_master_picture_attachment')) { + $builder->leftJoin('project.master_picture_attachment', '_master_picture_attachment'); + } + + return $builder; + } + + public function getDetailQuery(QueryBuilder $builder, array $filter_results): void + { + $ids = array_map(static fn($row) => $row['id'], $filter_results); + + $builder + ->select('project') + ->from(Project::class, 'project') + ->where('project.id IN (:ids)') + ->setParameter('ids', $ids); + + //Get the results in the same order as the IDs were passed + FieldHelper::addOrderByFieldParam($builder, 'project.id', 'ids'); + } +} diff --git a/src/Entity/AssemblySystem/Assembly.php b/src/Entity/AssemblySystem/Assembly.php new file mode 100644 index 000000000..20a7aa1b0 --- /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 000000000..500a44015 --- /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 000000000..c0c75c186 --- /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 d4b15ac73..3ad125d77 100644 --- a/src/Entity/Attachments/Attachment.php +++ b/src/Entity/Attachments/Attachment.php @@ -97,7 +97,7 @@ #[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 a088b3dfc..757da2d43 100644 --- a/src/Entity/Base/AbstractDBElement.php +++ b/src/Entity/Base/AbstractDBElement.php @@ -22,6 +22,9 @@ 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 @@ '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 34ab8fba8..15e0001ed 100644 --- a/src/Entity/LogSystem/CollectionElementDeleted.php +++ b/src/Entity/LogSystem/CollectionElementDeleted.php @@ -41,6 +41,8 @@ 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\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 @@ private function resolveAbstractClassToInstantiableClass(string $abstract_class) { 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 @@ private function resolveAbstractClassToInstantiableClass(string $abstract_class) 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 3b2d8682a..0095fd8fe 100644 --- a/src/Entity/LogSystem/LogTargetType.php +++ b/src/Entity/LogSystem/LogTargetType.php @@ -22,6 +22,8 @@ */ 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 @@ public function toClass(): ?string 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 d84e68adf..ed29e6a41 100644 --- a/src/Entity/Parameters/AbstractParameter.php +++ b/src/Entity/Parameters/AbstractParameter.php @@ -74,7 +74,7 @@ 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 000000000..349fa7906 --- /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 5ac81b602..aaad0ace1 100644 --- a/src/Entity/Parts/Part.php +++ b/src/Entity/Parts/Part.php @@ -22,6 +22,7 @@ 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 @@ public function __construct() $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 000000000..2d82c32f8 --- /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 2a7862ec5..fd613dc22 100644 --- a/src/Entity/ProjectSystem/ProjectBOMEntry.php +++ b/src/Entity/ProjectSystem/ProjectBOMEntry.php @@ -36,6 +36,7 @@ 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 @@ * 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 000000000..dd0a80381 --- /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 f4bf37f8a..f0020b253 100644 --- a/src/Form/AdminPages/BaseEntityAdminForm.php +++ b/src/Form/AdminPages/BaseEntityAdminForm.php @@ -22,10 +22,12 @@ 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 @@ 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 @@ public function buildForm(FormBuilderInterface $builder, array $options): void ->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 @@ public function buildForm(FormBuilderInterface $builder, array $options): void ); } - 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 000000000..1fa671266 --- /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 000000000..04293f4e0 --- /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 000000000..8b56dfeb0 --- /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 000000000..2da17e8ee --- /dev/null +++ b/src/Form/Filters/AssemblyFilterType.php @@ -0,0 +1,134 @@ +. + */ +namespace App\Form\Filters; + +use App\DataTables\Filters\AssemblyFilter; +use App\Entity\AssemblySystem\Assembly; +use App\Entity\Attachments\AttachmentType; +use App\Form\Filters\Constraints\ChoiceConstraintType; +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('category', StructuralEntityConstraintType::class, [ + 'label' => 'assembly.filter.parent', + 'entity_class' => Assembly::class, + ]); + + $status_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('status', ChoiceConstraintType::class, [ + 'label' => 'assembly.filter.status', + 'choices' => $status_choices, + ]); + + $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 ff80bd384..a44588955 100644 --- a/src/Form/Filters/AttachmentFilterType.php +++ b/src/Form/Filters/AttachmentFilterType.php @@ -23,6 +23,7 @@ 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 @@ public function buildForm(FormBuilderInterface $builder, array $options): void '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 30abf723d..dd4e1cdfc 100644 --- a/src/Form/Filters/LogFilterType.php +++ b/src/Form/Filters/LogFilterType.php @@ -114,6 +114,8 @@ public function buildForm(FormBuilderInterface $builder, array $options): void 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 25fe70b21..cb4f424fc 100644 --- a/src/Form/Filters/PartFilterType.php +++ b/src/Form/Filters/PartFilterType.php @@ -25,6 +25,7 @@ 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 @@ public function buildForm(FormBuilderInterface $builder, array $options): void } + /************************************************************************** + * 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/Filters/ProjectFilterType.php b/src/Form/Filters/ProjectFilterType.php new file mode 100644 index 000000000..bb92c35a4 --- /dev/null +++ b/src/Form/Filters/ProjectFilterType.php @@ -0,0 +1,131 @@ +. + */ + +namespace App\Form\Filters; + +use App\DataTables\Filters\ProjectFilter; +use App\Entity\Attachments\AttachmentType; +use App\Entity\ProjectSystem\Project; +use App\Form\Filters\Constraints\ChoiceConstraintType; +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 ProjectFilterType extends AbstractType +{ + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults([ + 'compound' => true, + 'data_class' => ProjectFilter::class, + 'csrf_protection' => false, + ]); + } + + public function buildForm(FormBuilderInterface $builder, array $options): void + { + /* + * Common tab + */ + + $builder->add('name', TextConstraintType::class, [ + 'label' => 'project.filter.name', + ]); + + $builder->add('description', TextConstraintType::class, [ + 'label' => 'project.filter.description', + ]); + + $builder->add('category', StructuralEntityConstraintType::class, [ + 'label' => 'project.filter.parent', + 'entity_class' => Project::class, + ]); + + $status_choices = [ + 'project.status.draft' => 'draft', + 'project.status.planning' => 'planning', + 'project.status.in_production' => 'in_production', + 'project.status.finished' => 'finished', + 'project.status.archived' => 'archived', + ]; + + $builder->add('status', ChoiceConstraintType::class, [ + 'label' => 'project.filter.status', + 'choices' => $status_choices, + ]); + + $builder->add('comment', TextConstraintType::class, [ + 'label' => 'project.filter.comment' + ]); + + /* + * Advanced tab + */ + + $builder->add('dbId', NumberConstraintType::class, [ + 'label' => 'project.filter.dbId', + 'min' => 1, + 'step' => 1, + ]); + + $builder->add('lastModified', DateTimeConstraintType::class, [ + 'label' => 'lastModified' + ]); + + $builder->add('addedDate', DateTimeConstraintType::class, [ + 'label' => 'createdAt' + ]); + + /** + * Attachments count + */ + $builder->add('attachmentsCount', NumberConstraintType::class, [ + 'label' => 'project.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' => 'project.filter.attachmentName', + ]); + + $builder->add('submit', SubmitType::class, [ + 'label' => 'filter.submit', + ]); + + $builder->add('discard', ResetType::class, [ + 'label' => 'filter.discard', + ]); + } +} diff --git a/src/Form/Type/AssemblySelectType.php b/src/Form/Type/AssemblySelectType.php new file mode 100644 index 000000000..0cf38caf4 --- /dev/null +++ b/src/Form/Type/AssemblySelectType.php @@ -0,0 +1,121 @@ +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-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 34b8fc7c4..063264388 100644 --- a/src/Form/Type/PartSelectType.php +++ b/src/Form/Type/PartSelectType.php @@ -50,7 +50,7 @@ public function buildForm(FormBuilderInterface $builder, array $options): void $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 @@ -80,7 +80,6 @@ public function configureOptions(OptionsResolver $resolver): void $resolver->setDefaults([ 'attr' => [ - 'data-controller' => 'elements--part-select', 'data-autocomplete' => $this->urlGenerator->generate('typeahead_parts', ['query' => '__QUERY__']), //Disable browser autocomplete 'autocomplete' => 'off', diff --git a/src/Helpers/Assemblies/AssemblyPartAggregator.php b/src/Helpers/Assemblies/AssemblyPartAggregator.php new file mode 100644 index 000000000..ecf336f4a --- /dev/null +++ b/src/Helpers/Assemblies/AssemblyPartAggregator.php @@ -0,0 +1,277 @@ +. + */ +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(), + 'status' => $assembly->getStatus() ?? '-', + '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(), + 'manufacturer' => $bomEntry->getPart()->getManufacturer()?->getName() ?? '-', + '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(), + 'ipn' => '-', + 'quantity' => $bomEntry->getQuantity(), + 'multiplier' => $parentMultiplier, + 'effectiveQuantity' => $bomEntry->getQuantity() * $parentMultiplier, + ]; + } + } + + return $result; + } +} diff --git a/src/Migration/AbstractMultiPlatformMigration.php b/src/Migration/AbstractMultiPlatformMigration.php index bc2b3f191..405ac0420 100644 --- a/src/Migration/AbstractMultiPlatformMigration.php +++ b/src/Migration/AbstractMultiPlatformMigration.php @@ -138,6 +138,16 @@ public function doesFKExists(string $table, string $fk_name): bool return $result > 0; } + /** + * Checks if a table exists in the database. + * @return bool Returns true, if the table exists + * @throws Exception + */ + public function doesTableExist(string $table): bool + { + return $this->connection->createSchemaManager()->tablesExist([$table]); + } + /** * Checks if a column exists in a table. * @return bool Returns true, if the column exists diff --git a/src/Repository/AssemblyRepository.php b/src/Repository/AssemblyRepository.php new file mode 100644 index 000000000..d4c57cbba --- /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 f737d91df..5adcad596 100644 --- a/src/Repository/DBElementRepository.php +++ b/src/Repository/DBElementRepository.php @@ -161,4 +161,14 @@ protected function setField(AbstractDBElement $element, string $field, int $new_ $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/PartRepository.php b/src/Repository/PartRepository.php index 9d5fee5ea..a523b7ebe 100644 --- a/src/Repository/PartRepository.php +++ b/src/Repository/PartRepository.php @@ -101,6 +101,7 @@ public function autocompleteSearch(string $query, int $max_limits = 50): array ->where('ILIKE(part.name, :query) = TRUE') ->orWhere('ILIKE(part.description, :query) = TRUE') + ->orWhere('ILIKE(part.ipn, :query) = TRUE') ->orWhere('ILIKE(category.name, :query) = TRUE') ->orWhere('ILIKE(footprint.name, :query) = TRUE'); diff --git a/src/Repository/Parts/DeviceRepository.php b/src/Repository/Parts/DeviceRepository.php index 442c91e58..3fa931838 100644 --- a/src/Repository/Parts/DeviceRepository.php +++ b/src/Repository/Parts/DeviceRepository.php @@ -51,4 +51,18 @@ public function getPartsCount(object $element): int //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 df3d73a72..d9c45bfd1 100644 --- a/src/Security/Voter/AttachmentVoter.php +++ b/src/Security/Voter/AttachmentVoter.php @@ -23,6 +23,7 @@ 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 @@ protected function voteOnAttribute(string $attribute, $subject, TokenInterface $ $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 16d38e058..cb05ffdd5 100644 --- a/src/Security/Voter/StructureVoter.php +++ b/src/Security/Voter/StructureVoter.php @@ -22,6 +22,7 @@ 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 000000000..9ecbbd070 --- /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 c7e69257e..245266a22 100644 --- a/src/Services/Attachments/AttachmentSubmitHandler.php +++ b/src/Services/Attachments/AttachmentSubmitHandler.php @@ -22,6 +22,7 @@ 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 @@ public function __construct( 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 000000000..9929dbd3c --- /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 19bb19f58..f1c3bcbd3 100644 --- a/src/Services/ElementTypeNameGenerator.php +++ b/src/Services/ElementTypeNameGenerator.php @@ -22,6 +22,8 @@ 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 @@ public function formatLabelHTMLForEntity(AbstractDBElement $entity, bool $includ $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 6ce8f8514..adac46f20 100644 --- a/src/Services/ElementTypes.php +++ b/src/Services/ElementTypes.php @@ -23,6 +23,8 @@ 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 @@ public function getDefaultLabelKey(): string 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 @@ public function getDefaultPluralLabelKey(): string 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 91e271cc0..bdf26fa94 100644 --- a/src/Services/EntityURLGenerator.php +++ b/src/Services/EntityURLGenerator.php @@ -22,6 +22,7 @@ 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 @@ public function timeTravelURL(AbstractDBElement $entity, \DateTimeInterface $dat 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 @@ public function infoURL(AbstractDBElement $entity): string 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 @@ public function editURL(AbstractDBElement $entity): string 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 @@ public function createURL(AbstractDBElement|string $entity): string 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 @@ public function cloneURL(AbstractDBElement $entity): string 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 @@ public function listPartsURL(AbstractDBElement $entity): string { $map = [ Project::class => 'project_info', + Assembly::class => 'assembly_info', Category::class => 'part_list_category', Footprint::class => 'part_list_footprint', @@ -347,6 +354,7 @@ public function deleteURL(AbstractDBElement $entity): string 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/Formatters/MarkdownParser.php b/src/Services/Formatters/MarkdownParser.php index f3ef07dfd..403f27582 100644 --- a/src/Services/Formatters/MarkdownParser.php +++ b/src/Services/Formatters/MarkdownParser.php @@ -37,13 +37,16 @@ public function __construct(protected TranslatorInterface $translator) * Mark the markdown for rendering. * The rendering of markdown is done on client side. * - * @param string $markdown the Markdown text that should be parsed to html + * @param string|null $markdown the Markdown text that should be parsed to html * @param bool $inline_mode When true, p blocks will have no margins behind them * * @return string the markdown in a version that can be parsed on client side */ - public function markForRendering(string $markdown, bool $inline_mode = false): string + public function markForRendering(?string $markdown, bool $inline_mode = false): string { + if ($markdown === null) { + $markdown = ''; + } return sprintf( '
%s
', $inline_mode ? 'markdown-inline' : '', //Add class if inline mode is enabled, to prevent margin after p diff --git a/src/Services/ImportExportSystem/BOMImporter.php b/src/Services/ImportExportSystem/BOMImporter.php index abf72d747..67c91d378 100644 --- a/src/Services/ImportExportSystem/BOMImporter.php +++ b/src/Services/ImportExportSystem/BOMImporter.php @@ -24,21 +24,37 @@ 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 @@ protected function configureOptions(OptionsResolver $resolver): OptionsResolver /** * 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 @@ public function validateBOMData(string $data, array $options): array /** * 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')), + }; + } + + /** + * 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, }; } - private function parseKiCADPCB(string $data): array + /** + * 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 @@ private function parseKiCADPCB(string $data): array 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 @@ private function validateKiCADSchematicData(string $data, array $options): array 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 @@ private function normalizeColumnNames(array $entry): array } //@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 @@ public function detectFields(string $data, ?string $delimiter = null): array 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 ab87a9053..ef7db9782 100644 --- a/src/Services/ImportExportSystem/EntityExporter.php +++ b/src/Services/ImportExportSystem/EntityExporter.php @@ -22,9 +22,23 @@ 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 @@ */ 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 @@ protected function configureOptions(OptionsResolver $resolver): void $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 @@ public function exportEntityFromRequest(AbstractNamedDBElement|array $entities, $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), + ]; + + // Determine the type of the entity + $type = null; + foreach ($entities as $entity) { + $entityClass = get_class($entity); + if (isset($entityExportMap[$entityClass])) { + $type = $entityClass; + break; + } + } + + // 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); + $response = new Response($serialized_data); - //Resolve the format - $optionsResolver = new OptionsResolver(); - $this->configureOptions($optionsResolver); - $options = $optionsResolver->resolve($options); + //Resolve the format + $optionsResolver = new OptionsResolver(); + $this->configureOptions($optionsResolver); + $options = $optionsResolver->resolve($options); + } //Determine the content type for the response @@ -242,6 +314,7 @@ public function exportEntityFromRequest(AbstractNamedDBElement|array $entities, '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 @@ public function exportEntityFromRequest(AbstractNamedDBElement|array $entities, //Remove percent for fallback $fallback = str_replace("%", "_", $filename); - + // Create the disposition of the file $disposition = $response->headers->makeDisposition( ResponseHeaderBag::DISPOSITION_ATTACHMENT, @@ -281,4 +354,317 @@ public function exportEntityFromRequest(AbstractNamedDBElement|array $entities, 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', 'AssemblyStatus', 'AssemblyNameHierarchical', 'AssemblyName', + 'AssemblyFullName', + + //BOM relevant attributes + 'Quantity', 'PartId', 'PartName', 'Ipn', 'Manufacturer', 'Mpn', 'Name', 'Designator', + 'Description', 'ReferencedAssemblyId', 'ReferencedAssemblyIpn', 'ReferencedAssemblyStatus', + 'ReferencedAssemblyFullName', + ], + 'processEntity' => fn(Assembly $entity, $depth) => [ + 'Id' => $entity->getId(), + 'ParentId' => $entity->getParent()?->getId() ?? '', + 'Type' => 'assembly', + 'AssemblyIpn' => $entity->getIpn(), + 'AssemblyStatus' => $entity->getStatus() ?? '-', + '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' => '-', + 'ReferencedAssemblyStatus' => '-', + '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(), + 'AssemblyStatus' => $bomEntry->getReferencedAssembly() ? $assembly->getStatus() : '-', + '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() ?? '-', + 'ReferencedAssemblyStatus' => $bomEntry->getReferencedAssembly()?->getStatus() ?? '-', + '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() : '', + 'AssemblyStatus' => $partAssembly ? $partAssembly->getStatus() : '-', + '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' => '-', + 'ReferencedAssemblyStatus' => '-', + '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 000000000..4e289d133 --- /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 00bb05c9e..653d96357 100644 --- a/src/Services/Tools/StatisticsHelper.php +++ b/src/Services/Tools/StatisticsHelper.php @@ -41,8 +41,10 @@ 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 @@ public function getDistinctPartsCount(): int 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 @@ public function getDataStructuresCount(string $type): int 'storelocation' => StorageLocation::class, 'supplier' => Supplier::class, 'currency' => Currency::class, + 'assembly' => Assembly::class, ]; if (!isset($arr[$type])) { @@ -164,4 +175,34 @@ public function getUserUploadedAttachmentsCount(): int { 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 6397e3af1..9b0af4369 100644 --- a/src/Services/Trees/ToolsTreeBuilder.php +++ b/src/Services/Trees/ToolsTreeBuilder.php @@ -22,6 +22,7 @@ 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 @@ protected function getEditNodes(): array $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 d55c87b7c..aeaa4f669 100644 --- a/src/Services/Trees/TreeViewGenerator.php +++ b/src/Services/Trees/TreeViewGenerator.php @@ -22,6 +22,7 @@ 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 @@ private function getTreeViewUncached( $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 @@ private function getTreeViewUncached( $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 @@ protected function entityClassToRootNodeIcon(string $class): ?string 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 3d125b277..f6f8036e6 100644 --- a/src/Services/UserSystem/PermissionPresetsHelper.php +++ b/src/Services/UserSystem/PermissionPresetsHelper.php @@ -105,6 +105,7 @@ private function admin(HasPermissionsInterface $perm_holder): void $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 @@ private function editor(HasPermissionsInterface $permHolder): HasPermissionsInte $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 @@ private function readOnly(HasPermissionsInterface $perm_holder): HasPermissionsI //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 000000000..2833a3dfc --- /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 000000000..34ddc6943 --- /dev/null +++ b/src/Settings/BehaviorSettings/AssemblyTableColumns.php @@ -0,0 +1,50 @@ +. + */ + +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 COMMENT = "comment"; + 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/ProjectTableColumns.php b/src/Settings/BehaviorSettings/ProjectTableColumns.php new file mode 100644 index 000000000..221bdc30d --- /dev/null +++ b/src/Settings/BehaviorSettings/ProjectTableColumns.php @@ -0,0 +1,46 @@ +. + */ + +declare(strict_types=1); + +namespace App\Settings\BehaviorSettings; + +use Symfony\Contracts\Translation\TranslatableInterface; +use Symfony\Contracts\Translation\TranslatorInterface; + +enum ProjectTableColumns : string implements TranslatableInterface +{ + case NAME = "name"; + case ID = "id"; + case DESCRIPTION = "description"; + case COMMENT = "comment"; + case ADDED_DATE = "addedDate"; + case LAST_MODIFIED = "lastModified"; + case EDIT = "edit"; + + public function trans(TranslatorInterface $translator, ?string $locale = null): string + { + $key = match($this) { + default => 'project.table.' . $this->value, + }; + + return $translator->trans($key, locale: $locale); + } +} diff --git a/src/Settings/BehaviorSettings/TableSettings.php b/src/Settings/BehaviorSettings/TableSettings.php index b3421e41b..648b3ff20 100644 --- a/src/Settings/BehaviorSettings/TableSettings.php +++ b/src/Settings/BehaviorSettings/TableSettings.php @@ -70,6 +70,52 @@ 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 ProjectTableColumns[] */ + #[SettingsParameter(ArrayType::class, + label: new TM("settings.behavior.table.projects_default_columns"), + description: new TM("settings.behavior.table.projects_default_columns.help"), + options: ['type' => EnumType::class, 'options' => ['class' => ProjectTableColumns::class]], + formType: \Symfony\Component\Form\Extension\Core\Type\EnumType::class, + formOptions: ['class' => ProjectTableColumns::class, 'multiple' => true, 'ordered' => true], + envVar: "TABLE_PROJECTS_DEFAULT_COLUMNS", envVarMode: EnvVarMode::OVERWRITE, envVarMapper: [self::class, 'mapProjectsDefaultColumnsEnv'] + )] + #[Assert\NotBlank()] + #[Assert\Unique()] + #[Assert\All([new Assert\Type(ProjectTableColumns::class)])] + public array $projectsDefaultColumns = [ProjectTableColumns::NAME, ProjectTableColumns::DESCRIPTION, + ProjectTableColumns::COMMENT, ProjectTableColumns::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 +147,52 @@ public static function mapPartsDefaultColumnsEnv(string $columns): array 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 mapProjectsDefaultColumnsEnv(string $columns): array + { + $exploded = explode(',', $columns); + $ret = []; + foreach ($exploded as $column) { + $enum = ProjectTableColumns::tryFrom($column); + if (!$enum) { + throw new \InvalidArgumentException("Invalid column '$column' in TABLE_PROJECTS_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 000000000..82fb26b66 --- /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 050dbcbcc..8ccf95cca 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 bff21eb86..0289134bb 100644 --- a/src/Twig/EntityExtension.php +++ b/src/Twig/EntityExtension.php @@ -22,6 +22,7 @@ */ 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 @@ public function entityType(object $entity): ?string 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 000000000..9d79b879c --- /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 000000000..c8fd18d3b --- /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 000000000..73234c86e --- /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 000000000..55a31440a --- /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 000000000..0b3eb3952 --- /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 07b00d43c..b02d4a8e8 100644 --- a/templates/admin/_export_form.html.twig +++ b/templates/admin/_export_form.html.twig @@ -1,6 +1,6 @@ -
+ -
+
@@ -23,7 +23,7 @@
-
+
@@ -34,9 +34,32 @@
+ {% if path is defined and 'assembly' in path %} +
+ +
+ +
+
+ {% else %} +
+ +
+ + +
+
+ {% endif %} +
- \ No newline at end of file + diff --git a/templates/admin/assembly_admin.html.twig b/templates/admin/assembly_admin.html.twig new file mode 100644 index 000000000..fe015b3bf --- /dev/null +++ b/templates/admin/assembly_admin.html.twig @@ -0,0 +1,48 @@ +{% extends "admin/base_admin.html.twig" %} + +{# @var entity App\Entity\AssemblySystem\Assembly #} + +{% block card_title %} + {% trans %}assembly.caption{% endtrans %} +{% endblock %} + +{% block edit_title %} + {% trans %}assembly.edit{% endtrans %}: {{ entity.name }} +{% endblock %} + +{% block new_title %} + {% trans %}assembly.new{% endtrans %} +{% endblock %} + +{% block additional_pills %} + +{% endblock %} + +{% block quick_links %} +
+
+ +
+
+{% endblock %} + +{% block additional_controls %} + {{ form_row(form.description) }} + {{ form_row(form.status) }} + {{ form_row(form.ipn) }} +{% endblock %} + +{% block additional_panes %} +
+ {% form_theme form.bom_entries with ['form/collection_types_layout_assembly.html.twig'] %} + {{ form_errors(form.bom_entries) }} + {{ form_widget(form.bom_entries) }} + {% if entity.id %} + + + {% trans %}assembly.edit.bom.import_bom{% endtrans %} + + {% endif %} +
+{% endblock %} diff --git a/templates/assemblies/add_parts.html.twig b/templates/assemblies/add_parts.html.twig new file mode 100644 index 000000000..d8d8e657f --- /dev/null +++ b/templates/assemblies/add_parts.html.twig @@ -0,0 +1,22 @@ +{% extends "main_card.html.twig" %} + +{% block title %}{% trans %}assembly.add_parts_to_assembly{% endtrans %}{% endblock %} + +{% block card_title %} + + {% trans %}assembly.add_parts_to_assembly{% endtrans %}{% if assembly %}: {{ assembly.name }}{% endif %} +{% endblock %} + +{% block card_content %} + + {{ form_start(form) }} + + {{ form_row(form.assembly) }} + {% form_theme form.bom_entries with ['form/collection_types_layout_assembly.html.twig'] %} + {{ form_widget(form.bom_entries) }} + + {{ form_row(form.submit) }} + + {{ form_end(form) }} + +{% endblock %} \ No newline at end of file diff --git a/templates/assemblies/export_bom_pdf.html.twig b/templates/assemblies/export_bom_pdf.html.twig new file mode 100644 index 000000000..61cc9d8ef --- /dev/null +++ b/templates/assemblies/export_bom_pdf.html.twig @@ -0,0 +1,109 @@ + + + + Assembly Hierarchy + + + + + +

Table of Contents

+ + + + + + + + + + + + {% for assembly in assemblies %} + + + + + + + + {% endfor %} + +
#Assembly NameIPNStatusSection
{{ loop.index }}Assembly: {{ assembly.name }}{% if assembly.ipn != '' %}{{ assembly.ipn }}{% else %}-{% endif %}{{ assembly.status|default('-') }}{{ loop.index + 1 }}
+
+ + +{% for assembly in assemblies %} +
Assembly: {{ assembly.name }} [IPN: {{ assembly.ipn|default('-') }}, Status: {{ assembly.status|default('-') }}]
+ + + + + + + + + + + + + {% for part in assembly.parts %} + + + + + + + + + {% endfor %} + {% for other in assembly.others %} + + + + + + + + + {% endfor %} + {% for referencedAssembly in assembly.referencedAssemblies %} + + + + + + + + + {% endfor %} + +
NameManufacturerIPNQuantityMultiplierEffective Quantity
{{ part.name }}{{ part.manufacturer }}{{ part.ipn }}{{ part.quantity }}{% if assembly.multiplier %}{{ assembly.multiplier }}{% else %}-{% endif %}{{ part.effectiveQuantity }}
{{ other.name }}-{{ other.ipn|default('-') }}{{ other.quantity }}{{ other.multiplier }}{{ other.effectiveQuantity|default('-') }}
{{ referencedAssembly.name }}-{{ referencedAssembly.ipn|default('-') }}{{ referencedAssembly.quantity }}{{ referencedAssembly.multiplier|default('-') }}{{ (referencedAssembly.quantity * (referencedAssembly.multiplier|default(1)))|default(referencedAssembly.quantity) }}
+ + {% for refAssembly in assembly.referencedAssemblies %} + {% include 'assemblies/export_bom_referenced_assembly_pdf.html.twig' with {'assembly': refAssembly} only %} + {% endfor %} + + {% if not loop.last %} +
+ {% endif %} + + +{% endfor %} + + diff --git a/templates/assemblies/export_bom_referenced_assembly_pdf.html.twig b/templates/assemblies/export_bom_referenced_assembly_pdf.html.twig new file mode 100644 index 000000000..b3ae29cad --- /dev/null +++ b/templates/assemblies/export_bom_referenced_assembly_pdf.html.twig @@ -0,0 +1,59 @@ +
+
Referenced Assembly: {{ assembly.name }} [IPN: {{ assembly.ipn|default('-') }}, Status: {{ assembly.status|default('-') }}, quantity: {{ assembly.quantity }}]
+ + + + + + + + + + + + + + + {% for part in assembly.parts %} + + + + + + + + + + {% endfor %} + + {% for other in assembly.others %} + + + + + + + + + + {% endfor %} + + {% for referencedAssembly in assembly.referencedAssemblies %} + + + + + + + + + + {% endfor %} + +
TypeNameManufacturerIPNQuantityMultiplierEffective Quantity
Part{{ part.name }}{{ part.manufacturer }}{{ part.ipn }}{{ part.quantity }}{% if assembly.multiplier %}{{ assembly.multiplier }}{% else %}-{% endif %}{{ part.effectiveQuantity }}
Other{{ other.name }}-{{ other.ipn|default('-') }}{{ other.quantity }}{{ other.multiplier }}{{ other.effectiveQuantity|default('-') }}
Referenced assembly{{ referencedAssembly.name }}-{{ referencedAssembly.ipn|default('-') }}{{ referencedAssembly.quantity }}{{ referencedAssembly.multiplier|default('-') }}{{ (referencedAssembly.quantity * (referencedAssembly.multiplier|default(1)))|default(referencedAssembly.quantity) }}
+ + + {% for refAssembly in assembly.referencedAssemblies %} + {% include 'assemblies/export_bom_referenced_assembly_pdf.html.twig' with {'assembly': refAssembly} only %} + {% endfor %} +
diff --git a/templates/assemblies/import_bom.html.twig b/templates/assemblies/import_bom.html.twig new file mode 100644 index 000000000..bfbecf8d4 --- /dev/null +++ b/templates/assemblies/import_bom.html.twig @@ -0,0 +1,114 @@ +{% extends "main_card.html.twig" %} + +{% block title %}{% trans %}assembly.import_bom{% endtrans %}{% endblock %} + +{% block before_card %} + {% if validationErrors or importerErrors %} +
+

{% trans %}parts.import.errors.title{% endtrans %}

+
    + {% if validationErrors %} + {% for violation in validationErrors %} +
  • + {{ violation.propertyPath }}: + {{ violation.message|trans(violation.parameters, 'validators') }} +
  • + {% endfor %} + {% endif %} + + {% if importerErrors %} + {% for violation in importerErrors %} +
  • + {{ violation.propertyPath }}: + {{ violation.message|trans(violation.parameters, 'validators')|raw }} +
  • + {% endfor %} + {% endif %} +
+
+ {% endif %} +{% endblock %} + +{% block card_title %} + + {% trans %}assembly.import_bom{% endtrans %}{% if assembly %}: {{ assembly.name }}{% endif %} +{% endblock %} + +{% block card_content %} + {{ form(form) }} +{% endblock %} + +{% block additional_content %} +
+
+
+
+ {% trans %}assembly.import_bom.template.header.json{% endtrans %} +
+
+
{{ jsonTemplate|json_encode(constant('JSON_PRETTY_PRINT') b-or constant('JSON_UNESCAPED_UNICODE')) }}
+ + {{ 'assembly.bom_import.template.json.table'|trans|raw }} +
+
+
+
+
+
+ {% trans %}assembly.import_bom.template.header.csv{% endtrans %} +
+
+ {{ 'assembly.bom_import.template.csv.exptected_columns'|trans }} + +
quantity;name;part_id;part_mpnr;part_ipn;part_name;part_description;part_manufacturer_id;part_manufacturer_name;part_category_id;part_category_name
+ +
    +
  • quantity
  • +
  • name
  • +
  • part_id
  • +
  • part_mpnr
  • +
  • part_ipn
  • +
  • part_name
  • +
  • part_description
  • +
  • part_manufacturer_id
  • +
  • part_manufacturer_name
  • +
  • part_category_id
  • +
  • part_category_name
  • +
+ + {{ 'assembly.bom_import.template.csv.table'|trans|raw }} +
+
+
+
+
+
+ {% trans %}assembly.import_bom.template.header.kicad_pcbnew{% endtrans %} +
+
+ {{ 'assembly.bom_import.template.kicad_pcbnew.exptected_columns'|trans }} +
Id;Designator;Package;Quantity;Designation;Supplier and ref
+ +
    +
  • Id
  • +
  • Designator
  • +
  • Package
  • +
  • Quantity
  • +
  • Designation
  • +
  • Supplier and ref
  • +
  • Note
  • +
  • Footprint
  • +
  • Value
  • +
  • Footprint
  • +
+ + {{ 'assembly.bom_import.template.kicad_pcbnew.exptected_columns.note'|trans|raw }} + + {{ 'assembly.bom_import.template.kicad_pcbnew.table'|trans|raw }} + + {{ 'assembly.bom_import.template.json.table'|trans|raw }} +
+
+
+
+{% endblock %} diff --git a/templates/assemblies/info/_attachments_info.html.twig b/templates/assemblies/info/_attachments_info.html.twig new file mode 100644 index 000000000..747426c3a --- /dev/null +++ b/templates/assemblies/info/_attachments_info.html.twig @@ -0,0 +1,91 @@ +{% import "helper.twig" as helper %} + + + + + + + + + + + + + + + + + {% for attachment in assembly.attachments %} + + + + + + + + + + {% endfor %} + + + +
{% trans %}attachment.name{% endtrans %}{% trans %}attachment.attachment_type{% endtrans %}{% trans %}attachment.file_name{% endtrans %}{% trans %}attachment.file_size{% endtrans %}
+ {% import "components/attachments.macro.html.twig" as attachments %} + {{ attachments.attachment_icon(attachment, attachment_manager) }} + {{ attachment.name }}{{ attachment.attachmentType.fullPath }} + {% if attachment.hasInternal() %} + {{ attachment.filename }} + {% endif %} + + {% if not attachment.hasInternal() %} + + {% trans %}attachment.external_only{% endtrans %} + + {% elseif attachment_manager.internalFileExisting(attachment) %} + + {{ attachment_manager.humanFileSize(attachment) }} + + {% else %} + + {% trans %}attachment.file_not_found{% endtrans %} + + {% endif %} + {% if attachment.secure %} +
+ {% trans %}attachment.secure{% endtrans %} + + {% endif %} + {% if attachment == assembly.masterPictureAttachment %} +
+ + {% trans %}attachment.preview{% endtrans %} + + {% endif %} +
+ + + + + + + + + + +
+ + +
+
\ No newline at end of file diff --git a/templates/assemblies/info/_info.html.twig b/templates/assemblies/info/_info.html.twig new file mode 100644 index 000000000..97da3f708 --- /dev/null +++ b/templates/assemblies/info/_info.html.twig @@ -0,0 +1,72 @@ +{% import "helper.twig" as helper %} + +
+
+
+
+ {% if assembly.masterPictureAttachment %} + + + + {% else %} + Part main image + {% endif %} +
+
+

{{ assembly.name }} + {# You need edit permission to use the edit button #} + {% if is_granted('edit', assembly) %} + + {% endif %} +

+
{{ assembly.description|format_markdown(true) }}
+
+
+
+ + +
{# Sidebar panel with infos about last creation date, etc. #} +
+ + {{ helper.date_user_combination(assembly, true) }} + +
+ + {{ helper.date_user_combination(assembly, false) }} + +
+ +
+
+ {{ helper.assemblies_status_to_badge(assembly.status) }} +
+
+
+
+ + + {{ assembly.bomEntries | length }} + {% trans %}assembly.info.bom_entries_count{% endtrans %} + +
+
+ {% if assembly.children is not empty %} +
+
+ + + {{ assembly.children | length }} + {% trans %}assembly.info.sub_assemblies_count{% endtrans %} + +
+
+ {% endif %} +
+ + {% if assembly.comment is not empty %} +

+

{% trans %}comment.label{% endtrans %}:
+ {{ assembly.comment|format_markdown }} +

+ {% endif %} +
diff --git a/templates/assemblies/info/_info_card.html.twig b/templates/assemblies/info/_info_card.html.twig new file mode 100644 index 000000000..2d0c535b2 --- /dev/null +++ b/templates/assemblies/info/_info_card.html.twig @@ -0,0 +1,118 @@ +{% import "helper.twig" as helper %} +{% import "label_system/dropdown_macro.html.twig" as dropdown %} + +{{ helper.breadcrumb_entity_link(assembly) }} + +
+
+
+ +
+
+
+ {% if assembly.description is not empty %} + {{ assembly.description|format_markdown }} + {% endif %} +
+ +
+
+
+
+
+
+ + {{ assembly.name }} +
+
+ + + {% if assembly.parent %} + {{ assembly.parent.fullPath }} + {% else %} + - + {% endif %} + +
+
+
+ {% block quick_links %}{% endblock %} + + + {% trans %}entity.edit.btn{% endtrans %} + +
+ + {{ assembly.lastModified | format_datetime("short") }} + +
+ + {{ assembly.addedDate | format_datetime("short") }} + +
+
+
+
+
+
+
+ + {{ assembly.children | length }} +
+
+ + {{ assembly.bomEntries | length }} +
+
+
+ + {% if assembly.attachments is not empty %} +
+ {% include "parts/info/_attachments_info.html.twig" with {"part": assembly} %} +
+ {% endif %} + + {% if assembly.comment is not empty %} +
+
+ {{ assembly.comment|format_markdown }} +
+
+ {% endif %} +
+
+
+
+
+
+
\ No newline at end of file diff --git a/templates/assemblies/info/_part.html.twig b/templates/assemblies/info/_part.html.twig new file mode 100644 index 000000000..1fa8b90ed --- /dev/null +++ b/templates/assemblies/info/_part.html.twig @@ -0,0 +1,5 @@ +{% import "components/datatables.macro.html.twig" as datatables %} + +
+ +{{ datatables.datatable(datatable, 'elements/datatables/datatables', 'assemblies') }} \ No newline at end of file diff --git a/templates/assemblies/info/_subassemblies.html.twig b/templates/assemblies/info/_subassemblies.html.twig new file mode 100644 index 000000000..243e7d332 --- /dev/null +++ b/templates/assemblies/info/_subassemblies.html.twig @@ -0,0 +1,28 @@ + + + + + + + + + + + {% for assembly in assembly.children %} + + + + + + + {% endfor %} + +
{% trans %}name.label{% endtrans %}{% trans %}description.label{% endtrans %}# {% trans %}assembly.info.bom_entries_count{% endtrans %}# {% trans %}assembly.info.sub_assemblies_count{% endtrans %}
{# Name #} + {{ assembly.name }} + {# Description #} + {{ assembly.description | format_markdown }} + + {{ assembly.bomEntries | length }} + + {{ assembly.children | length }} +
diff --git a/templates/assemblies/info/info.html.twig b/templates/assemblies/info/info.html.twig new file mode 100644 index 000000000..9f345b976 --- /dev/null +++ b/templates/assemblies/info/info.html.twig @@ -0,0 +1,135 @@ +{% extends "main_card.html.twig" %} +{% import "helper.twig" as helper %} + +{% block title %} + {% trans %}assembly.info.title{% endtrans %}: {{ assembly.name }} +{% endblock %} + +{% block before_card %} + +{% endblock %} + +{% block content %} + {{ helper.breadcrumb_entity_link(assembly) }} + {{ parent() }} +{% endblock %} + +{% block card_title %} + {% if assembly.masterPictureAttachment is not null and attachment_manager.isFileExisting(assembly.masterPictureAttachment) %} + + {% else %} + {{ helper.entity_icon(assembly, "me-1") }} + {% endif %} + {% trans %}assembly.info.title{% endtrans %}: {{ assembly.name }} +{% endblock %} + +{% block card_content %} + +
+
+ {% include "assemblies/info/_info.html.twig" %} +
+ {% if assembly.children is not empty %} +
+ {% include "assemblies/info/_subassemblies.html.twig" %} +
+ {% endif %} +
+ {% include "assemblies/info/_part.html.twig" %} +
+
+ {% include "assemblies/info/_attachments_info.html.twig" with {"assembly": assembly} %} +
+
+ {% for name, parameters in assembly.groupedParameters %} + {% if name is not empty %}
{{ name }}
{% endif %} + {{ helper.parameters_table(assembly.parameters) }} + {% endfor %} +
+
+ +{% endblock %} diff --git a/templates/assemblies/lists/_action_bar.html.twig b/templates/assemblies/lists/_action_bar.html.twig new file mode 100644 index 000000000..37289812a --- /dev/null +++ b/templates/assemblies/lists/_action_bar.html.twig @@ -0,0 +1,6 @@ + \ No newline at end of file diff --git a/templates/assemblies/lists/_filter.html.twig b/templates/assemblies/lists/_filter.html.twig new file mode 100644 index 000000000..11be7bc24 --- /dev/null +++ b/templates/assemblies/lists/_filter.html.twig @@ -0,0 +1,62 @@ +
+
+ +
+
+
+ + + {{ form_start(filterForm, {"attr": {"data-controller": "helpers--form-cleanup", "data-action": "helpers--form-cleanup#submit"}}) }} + +
+
+ {{ form_row(filterForm.name) }} + {{ form_row(filterForm.description) }} + {{ form_row(filterForm.comment) }} +
+ +
+ {{ form_row(filterForm.dbId) }} + {{ form_row(filterForm.ipn) }} + {{ form_row(filterForm.lastModified) }} + {{ form_row(filterForm.addedDate) }} +
+ +
+ {{ form_row(filterForm.attachmentsCount) }} + {{ form_row(filterForm.attachmentType) }} + {{ form_row(filterForm.attachmentName) }} +
+
+ + {{ form_row(filterForm.submit) }} + {{ form_row(filterForm.discard) }} + +
+
+ +
+
+ + {# Retain the query parameters of the search form if it is existing #} + {% if searchFilter is defined %} + {% for property, value in searchFilter|to_array %} + + {% endfor %} + + {% endif %} + + {{ form_end(filterForm) }} +
+
+
\ No newline at end of file diff --git a/templates/assemblies/lists/all_list.html.twig b/templates/assemblies/lists/all_list.html.twig new file mode 100644 index 000000000..70d75ad40 --- /dev/null +++ b/templates/assemblies/lists/all_list.html.twig @@ -0,0 +1,30 @@ +{% extends "base.html.twig" %} + +{% block title %} + {% trans %}assembly_list.all.title{% endtrans %} +{% endblock %} + +{% block content %} + +
+
+
+ +
+
+
+ +
+
+
+ + {% include "assemblies/lists/_filter.html.twig" %} +
+ + {% include "assemblies/lists/_action_bar.html.twig" with {'url_options': {}} %} + {% include "assemblies/lists/data.html.twig" %} + +{% endblock %} diff --git a/templates/assemblies/lists/data.html.twig b/templates/assemblies/lists/data.html.twig new file mode 100644 index 000000000..69e13e4f5 --- /dev/null +++ b/templates/assemblies/lists/data.html.twig @@ -0,0 +1,3 @@ +{% import "components/datatables.macro.html.twig" as datatables %} + +{{ datatables.partsDatatableWithForm(datatable) }} diff --git a/templates/base.html.twig b/templates/base.html.twig index 2db726eea..62fbee53f 100644 --- a/templates/base.html.twig +++ b/templates/base.html.twig @@ -95,6 +95,14 @@ {# Here will be the real content be injected#} + {# These empty divs are here to ensure that Turbo Morphing correctly removes homepage elements when navigating away #} +
+
+
+
+
+
+ {% block content %} {% endblock %} diff --git a/templates/components/new_version.macro.html.twig b/templates/components/new_version.macro.html.twig index f8bc1e2e7..2b8294d62 100644 --- a/templates/components/new_version.macro.html.twig +++ b/templates/components/new_version.macro.html.twig @@ -1,9 +1,9 @@ {% macro new_version_alert(is_available, new_version, new_version_url) %} {% if is_available %} -