diff --git a/.env b/.env index 982d4bbd0..73b7b350d 100644 --- a/.env +++ b/.env @@ -60,7 +60,6 @@ ERROR_PAGE_ADMIN_EMAIL='' # If this is set to true, solutions to common problems are shown on error pages. Disable this, if you do not want your users to see them... ERROR_PAGE_SHOW_HELP=1 - ################################################################################### # SAML Single sign on-settings ################################################################################### diff --git a/assets/controllers/elements/assembly_select_controller.js b/assets/controllers/elements/assembly_select_controller.js new file mode 100644 index 000000000..98702d419 --- /dev/null +++ b/assets/controllers/elements/assembly_select_controller.js @@ -0,0 +1,70 @@ +import {Controller} from "@hotwired/stimulus"; + +import "tom-select/dist/css/tom-select.bootstrap5.css"; +import '../../css/components/tom-select_extensions.css'; +import TomSelect from "tom-select"; +import {marked} from "marked"; + +export default class extends Controller { + _tomSelect; + + connect() { + + let settings = { + allowEmptyOption: true, + plugins: ['dropdown_input', 'clear_button'], + searchField: ["name", "description", "category", "footprint"], + valueField: "id", + labelField: "name", + preload: "focus", + render: { + item: (data, escape) => { + return '' + (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) { + const base_url = this.element.dataset.autocomplete; + settings.valueField = "id"; + settings.load = (query, callback) => { + const url = base_url.replace('__QUERY__', encodeURIComponent(query)); + + fetch(url) + .then(response => response.json()) + .then(json => {callback(json);}) + .catch(() => { + callback() + }); + }; + + + this._tomSelect = new TomSelect(this.element, settings); + //this._tomSelect.clearOptions(); + } + } + + disconnect() { + super.disconnect(); + //Destroy the TomSelect instance + this._tomSelect.destroy(); + } +} \ No newline at end of file diff --git a/assets/controllers/elements/ckeditor_controller.js b/assets/controllers/elements/ckeditor_controller.js index 62a48b151..7f55dd5ca 100644 --- a/assets/controllers/elements/ckeditor_controller.js +++ b/assets/controllers/elements/ckeditor_controller.js @@ -78,6 +78,15 @@ export default class extends Controller { editor_div.classList.add(...new_classes.split(",")); } + // Automatic synchronization of source input + editor.model.document.on("change:data", () => { + editor.updateSourceElement(); + + // Dispatch the input event for further treatment + const event = new Event("input"); + this.element.dispatchEvent(event); + }); + //This return is important! Otherwise we get mysterious errors in the console //See: https://github.com/ckeditor/ckeditor5/issues/5897#issuecomment-628471302 return editor; diff --git a/assets/controllers/elements/ipn_suggestion_controller.js b/assets/controllers/elements/ipn_suggestion_controller.js new file mode 100644 index 000000000..c8b543cbf --- /dev/null +++ b/assets/controllers/elements/ipn_suggestion_controller.js @@ -0,0 +1,250 @@ +import { Controller } from "@hotwired/stimulus"; +import "../../css/components/autocomplete_bootstrap_theme.css"; + +export default class extends Controller { + static targets = ["input"]; + static values = { + partId: Number, + partCategoryId: Number, + partDescription: String, + suggestions: Object, + commonSectionHeader: String, // Dynamic header for common Prefixes + partIncrementHeader: String, // Dynamic header for new possible part increment + suggestUrl: String, + }; + + connect() { + this.configureAutocomplete(); + this.watchCategoryChanges(); + this.watchDescriptionChanges(); + } + + templates = { + commonSectionHeader({ title, html }) { + return html` +
+
+ ${title} +
+
+
+ `; + }, + partIncrementHeader({ title, html }) { + return html` +
+
+ ${title} +
+
+
+ `; + }, + list({ html }) { + return html` + + `; + }, + item({ suggestion, description, html }) { + return html` +
  • +
    +
    +
    + + + +
    +
    +
    ${suggestion}
    +
    ${description}
    +
    +
    +
    +
  • + `; + }, + }; + + configureAutocomplete() { + const inputField = this.inputTarget; + const commonPrefixes = this.suggestionsValue.commonPrefixes || []; + const prefixesPartIncrement = this.suggestionsValue.prefixesPartIncrement || []; + const commonHeader = this.commonSectionHeaderValue; + const partIncrementHeader = this.partIncrementHeaderValue; + + if (!inputField || (!commonPrefixes.length && !prefixesPartIncrement.length)) return; + + // Check whether the panel should be created at the update + if (this.isPanelInitialized) { + const existingPanel = inputField.parentNode.querySelector(".aa-Panel"); + if (existingPanel) { + // Only remove the panel in the update phase + + existingPanel.remove(); + } + } + + // Create panel + const panel = document.createElement("div"); + panel.classList.add("aa-Panel"); + panel.style.display = "none"; + + // Create panel layout + const panelLayout = document.createElement("div"); + panelLayout.classList.add("aa-PanelLayout", "aa-Panel--scrollable"); + + // Section for prefixes part increment + if (prefixesPartIncrement.length) { + const partIncrementSection = document.createElement("section"); + partIncrementSection.classList.add("aa-Source"); + + const partIncrementHeaderHtml = this.templates.partIncrementHeader({ + title: partIncrementHeader, + html: String.raw, + }); + partIncrementSection.innerHTML += partIncrementHeaderHtml; + + const partIncrementList = document.createElement("ul"); + partIncrementList.classList.add("aa-List"); + partIncrementList.setAttribute("role", "listbox"); + + prefixesPartIncrement.forEach((prefix) => { + const itemHTML = this.templates.item({ + suggestion: prefix.title, + description: prefix.description, + html: String.raw, + }); + partIncrementList.innerHTML += itemHTML; + }); + + partIncrementSection.appendChild(partIncrementList); + panelLayout.appendChild(partIncrementSection); + } + + // Section for common prefixes + if (commonPrefixes.length) { + const commonSection = document.createElement("section"); + commonSection.classList.add("aa-Source"); + + const commonSectionHeader = this.templates.commonSectionHeader({ + title: commonHeader, + html: String.raw, + }); + commonSection.innerHTML += commonSectionHeader; + + const commonList = document.createElement("ul"); + commonList.classList.add("aa-List"); + commonList.setAttribute("role", "listbox"); + + commonPrefixes.forEach((prefix) => { + const itemHTML = this.templates.item({ + suggestion: prefix.title, + description: prefix.description, + html: String.raw, + }); + commonList.innerHTML += itemHTML; + }); + + commonSection.appendChild(commonList); + panelLayout.appendChild(commonSection); + } + + panel.appendChild(panelLayout); + inputField.parentNode.appendChild(panel); + + inputField.addEventListener("focus", () => { + panel.style.display = "block"; + }); + + inputField.addEventListener("blur", () => { + setTimeout(() => { + panel.style.display = "none"; + }, 100); + }); + + // Selection of an item + panelLayout.addEventListener("mousedown", (event) => { + const target = event.target.closest("li"); + + if (target) { + inputField.value = target.dataset.suggestion; + panel.style.display = "none"; + } + }); + + this.isPanelInitialized = true; + }; + + watchCategoryChanges() { + const categoryField = document.querySelector('[data-ipn-suggestion="categoryField"]'); + const descriptionField = document.querySelector('[data-ipn-suggestion="descriptionField"]'); + this.previousCategoryId = Number(this.partCategoryIdValue); + + if (categoryField) { + categoryField.addEventListener("change", () => { + const categoryId = Number(categoryField.value); + const description = String(descriptionField?.value ?? ''); + + // Check whether the category has changed compared to the previous ID + if (categoryId !== this.previousCategoryId) { + this.fetchNewSuggestions(categoryId, description); + this.previousCategoryId = categoryId; + } + }); + } + } + + watchDescriptionChanges() { + const categoryField = document.querySelector('[data-ipn-suggestion="categoryField"]'); + const descriptionField = document.querySelector('[data-ipn-suggestion="descriptionField"]'); + this.previousDescription = String(this.partDescriptionValue); + + if (descriptionField) { + descriptionField.addEventListener("input", () => { + const categoryId = Number(categoryField.value); + const description = String(descriptionField?.value ?? ''); + + // Check whether the description has changed compared to the previous one + if (description !== this.previousDescription) { + this.fetchNewSuggestions(categoryId, description); + this.previousDescription = description; + } + }); + } + } + + fetchNewSuggestions(categoryId, description) { + const baseUrl = this.suggestUrlValue; + const partId = this.partIdValue; + const truncatedDescription = description.length > 150 ? description.substring(0, 150) : description; + const encodedDescription = this.base64EncodeUtf8(truncatedDescription); + const url = `${baseUrl}?partId=${partId}&categoryId=${categoryId}` + (description !== '' ? `&description=${encodedDescription}` : ''); + + fetch(url, { + method: "GET", + headers: { + "Content-Type": "application/json", + "Accept": "application/json", + }, + }) + .then((response) => { + if (!response.ok) { + throw new Error(`Error when calling up the IPN-suggestions: ${response.status}`); + } + return response.json(); + }) + .then((data) => { + this.suggestionsValue = data; + this.configureAutocomplete(); + }) + .catch((error) => { + console.error("Errors when loading the new IPN-suggestions:", error); + }); + }; + + base64EncodeUtf8(text) { + const utf8Bytes = new TextEncoder().encode(text); + return btoa(String.fromCharCode(...utf8Bytes)); + }; +} diff --git a/assets/controllers/elements/part_select_controller.js b/assets/controllers/elements/part_select_controller.js index 0658f4b46..2b658d526 100644 --- a/assets/controllers/elements/part_select_controller.js +++ b/assets/controllers/elements/part_select_controller.js @@ -12,7 +12,7 @@ export default class extends Controller { let settings = { allowEmptyOption: true, - plugins: ['dropdown_input'], + plugins: ['dropdown_input', 'clear_button'], searchField: ["name", "description", "category", "footprint"], valueField: "id", labelField: "name", 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/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/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..7c82439cc 100644 --- a/assets/js/lib/datatables.js +++ b/assets/js/lib/datatables.js @@ -13,11 +13,16 @@ * Initializes the datatable dynamically. */ $.fn.initDataTables = function(config, options) { - //Update default used url, so it reflects the current location (useful on single side apps) //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 +110,6 @@ } } - root.html(data.template); dt = $('table', root).DataTable(dtOpts); if (config.state !== 'none') { diff --git a/config/packages/translation.yaml b/config/packages/translation.yaml index cbc1cd7e5..a3f529e31 100644 --- a/config/packages/translation.yaml +++ b/config/packages/translation.yaml @@ -1,7 +1,7 @@ framework: default_locale: 'en' # Just enable the locales we need for performance reasons. - enabled_locale: '%partdb.locale_menu%' + enabled_locale: ['en', 'de', 'it', 'fr', 'ru', 'ja', 'cs', 'da', 'zh', 'pl'] translator: default_path: '%kernel.project_dir%/translations' fallbacks: diff --git a/config/packages/twig.yaml b/config/packages/twig.yaml index 674aa3177..789560261 100644 --- a/config/packages/twig.yaml +++ b/config/packages/twig.yaml @@ -7,7 +7,7 @@ twig: globals: allow_email_pw_reset: '%partdb.users.email_pw_reset%' - locale_menu: '%partdb.locale_menu%' + location_settings: '@App\Settings\SystemSettings\LocalizationSettings' attachment_manager: '@App\Services\Attachments\AttachmentManager' label_profile_dropdown_helper: '@App\Services\LabelSystem\LabelProfileDropdownHelper' error_page_admin_email: '%partdb.error_pages.admin_email%' @@ -20,4 +20,4 @@ twig: when@test: twig: - strict_variables: true \ No newline at end of file + strict_variables: true diff --git a/config/parameters.yaml b/config/parameters.yaml index 5b40899dc..916d9755c 100644 --- a/config/parameters.yaml +++ b/config/parameters.yaml @@ -8,7 +8,6 @@ parameters: # This is used as workaround for places where we can not access the settings directly (like the 2FA application names) partdb.title: '%env(string:settings:customization:instanceName)%' # The title shown inside of Part-DB (e.g. in the navbar and on homepage) - partdb.locale_menu: ['en', 'de', 'it', 'fr', 'ru', 'ja', 'cs', 'da', 'zh', 'pl'] # The languages that are shown in user drop down menu partdb.default_uri: '%env(string:DEFAULT_URI)%' # The default URI to use for the Part-DB instance (e.g. https://part-db.example.com/). This is used for generating links in emails @@ -43,7 +42,6 @@ parameters: ###################################################################################################################### partdb.saml.enabled: '%env(bool:SAML_ENABLED)%' # If this is set to true, SAML authentication is enabled - ###################################################################################################################### # Miscellaneous ###################################################################################################################### diff --git a/config/permissions.yaml b/config/permissions.yaml index 8cbd60c3f..2423177da 100644 --- a/config/permissions.yaml +++ b/config/permissions.yaml @@ -24,7 +24,7 @@ perms: # Here comes a list with all Permission names (they have a perm_[name] co label: "perm.read" # If a part can be read by a user, he can also see all the datastructures (except devices) alsoSet: ['storelocations.read', 'footprints.read', 'categories.read', 'suppliers.read', 'manufacturers.read', - 'currencies.read', 'attachment_types.read', 'measurement_units.read'] + 'currencies.read', 'attachment_types.read', 'measurement_units.read', 'part_custom_states.read'] apiTokenRole: ROLE_API_READ_ONLY edit: label: "perm.edit" @@ -121,6 +121,10 @@ perms: # Here comes a list with all Permission names (they have a perm_[name] co <<: *PART_CONTAINING label: "perm.projects" + assemblies: + <<: *PART_CONTAINING + label: "perm.assemblies" + attachment_types: <<: *PART_CONTAINING label: "perm.part.attachment_types" @@ -133,6 +137,10 @@ perms: # Here comes a list with all Permission names (they have a perm_[name] co <<: *PART_CONTAINING label: "perm.measurement_units" + part_custom_states: + <<: *PART_CONTAINING + label: "perm.part_custom_states" + tools: label: "perm.part.tools" operations: diff --git a/config/services.yaml b/config/services.yaml index 17611ceab..fb6584887 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -164,10 +164,12 @@ services: arguments: $saml_enabled: '%partdb.saml.enabled%' + App\Validator\Constraints\AssemblySystem\AssemblyCycleValidator: + tags: [ 'validator.constraint_validator' ] + #################################################################################################################### # Table settings #################################################################################################################### - App\DataTables\Helpers\ColumnSortHelper: shared: false # Service has a state so not share it between different tables @@ -188,6 +190,13 @@ services: $fontDirectory: '%kernel.project_dir%/var/dompdf/fonts/' $tmpDirectory: '%kernel.project_dir%/var/dompdf/tmp/' + #################################################################################################################### + # Twig Extensions + #################################################################################################################### + + App\Twig\DataSourceNameExtension: + tags: [ 'twig.extension' ] + #################################################################################################################### # Part info provider system #################################################################################################################### @@ -231,6 +240,18 @@ services: tags: - { name: 'doctrine.fixtures.purger_factory', alias: 'reset_autoincrement_purger' } + App\Repository\PartRepository: + arguments: + $translator: '@translator' + tags: ['doctrine.repository_service'] + + App\EventSubscriber\UserSystem\PartUniqueIpnSubscriber: + tags: + - { name: doctrine.event_listener, event: onFlush, connection: default } + + App\Validator\Constraints\UniquePartIpnValidator: + tags: [ 'validator.constraint_validator' ] + # We are needing this service inside a migration, where only the container is injected. So we need to define it as public, to access it from the container. App\Services\UserSystem\PermissionPresetsHelper: public: true diff --git a/docs/configuration.md b/docs/configuration.md index d4b217816..e6279981d 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -116,6 +116,16 @@ bundled with Part-DB. Set `DATABASE_MYSQL_SSL_VERIFY_CERT` if you want to accept value should be handled as confidential data and not shared publicly. * `SHOW_PART_IMAGE_OVERLAY`: Set to 0 to disable the part image overlay, which appears if you hover over an image in the part image gallery +* `IPN_SUGGEST_REGEX`: A global regular expression, that part IPNs have to fullfill. Enforce your own format for your users. +* `IPN_SUGGEST_REGEX_HELP`: Define your own user help text for the Regex format specification. +* `IPN_AUTO_APPEND_SUFFIX`: When enabled, an incremental suffix will be added to the user input when entering an existing +* IPN again upon saving. +* `IPN_SUGGEST_PART_DIGITS`: Defines the fixed number of digits used as the increment at the end of an IPN (Internal Part Number). + IPN prefixes, maintained within part categories and their hierarchy, form the foundation for suggesting complete IPNs. + These suggestions become accessible during IPN input of a part. The constant specifies the digits used to calculate and assign + unique increments for parts within a category hierarchy, ensuring consistency and uniqueness in IPN generation. +* `IPN_USE_DUPLICATE_DESCRIPTION`: When enabled, the part’s description is used to find existing parts with the same + description and to determine the next available IPN by incrementing their numeric suffix for the suggestion list. ### E-Mail settings (all env only) @@ -128,6 +138,8 @@ bundled with Part-DB. Set `DATABASE_MYSQL_SSL_VERIFY_CERT` if you want to accept sent from. * `ALLOW_EMAIL_PW_RESET`: Set this value to true, if you want to allow users to reset their password via an email notification. You have to configure the mail provider first before via the MAILER_DSN setting. +* `ENFORCE_UNIQUE_IPN`: Set this value to false, if you want to allow users to enter a already available IPN for a part entry. + In this case a unique increment is appended to the user input. ### Table related settings @@ -136,7 +148,14 @@ bundled with Part-DB. Set `DATABASE_MYSQL_SSL_VERIFY_CERT` if you want to accept * `TABLE_PARTS_DEFAULT_COLUMNS`: The columns in parts 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`, `category`, `footprint`, `manufacturer`, `storage_location`, `amount`, `minamount`, `partUnit`, `addedDate`, `lastModified`, `needs_review`, `favorite`, `manufacturing_status`, `manufacturer_product_number`, `mass`, `tags`, `attachments`, `edit`. + are: `name`, `id`, `ipn`, `description`, `category`, `footprint`, `manufacturer`, `storage_location`, `amount`, `minamount`, `partUnit`, `partCustomState`, `addedDate`, `lastModified`, `needs_review`, `favorite`, `manufacturing_status`, `manufacturer_product_number`, `mass`, `tags`, `attachments`, `edit`. +* `TABLE_ASSEMBLIES_DEFAULT_COLUMNS`: The columns in assemblies tables, which are visible by default (when loading table for first time). + Also specify the default order of the columns. This is a comma separated list of column names. Available columns + are: `name`, `id`, `ipn`, `description`, `referencedAssemblies`, `edit`, `addedDate`, `lastModified`. +* `TABLE_ASSEMBLIES_BOM_DEFAULT_COLUMNS`: The columns in assemblies bom tables, which are visible by default (when loading table for first time). + Also specify the default order of the columns. This is a comma separated list of column names. Available columns + are: `quantity`, `name`, `id`, `ipn`, `description`, `category`, `footprint`, `manufacturer`, `designator`, `mountnames`, `storage_location`, `amount`, `addedDate`, `lastModified`. +* `CREATE_ASSEMBLY_USE_IPN_PLACEHOLDER_IN_NAME`: Use an %%ipn%% placeholder in the name of a assembly. Placeholder is replaced with the ipn input while saving. ### History/Eventlog-related settings @@ -262,8 +281,6 @@ command `bin/console cache:clear`. The following options are available: -* `partdb.locale_menu`: The codes of the languages, which should be shown in the language chooser menu (the one with the - user icon in the navbar). The first language in the list will be the default language. * `partdb.gdpr_compliance`: When set to true (default value), IP addresses which are saved in the database will be anonymized, by removing the last byte of the IP. This is required by the GDPR (General Data Protection Regulation) in the EU. diff --git a/migrations/Version20250304081039.php b/migrations/Version20250304081039.php new file mode 100644 index 000000000..0c64e2507 --- /dev/null +++ b/migrations/Version20250304081039.php @@ -0,0 +1,282 @@ +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, + 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), + PRIMARY KEY(id) + ) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` + SQL); + $this->addSql(<<<'SQL' + CREATE TABLE assembly_bom_entries ( + id INT AUTO_INCREMENT NOT NULL, + id_assembly INT DEFAULT NULL, + id_part INT DEFAULT NULL, + id_project INT DEFAULT NULL, + quantity DOUBLE PRECISION NOT NULL, + mountnames 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_8C74887E2F180363 (id_assembly), + INDEX IDX_8C74887EC22F6CC4 (id_part), + INDEX IDX_8C74887EF12E799E (id_project), + INDEX IDX_8C74887E3FFDCD60 (price_currency_id), + PRIMARY KEY(id) + ) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` + SQL); + $this->addSql(<<<'SQL' + ALTER TABLE assemblies ADD CONSTRAINT FK_5F3832C0727ACA70 FOREIGN KEY (parent_id) REFERENCES assemblies (id) + SQL); + $this->addSql(<<<'SQL' + ALTER TABLE assemblies ADD CONSTRAINT FK_5F3832C0EA7100A1 FOREIGN KEY (id_preview_attachment) REFERENCES `attachments` (id) ON DELETE SET NULL + SQL); + $this->addSql(<<<'SQL' + ALTER TABLE assembly_bom_entries ADD CONSTRAINT FK_8C74887E2F180363 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_8C74887EF12E799E FOREIGN KEY (id_project) REFERENCES `projects` (id) + 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(<<<'SQL' + ALTER TABLE assemblies DROP FOREIGN KEY FK_5F3832C0727ACA70 + SQL); + $this->addSql(<<<'SQL' + ALTER TABLE assemblies DROP FOREIGN KEY FK_5F3832C0EA7100A1 + SQL); + $this->addSql(<<<'SQL' + ALTER TABLE assembly_bom_entries DROP FOREIGN KEY FK_8C74887E2F180363 + SQL); + $this->addSql(<<<'SQL' + ALTER TABLE assembly_bom_entries DROP FOREIGN KEY FK_8C74887EC22F6CC4 + SQL); + $this->addSql(<<<'SQL' + ALTER TABLE assembly_bom_entries DROP FOREIGN KEY FK_8C74887EF12E799E + SQL); + $this->addSql(<<<'SQL' + ALTER TABLE assembly_bom_entries DROP FOREIGN KEY FK_8C74887E3FFDCD60 + SQL); + $this->addSql(<<<'SQL' + DROP TABLE assemblies + SQL); + $this->addSql(<<<'SQL' + DROP TABLE assembly_bom_entries + SQL); + } + + public function sqLiteUp(Schema $schema): void + { + $this->addSql(<<<'SQL' + CREATE TABLE assemblies ( + id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + parent_id INTEGER DEFAULT NULL, + id_preview_attachment INTEGER DEFAULT NULL, + order_quantity INTEGER NOT NULL, + order_only_missing_parts BOOLEAN NOT NULL, + comment CLOB NOT NULL, + not_selectable BOOLEAN NOT NULL, + name VARCHAR(255) NOT NULL, + last_modified DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, + datetime_added DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, + status VARCHAR(64) DEFAULT NULL, + ipn VARCHAR(100) DEFAULT NULL, + description CLOB NOT NULL, + alternative_names CLOB DEFAULT NULL, + CONSTRAINT FK_5F3832C0727ACA70 FOREIGN KEY (parent_id) REFERENCES assemblies (id) NOT DEFERRABLE INITIALLY IMMEDIATE, + CONSTRAINT FK_5F3832C0EA7100A1 FOREIGN KEY (id_preview_attachment) REFERENCES "attachments" (id) ON DELETE SET NULL NOT DEFERRABLE INITIALLY IMMEDIATE + ) + SQL); + $this->addSql(<<<'SQL' + CREATE INDEX IDX_5F3832C0727ACA70 ON assemblies (parent_id) + SQL); + $this->addSql(<<<'SQL' + CREATE INDEX IDX_5F3832C0EA7100A1 ON assemblies (id_preview_attachment) + SQL); + $this->addSql(<<<'SQL' + CREATE UNIQUE INDEX UNIQ_5F3832C03D721C14 ON assemblies (ipn) + SQL); + $this->addSql(<<<'SQL' + CREATE INDEX assembly_idx_ipn ON assemblies (ipn) + SQL); + + $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, + 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(<<<'SQL' + CREATE INDEX IDX_8C74887E4AD2039E ON assembly_bom_entries (id_assembly) + SQL); + $this->addSql(<<<'SQL' + CREATE INDEX IDX_8C74887EC22F6CC4 ON assembly_bom_entries (id_part) + SQL); + $this->addSql(<<<'SQL' + CREATE INDEX IDX_8C74887E22522999 ON assembly_bom_entries (id_referenced_assembly) + SQL); + $this->addSql(<<<'SQL' + CREATE INDEX IDX_8C74887E3FFDCD60 ON assembly_bom_entries (price_currency_id) + SQL); + } + + public function sqLiteDown(Schema $schema): void + { + $this->addSql(<<<'SQL' + DROP TABLE assembly_bom_entries + SQL); + $this->addSql(<<<'SQL' + DROP TABLE assemblies + SQL); + } + + public function postgreSQLUp(Schema $schema): void + { + $this->addSql(<<<'SQL' + CREATE TABLE assemblies ( + id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL, + name VARCHAR(255) NOT NULL, + last_modified TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT CURRENT_TIMESTAMP NOT NULL, + datetime_added TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT CURRENT_TIMESTAMP NOT NULL, + comment TEXT NOT NULL, + not_selectable BOOLEAN NOT NULL, + alternative_names TEXT DEFAULT NULL, + order_quantity INT NOT NULL, + status VARCHAR(64) DEFAULT NULL, + 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(<<<'SQL' + CREATE INDEX IDX_5F3832C0727ACA70 ON assemblies (parent_id) + SQL); + $this->addSql(<<<'SQL' + CREATE INDEX IDX_5F3832C0EA7100A1 ON assemblies (id_preview_attachment) + SQL); + $this->addSql(<<<'SQL' + CREATE TABLE assembly_bom_entries ( + id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL, + id_assembly INT DEFAULT NULL, + id_part INT DEFAULT NULL, + quantity DOUBLE PRECISION NOT NULL, + mountnames 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(<<<'SQL' + CREATE INDEX IDX_8C74887E4AD2039E ON assembly_bom_entries (id_assembly) + SQL); + $this->addSql(<<<'SQL' + CREATE INDEX IDX_8C74887EC22F6CC4 ON assembly_bom_entries (id_part) + SQL); + $this->addSql(<<<'SQL' + CREATE INDEX IDX_8C74887E3FFDCD60 ON assembly_bom_entries (price_currency_id) + SQL); + $this->addSql(<<<'SQL' + ALTER TABLE assemblies ADD CONSTRAINT FK_5F3832C0727ACA70 FOREIGN KEY (parent_id) REFERENCES assemblies (id) NOT DEFERRABLE INITIALLY IMMEDIATE + SQL); + $this->addSql(<<<'SQL' + ALTER TABLE assemblies ADD CONSTRAINT FK_5F3832C0EA7100A1 FOREIGN KEY (id_preview_attachment) REFERENCES "attachments" (id) ON DELETE SET NULL NOT DEFERRABLE INITIALLY IMMEDIATE + SQL); + $this->addSql(<<<'SQL' + 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_8C74887E3FFDCD60 FOREIGN KEY (price_currency_id) REFERENCES currencies (id) NOT DEFERRABLE INITIALLY IMMEDIATE + SQL); + } + + public function postgreSQLDown(Schema $schema): void + { + $this->addSql(<<<'SQL' + ALTER TABLE assemblies DROP CONSTRAINT FK_5F3832C0727ACA70 + SQL); + $this->addSql(<<<'SQL' + ALTER TABLE assemblies DROP CONSTRAINT FK_5F3832C0EA7100A1 + SQL); + $this->addSql(<<<'SQL' + ALTER TABLE assembly_bom_entries DROP CONSTRAINT FK_8C74887E4AD2039E + SQL); + $this->addSql(<<<'SQL' + ALTER TABLE assembly_bom_entries DROP CONSTRAINT FK_8C74887EC22F6CC4 + SQL); + $this->addSql(<<<'SQL' + ALTER TABLE assembly_bom_entries DROP CONSTRAINT FK_8C74887EF12E799E + SQL); + $this->addSql(<<<'SQL' + ALTER TABLE assembly_bom_entries DROP CONSTRAINT FK_8C74887E3FFDCD60 + SQL); + $this->addSql(<<<'SQL' + DROP TABLE assemblies + SQL); + $this->addSql(<<<'SQL' + DROP TABLE assembly_bom_entries + SQL); + } +} diff --git a/migrations/Version20250304154507.php b/migrations/Version20250304154507.php new file mode 100644 index 000000000..18692991c --- /dev/null +++ b/migrations/Version20250304154507.php @@ -0,0 +1,64 @@ +addSql(<<<'SQL' + ALTER TABLE parts ADD built_assembly_id INT DEFAULT NULL AFTER built_project_id + SQL); + $this->addSql(<<<'SQL' + ALTER TABLE parts ADD CONSTRAINT FK_6940A7FECC660B3C FOREIGN KEY (built_assembly_id) REFERENCES assemblies (id) + SQL); + $this->addSql(<<<'SQL' + CREATE UNIQUE INDEX UNIQ_6940A7FECC660B3C ON parts (built_assembly_id) + SQL); + + // reverted in Version20251016124311, because built_assembly_id isn't required after testing time + } + + public function mySQLDown(Schema $schema): void + { + $this->addSql(<<<'SQL' + ALTER TABLE parts DROP FOREIGN KEY FK_6940A7FECC660B3C + SQL); + $this->addSql(<<<'SQL' + DROP INDEX UNIQ_6940A7FECC660B3C ON parts + SQL); + $this->addSql(<<<'SQL' + ALTER TABLE `parts` DROP built_assembly_id + SQL); + } + + public function sqLiteUp(Schema $schema): void + { + // nothing do to, built_assembly_id not required + } + + public function sqLiteDown(Schema $schema): void + { + // nothing do to, built_assembly_id not required + } + + public function postgreSQLUp(Schema $schema): void + { + // nothing do to, built_assembly_id not required + } + + public function postgreSQLDown(Schema $schema): void + { + // nothing do to, built_assembly_id not required + } +} diff --git a/migrations/Version20250321075747.php b/migrations/Version20250321075747.php new file mode 100644 index 000000000..14bcb8a9d --- /dev/null +++ b/migrations/Version20250321075747.php @@ -0,0 +1,605 @@ +addSql(<<<'SQL' + CREATE TABLE part_custom_states ( + 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, + last_modified DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, + datetime_added DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, + INDEX IDX_F552745D727ACA70 (parent_id), + INDEX IDX_F552745DEA7100A1 (id_preview_attachment), + INDEX part_custom_state_name (name), + PRIMARY KEY(id) + ) + DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` + SQL); + $this->addSql(<<<'SQL' + ALTER TABLE part_custom_states ADD CONSTRAINT FK_F552745D727ACA70 FOREIGN KEY (parent_id) REFERENCES part_custom_states (id) + SQL); + $this->addSql(<<<'SQL' + ALTER TABLE part_custom_states ADD CONSTRAINT FK_F552745DEA7100A1 FOREIGN KEY (id_preview_attachment) REFERENCES attachments (id) ON DELETE SET NULL + SQL); + + $this->addSql(<<<'SQL' + ALTER TABLE parts ADD id_part_custom_state INT DEFAULT NULL AFTER id_part_unit + SQL); + $this->addSql(<<<'SQL' + ALTER TABLE parts ADD CONSTRAINT FK_6940A7FEA3ED1215 FOREIGN KEY (id_part_custom_state) REFERENCES part_custom_states (id) + SQL); + $this->addSql(<<<'SQL' + CREATE INDEX IDX_6940A7FEA3ED1215 ON parts (id_part_custom_state) + SQL); + } + + public function mySQLDown(Schema $schema): void + { + $this->addSql(<<<'SQL' + ALTER TABLE parts DROP FOREIGN KEY FK_6940A7FEA3ED1215 + SQL); + $this->addSql(<<<'SQL' + DROP INDEX IDX_6940A7FEA3ED1215 ON parts + SQL); + $this->addSql(<<<'SQL' + ALTER TABLE parts DROP id_part_custom_state + SQL); + + $this->addSql(<<<'SQL' + ALTER TABLE part_custom_states DROP FOREIGN KEY FK_F552745D727ACA70 + SQL); + $this->addSql(<<<'SQL' + ALTER TABLE part_custom_states DROP FOREIGN KEY FK_F552745DEA7100A1 + SQL); + $this->addSql(<<<'SQL' + DROP TABLE part_custom_states + SQL); + } + + public function sqLiteUp(Schema $schema): void + { + $this->addSql(<<<'SQL' + CREATE TABLE "part_custom_states" ( + id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + parent_id INTEGER DEFAULT NULL, + id_preview_attachment INTEGER DEFAULT NULL, + name VARCHAR(255) NOT NULL, + comment CLOB NOT NULL, + not_selectable BOOLEAN NOT NULL, + alternative_names CLOB DEFAULT NULL, + last_modified DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, + datetime_added DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, + CONSTRAINT FK_F552745D727ACA70 FOREIGN KEY (parent_id) REFERENCES "part_custom_states" (id) NOT DEFERRABLE INITIALLY IMMEDIATE, + CONSTRAINT FK_F5AF83CFEA7100A1 FOREIGN KEY (id_preview_attachment) REFERENCES "attachments" (id) ON DELETE SET NULL NOT DEFERRABLE INITIALLY IMMEDIATE + ) + SQL); + $this->addSql(<<<'SQL' + CREATE INDEX IDX_F552745D727ACA70 ON "part_custom_states" (parent_id) + SQL); + $this->addSql(<<<'SQL' + CREATE INDEX part_custom_state_name ON "part_custom_states" (name) + SQL); + + $this->addSql(<<<'SQL' + CREATE TEMPORARY TABLE __temp__parts AS + SELECT + id, + id_preview_attachment, + id_category, + id_footprint, + id_part_unit, + id_manufacturer, + order_orderdetails_id, + built_project_id, + datetime_added, + name, + last_modified, + needs_review, + tags, + mass, + description, + comment, + visible, + favorite, + minamount, + manufacturer_product_url, + manufacturer_product_number, + manufacturing_status, + order_quantity, + manual_order, + ipn, + provider_reference_provider_key, + provider_reference_provider_id, + provider_reference_provider_url, + provider_reference_last_updated, + eda_info_reference_prefix, + eda_info_value, + eda_info_invisible, + eda_info_exclude_from_bom, + eda_info_exclude_from_board, + eda_info_exclude_from_sim, + eda_info_kicad_symbol, + eda_info_kicad_footprint + FROM parts + SQL); + + $this->addSql(<<<'SQL' + DROP TABLE parts + SQL); + + $this->addSql(<<<'SQL' + CREATE TABLE parts ( + id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + id_preview_attachment INTEGER DEFAULT NULL, + id_category INTEGER NOT NULL, + id_footprint INTEGER DEFAULT NULL, + id_part_unit INTEGER DEFAULT NULL, + id_manufacturer INTEGER DEFAULT NULL, + id_part_custom_state INTEGER DEFAULT NULL, + order_orderdetails_id INTEGER DEFAULT NULL, + built_project_id INTEGER DEFAULT NULL, + datetime_added DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, + name VARCHAR(255) NOT NULL, + last_modified DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, + needs_review BOOLEAN NOT NULL, + tags CLOB NOT NULL, + mass DOUBLE PRECISION DEFAULT NULL, + description CLOB NOT NULL, + comment CLOB NOT NULL, + visible BOOLEAN NOT NULL, + favorite BOOLEAN NOT NULL, + minamount DOUBLE PRECISION NOT NULL, + manufacturer_product_url CLOB NOT NULL, + manufacturer_product_number VARCHAR(255) NOT NULL, + manufacturing_status VARCHAR(255) DEFAULT NULL, + order_quantity INTEGER NOT NULL, + manual_order BOOLEAN NOT NULL, + ipn VARCHAR(100) DEFAULT NULL, + provider_reference_provider_key VARCHAR(255) DEFAULT NULL, + provider_reference_provider_id VARCHAR(255) DEFAULT NULL, + provider_reference_provider_url VARCHAR(255) DEFAULT NULL, + provider_reference_last_updated DATETIME DEFAULT NULL, + eda_info_reference_prefix VARCHAR(255) DEFAULT NULL, + eda_info_value VARCHAR(255) DEFAULT NULL, + eda_info_invisible BOOLEAN DEFAULT NULL, + eda_info_exclude_from_bom BOOLEAN DEFAULT NULL, + eda_info_exclude_from_board BOOLEAN DEFAULT NULL, + eda_info_exclude_from_sim BOOLEAN DEFAULT NULL, + eda_info_kicad_symbol VARCHAR(255) DEFAULT NULL, + eda_info_kicad_footprint VARCHAR(255) DEFAULT NULL, + CONSTRAINT FK_6940A7FEEA7100A1 FOREIGN KEY (id_preview_attachment) REFERENCES attachments (id) ON UPDATE NO ACTION ON DELETE SET NULL NOT DEFERRABLE INITIALLY IMMEDIATE, + CONSTRAINT FK_6940A7FE5697F554 FOREIGN KEY (id_category) REFERENCES categories (id) ON UPDATE NO ACTION ON DELETE NO ACTION NOT DEFERRABLE INITIALLY IMMEDIATE, + CONSTRAINT FK_6940A7FE7E371A10 FOREIGN KEY (id_footprint) REFERENCES footprints (id) ON UPDATE NO ACTION ON DELETE NO ACTION NOT DEFERRABLE INITIALLY IMMEDIATE, + CONSTRAINT FK_6940A7FE2626CEF9 FOREIGN KEY (id_part_unit) REFERENCES measurement_units (id) ON UPDATE NO ACTION ON DELETE NO ACTION NOT DEFERRABLE INITIALLY IMMEDIATE, + CONSTRAINT FK_6940A7FE1ECB93AE FOREIGN KEY (id_manufacturer) REFERENCES manufacturers (id) ON UPDATE NO ACTION ON DELETE NO ACTION NOT DEFERRABLE INITIALLY IMMEDIATE, + CONSTRAINT FK_6940A7FEA3ED1215 FOREIGN KEY (id_part_custom_state) REFERENCES "part_custom_states" (id) ON UPDATE NO ACTION ON DELETE NO ACTION NOT DEFERRABLE INITIALLY IMMEDIATE, + CONSTRAINT FK_6940A7FE81081E9B FOREIGN KEY (order_orderdetails_id) REFERENCES orderdetails (id) ON UPDATE NO ACTION ON DELETE NO ACTION NOT DEFERRABLE INITIALLY IMMEDIATE, + CONSTRAINT FK_6940A7FEE8AE70D9 FOREIGN KEY (built_project_id) REFERENCES projects (id) ON UPDATE NO ACTION ON DELETE NO ACTION NOT DEFERRABLE INITIALLY IMMEDIATE + ) + SQL); + + $this->addSql(<<<'SQL' + INSERT INTO parts ( + id, + id_preview_attachment, + id_category, + id_footprint, + id_part_unit, + id_manufacturer, + order_orderdetails_id, + built_project_id, + datetime_added, + name, + last_modified, + needs_review, + tags, + mass, + description, + comment, + visible, + favorite, + minamount, + manufacturer_product_url, + manufacturer_product_number, + manufacturing_status, + order_quantity, + manual_order, + ipn, + provider_reference_provider_key, + provider_reference_provider_id, + provider_reference_provider_url, + provider_reference_last_updated, + eda_info_reference_prefix, + eda_info_value, + eda_info_invisible, + eda_info_exclude_from_bom, + eda_info_exclude_from_board, + eda_info_exclude_from_sim, + eda_info_kicad_symbol, + eda_info_kicad_footprint) + SELECT + id, + id_preview_attachment, + id_category, + id_footprint, + id_part_unit, + id_manufacturer, + order_orderdetails_id, + built_project_id, + datetime_added, + name, + last_modified, + needs_review, + tags, + mass, + description, + comment, + visible, + favorite, + minamount, + manufacturer_product_url, + manufacturer_product_number, + manufacturing_status, + order_quantity, + manual_order, + ipn, + provider_reference_provider_key, + provider_reference_provider_id, + provider_reference_provider_url, + provider_reference_last_updated, + eda_info_reference_prefix, + eda_info_value, + eda_info_invisible, + eda_info_exclude_from_bom, + eda_info_exclude_from_board, + eda_info_exclude_from_sim, + eda_info_kicad_symbol, + eda_info_kicad_footprint + FROM __temp__parts + SQL); + + $this->addSql(<<<'SQL' + DROP TABLE __temp__parts + SQL); + + $this->addSql(<<<'SQL' + CREATE INDEX parts_idx_name ON parts (name) + SQL); + $this->addSql(<<<'SQL' + CREATE INDEX parts_idx_ipn ON parts (ipn) + SQL); + $this->addSql(<<<'SQL' + CREATE INDEX parts_idx_datet_name_last_id_needs ON parts (datetime_added, name, last_modified, id, needs_review) + SQL); + $this->addSql(<<<'SQL' + CREATE UNIQUE INDEX UNIQ_6940A7FEE8AE70D9 ON parts (built_project_id) + SQL); + $this->addSql(<<<'SQL' + CREATE UNIQUE INDEX UNIQ_6940A7FE81081E9B ON parts (order_orderdetails_id) + SQL); + $this->addSql(<<<'SQL' + CREATE UNIQUE INDEX UNIQ_6940A7FE3D721C14 ON parts (ipn) + SQL); + $this->addSql(<<<'SQL' + CREATE INDEX IDX_6940A7FEEA7100A1 ON parts (id_preview_attachment) + SQL); + $this->addSql(<<<'SQL' + CREATE INDEX IDX_6940A7FE7E371A10 ON parts (id_footprint) + SQL); + $this->addSql(<<<'SQL' + CREATE INDEX IDX_6940A7FE5697F554 ON parts (id_category) + SQL); + $this->addSql(<<<'SQL' + CREATE INDEX IDX_6940A7FE2626CEF9 ON parts (id_part_unit) + SQL); + $this->addSql(<<<'SQL' + CREATE INDEX IDX_6940A7FE1ECB93AE ON parts (id_manufacturer) + SQL); + $this->addSql(<<<'SQL' + CREATE INDEX IDX_6940A7FEA3ED1215 ON parts (id_part_custom_state) + SQL); + } + + public function sqLiteDown(Schema $schema): void + { + $this->addSql(<<<'SQL' + CREATE TEMPORARY TABLE __temp__parts AS + SELECT + id, + id_preview_attachment, + id_category, + id_footprint, + id_part_unit, + id_manufacturer, + order_orderdetails_id, + built_project_id, + datetime_added, + name, + last_modified, + needs_review, + tags, + mass, + description, + comment, + visible, + favorite, + minamount, + manufacturer_product_url, + manufacturer_product_number, + manufacturing_status, + order_quantity, + manual_order, + ipn, + provider_reference_provider_key, + provider_reference_provider_id, + provider_reference_provider_url, + provider_reference_last_updated, + eda_info_reference_prefix, + eda_info_value, + eda_info_invisible, + eda_info_exclude_from_bom, + eda_info_exclude_from_board, + eda_info_exclude_from_sim, + eda_info_kicad_symbol, + eda_info_kicad_footprint + FROM "parts" + SQL); + $this->addSql(<<<'SQL' + DROP TABLE "parts" + SQL); + $this->addSql(<<<'SQL' + CREATE TABLE "parts" ( + id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + id_preview_attachment INTEGER DEFAULT NULL, + id_category INTEGER NOT NULL, + id_footprint INTEGER DEFAULT NULL, + id_part_unit INTEGER DEFAULT NULL, + id_manufacturer INTEGER DEFAULT NULL, + order_orderdetails_id INTEGER DEFAULT NULL, + built_project_id INTEGER DEFAULT NULL, + datetime_added DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, + name VARCHAR(255) NOT NULL, + last_modified DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, + needs_review BOOLEAN NOT NULL, + tags CLOB NOT NULL, + mass DOUBLE PRECISION DEFAULT NULL, + description CLOB NOT NULL, + comment CLOB NOT NULL, + visible BOOLEAN NOT NULL, + favorite BOOLEAN NOT NULL, + minamount DOUBLE PRECISION NOT NULL, + manufacturer_product_url CLOB NOT NULL, + manufacturer_product_number VARCHAR(255) NOT NULL, + manufacturing_status VARCHAR(255) DEFAULT NULL, + order_quantity INTEGER NOT NULL, + manual_order BOOLEAN NOT NULL, + ipn VARCHAR(100) DEFAULT NULL, + provider_reference_provider_key VARCHAR(255) DEFAULT NULL, + provider_reference_provider_id VARCHAR(255) DEFAULT NULL, + provider_reference_provider_url VARCHAR(255) DEFAULT NULL, + provider_reference_last_updated DATETIME DEFAULT NULL, + eda_info_reference_prefix VARCHAR(255) DEFAULT NULL, + eda_info_value VARCHAR(255) DEFAULT NULL, + eda_info_invisible BOOLEAN DEFAULT NULL, + eda_info_exclude_from_bom BOOLEAN DEFAULT NULL, + eda_info_exclude_from_board BOOLEAN DEFAULT NULL, + eda_info_exclude_from_sim BOOLEAN DEFAULT NULL, + eda_info_kicad_symbol VARCHAR(255) DEFAULT NULL, + eda_info_kicad_footprint VARCHAR(255) DEFAULT NULL, + CONSTRAINT FK_6940A7FEEA7100A1 FOREIGN KEY (id_preview_attachment) REFERENCES "attachments" (id) ON DELETE SET NULL NOT DEFERRABLE INITIALLY IMMEDIATE, + CONSTRAINT FK_6940A7FE5697F554 FOREIGN KEY (id_category) REFERENCES "categories" (id) NOT DEFERRABLE INITIALLY IMMEDIATE, + CONSTRAINT FK_6940A7FE7E371A10 FOREIGN KEY (id_footprint) REFERENCES "footprints" (id) NOT DEFERRABLE INITIALLY IMMEDIATE, + CONSTRAINT FK_6940A7FE2626CEF9 FOREIGN KEY (id_part_unit) REFERENCES "measurement_units" (id) NOT DEFERRABLE INITIALLY IMMEDIATE, + CONSTRAINT FK_6940A7FE1ECB93AE FOREIGN KEY (id_manufacturer) REFERENCES "manufacturers" (id) NOT DEFERRABLE INITIALLY IMMEDIATE, + CONSTRAINT FK_6940A7FE81081E9B FOREIGN KEY (order_orderdetails_id) REFERENCES "orderdetails" (id) NOT DEFERRABLE INITIALLY IMMEDIATE, + CONSTRAINT FK_6940A7FEE8AE70D9 FOREIGN KEY (built_project_id) REFERENCES projects (id) NOT DEFERRABLE INITIALLY IMMEDIATE + ) + SQL); + $this->addSql(<<<'SQL' + INSERT INTO "parts" ( + id, + id_preview_attachment, + id_category, + id_footprint, + id_part_unit, + id_manufacturer, + order_orderdetails_id, + built_project_id, + datetime_added, + name, + last_modified, + needs_review, + tags, + mass, + description, + comment, + visible, + favorite, + minamount, + manufacturer_product_url, + manufacturer_product_number, + manufacturing_status, + order_quantity, + manual_order, + ipn, + provider_reference_provider_key, + provider_reference_provider_id, + provider_reference_provider_url, + provider_reference_last_updated, + eda_info_reference_prefix, + eda_info_value, + eda_info_invisible, + eda_info_exclude_from_bom, + eda_info_exclude_from_board, + eda_info_exclude_from_sim, + eda_info_kicad_symbol, + eda_info_kicad_footprint + ) SELECT + id, + id_preview_attachment, + id_category, + id_footprint, + id_part_unit, + id_manufacturer, + order_orderdetails_id, + built_project_id, + datetime_added, + name, + last_modified, + needs_review, + tags, + mass, + description, + comment, + visible, + favorite, + minamount, + manufacturer_product_url, + manufacturer_product_number, + manufacturing_status, + order_quantity, + manual_order, + ipn, + provider_reference_provider_key, + provider_reference_provider_id, + provider_reference_provider_url, + provider_reference_last_updated, + eda_info_reference_prefix, + eda_info_value, + eda_info_invisible, + eda_info_exclude_from_bom, + eda_info_exclude_from_board, + eda_info_exclude_from_sim, + eda_info_kicad_symbol, + eda_info_kicad_footprint + FROM __temp__parts + SQL); + + $this->addSql(<<<'SQL' + DROP TABLE __temp__parts + SQL); + $this->addSql(<<<'SQL' + CREATE UNIQUE INDEX UNIQ_6940A7FE3D721C14 ON "parts" (ipn) + SQL); + $this->addSql(<<<'SQL' + CREATE INDEX IDX_6940A7FEEA7100A1 ON "parts" (id_preview_attachment) + SQL); + $this->addSql(<<<'SQL' + CREATE INDEX IDX_6940A7FE5697F554 ON "parts" (id_category) + SQL); + $this->addSql(<<<'SQL' + CREATE INDEX IDX_6940A7FE7E371A10 ON "parts" (id_footprint) + SQL); + $this->addSql(<<<'SQL' + CREATE INDEX IDX_6940A7FE2626CEF9 ON "parts" (id_part_unit) + SQL); + $this->addSql(<<<'SQL' + CREATE INDEX IDX_6940A7FE1ECB93AE ON "parts" (id_manufacturer) + SQL); + $this->addSql(<<<'SQL' + CREATE UNIQUE INDEX UNIQ_6940A7FE81081E9B ON "parts" (order_orderdetails_id) + SQL); + $this->addSql(<<<'SQL' + CREATE UNIQUE INDEX UNIQ_6940A7FEE8AE70D9 ON "parts" (built_project_id) + SQL); + $this->addSql(<<<'SQL' + CREATE INDEX parts_idx_datet_name_last_id_needs ON "parts" (datetime_added, name, last_modified, id, needs_review) + SQL); + $this->addSql(<<<'SQL' + CREATE INDEX parts_idx_name ON "parts" (name) + SQL); + $this->addSql(<<<'SQL' + CREATE INDEX parts_idx_ipn ON "parts" (ipn) + SQL); + + $this->addSql(<<<'SQL' + DROP TABLE "part_custom_states" + SQL); + } + + public function postgreSQLUp(Schema $schema): void + { + $this->addSql(<<<'SQL' + CREATE TABLE "part_custom_states" ( + id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL, + parent_id INT DEFAULT NULL, + id_preview_attachment INT DEFAULT NULL, PRIMARY KEY(id), + name VARCHAR(255) NOT NULL, + comment TEXT NOT NULL, + not_selectable BOOLEAN NOT NULL, + alternative_names TEXT 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 + ) + SQL); + $this->addSql(<<<'SQL' + CREATE INDEX IDX_F552745D727ACA70 ON "part_custom_states" (parent_id) + SQL); + $this->addSql(<<<'SQL' + CREATE INDEX IDX_F552745DEA7100A1 ON "part_custom_states" (id_preview_attachment) + SQL); + $this->addSql(<<<'SQL' + ALTER TABLE "part_custom_states" + ADD CONSTRAINT FK_F552745D727ACA70 + FOREIGN KEY (parent_id) REFERENCES "part_custom_states" (id) NOT DEFERRABLE INITIALLY IMMEDIATE + SQL); + $this->addSql(<<<'SQL' + ALTER TABLE "part_custom_states" + ADD CONSTRAINT FK_F552745DEA7100A1 + FOREIGN KEY (id_preview_attachment) REFERENCES "attachments" (id) ON DELETE SET NULL NOT DEFERRABLE INITIALLY IMMEDIATE + SQL); + + + $this->addSql(<<<'SQL' + ALTER TABLE parts ADD id_part_custom_state INT DEFAULT NULL + SQL); + $this->addSql(<<<'SQL' + ALTER TABLE parts ADD CONSTRAINT FK_6940A7FEA3ED1215 FOREIGN KEY (id_part_custom_state) REFERENCES "part_custom_states" (id) NOT DEFERRABLE INITIALLY IMMEDIATE + SQL); + $this->addSql(<<<'SQL' + CREATE INDEX IDX_6940A7FEA3ED1215 ON parts (id_part_custom_state) + SQL); + } + + public function postgreSQLDown(Schema $schema): void + { + $this->addSql(<<<'SQL' + ALTER TABLE "parts" DROP CONSTRAINT FK_6940A7FEA3ED1215 + SQL); + $this->addSql(<<<'SQL' + DROP INDEX IDX_6940A7FEA3ED1215 + SQL); + $this->addSql(<<<'SQL' + ALTER TABLE "parts" DROP id_part_custom_state + SQL); + + $this->addSql(<<<'SQL' + ALTER TABLE "part_custom_states" DROP CONSTRAINT FK_F552745D727ACA70 + SQL); + $this->addSql(<<<'SQL' + ALTER TABLE "part_custom_states" DROP CONSTRAINT FK_F552745DEA7100A1 + SQL); + $this->addSql(<<<'SQL' + DROP TABLE "part_custom_states" + SQL); + } +} diff --git a/migrations/Version20250325073036.php b/migrations/Version20250325073036.php new file mode 100644 index 000000000..3bae80abc --- /dev/null +++ b/migrations/Version20250325073036.php @@ -0,0 +1,307 @@ +addSql(<<<'SQL' + ALTER TABLE categories ADD COLUMN part_ipn_prefix VARCHAR(255) NOT NULL DEFAULT '' + SQL); + } + + public function mySQLDown(Schema $schema): void + { + $this->addSql(<<<'SQL' + ALTER TABLE categories DROP part_ipn_prefix + SQL); + } + + public function sqLiteUp(Schema $schema): void + { + $this->addSql(<<<'SQL' + CREATE TEMPORARY TABLE __temp__categories AS + SELECT + id, + parent_id, + id_preview_attachment, + partname_hint, + partname_regex, + disable_footprints, + disable_manufacturers, + disable_autodatasheets, + disable_properties, + default_description, + default_comment, + comment, + not_selectable, + name, + last_modified, + datetime_added, + alternative_names, + eda_info_reference_prefix, + eda_info_invisible, + eda_info_exclude_from_bom, + eda_info_exclude_from_board, + eda_info_exclude_from_sim, + eda_info_kicad_symbol + FROM categories + SQL); + + $this->addSql('DROP TABLE categories'); + + $this->addSql(<<<'SQL' + CREATE TABLE categories ( + id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + parent_id INTEGER DEFAULT NULL, + id_preview_attachment INTEGER DEFAULT NULL, + partname_hint CLOB NOT NULL, + partname_regex CLOB NOT NULL, + part_ipn_prefix VARCHAR(255) DEFAULT '' NOT NULL, + disable_footprints BOOLEAN NOT NULL, + disable_manufacturers BOOLEAN NOT NULL, + disable_autodatasheets BOOLEAN NOT NULL, + disable_properties BOOLEAN NOT NULL, + default_description CLOB NOT NULL, + default_comment CLOB 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, + alternative_names CLOB DEFAULT NULL, + eda_info_reference_prefix VARCHAR(255) DEFAULT NULL, + eda_info_invisible BOOLEAN DEFAULT NULL, + eda_info_exclude_from_bom BOOLEAN DEFAULT NULL, + eda_info_exclude_from_board BOOLEAN DEFAULT NULL, + eda_info_exclude_from_sim BOOLEAN DEFAULT NULL, + eda_info_kicad_symbol VARCHAR(255) DEFAULT NULL, + CONSTRAINT FK_3AF34668727ACA70 FOREIGN KEY (parent_id) REFERENCES categories (id) ON UPDATE NO ACTION ON DELETE NO ACTION NOT DEFERRABLE INITIALLY IMMEDIATE, + CONSTRAINT FK_3AF34668EA7100A1 FOREIGN KEY (id_preview_attachment) REFERENCES attachments (id) ON UPDATE NO ACTION ON DELETE SET NULL NOT DEFERRABLE INITIALLY IMMEDIATE + ) + SQL); + + $this->addSql(<<<'SQL' + INSERT INTO categories ( + id, + parent_id, + id_preview_attachment, + partname_hint, + partname_regex, + disable_footprints, + disable_manufacturers, + disable_autodatasheets, + disable_properties, + default_description, + default_comment, + comment, + not_selectable, + name, + last_modified, + datetime_added, + alternative_names, + eda_info_reference_prefix, + eda_info_invisible, + eda_info_exclude_from_bom, + eda_info_exclude_from_board, + eda_info_exclude_from_sim, + eda_info_kicad_symbol + ) SELECT + id, + parent_id, + id_preview_attachment, + partname_hint, + partname_regex, + disable_footprints, + disable_manufacturers, + disable_autodatasheets, + disable_properties, + default_description, + default_comment, + comment, + not_selectable, + name, + last_modified, + datetime_added, + alternative_names, + eda_info_reference_prefix, + eda_info_invisible, + eda_info_exclude_from_bom, + eda_info_exclude_from_board, + eda_info_exclude_from_sim, + eda_info_kicad_symbol + FROM __temp__categories + SQL); + + $this->addSql('DROP TABLE __temp__categories'); + + $this->addSql(<<<'SQL' + CREATE INDEX IDX_3AF34668727ACA70 ON categories (parent_id) + SQL); + $this->addSql(<<<'SQL' + CREATE INDEX IDX_3AF34668EA7100A1 ON categories (id_preview_attachment) + SQL); + $this->addSql(<<<'SQL' + CREATE INDEX category_idx_name ON categories (name) + SQL); + $this->addSql(<<<'SQL' + CREATE INDEX category_idx_parent_name ON categories (parent_id, name) + SQL); + } + + public function sqLiteDown(Schema $schema): void + { + $this->addSql(<<<'SQL' + CREATE TEMPORARY TABLE __temp__categories AS + SELECT + id, + parent_id, + id_preview_attachment, + partname_hint, + partname_regex, + disable_footprints, + disable_manufacturers, + disable_autodatasheets, + disable_properties, + default_description, + default_comment, + comment, + not_selectable, + name, + last_modified, + datetime_added, + alternative_names, + eda_info_reference_prefix, + eda_info_invisible, + eda_info_exclude_from_bom, + eda_info_exclude_from_board, + eda_info_exclude_from_sim, + eda_info_kicad_symbol + FROM categories + SQL); + + $this->addSql('DROP TABLE categories'); + + $this->addSql(<<<'SQL' + CREATE TABLE categories ( + id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + parent_id INTEGER DEFAULT NULL, + id_preview_attachment INTEGER DEFAULT NULL, + partname_hint CLOB NOT NULL, + partname_regex CLOB NOT NULL, + disable_footprints BOOLEAN NOT NULL, + disable_manufacturers BOOLEAN NOT NULL, + disable_autodatasheets BOOLEAN NOT NULL, + disable_properties BOOLEAN NOT NULL, + default_description CLOB NOT NULL, + default_comment CLOB 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, + alternative_names CLOB DEFAULT NULL, + eda_info_reference_prefix VARCHAR(255) DEFAULT NULL, + eda_info_invisible BOOLEAN DEFAULT NULL, + eda_info_exclude_from_bom BOOLEAN DEFAULT NULL, + eda_info_exclude_from_board BOOLEAN DEFAULT NULL, + eda_info_exclude_from_sim BOOLEAN DEFAULT NULL, + eda_info_kicad_symbol VARCHAR(255) DEFAULT NULL, + CONSTRAINT FK_3AF34668727ACA70 FOREIGN KEY (parent_id) REFERENCES categories (id) ON UPDATE NO ACTION ON DELETE NO ACTION NOT DEFERRABLE INITIALLY IMMEDIATE, + CONSTRAINT FK_3AF34668EA7100A1 FOREIGN KEY (id_preview_attachment) REFERENCES attachments (id) ON UPDATE NO ACTION ON DELETE SET NULL NOT DEFERRABLE INITIALLY IMMEDIATE + ) + SQL); + + $this->addSql(<<<'SQL' + INSERT INTO categories ( + id, + parent_id, + id_preview_attachment, + partname_hint, + partname_regex, + disable_footprints, + disable_manufacturers, + disable_autodatasheets, + disable_properties, + default_description, + default_comment, + comment, + not_selectable, + name, + last_modified, + datetime_added, + alternative_names, + eda_info_reference_prefix, + eda_info_invisible, + eda_info_exclude_from_bom, + eda_info_exclude_from_board, + eda_info_exclude_from_sim, + eda_info_kicad_symbol + ) SELECT + id, + parent_id, + id_preview_attachment, + partname_hint, + partname_regex, + disable_footprints, + disable_manufacturers, + disable_autodatasheets, + disable_properties, + default_description, + default_comment, + comment, + not_selectable, + name, + last_modified, + datetime_added, + alternative_names, + eda_info_reference_prefix, + eda_info_invisible, + eda_info_exclude_from_bom, + eda_info_exclude_from_board, + eda_info_exclude_from_sim, + eda_info_kicad_symbol + FROM __temp__categories + SQL); + + $this->addSql('DROP TABLE __temp__categories'); + + $this->addSql(<<<'SQL' + CREATE INDEX IDX_3AF34668727ACA70 ON categories (parent_id) + SQL); + $this->addSql(<<<'SQL' + CREATE INDEX IDX_3AF34668EA7100A1 ON categories (id_preview_attachment) + SQL); + $this->addSql(<<<'SQL' + CREATE INDEX category_idx_name ON categories (name) + SQL); + $this->addSql(<<<'SQL' + CREATE INDEX category_idx_parent_name ON categories (parent_id, name) + SQL); + } + + public function postgreSQLUp(Schema $schema): void + { + $this->addSql(<<<'SQL' + ALTER TABLE categories ADD part_ipn_prefix VARCHAR(255) DEFAULT '' NOT NULL + SQL); + } + + public function postgreSQLDown(Schema $schema): void + { + $this->addSql(<<<'SQL' + ALTER TABLE "categories" DROP part_ipn_prefix + SQL); + } +} diff --git a/migrations/Version20250624095045.php b/migrations/Version20250624095045.php new file mode 100644 index 000000000..d875f1d80 --- /dev/null +++ b/migrations/Version20250624095045.php @@ -0,0 +1,84 @@ +addSql(<<<'SQL' + ALTER TABLE assemblies ADD ipn VARCHAR(100) DEFAULT NULL AFTER status + SQL); + $this->addSql(<<<'SQL' + CREATE UNIQUE INDEX UNIQ_5F3832C03D721C14 ON assemblies (ipn) + SQL); + $this->addSql(<<<'SQL' + CREATE INDEX assembly_idx_ipn ON assemblies (ipn) + SQL); + $this->addSql(<<<'SQL' + ALTER TABLE assembly_bom_entries RENAME INDEX idx_8c74887e2f180363 TO IDX_8C74887E4AD2039E + SQL); + } + + public function mySQLDown(Schema $schema): void + { + $this->addSql(<<<'SQL' + DROP INDEX UNIQ_5F3832C03D721C14 ON assemblies + SQL); + $this->addSql(<<<'SQL' + DROP INDEX assembly_idx_ipn ON assemblies + SQL); + $this->addSql(<<<'SQL' + ALTER TABLE assemblies DROP ipn + SQL); + $this->addSql(<<<'SQL' + ALTER TABLE assembly_bom_entries RENAME INDEX idx_8c74887e4ad2039e TO IDX_8C74887E2F180363 + SQL); + } + + public function sqLiteUp(Schema $schema): void + { + //nothing to do. Done via Version20250304081039 + } + + public function sqLiteDown(Schema $schema): void + { + //nothing to do. Done via Version20250304081039 + } + + public function postgreSQLUp(Schema $schema): void + { + $this->addSql(<<<'SQL' + ALTER TABLE assemblies ADD ipn VARCHAR(100) DEFAULT NULL + SQL); + $this->addSql(<<<'SQL' + CREATE UNIQUE INDEX UNIQ_5F3832C03D721C14 ON assemblies (ipn) + SQL); + $this->addSql(<<<'SQL' + CREATE INDEX assembly_idx_ipn ON assemblies (ipn) + SQL); + } + + public function postgreSQLDown(Schema $schema): void + { + $this->addSql(<<<'SQL' + DROP INDEX UNIQ_5F3832C03D721C14 + SQL); + $this->addSql(<<<'SQL' + DROP INDEX assembly_idx_ipn + SQL); + $this->addSql(<<<'SQL' + ALTER TABLE assemblies DROP ipn + SQL); + } +} diff --git a/migrations/Version20250627130848.php b/migrations/Version20250627130848.php new file mode 100644 index 000000000..6223de13b --- /dev/null +++ b/migrations/Version20250627130848.php @@ -0,0 +1,78 @@ +addSql(<<<'SQL' + ALTER TABLE assembly_bom_entries ADD id_referenced_assembly INT DEFAULT NULL AFTER id_part + 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' + CREATE INDEX IDX_8C74887E22522999 ON assembly_bom_entries (id_referenced_assembly) + SQL); + } + + public function mySQLDown(Schema $schema): void + { + $this->addSql(<<<'SQL' + ALTER TABLE assembly_bom_entries DROP FOREIGN KEY FK_8C74887E22522999 + SQL); + $this->addSql(<<<'SQL' + DROP INDEX IDX_8C74887E22522999 ON assembly_bom_entries + SQL); + $this->addSql(<<<'SQL' + ALTER TABLE assembly_bom_entries DROP id_referenced_assembly + SQL); + } + + public function sqLiteUp(Schema $schema): void + { + //nothing to do. Done via Version20250304081039 + } + + public function sqLiteDown(Schema $schema): void + { + //nothing to do. Done via Version20250304081039 + } + + public function postgreSQLUp(Schema $schema): void + { + $this->addSql(<<<'SQL' + ALTER TABLE assembly_bom_entries ADD id_referenced_assembly INT DEFAULT NULL + 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' + CREATE INDEX IDX_8C74887E22522999 ON assembly_bom_entries (id_referenced_assembly) + SQL); + } + + public function postgreSQLDown(Schema $schema): void + { + $this->addSql(<<<'SQL' + ALTER TABLE assembly_bom_entries DROP CONSTRAINT FK_8C74887E22522999 + SQL); + $this->addSql(<<<'SQL' + DROP INDEX IDX_8C74887E22522999 + SQL); + $this->addSql(<<<'SQL' + ALTER TABLE assembly_bom_entries DROP id_referenced_assembly + SQL); + } +} diff --git a/migrations/Version20250910113423.php b/migrations/Version20250910113423.php new file mode 100644 index 000000000..0e65e4aba --- /dev/null +++ b/migrations/Version20250910113423.php @@ -0,0 +1,51 @@ +addSql('ALTER TABLE assembly_bom_entries DROP FOREIGN KEY `FK_8C74887EF12E799E`'); + $this->addSql('DROP INDEX IDX_8C74887EF12E799E ON assembly_bom_entries'); + $this->addSql('ALTER TABLE assembly_bom_entries DROP id_project'); + } + + public function mySQLDown(Schema $schema): void + { + $this->addSql('ALTER TABLE assembly_bom_entries ADD id_project INT DEFAULT NULL'); + $this->addSql('ALTER TABLE assembly_bom_entries ADD CONSTRAINT `FK_8C74887EF12E799E` FOREIGN KEY (id_project) REFERENCES projects (id)'); + $this->addSql('CREATE INDEX IDX_8C74887EF12E799E ON assembly_bom_entries (id_project)'); + } + + public function sqLiteUp(Schema $schema): void + { + //nothing to do. Already removed from AssemblyBOMEntry and Version20250304081039 + } + + public function sqLiteDown(Schema $schema): void + { + //nothing to do. + } + + public function postgreSQLUp(Schema $schema): void + { + //nothing to do. Already removed from AssemblyBOMEntry and Version20250304081039 + } + + public function postgreSQLDown(Schema $schema): void + { + //nothing to do. + } +} diff --git a/migrations/Version20250929140755.php b/migrations/Version20250929140755.php new file mode 100644 index 000000000..a45dd8fb6 --- /dev/null +++ b/migrations/Version20250929140755.php @@ -0,0 +1,26 @@ +addSql('ALTER TABLE assembly_bom_entries ADD designator LONGTEXT NOT NULL AFTER mountnames'); + } + + public function down(Schema $schema): void + { + $this->addSql('ALTER TABLE assembly_bom_entries DROP designator'); + } +} diff --git a/migrations/Version20251016124311.php b/migrations/Version20251016124311.php new file mode 100644 index 000000000..7e580b660 --- /dev/null +++ b/migrations/Version20251016124311.php @@ -0,0 +1,54 @@ +addSql(<<<'SQL' + ALTER TABLE parts DROP FOREIGN KEY FK_6940A7FECC660B3C + SQL); + $this->addSql(<<<'SQL' + DROP INDEX UNIQ_6940A7FECC660B3C ON parts + SQL); + $this->addSql(<<<'SQL' + ALTER TABLE `parts` DROP built_assembly_id + SQL); + } + + public function mySQLDown(Schema $schema): void + { + // nothing do to, built_assembly_id not required + } + + public function sqLiteUp(Schema $schema): void + { + // nothing do to, built_assembly_id not required, already removed from Version20250304154507 + } + + public function sqLiteDown(Schema $schema): void + { + // nothing do to, built_assembly_id not required, already removed from Version20250304154507 + } + + public function postgreSQLUp(Schema $schema): void + { + // nothing do to, built_assembly_id not required, already removed from Version20250304154507 + } + + public function postgreSQLDown(Schema $schema): void + { + // nothing do to, built_assembly_id not required, already removed from Version20250304154507 + } +} 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/Command/Migrations/ImportPartKeeprCommand.php b/src/Command/Migrations/ImportPartKeeprCommand.php index aee71afe7..429f018d5 100644 --- a/src/Command/Migrations/ImportPartKeeprCommand.php +++ b/src/Command/Migrations/ImportPartKeeprCommand.php @@ -121,6 +121,11 @@ private function doImport(SymfonyStyle $io, array $data): void $count = $this->datastructureImporter->importPartUnits($data); $io->success('Imported '.$count.' measurement units.'); + //Import the custom states + $io->info('Importing custom states...'); + $count = $this->datastructureImporter->importPartCustomStates($data); + $io->success('Imported '.$count.' custom states.'); + //Import manufacturers $io->info('Importing manufacturers...'); $count = $this->datastructureImporter->importManufacturers($data); 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 edc5917ac..d928deb41 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); @@ -286,6 +297,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(); @@ -434,6 +454,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(); @@ -481,4 +505,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/AdminPages/PartCustomStateController.php b/src/Controller/AdminPages/PartCustomStateController.php new file mode 100644 index 000000000..60f63abf3 --- /dev/null +++ b/src/Controller/AdminPages/PartCustomStateController.php @@ -0,0 +1,83 @@ +. + */ + +declare(strict_types=1); + +namespace App\Controller\AdminPages; + +use App\Entity\Attachments\PartCustomStateAttachment; +use App\Entity\Parameters\PartCustomStateParameter; +use App\Entity\Parts\PartCustomState; +use App\Form\AdminPages\PartCustomStateAdminForm; +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; + +/** + * @see \App\Tests\Controller\AdminPages\PartCustomStateControllerTest + */ +#[Route(path: '/part_custom_state')] +class PartCustomStateController extends BaseAdminController +{ + protected string $entity_class = PartCustomState::class; + protected string $twig_template = 'admin/part_custom_state_admin.html.twig'; + protected string $form_class = PartCustomStateAdminForm::class; + protected string $route_base = 'part_custom_state'; + protected string $attachment_class = PartCustomStateAttachment::class; + protected ?string $parameter_class = PartCustomStateParameter::class; + + #[Route(path: '/{id}', name: 'part_custom_state_delete', methods: ['DELETE'])] + public function delete(Request $request, PartCustomState $entity, StructuralElementRecursionHelper $recursionHelper): RedirectResponse + { + return $this->_delete($request, $entity, $recursionHelper); + } + + #[Route(path: '/{id}/edit/{timestamp}', name: 'part_custom_state_edit', requirements: ['id' => '\d+'])] + #[Route(path: '/{id}', requirements: ['id' => '\d+'])] + public function edit(PartCustomState $entity, Request $request, EntityManagerInterface $em, ?string $timestamp = null): Response + { + return $this->_edit($entity, $request, $em, $timestamp); + } + + #[Route(path: '/new', name: 'part_custom_state_new')] + #[Route(path: '/{id}/clone', name: 'part_custom_state_clone')] + #[Route(path: '/')] + public function new(Request $request, EntityManagerInterface $em, EntityImporter $importer, ?PartCustomState $entity = null): Response + { + return $this->_new($request, $em, $importer, $entity); + } + + #[Route(path: '/export', name: 'part_custom_state_export_all')] + public function exportAll(EntityManagerInterface $em, EntityExporter $exporter, Request $request): Response + { + return $this->_exportAll($em, $exporter, $request); + } + + #[Route(path: '/{id}/export', name: 'part_custom_state_export')] + public function exportEntity(PartCustomState $entity, EntityExporter $exporter, Request $request): Response + { + return $this->_exportEntity($entity, $exporter, $request); + } +} diff --git a/src/Controller/AssemblyController.php b/src/Controller/AssemblyController.php new file mode 100644 index 000000000..be97045b5 --- /dev/null +++ b/src/Controller/AssemblyController.php @@ -0,0 +1,318 @@ +. + */ +namespace App\Controller; + +use App\DataTables\AssemblyBomEntriesDataTable; +use App\DataTables\AssemblyDataTable; +use App\DataTables\ErrorDataTable; +use App\DataTables\Filters\AssemblyFilter; +use App\Entity\AssemblySystem\Assembly; +use App\Entity\AssemblySystem\AssemblyBOMEntry; +use App\Entity\Parts\Part; +use App\Exceptions\InvalidRegexException; +use App\Form\AssemblySystem\AssemblyAddPartsType; +use App\Form\Filters\AssemblyFilterType; +use App\Services\ImportExportSystem\BOMImporter; +use App\Services\Trees\NodesListBuilder; +use Doctrine\Common\Collections\ArrayCollection; +use Doctrine\DBAL\Exception\DriverException; +use Doctrine\ORM\EntityManagerInterface; +use League\Csv\SyntaxError; +use Omines\DataTablesBundle\DataTableFactory; +use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; +use Symfony\Component\Form\Extension\Core\Type\CheckboxType; +use Symfony\Component\Form\Extension\Core\Type\ChoiceType; +use Symfony\Component\Form\Extension\Core\Type\FileType; +use Symfony\Component\Form\Extension\Core\Type\SubmitType; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\Routing\Attribute\Route; +use Symfony\Component\Validator\Validator\ValidatorInterface; + +use Symfony\Contracts\Translation\TranslatorInterface; +use function Symfony\Component\Translation\t; + +#[Route(path: '/assembly')] +class AssemblyController extends AbstractController +{ + public function __construct( + private readonly DataTableFactory $dataTableFactory, + private readonly TranslatorInterface $translator, + private readonly NodesListBuilder $nodesListBuilder + ) { + } + + #[Route(path: '/list', name: 'assemblies_list')] + public function showAll(Request $request): Response + { + return $this->showListWithFilter($request,'assemblies/lists/all_list.html.twig'); + } + + /** + * Common implementation for the part list pages. + * @param Request $request The request to parse + * @param string $template The template that should be rendered + * @param callable|null $filter_changer A function that is called with the filter object as parameter. This function can be used to customize the filter + * @param callable|null $form_changer A function that is called with the form object as parameter. This function can be used to customize the form + * @param array $additonal_template_vars Any additional template variables that should be passed to the template + * @param array $additional_table_vars Any additional variables that should be passed to the table creation + */ + protected function showListWithFilter(Request $request, string $template, ?callable $filter_changer = null, ?callable $form_changer = null, array $additonal_template_vars = [], array $additional_table_vars = []): Response + { + $this->denyAccessUnlessGranted('@assemblies.read'); + + $formRequest = clone $request; + $formRequest->setMethod('GET'); + $filter = new AssemblyFilter($this->nodesListBuilder); + if($filter_changer !== null){ + $filter_changer($filter); + } + + $filterForm = $this->createForm(AssemblyFilterType::class, $filter, ['method' => 'GET']); + if($form_changer !== null) { + $form_changer($filterForm); + } + + $filterForm->handleRequest($formRequest); + + $table = $this->dataTableFactory->createFromType( + AssemblyDataTable::class, + array_merge(['filter' => $filter], $additional_table_vars), + ['lengthMenu' => AssemblyDataTable::LENGTH_MENU] + ) + ->handleRequest($request); + + if ($table->isCallback()) { + try { + try { + return $table->getResponse(); + } catch (DriverException $driverException) { + if ($driverException->getCode() === 1139) { + //Convert the driver exception to InvalidRegexException so it has the same handler as for SQLite + throw InvalidRegexException::fromDriverException($driverException); + } else { + throw $driverException; + } + } + } catch (InvalidRegexException $exception) { + $errors = $this->translator->trans('assembly.table.invalid_regex').': '.$exception->getReason(); + $request->request->set('order', []); + + return ErrorDataTable::errorTable($this->dataTableFactory, $request, $errors); + } + } + + return $this->render($template, array_merge([ + 'datatable' => $table, + 'filterForm' => $filterForm->createView(), + ], $additonal_template_vars)); + } + + #[Route(path: '/{id}/info', name: 'assembly_info', requirements: ['id' => '\d+'])] + public function info(Assembly $assembly, Request $request): Response + { + $this->denyAccessUnlessGranted('read', $assembly); + + $table = $this->dataTableFactory->createFromType(AssemblyBomEntriesDataTable::class, ['assembly' => $assembly]) + ->handleRequest($request); + + if ($table->isCallback()) { + return $table->getResponse(); + } + + return $this->render('assemblies/info/info.html.twig', [ + 'datatable' => $table, + 'assembly' => $assembly, + ]); + } + + #[Route(path: '/{id}/import_bom', name: 'assembly_import_bom', requirements: ['id' => '\d+'])] + public function importBOM(Request $request, EntityManagerInterface $entityManager, Assembly $assembly, + BOMImporter $BOMImporter, ValidatorInterface $validator): Response + { + $this->denyAccessUnlessGranted('edit', $assembly); + + $builder = $this->createFormBuilder(); + $builder->add('file', FileType::class, [ + 'label' => 'import.file', + 'required' => true, + 'attr' => [ + 'accept' => '.csv, .json' + ] + ]); + $builder->add('type', ChoiceType::class, [ + 'label' => 'assembly.bom_import.type', + 'required' => true, + 'choices' => [ + 'assembly.bom_import.type.json' => 'json', + 'assembly.bom_import.type.csv' => 'csv', + 'assembly.bom_import.type.kicad_pcbnew' => 'kicad_pcbnew', + 'assembly.bom_import.type.kicad_schematic' => 'kicad_schematic', + ] + ]); + $builder->add('clear_existing_bom', CheckboxType::class, [ + 'label' => 'assembly.bom_import.clear_existing_bom', + 'required' => false, + 'data' => false, + 'help' => 'assembly.bom_import.clear_existing_bom.help', + ]); + $builder->add('submit', SubmitType::class, [ + 'label' => 'import.btn', + ]); + + $form = $builder->getForm(); + + $form->handleRequest($request); + if ($form->isSubmitted() && $form->isValid()) { + // Clear existing entries if requested + if ($form->get('clear_existing_bom')->getData()) { + $assembly->getBomEntries()->clear(); + $entityManager->flush(); + } + + try { + $importerResult = $BOMImporter->importFileIntoAssembly($form->get('file')->getData(), $assembly, [ + 'type' => $form->get('type')->getData(), + ]); + + //Validate the assembly entries + $errors = $validator->validateProperty($assembly, 'bom_entries'); + + //If no validation errors occured, save the changes and redirect to edit page + if (count ($errors) === 0 && $importerResult->getViolations()->count() === 0) { + $entries = $importerResult->getBomEntries(); + + $this->addFlash('success', t('assembly.bom_import.flash.success', ['%count%' => count($entries)])); + $entityManager->flush(); + + return $this->redirectToRoute('assembly_edit', ['id' => $assembly->getID()]); + } + + //Show validation errors + $this->addFlash('error', t('assembly.bom_import.flash.invalid_entries')); + } catch (\UnexpectedValueException|\RuntimeException|SyntaxError $e) { + $this->addFlash('error', t('assembly.bom_import.flash.invalid_file', ['%message%' => $e->getMessage()])); + } + } + + $jsonTemplate = [ + [ + "quantity" => 1.0, + "name" => $this->translator->trans('assembly.bom_import.template.entry.name'), + "part" => [ + "id" => null, + "ipn" => $this->translator->trans('assembly.bom_import.template.entry.part.ipn'), + "mpnr" => $this->translator->trans('assembly.bom_import.template.entry.part.mpnr'), + "name" => $this->translator->trans('assembly.bom_import.template.entry.part.name'), + "description" => null, + "manufacturer" => [ + "id" => null, + "name" => $this->translator->trans('assembly.bom_import.template.entry.part.manufacturer.name') + ], + "category" => [ + "id" => null, + "name" => $this->translator->trans('assembly.bom_import.template.entry.part.category.name') + ] + ] + ] + ]; + + return $this->render('assemblies/import_bom.html.twig', [ + 'assembly' => $assembly, + 'jsonTemplate' => $jsonTemplate, + 'form' => $form, + 'validationErrors' => $errors ?? null, + 'importerErrors' => isset($importerResult) ? $importerResult->getViolations() : null, + ]); + } + + #[Route(path: '/add_parts', name: 'assembly_add_parts_no_id')] + #[Route(path: '/{id}/add_parts', name: 'assembly_add_parts', requirements: ['id' => '\d+'])] + public function addPart(Request $request, EntityManagerInterface $entityManager, ?Assembly $assembly): Response + { + if($assembly instanceof Assembly) { + $this->denyAccessUnlessGranted('edit', $assembly); + } else { + $this->denyAccessUnlessGranted('@assemblies.edit'); + } + + $form = $this->createForm(AssemblyAddPartsType::class, null, [ + 'assembly' => $assembly, + ]); + + //Preset the BOM entries with the selected parts, when the form was not submitted yet + $preset_data = new ArrayCollection(); + foreach (explode(',', (string) $request->get('parts', '')) as $part_id) { + //Skip empty part IDs. Postgres seems to be especially sensitive to empty strings, as it does not allow them in integer columns + if ($part_id === '') { + continue; + } + + $part = $entityManager->getRepository(Part::class)->find($part_id); + if (null !== $part) { + //If there is already a BOM entry for this part, we use this one (we edit it then) + $bom_entry = $entityManager->getRepository(AssemblyBOMEntry::class)->findOneBy([ + 'assembly' => $assembly, + 'part' => $part + ]); + if ($bom_entry !== null) { + $preset_data->add($bom_entry); + } else { //Otherwise create an empty one + $entry = new AssemblyBOMEntry(); + $entry->setAssembly($assembly); + $entry->setPart($part); + $preset_data->add($entry); + } + } + } + $form['bom_entries']->setData($preset_data); + + $form->handleRequest($request); + if ($form->isSubmitted() && $form->isValid()) { + $target_assembly = $assembly ?? $form->get('assembly')->getData(); + + //Ensure that we really have acces to the selected assembly + $this->denyAccessUnlessGranted('edit', $target_assembly); + + $data = $form->getData(); + $bom_entries = $data['bom_entries']; + foreach ($bom_entries as $bom_entry){ + $target_assembly->addBOMEntry($bom_entry); + } + + $entityManager->flush(); + + //If a redirect query parameter is set, redirect to this page + if ($request->query->get('_redirect')) { + return $this->redirect($request->query->get('_redirect')); + } + //Otherwise just show the assembly info page + return $this->redirectToRoute('assembly_info', ['id' => $target_assembly->getID()]); + } + + return $this->render('assemblies/add_parts.html.twig', [ + 'assembly' => $assembly, + 'form' => $form, + ]); + } +} diff --git a/src/Controller/PartController.php b/src/Controller/PartController.php index aeb2664ed..e5c64a111 100644 --- a/src/Controller/PartController.php +++ b/src/Controller/PartController.php @@ -33,6 +33,7 @@ use App\Entity\Parts\Supplier; use App\Entity\PriceInformations\Orderdetail; use App\Entity\ProjectSystem\Project; +use App\Entity\AssemblySystem\Assembly; use App\Exceptions\AttachmentDownloadException; use App\Form\Part\PartBaseType; use App\Services\Attachments\AttachmentSubmitHandler; @@ -47,6 +48,7 @@ use App\Services\Parts\PricedetailHelper; use App\Services\ProjectSystem\ProjectBuildPartHelper; use App\Settings\BehaviorSettings\PartInfoSettings; +use App\Settings\MiscSettings\IpnSuggestSettings; use DateTime; use Doctrine\ORM\EntityManagerInterface; use Exception; @@ -74,6 +76,7 @@ public function __construct( private readonly EntityManagerInterface $em, private readonly EventCommentHelper $commentHelper, private readonly PartInfoSettings $partInfoSettings, + private readonly IpnSuggestSettings $ipnSuggestSettings, ) { } @@ -204,7 +207,7 @@ public function delete(Request $request, Part $part): RedirectResponse #[Route(path: '/new', name: 'part_new')] #[Route(path: '/{id}/clone', name: 'part_clone')] - #[Route(path: '/new_build_part/{project_id}', name: 'part_new_build_part')] + #[Route(path: '/new_build_part_project/{project_id}', name: 'part_new_build_part_project')] public function new( Request $request, EntityManagerInterface $em, @@ -444,10 +447,13 @@ private function renderPartForm(string $mode, Request $request, Part $data, arra $template = 'parts/edit/update_from_ip.html.twig'; } + $partRepository = $this->em->getRepository(Part::class); + return $this->render( $template, [ 'part' => $new_part, + 'ipnSuggestions' => $partRepository->autoCompleteIpn($data, $data->getDescription(), $this->ipnSuggestSettings->suggestPartDigits), 'form' => $form, 'merge_old_name' => $merge_infos['tname_before'] ?? null, 'merge_other' => $merge_infos['other_part'] ?? null, @@ -457,7 +463,6 @@ private function renderPartForm(string $mode, Request $request, Part $data, arra ); } - #[Route(path: '/{id}/add_withdraw', name: 'part_add_withdraw', methods: ['POST'])] public function withdrawAddHandler(Part $part, Request $request, EntityManagerInterface $em, PartLotWithdrawAddHelper $withdrawAddHelper): Response { diff --git a/src/Controller/ProjectController.php b/src/Controller/ProjectController.php index 2a6d19ee2..e510506f3 100644 --- a/src/Controller/ProjectController.php +++ b/src/Controller/ProjectController.php @@ -46,14 +46,16 @@ use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Routing\Attribute\Route; use Symfony\Component\Validator\Validator\ValidatorInterface; - +use Symfony\Contracts\Translation\TranslatorInterface; use function Symfony\Component\Translation\t; #[Route(path: '/project')] class ProjectController extends AbstractController { - public function __construct(private readonly DataTableFactory $dataTableFactory) - { + public function __construct( + private readonly DataTableFactory $dataTableFactory, + private readonly TranslatorInterface $translator, + ) { } #[Route(path: '/{id}/info', name: 'project_info', requirements: ['id' => '\d+'])] @@ -147,6 +149,8 @@ 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 +193,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 +218,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 +421,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/ToolsController.php b/src/Controller/ToolsController.php index d78aff620..5d3536155 100644 --- a/src/Controller/ToolsController.php +++ b/src/Controller/ToolsController.php @@ -61,7 +61,7 @@ public function systemInfos(GitVersionInfo $versionInfo, DBInfoHelper $DBInfoHel 'default_timezone' => $settings->system->localization->timezone, 'default_currency' => $settings->system->localization->baseCurrency, 'default_theme' => $settings->system->customization->theme, - 'enabled_locales' => $this->getParameter('partdb.locale_menu'), + 'enabled_locales' => array_column($settings->system->localization->preferredLanguages, 'value'), 'demo_mode' => $this->getParameter('partdb.demo_mode'), 'use_gravatar' => $settings->system->privacy->useGravatar, 'gdpr_compliance' => $this->getParameter('partdb.gdpr_compliance'), 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 89eac7ff7..efa17bb03 100644 --- a/src/Controller/TypeaheadController.php +++ b/src/Controller/TypeaheadController.php @@ -22,7 +22,10 @@ namespace App\Controller; +use App\Entity\AssemblySystem\Assembly; use App\Entity\Parameters\AbstractParameter; +use App\Settings\MiscSettings\IpnSuggestSettings; +use App\Services\Attachments\AssemblyPreviewGenerator; use Symfony\Component\HttpFoundation\Response; use App\Entity\Attachments\Attachment; use App\Entity\Parts\Category; @@ -53,6 +56,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. @@ -60,8 +64,11 @@ #[Route(path: '/typeahead')] class TypeaheadController extends AbstractController { - public function __construct(protected AttachmentURLGenerator $urlGenerator, protected Packages $assets) - { + public function __construct( + protected AttachmentURLGenerator $urlGenerator, + protected Packages $assets, + protected IpnSuggestSettings $ipnSuggestSettings, + ) { } #[Route(path: '/builtInResources/search', name: 'typeahead_builtInRessources')] @@ -109,19 +116,22 @@ 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, + AttachmentURLGenerator $attachmentURLGenerator, + string $query = "" + ): JsonResponse { $this->denyAccessUnlessGranted('@parts.read'); - $repo = $entityManager->getRepository(Part::class); + $partRepository = $entityManager->getRepository(Part::class); - $parts = $repo->autocompleteSearch($query, 100); + $parts = $partRepository->autocompleteSearch($query, 100); $data = []; foreach ($parts as $part) { @@ -141,12 +151,50 @@ public function parts(EntityManagerInterface $entityManager, PartPreviewGenerato 'footprint' => $part->getFootprint() instanceof Footprint ? $part->getFootprint()->getName() : '', 'description' => mb_strimwidth($part->getDescription(), 0, 127, '...'), 'image' => $preview_url, - ]; + ]; } return new JsonResponse($data); } + #[Route(path: '/assemblies/search/{query}', name: 'typeahead_assemblies')] + public function assemblies( + EntityManagerInterface $entityManager, + AssemblyPreviewGenerator $assemblyPreviewGenerator, + AttachmentURLGenerator $attachmentURLGenerator, + string $query = "" + ): JsonResponse { + $this->denyAccessUnlessGranted('@assemblies.read'); + + $result = []; + + $assemblyRepository = $entityManager->getRepository(Assembly::class); + + $assemblies = $assemblyRepository->autocompleteSearch($query, 100); + + foreach ($assemblies as $assembly) { + $preview_attachment = $assemblyPreviewGenerator->getTablePreviewAttachment($assembly); + + if($preview_attachment instanceof Attachment) { + $preview_url = $attachmentURLGenerator->getThumbnailURL($preview_attachment, 'thumbnail_sm'); + } else { + $preview_url = ''; + } + + /** @var Assembly $assembly */ + $result[] = [ + 'id' => $assembly->getID(), + 'name' => $assembly->getName(), + 'category' => '', + 'footprint' => '', + 'description' => mb_strimwidth($assembly->getDescription(), 0, 127, '...'), + 'image' => $preview_url, + ]; + } + + return new JsonResponse($result); + } + #[Route(path: '/parameters/{type}/search/{query}', name: 'typeahead_parameters', requirements: ['type' => '.+'])] public function parameters(string $type, EntityManagerInterface $entityManager, string $query = ""): JsonResponse { @@ -183,4 +231,30 @@ public function tags(string $query, TagFinder $finder): JsonResponse return new JsonResponse($data, Response::HTTP_OK, [], true); } + + #[Route(path: '/parts/ipn-suggestions', name: 'ipn_suggestions', methods: ['GET'])] + public function ipnSuggestions( + Request $request, + EntityManagerInterface $entityManager + ): JsonResponse { + $partId = $request->query->get('partId'); + if ($partId === '0' || $partId === 'undefined' || $partId === 'null') { + $partId = null; + } + $categoryId = $request->query->getInt('categoryId'); + $description = base64_decode($request->query->getString('description'), true); + + /** @var Part $part */ + $part = $partId !== null ? $entityManager->getRepository(Part::class)->find($partId) : new Part(); + /** @var Category|null $category */ + $category = $entityManager->getRepository(Category::class)->find($categoryId); + + $clonedPart = clone $part; + $clonedPart->setCategory($category); + + $partRepository = $entityManager->getRepository(Part::class); + $ipnSuggestions = $partRepository->autoCompleteIpn($clonedPart, $description, $this->ipnSuggestSettings->suggestPartDigits); + + return new JsonResponse($ipnSuggestions); + } } diff --git a/src/DataFixtures/DataStructureFixtures.php b/src/DataFixtures/DataStructureFixtures.php index fc713d4df..9c6853384 100644 --- a/src/DataFixtures/DataStructureFixtures.php +++ b/src/DataFixtures/DataStructureFixtures.php @@ -24,6 +24,7 @@ use App\Entity\Attachments\AttachmentType; use App\Entity\Base\AbstractStructuralDBElement; +use App\Entity\Parts\PartCustomState; use App\Entity\ProjectSystem\Project; use App\Entity\Parts\Category; use App\Entity\Parts\Footprint; @@ -50,7 +51,7 @@ public function load(ObjectManager $manager): void { //Reset autoincrement $types = [AttachmentType::class, Project::class, Category::class, Footprint::class, Manufacturer::class, - MeasurementUnit::class, StorageLocation::class, Supplier::class,]; + MeasurementUnit::class, StorageLocation::class, Supplier::class, PartCustomState::class]; foreach ($types as $type) { $this->createNodesForClass($type, $manager); diff --git a/src/DataTables/AssemblyBomEntriesDataTable.php b/src/DataTables/AssemblyBomEntriesDataTable.php new file mode 100644 index 000000000..90924cfe1 --- /dev/null +++ b/src/DataTables/AssemblyBomEntriesDataTable.php @@ -0,0 +1,241 @@ +. + */ +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'), + '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', + '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..aaad2e45e --- /dev/null +++ b/src/DataTables/AssemblyDataTable.php @@ -0,0 +1,250 @@ +. + */ + +declare(strict_types=1); + +namespace App\DataTables; + +use App\DataTables\Adapters\TwoStepORMAdapter; +use App\DataTables\Column\IconLinkColumn; +use App\DataTables\Column\LocaleDateTimeColumn; +use App\DataTables\Column\MarkdownColumn; +use App\DataTables\Column\SelectColumn; +use App\DataTables\Filters\AssemblyFilter; +use App\DataTables\Filters\AssemblySearchFilter; +use App\DataTables\Helpers\AssemblyDataTableHelper; +use App\DataTables\Helpers\ColumnSortHelper; +use App\Doctrine\Helpers\FieldHelper; +use App\Entity\AssemblySystem\Assembly; +use App\Services\EntityURLGenerator; +use App\Settings\BehaviorSettings\TableSettings; +use Doctrine\ORM\AbstractQuery; +use Doctrine\ORM\QueryBuilder; +use Omines\DataTablesBundle\Adapter\Doctrine\ORM\SearchCriteriaProvider; +use Omines\DataTablesBundle\Column\TextColumn; +use Omines\DataTablesBundle\DataTable; +use Omines\DataTablesBundle\DataTableTypeInterface; +use Symfony\Bundle\SecurityBundle\Security; +use Symfony\Component\OptionsResolver\OptionsResolver; +use Symfony\Contracts\Translation\TranslatorInterface; + +final class AssemblyDataTable implements DataTableTypeInterface +{ + const LENGTH_MENU = [[10, 25, 50, 100, -1], [10, 25, 50, 100, "All"]]; + + public function __construct( + private readonly EntityURLGenerator $urlGenerator, + private readonly TranslatorInterface $translator, + private readonly AssemblyDataTableHelper $assemblyDataTableHelper, + private readonly Security $security, + private readonly ColumnSortHelper $csh, + private readonly TableSettings $tableSettings, + ) { + } + + public function configureOptions(OptionsResolver $optionsResolver): void + { + $optionsResolver->setDefaults([ + 'filter' => null, + 'search' => null + ]); + + $optionsResolver->setAllowedTypes('filter', [AssemblyFilter::class, 'null']); + $optionsResolver->setAllowedTypes('search', [AssemblySearchFilter::class, 'null']); + } + + public function configure(DataTable $dataTable, array $options): void + { + $resolver = new OptionsResolver(); + $this->configureOptions($resolver); + $options = $resolver->resolve($options); + + $this->csh + ->add('select', SelectColumn::class, visibility_configurable: false) + ->add('picture', TextColumn::class, [ + 'label' => '', + 'className' => 'no-colvis', + 'render' => fn($value, Assembly $context) => $this->assemblyDataTableHelper->renderPicture($context), + ], visibility_configurable: false) + ->add('name', TextColumn::class, [ + 'label' => $this->translator->trans('assembly.table.name'), + 'render' => fn($value, Assembly $context) => $this->assemblyDataTableHelper->renderName($context), + 'orderField' => 'NATSORT(assembly.name)' + ]) + ->add('id', TextColumn::class, [ + 'label' => $this->translator->trans('assembly.table.id'), + ]) + ->add('ipn', TextColumn::class, [ + 'label' => $this->translator->trans('assembly.table.ipn'), + 'orderField' => 'NATSORT(assembly.ipn)' + ]) + ->add('description', MarkdownColumn::class, [ + 'label' => $this->translator->trans('assembly.table.description'), + ]) + ->add('addedDate', LocaleDateTimeColumn::class, [ + 'label' => $this->translator->trans('assembly.table.addedDate'), + ]) + ->add('lastModified', LocaleDateTimeColumn::class, [ + 'label' => $this->translator->trans('assembly.table.lastModified'), + ]); + + //Add a assembly column to list where the assembly is used as referenced assembly as bom-entry, when the user has the permission to see the assemblies + if ($this->security->isGranted('read', Assembly::class)) { + $this->csh->add('referencedAssemblies', TextColumn::class, [ + 'label' => $this->translator->trans('assembly.referencedAssembly.labelp'), + 'render' => function ($value, Assembly $context): string { + $assemblies = $context->getAllReferencedAssembliesRecursive($context); + + $max = 5; + $tmp = ""; + + for ($i = 0; $i < min($max, count($assemblies)); $i++) { + $tmp .= $this->assemblyDataTableHelper->renderName($assemblies[$i]); + if ($i < count($assemblies) - 1) { + $tmp .= ", "; + } + } + + if (count($assemblies) > $max) { + $tmp .= ", + ".(count($assemblies) - $max); + } + + return $tmp; + } + ]); + } + + $this->csh + ->add('edit', IconLinkColumn::class, [ + 'label' => $this->translator->trans('assembly.table.edit'), + 'href' => fn($value, Assembly $context) => $this->urlGenerator->editURL($context), + 'disabled' => fn($value, Assembly $context) => !$this->security->isGranted('edit', $context), + 'title' => $this->translator->trans('assembly.table.edit.title'), + ]); + + //Apply the user configured order and visibility and add the columns to the table + $this->csh->applyVisibilityAndConfigureColumns($dataTable, $this->tableSettings->assembliesDefaultColumns, + "TABLE_ASSEMBLIES_DEFAULT_COLUMNS"); + + $dataTable->addOrderBy('name') + ->createAdapter(TwoStepORMAdapter::class, [ + 'filter_query' => $this->getFilterQuery(...), + 'detail_query' => $this->getDetailQuery(...), + 'entity' => Assembly::class, + 'hydrate' => AbstractQuery::HYDRATE_OBJECT, + //Use the simple total query, as we just want to get the total number of assemblies without any conditions + //For this the normal query would be pretty slow + 'simple_total_query' => true, + 'criteria' => [ + function (QueryBuilder $builder) use ($options): void { + $this->buildCriteria($builder, $options); + }, + new SearchCriteriaProvider(), + ], + 'query_modifier' => $this->addJoins(...), + ]); + } + + + private function getFilterQuery(QueryBuilder $builder): void + { + /* In the filter query we only select the IDs. The fetching of the full entities is done in the detail query. + * We only need to join the entities here, so we can filter by them. + * The filter conditions are added to this QB in the buildCriteria method. + * + * The amountSum field and the joins are dynamically added by the addJoins method, if the fields are used in the query. + * This improves the performance, as we do not need to join all tables, if we do not need them. + */ + $builder + ->select('assembly.id') + ->from(Assembly::class, 'assembly') + + //The other group by fields, are dynamically added by the addJoins method + ->addGroupBy('assembly'); + } + + private function getDetailQuery(QueryBuilder $builder, array $filter_results): void + { + $ids = array_map(static fn($row) => $row['id'], $filter_results); + + /* + * In this query we take the IDs which were filtered, paginated and sorted in the filter query, and fetch the + * full entities. + * We can do complex fetch joins, as we do not need to filter or sort here (which would kill the performance). + * The only condition should be for the IDs. + * It is important that elements are ordered the same way, as the IDs are passed, or ordering will be wrong. + * + * We do not require the subqueries like amountSum here, as it is not used to render the table (and only for sorting) + */ + $builder + ->select('assembly') + ->addSelect('master_picture_attachment') + ->addSelect('attachments') + ->from(Assembly::class, 'assembly') + ->leftJoin('assembly.master_picture_attachment', 'master_picture_attachment') + ->leftJoin('assembly.attachments', 'attachments') + ->where('assembly.id IN (:ids)') + ->setParameter('ids', $ids) + ->addGroupBy('assembly') + ->addGroupBy('master_picture_attachment') + ->addGroupBy('attachments'); + + //Get the results in the same order as the IDs were passed + FieldHelper::addOrderByFieldParam($builder, 'assembly.id', 'ids'); + } + + /** + * This function is called right before the filter query is executed. + * We use it to dynamically add joins to the query, if the fields are used in the query. + * @param QueryBuilder $builder + * @return QueryBuilder + */ + private function addJoins(QueryBuilder $builder): QueryBuilder + { + //Check if the query contains certain conditions, for which we need to add additional joins + //The join fields get prefixed with an underscore, so we can check if they are used in the query easy without confusing them for a assembly subfield + $dql = $builder->getDQL(); + + if (str_contains($dql, '_master_picture_attachment')) { + $builder->leftJoin('assembly.master_picture_attachment', '_master_picture_attachment'); + $builder->addGroupBy('_master_picture_attachment'); + } + if (str_contains($dql, '_attachments')) { + $builder->leftJoin('assembly.attachments', '_attachments'); + } + + return $builder; + } + + private function buildCriteria(QueryBuilder $builder, array $options): void + { + //Apply the search criterias first + if ($options['search'] instanceof AssemblySearchFilter) { + $search = $options['search']; + $search->apply($builder); + } + + //We do the most stuff here in the filter class + if ($options['filter'] instanceof AssemblyFilter) { + $filter = $options['filter']; + $filter->apply($builder); + } + } +} diff --git a/src/DataTables/Filters/AssemblyFilter.php b/src/DataTables/Filters/AssemblyFilter.php new file mode 100644 index 000000000..d8d07a1ec --- /dev/null +++ b/src/DataTables/Filters/AssemblyFilter.php @@ -0,0 +1,68 @@ +. + */ +namespace App\DataTables\Filters; + +use App\DataTables\Filters\Constraints\DateTimeConstraint; +use App\DataTables\Filters\Constraints\EntityConstraint; +use App\DataTables\Filters\Constraints\IntConstraint; +use App\DataTables\Filters\Constraints\TextConstraint; +use App\Entity\Attachments\AttachmentType; +use App\Services\Trees\NodesListBuilder; +use Doctrine\ORM\QueryBuilder; + +class AssemblyFilter implements FilterInterface +{ + + use CompoundFilterTrait; + + public readonly IntConstraint $dbId; + public readonly TextConstraint $ipn; + public readonly TextConstraint $name; + public readonly TextConstraint $description; + public readonly TextConstraint $comment; + public readonly DateTimeConstraint $lastModified; + public readonly DateTimeConstraint $addedDate; + + public readonly IntConstraint $attachmentsCount; + public readonly EntityConstraint $attachmentType; + public readonly TextConstraint $attachmentName; + + public function __construct(NodesListBuilder $nodesListBuilder) + { + $this->name = new TextConstraint('assembly.name'); + $this->description = new TextConstraint('assembly.description'); + $this->comment = new TextConstraint('assembly.comment'); + $this->dbId = new IntConstraint('assembly.id'); + $this->ipn = new TextConstraint('assembly.ipn'); + $this->addedDate = new DateTimeConstraint('assembly.addedDate'); + $this->lastModified = new DateTimeConstraint('assembly.lastModified'); + $this->attachmentsCount = new IntConstraint('COUNT(_attachments)'); + $this->attachmentType = new EntityConstraint($nodesListBuilder, AttachmentType::class, '_attachments.attachment_type'); + $this->attachmentName = new TextConstraint('_attachments.name'); + } + + public function apply(QueryBuilder $queryBuilder): void + { + $this->applyAllChildFilters($queryBuilder); + } +} diff --git a/src/DataTables/Filters/AssemblySearchFilter.php b/src/DataTables/Filters/AssemblySearchFilter.php new file mode 100644 index 000000000..2ab33c839 --- /dev/null +++ b/src/DataTables/Filters/AssemblySearchFilter.php @@ -0,0 +1,172 @@ +. + */ +namespace App\DataTables\Filters; +use Doctrine\ORM\QueryBuilder; + +class AssemblySearchFilter implements FilterInterface +{ + + /** @var boolean Whether to use regex for searching */ + protected bool $regex = false; + + /** @var bool Use name field for searching */ + protected bool $name = true; + + /** @var bool Use description for searching */ + protected bool $description = true; + + /** @var bool Use comment field for searching */ + protected bool $comment = true; + + /** @var bool Use ordernr for searching */ + protected bool $ordernr = true; + + /** @var bool Use Internal part number for searching */ + protected bool $ipn = true; + + public function __construct( + /** @var string The string to query for */ + protected string $keyword + ) + { + } + + protected function getFieldsToSearch(): array + { + $fields_to_search = []; + + if($this->name) { + $fields_to_search[] = 'assembly.name'; + } + if($this->description) { + $fields_to_search[] = 'assembly.description'; + } + if ($this->comment) { + $fields_to_search[] = 'assembly.comment'; + } + if ($this->ipn) { + $fields_to_search[] = 'assembly.ipn'; + } + + return $fields_to_search; + } + + public function apply(QueryBuilder $queryBuilder): void + { + $fields_to_search = $this->getFieldsToSearch(); + + //If we have nothing to search for, do nothing + if ($fields_to_search === [] || $this->keyword === '') { + return; + } + + //Convert the fields to search to a list of expressions + $expressions = array_map(function (string $field): string { + if ($this->regex) { + return sprintf("REGEXP(%s, :search_query) = TRUE", $field); + } + + return sprintf("ILIKE(%s, :search_query) = TRUE", $field); + }, $fields_to_search); + + //Add Or concatenation of the expressions to our query + $queryBuilder->andWhere( + $queryBuilder->expr()->orX(...$expressions) + ); + + //For regex, we pass the query as is, for like we add % to the start and end as wildcards + if ($this->regex) { + $queryBuilder->setParameter('search_query', $this->keyword); + } else { + $queryBuilder->setParameter('search_query', '%' . $this->keyword . '%'); + } + } + + public function getKeyword(): string + { + return $this->keyword; + } + + public function setKeyword(string $keyword): AssemblySearchFilter + { + $this->keyword = $keyword; + return $this; + } + + public function isRegex(): bool + { + return $this->regex; + } + + public function setRegex(bool $regex): AssemblySearchFilter + { + $this->regex = $regex; + return $this; + } + + public function isName(): bool + { + return $this->name; + } + + public function setName(bool $name): AssemblySearchFilter + { + $this->name = $name; + return $this; + } + + public function isDescription(): bool + { + return $this->description; + } + + public function setDescription(bool $description): AssemblySearchFilter + { + $this->description = $description; + return $this; + } + + public function isIPN(): bool + { + return $this->ipn; + } + + public function setIPN(bool $ipn): AssemblySearchFilter + { + $this->ipn = $ipn; + return $this; + } + + public function isComment(): bool + { + return $this->comment; + } + + public function setComment(bool $comment): AssemblySearchFilter + { + $this->comment = $comment; + return $this; + } + + +} diff --git a/src/DataTables/Filters/PartFilter.php b/src/DataTables/Filters/PartFilter.php index e44cf69d7..cf185dfd7 100644 --- a/src/DataTables/Filters/PartFilter.php +++ b/src/DataTables/Filters/PartFilter.php @@ -41,6 +41,7 @@ use App\Entity\Parts\Footprint; use App\Entity\Parts\Manufacturer; use App\Entity\Parts\MeasurementUnit; +use App\Entity\Parts\PartCustomState; use App\Entity\Parts\PartLot; use App\Entity\Parts\StorageLocation; use App\Entity\Parts\Supplier; @@ -86,6 +87,7 @@ class PartFilter implements FilterInterface public readonly EntityConstraint $lotOwner; public readonly EntityConstraint $measurementUnit; + public readonly EntityConstraint $partCustomState; public readonly TextConstraint $manufacturer_product_url; public readonly TextConstraint $manufacturer_product_number; public readonly IntConstraint $attachmentsCount; @@ -128,6 +130,7 @@ public function __construct(NodesListBuilder $nodesListBuilder) $this->favorite = new BooleanConstraint('part.favorite'); $this->needsReview = new BooleanConstraint('part.needs_review'); $this->measurementUnit = new EntityConstraint($nodesListBuilder, MeasurementUnit::class, 'part.partUnit'); + $this->partCustomState = new EntityConstraint($nodesListBuilder, PartCustomState::class, 'part.partCustomState'); $this->mass = new NumberConstraint('part.mass'); $this->dbId = new IntConstraint('part.id'); $this->ipn = new TextConstraint('part.ipn'); 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..baa0e24e1 --- /dev/null +++ b/src/DataTables/Helpers/ProjectDataTableHelper.php @@ -0,0 +1,48 @@ +. + */ + +namespace App\DataTables\Helpers; + +use App\Entity\ProjectSystem\Project; +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) { + } + + public function renderName(Project $context): string + { + $icon = ''; + + return sprintf( + '%s%s', + $this->entityURLGenerator->infoURL($context), + $icon, + htmlspecialchars($context->getName()) + ); + } +} diff --git a/src/DataTables/PartsDataTable.php b/src/DataTables/PartsDataTable.php index a97762b11..fd798620d 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; @@ -174,6 +175,19 @@ public function configure(DataTable $dataTable, array $options): void return $tmp; } ]) + ->add('partCustomState', TextColumn::class, [ + 'label' => $this->translator->trans('part.table.partCustomState'), + 'orderField' => 'NATSORT(_partCustomState.name)', + 'render' => function($value, Part $context): string { + $partCustomState = $context->getPartCustomState(); + + if ($partCustomState === null) { + return ''; + } + + return htmlspecialchars($partCustomState->getName()); + } + ]) ->add('addedDate', LocaleDateTimeColumn::class, [ 'label' => $this->translator->trans('part.table.addedDate'), ]) @@ -240,6 +254,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'), @@ -309,6 +351,7 @@ private function getDetailQuery(QueryBuilder $builder, array $filter_results): v ->addSelect('footprint') ->addSelect('manufacturer') ->addSelect('partUnit') + ->addSelect('partCustomState') ->addSelect('master_picture_attachment') ->addSelect('footprint_attachment') ->addSelect('partLots') @@ -327,6 +370,7 @@ private function getDetailQuery(QueryBuilder $builder, array $filter_results): v ->leftJoin('orderdetails.supplier', 'suppliers') ->leftJoin('part.attachments', 'attachments') ->leftJoin('part.partUnit', 'partUnit') + ->leftJoin('part.partCustomState', 'partCustomState') ->leftJoin('part.parameters', 'parameters') ->where('part.id IN (:ids)') ->setParameter('ids', $ids) @@ -344,6 +388,7 @@ private function getDetailQuery(QueryBuilder $builder, array $filter_results): v ->addGroupBy('suppliers') ->addGroupBy('attachments') ->addGroupBy('partUnit') + ->addGroupBy('partCustomState') ->addGroupBy('parameters'); //Get the results in the same order as the IDs were passed @@ -415,6 +460,10 @@ private function addJoins(QueryBuilder $builder): QueryBuilder $builder->leftJoin('part.partUnit', '_partUnit'); $builder->addGroupBy('_partUnit'); } + if (str_contains($dql, '_partCustomState')) { + $builder->leftJoin('part.partCustomState', '_partCustomState'); + $builder->addGroupBy('_partCustomState'); + } if (str_contains($dql, '_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 diff --git a/src/DataTables/ProjectBomEntriesDataTable.php b/src/DataTables/ProjectBomEntriesDataTable.php index fcb069844..89572a8ab 100644 --- a/src/DataTables/ProjectBomEntriesDataTable.php +++ b/src/DataTables/ProjectBomEntriesDataTable.php @@ -41,11 +41,14 @@ class ProjectBomEntriesDataTable implements DataTableTypeInterface { - public function __construct(protected TranslatorInterface $translator, protected PartDataTableHelper $partDataTableHelper, protected EntityURLGenerator $entityURLGenerator, protected AmountFormatter $amountFormatter) - { + public function __construct( + protected TranslatorInterface $translator, + protected PartDataTableHelper $partDataTableHelper, + protected EntityURLGenerator $entityURLGenerator, + protected AmountFormatter $amountFormatter + ) { } - public function configure(DataTable $dataTable, array $options): void { $dataTable @@ -105,6 +108,8 @@ public function configure(DataTable $dataTable, array $options): void if($context->getPart() instanceof Part) { return $context->getPart()->getIpn(); } + + return ''; } ]) ->add('description', MarkdownColumn::class, [ 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 00cf581a8..70ffbaa01 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 { - private const ORM_DISCRIMINATOR_MAP = ['Part' => PartAttachment::class, 'Device' => ProjectAttachment::class, + private const ORM_DISCRIMINATOR_MAP = ['Part' => PartAttachment::class, 'PartCustomState' => PartCustomStateAttachment::class, 'Device' => ProjectAttachment::class, 'Assembly' => AssemblyAttachment::class, 'AttachmentType' => AttachmentTypeAttachment::class, 'Category' => CategoryAttachment::class, 'Footprint' => FootprintAttachment::class, 'Manufacturer' => ManufacturerAttachment::class, 'Currency' => CurrencyAttachment::class, 'Group' => GroupAttachment::class, 'MeasurementUnit' => MeasurementUnitAttachment::class, @@ -107,7 +107,8 @@ 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, "Project" => ProjectAttachment::class, "AttachmentType" => AttachmentTypeAttachment::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, "StorageLocation" => StorageLocationAttachment::class, "Supplier" => SupplierAttachment::class, "User" => UserAttachment::class, "LabelProfile" => LabelAttachment::class]; @@ -550,8 +551,8 @@ public function setAttachmentType(AttachmentType $attachement_type): self */ #[Groups(['attachment:write'])] #[SerializedName('url')] - #[ApiProperty(description: 'Set the path of the attachment here. - Provide either an external URL, a path to a builtin file (like %FOOTPRINTS%/Active/ICs/IC_DFS.png) or an empty + #[ApiProperty(description: 'Set the path of the attachment here. + Provide either an external URL, a path to a builtin file (like %FOOTPRINTS%/Active/ICs/IC_DFS.png) or an empty string if the attachment has an internal file associated and you\'d like to reset the external source. If you set a new (nonempty) file path any associated internal file will be removed!')] public function setURL(?string $url): self diff --git a/src/Entity/Attachments/PartCustomStateAttachment.php b/src/Entity/Attachments/PartCustomStateAttachment.php new file mode 100644 index 000000000..3a561b136 --- /dev/null +++ b/src/Entity/Attachments/PartCustomStateAttachment.php @@ -0,0 +1,45 @@ +. + */ + +declare(strict_types=1); + +namespace App\Entity\Attachments; + +use App\Entity\Parts\PartCustomState; +use App\Serializer\APIPlatform\OverrideClassDenormalizer; +use Doctrine\ORM\Mapping as ORM; +use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity; +use Symfony\Component\Serializer\Attribute\Context; + +/** + * An attachment attached to a part custom state element. + * @extends Attachment + */ +#[UniqueEntity(['name', 'attachment_type', 'element'])] +#[ORM\Entity] +class PartCustomStateAttachment extends Attachment +{ + final public const ALLOWED_ELEMENT_CLASS = PartCustomState::class; + + #[ORM\ManyToOne(targetEntity: PartCustomState::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/Base/AbstractDBElement.php b/src/Entity/Base/AbstractDBElement.php index 9fb5d6489..7768f7aa3 100644 --- a/src/Entity/Base/AbstractDBElement.php +++ b/src/Entity/Base/AbstractDBElement.php @@ -33,15 +33,20 @@ use App\Entity\Attachments\ManufacturerAttachment; use App\Entity\Attachments\MeasurementUnitAttachment; use App\Entity\Attachments\PartAttachment; +use App\Entity\Attachments\PartCustomStateAttachment; use App\Entity\Attachments\ProjectAttachment; +use App\Entity\Attachments\AssemblyAttachment; use App\Entity\Attachments\StorageLocationAttachment; use App\Entity\Attachments\SupplierAttachment; use App\Entity\Attachments\UserAttachment; use App\Entity\Parameters\AbstractParameter; use App\Entity\Parts\Category; use App\Entity\PriceInformations\Pricedetail; +use App\Entity\Parts\PartCustomState; use App\Entity\ProjectSystem\Project; use App\Entity\ProjectSystem\ProjectBOMEntry; +use App\Entity\AssemblySystem\Assembly; +use App\Entity\AssemblySystem\AssemblyBOMEntry; use App\Entity\Parts\Footprint; use App\Entity\UserSystem\Group; use App\Entity\Parts\Manufacturer; @@ -68,7 +73,44 @@ * Every database table which are managed with this class (or a subclass of it) * must have the table row "id"!! The ID is the unique key to identify the elements. */ -#[DiscriminatorMap(typeProperty: 'type', mapping: ['attachment_type' => AttachmentType::class, 'attachment' => Attachment::class, 'attachment_type_attachment' => AttachmentTypeAttachment::class, 'category_attachment' => CategoryAttachment::class, 'currency_attachment' => CurrencyAttachment::class, 'footprint_attachment' => FootprintAttachment::class, 'group_attachment' => GroupAttachment::class, 'label_attachment' => LabelAttachment::class, 'manufacturer_attachment' => ManufacturerAttachment::class, 'measurement_unit_attachment' => MeasurementUnitAttachment::class, 'part_attachment' => PartAttachment::class, 'project_attachment' => ProjectAttachment::class, 'storelocation_attachment' => StorageLocationAttachment::class, 'supplier_attachment' => SupplierAttachment::class, 'user_attachment' => UserAttachment::class, 'category' => Category::class, 'project' => Project::class, 'project_bom_entry' => ProjectBOMEntry::class, 'footprint' => Footprint::class, 'group' => Group::class, 'manufacturer' => Manufacturer::class, 'orderdetail' => Orderdetail::class, 'part' => Part::class, 'pricedetail' => Pricedetail::class, 'storelocation' => StorageLocation::class, 'part_lot' => PartLot::class, 'currency' => Currency::class, 'measurement_unit' => MeasurementUnit::class, 'parameter' => AbstractParameter::class, 'supplier' => Supplier::class, 'user' => User::class])] +#[DiscriminatorMap(typeProperty: 'type', mapping: [ + 'attachment_type' => AttachmentType::class, + 'attachment' => Attachment::class, + 'attachment_type_attachment' => AttachmentTypeAttachment::class, + 'category_attachment' => CategoryAttachment::class, + 'currency_attachment' => CurrencyAttachment::class, + 'footprint_attachment' => FootprintAttachment::class, + 'group_attachment' => GroupAttachment::class, + 'label_attachment' => LabelAttachment::class, + 'manufacturer_attachment' => ManufacturerAttachment::class, + 'measurement_unit_attachment' => MeasurementUnitAttachment::class, + '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, + 'orderdetail' => Orderdetail::class, + 'part' => Part::class, + 'part_custom_state' => PartCustomState::class, + 'pricedetail' => Pricedetail::class, + 'storelocation' => StorageLocation::class, + 'part_lot' => PartLot::class, + 'currency' => Currency::class, + 'measurement_unit' => MeasurementUnit::class, + 'parameter' => AbstractParameter::class, + 'supplier' => Supplier::class, + 'user' => User::class] +)] #[ORM\MappedSuperclass(repositoryClass: DBElementRepository::class)] abstract class AbstractDBElement implements JsonSerializable { diff --git a/src/Entity/LogSystem/CollectionElementDeleted.php b/src/Entity/LogSystem/CollectionElementDeleted.php index 16bf33f59..20ed580bf 100644 --- a/src/Entity/LogSystem/CollectionElementDeleted.php +++ b/src/Entity/LogSystem/CollectionElementDeleted.php @@ -41,11 +41,14 @@ 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; use App\Entity\Attachments\CategoryAttachment; use App\Entity\Attachments\CurrencyAttachment; +use App\Entity\Attachments\PartCustomStateAttachment; use App\Entity\Attachments\ProjectAttachment; use App\Entity\Attachments\FootprintAttachment; use App\Entity\Attachments\GroupAttachment; @@ -58,6 +61,9 @@ use App\Entity\Base\AbstractDBElement; use App\Entity\Contracts\LogWithEventUndoInterface; use App\Entity\Contracts\NamedElementInterface; +use App\Entity\Parameters\AssemblyParameter; +use App\Entity\Parameters\PartCustomStateParameter; +use App\Entity\Parts\PartCustomState; use App\Entity\ProjectSystem\Project; use App\Entity\Parameters\AbstractParameter; use App\Entity\Parameters\AttachmentTypeParameter; @@ -147,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, @@ -158,6 +165,7 @@ private function resolveAbstractClassToInstantiableClass(string $abstract_class) Part::class => PartParameter::class, StorageLocation::class => StorageLocationParameter::class, Supplier::class => SupplierParameter::class, + PartCustomState::class => PartCustomStateParameter::class, default => throw new \RuntimeException('Unknown target class for parameter: '.$this->getTargetClass()), }; } @@ -168,11 +176,13 @@ 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, MeasurementUnit::class => MeasurementUnitAttachment::class, Part::class => PartAttachment::class, + PartCustomState::class => PartCustomStateAttachment::class, StorageLocation::class => StorageLocationAttachment::class, Supplier::class => SupplierAttachment::class, User::class => UserAttachment::class, diff --git a/src/Entity/LogSystem/LogTargetType.php b/src/Entity/LogSystem/LogTargetType.php index 61a2b081c..72efe6c75 100644 --- a/src/Entity/LogSystem/LogTargetType.php +++ b/src/Entity/LogSystem/LogTargetType.php @@ -34,6 +34,7 @@ use App\Entity\Parts\MeasurementUnit; use App\Entity\Parts\Part; use App\Entity\Parts\PartAssociation; +use App\Entity\Parts\PartCustomState; use App\Entity\Parts\PartLot; use App\Entity\Parts\StorageLocation; use App\Entity\Parts\Supplier; @@ -42,6 +43,8 @@ use App\Entity\PriceInformations\Pricedetail; use App\Entity\ProjectSystem\Project; use App\Entity\ProjectSystem\ProjectBOMEntry; +use App\Entity\AssemblySystem\Assembly; +use App\Entity\AssemblySystem\AssemblyBOMEntry; use App\Entity\UserSystem\Group; use App\Entity\UserSystem\User; @@ -71,6 +74,9 @@ enum LogTargetType: int case PART_ASSOCIATION = 20; case BULK_INFO_PROVIDER_IMPORT_JOB = 21; case BULK_INFO_PROVIDER_IMPORT_JOB_PART = 22; + case ASSEMBLY = 23; + case ASSEMBLY_BOM_ENTRY = 24; + case PART_CUSTOM_STATE = 25; /** * Returns the class name of the target type or null if the target type is NONE. @@ -86,6 +92,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, @@ -102,6 +110,7 @@ public function toClass(): ?string self::PART_ASSOCIATION => PartAssociation::class, self::BULK_INFO_PROVIDER_IMPORT_JOB => BulkInfoProviderImportJob::class, self::BULK_INFO_PROVIDER_IMPORT_JOB_PART => BulkInfoProviderImportJobPart::class, + self::PART_CUSTOM_STATE => PartCustomState::class }; } diff --git a/src/Entity/Parameters/AbstractParameter.php b/src/Entity/Parameters/AbstractParameter.php index 39f333dad..8170d3e3c 100644 --- a/src/Entity/Parameters/AbstractParameter.php +++ b/src/Entity/Parameters/AbstractParameter.php @@ -73,7 +73,8 @@ #[ORM\DiscriminatorMap([0 => CategoryParameter::class, 1 => CurrencyParameter::class, 2 => ProjectParameter::class, 3 => FootprintParameter::class, 4 => GroupParameter::class, 5 => ManufacturerParameter::class, 6 => MeasurementUnitParameter::class, 7 => PartParameter::class, 8 => StorageLocationParameter::class, - 9 => SupplierParameter::class, 10 => AttachmentTypeParameter::class])] + 9 => SupplierParameter::class, 10 => AttachmentTypeParameter::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')] @@ -103,9 +104,9 @@ 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]; + "StorageLocation" => StorageLocationParameter::class, "Supplier" => SupplierParameter::class, "PartCustomState" => PartCustomStateParameter::class]; /** * @var string The class of the element that can be passed to this attachment. Must be overridden in subclasses. @@ -460,7 +461,7 @@ protected function formatWithUnit(float $value, string $format = '%g', bool $wit return $str; } - + /** * Returns the class of the element that is allowed to be associated with this attachment. * @return string 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/Parameters/PartCustomStateParameter.php b/src/Entity/Parameters/PartCustomStateParameter.php new file mode 100644 index 000000000..ceedf7b4d --- /dev/null +++ b/src/Entity/Parameters/PartCustomStateParameter.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\Base\AbstractDBElement; +use App\Entity\Parts\PartCustomState; +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 PartCustomStateParameter extends AbstractParameter +{ + final public const ALLOWED_ELEMENT_CLASS = PartCustomState::class; + + /** + * @var PartCustomState the element this para is associated with + */ + #[ORM\ManyToOne(targetEntity: PartCustomState::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/Category.php b/src/Entity/Parts/Category.php index 99ed3c6d0..7fca81bc2 100644 --- a/src/Entity/Parts/Category.php +++ b/src/Entity/Parts/Category.php @@ -118,6 +118,13 @@ class Category extends AbstractPartsContainingDBElement #[ORM\Column(type: Types::TEXT)] protected string $partname_regex = ''; + /** + * @var string The prefix for ipn generation for created parts in this category. + */ + #[Groups(['full', 'import', 'category:read', 'category:write'])] + #[ORM\Column(type: Types::STRING, length: 255, nullable: false, options: ['default' => ''])] + protected string $part_ipn_prefix = ''; + /** * @var bool Set to true, if the footprints should be disabled for parts this category (not implemented yet). */ @@ -225,6 +232,16 @@ public function setPartnameRegex(string $partname_regex): self return $this; } + public function getPartIpnPrefix(): string + { + return $this->part_ipn_prefix; + } + + public function setPartIpnPrefix(string $part_ipn_prefix): void + { + $this->part_ipn_prefix = $part_ipn_prefix; + } + public function isDisableFootprints(): bool { return $this->disable_footprints; diff --git a/src/Entity/Parts/Part.php b/src/Entity/Parts/Part.php index 2f274a8af..b0824fc8e 100644 --- a/src/Entity/Parts/Part.php +++ b/src/Entity/Parts/Part.php @@ -54,6 +54,7 @@ use App\Entity\Parts\PartTraits\ManufacturerTrait; use App\Entity\Parts\PartTraits\OrderTrait; use App\Entity\Parts\PartTraits\ProjectTrait; +use App\Entity\Parts\PartTraits\AssemblyTrait; use App\EntityListeners\TreeCacheInvalidationListener; use App\Repository\PartRepository; use App\Validator\Constraints\UniqueObjectCollection; @@ -61,7 +62,6 @@ use Doctrine\Common\Collections\Collection; use Doctrine\Common\Collections\Criteria; use Doctrine\ORM\Mapping as ORM; -use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity; use Symfony\Component\Serializer\Annotation\Groups; use Symfony\Component\Validator\Constraints as Assert; use Symfony\Component\Validator\Context\ExecutionContextInterface; @@ -75,7 +75,6 @@ * @extends AttachmentContainingDBElement * @template-use ParametersTrait */ -#[UniqueEntity(fields: ['ipn'], message: 'part.ipn.must_be_unique')] #[ORM\Entity(repositoryClass: PartRepository::class)] #[ORM\EntityListeners([TreeCacheInvalidationListener::class])] #[ORM\Table('`parts`')] @@ -107,7 +106,7 @@ denormalizationContext: ['groups' => ['part:write', 'api:basic:write', 'eda_info:write', 'attachment:write', 'parameter:write'], 'openapi_definition_name' => 'Write'], )] #[ApiFilter(PropertyFilter::class)] -#[ApiFilter(EntityFilter::class, properties: ["category", "footprint", "manufacturer", "partUnit"])] +#[ApiFilter(EntityFilter::class, properties: ["category", "footprint", "manufacturer", "partUnit", "partCustomState"])] #[ApiFilter(PartStoragelocationFilter::class, properties: ["storage_location"])] #[ApiFilter(LikeFilter::class, properties: ["name", "comment", "description", "ipn", "manufacturer_product_number"])] #[ApiFilter(TagFilter::class, properties: ["tags"])] @@ -125,6 +124,7 @@ class Part extends AttachmentContainingDBElement use OrderTrait; use ParametersTrait; use ProjectTrait; + use AssemblyTrait; use AssociationTrait; use EDATrait; @@ -186,6 +186,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/PartCustomState.php b/src/Entity/Parts/PartCustomState.php new file mode 100644 index 000000000..136ff9847 --- /dev/null +++ b/src/Entity/Parts/PartCustomState.php @@ -0,0 +1,127 @@ +. + */ + +declare(strict_types=1); + +namespace App\Entity\Parts; + +use ApiPlatform\Metadata\ApiProperty; +use App\Entity\Attachments\Attachment; +use App\Entity\Attachments\PartCustomStateAttachment; +use ApiPlatform\Doctrine\Common\Filter\DateFilterInterface; +use ApiPlatform\Doctrine\Orm\Filter\DateFilter; +use ApiPlatform\Doctrine\Orm\Filter\OrderFilter; +use ApiPlatform\Metadata\ApiFilter; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Delete; +use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\GetCollection; +use ApiPlatform\Metadata\Patch; +use ApiPlatform\Metadata\Post; +use ApiPlatform\Serializer\Filter\PropertyFilter; +use App\ApiPlatform\Filter\LikeFilter; +use App\Entity\Base\AbstractPartsContainingDBElement; +use App\Entity\Base\AbstractStructuralDBElement; +use App\Entity\Parameters\PartCustomStateParameter; +use App\Repository\Parts\PartCustomStateRepository; +use Doctrine\Common\Collections\ArrayCollection; +use Doctrine\Common\Collections\Collection; +use Doctrine\Common\Collections\Criteria; +use Doctrine\ORM\Mapping as ORM; +use Symfony\Component\Serializer\Annotation\Groups; +use Symfony\Component\Validator\Constraints as Assert; + +/** + * This entity represents a custom part state. + * If an organisation uses Part-DB and has its custom part states, this is useful. + * + * @extends AbstractPartsContainingDBElement + */ +#[ORM\Entity(repositoryClass: PartCustomStateRepository::class)] +#[ORM\Table('`part_custom_states`')] +#[ORM\Index(columns: ['name'], name: 'part_custom_state_name')] +#[ApiResource( + operations: [ + new Get(security: 'is_granted("read", object)'), + new GetCollection(security: 'is_granted("@part_custom_states.read")'), + new Post(securityPostDenormalize: 'is_granted("create", object)'), + new Patch(security: 'is_granted("edit", object)'), + new Delete(security: 'is_granted("delete", object)'), + ], + normalizationContext: ['groups' => ['part_custom_state:read', 'api:basic:read'], 'openapi_definition_name' => 'Read'], + denormalizationContext: ['groups' => ['part_custom_state:write', 'api:basic:write'], 'openapi_definition_name' => 'Write'], +)] +#[ApiFilter(PropertyFilter::class)] +#[ApiFilter(LikeFilter::class, properties: ["name"])] +#[ApiFilter(DateFilter::class, strategy: DateFilterInterface::EXCLUDE_NULL)] +#[ApiFilter(OrderFilter::class, properties: ['name', 'id', 'addedDate', 'lastModified'])] +class PartCustomState extends AbstractPartsContainingDBElement +{ + /** + * @var string The comment info for this element as markdown + */ + #[Groups(['part_custom_state:read', 'part_custom_state:write', 'full', 'import'])] + protected string $comment = ''; + + #[ORM\OneToMany(mappedBy: 'parent', targetEntity: self::class, cascade: ['persist'])] + #[ORM\OrderBy(['name' => Criteria::ASC])] + protected Collection $children; + + #[ORM\ManyToOne(targetEntity: self::class, inversedBy: 'children')] + #[ORM\JoinColumn(name: 'parent_id')] + #[Groups(['part_custom_state:read', 'part_custom_state:write'])] + #[ApiProperty(readableLink: false, writableLink: false)] + protected ?AbstractStructuralDBElement $parent = null; + + /** + * @var Collection + */ + #[Assert\Valid] + #[ORM\OneToMany(targetEntity: PartCustomStateAttachment::class, mappedBy: 'element', cascade: ['persist', 'remove'], orphanRemoval: true)] + #[ORM\OrderBy(['name' => Criteria::ASC])] + #[Groups(['part_custom_state:read', 'part_custom_state:write'])] + protected Collection $attachments; + + #[ORM\ManyToOne(targetEntity: PartCustomStateAttachment::class)] + #[ORM\JoinColumn(name: 'id_preview_attachment', onDelete: 'SET NULL')] + #[Groups(['part_custom_state:read', 'part_custom_state:write'])] + protected ?Attachment $master_picture_attachment = null; + + /** @var Collection + */ + #[Assert\Valid] + #[ORM\OneToMany(mappedBy: 'element', targetEntity: PartCustomStateParameter::class, cascade: ['persist', 'remove'], orphanRemoval: true)] + #[ORM\OrderBy(['name' => 'ASC'])] + #[Groups(['part_custom_state:read', 'part_custom_state:write'])] + protected Collection $parameters; + + #[Groups(['part_custom_state:read'])] + protected ?\DateTimeImmutable $addedDate = null; + #[Groups(['part_custom_state:read'])] + protected ?\DateTimeImmutable $lastModified = null; + + public function __construct() + { + parent::__construct(); + $this->children = new ArrayCollection(); + $this->attachments = new ArrayCollection(); + $this->parameters = new ArrayCollection(); + } +} diff --git a/src/Entity/Parts/PartTraits/AdvancedPropertyTrait.php b/src/Entity/Parts/PartTraits/AdvancedPropertyTrait.php index 230ba7b76..b4138f934 100644 --- a/src/Entity/Parts/PartTraits/AdvancedPropertyTrait.php +++ b/src/Entity/Parts/PartTraits/AdvancedPropertyTrait.php @@ -23,12 +23,14 @@ namespace App\Entity\Parts\PartTraits; use App\Entity\Parts\InfoProviderReference; +use App\Entity\Parts\PartCustomState; use Doctrine\DBAL\Types\Types; use App\Entity\Parts\Part; use Doctrine\ORM\Mapping as ORM; use Symfony\Component\Serializer\Annotation\Groups; use Symfony\Component\Validator\Constraints as Assert; use Symfony\Component\Validator\Constraints\Length; +use App\Validator\Constraints\UniquePartIpnConstraint; /** * Advanced properties of a part, not related to a more specific group. @@ -62,8 +64,9 @@ trait AdvancedPropertyTrait */ #[Assert\Length(max: 100)] #[Groups(['extended', 'full', 'import', 'part:read', 'part:write'])] - #[ORM\Column(type: Types::STRING, length: 100, unique: true, nullable: true)] + #[ORM\Column(type: Types::STRING, length: 100, nullable: true)] #[Length(max: 100)] + #[UniquePartIpnConstraint] protected ?string $ipn = null; /** @@ -73,6 +76,14 @@ trait AdvancedPropertyTrait #[Groups(['full', 'part:read'])] protected InfoProviderReference $providerReference; + /** + * @var ?PartCustomState the custom state for the part + */ + #[Groups(['extended', 'full', 'import', 'part:read', 'part:write'])] + #[ORM\ManyToOne(targetEntity: PartCustomState::class)] + #[ORM\JoinColumn(name: 'id_part_custom_state')] + protected ?PartCustomState $partCustomState = null; + /** * Checks if this part is marked, for that it needs further review. */ @@ -180,7 +191,24 @@ public function setProviderReference(InfoProviderReference $providerReference): return $this; } + /** + * Gets the custom part state for the part + * Returns null if no specific part state is set. + */ + public function getPartCustomState(): ?PartCustomState + { + return $this->partCustomState; + } + /** + * Sets the custom part state. + * + * @return $this + */ + public function setPartCustomState(?PartCustomState $partCustomState): self + { + $this->partCustomState = $partCustomState; - + return $this; + } } diff --git a/src/Entity/Parts/PartTraits/AssemblyTrait.php b/src/Entity/Parts/PartTraits/AssemblyTrait.php new file mode 100644 index 000000000..3b5f931c6 --- /dev/null +++ b/src/Entity/Parts/PartTraits/AssemblyTrait.php @@ -0,0 +1,46 @@ + $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..b2a3b2e95 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: [ @@ -212,8 +213,6 @@ public function setProject(?Project $project): ProjectBOMEntry return $this; } - - public function getPart(): ?Part { return $this->part; diff --git a/src/EventSubscriber/UserSystem/PartUniqueIpnSubscriber.php b/src/EventSubscriber/UserSystem/PartUniqueIpnSubscriber.php new file mode 100644 index 000000000..ecc25b4fa --- /dev/null +++ b/src/EventSubscriber/UserSystem/PartUniqueIpnSubscriber.php @@ -0,0 +1,97 @@ +ipnSuggestSettings->autoAppendSuffix) { + return; + } + + $em = $args->getObjectManager(); + $uow = $em->getUnitOfWork(); + $meta = $em->getClassMetadata(Part::class); + + // Collect all IPNs already reserved in the current flush (so new entities do not collide with each other) + $reservedIpns = []; + + // Helper to assign a collision-free IPN for a Part entity + $ensureUnique = function (Part $part) use ($em, $uow, $meta, &$reservedIpns) { + $ipn = $part->getIpn(); + if ($ipn === null || $ipn === '') { + return; + } + + // Check against IPNs already reserved in the current flush (except itself) + $originalIpn = $ipn; + $candidate = $originalIpn; + $increment = 1; + + $conflicts = function (string $candidate) use ($em, $part, $reservedIpns) { + // Collision within the current flush session? + if (isset($reservedIpns[$candidate]) && $reservedIpns[$candidate] !== $part) { + return true; + } + // Collision with an existing DB row? + $existing = $em->getRepository(Part::class)->findOneBy(['ipn' => $candidate]); + return $existing !== null && $existing->getId() !== $part->getId(); + }; + + while ($conflicts($candidate)) { + $candidate = $originalIpn . '_' . $increment; + $increment++; + } + + if ($candidate !== $ipn) { + $before = $part->getIpn(); + $part->setIpn($candidate); + + // Recompute the change set so Doctrine writes the change + $uow->recomputeSingleEntityChangeSet($meta, $part); + $reservedIpns[$candidate] = $part; + + // If the old IPN was reserved already, clean it up + if ($before !== null && isset($reservedIpns[$before]) && $reservedIpns[$before] === $part) { + unset($reservedIpns[$before]); + } + } else { + // Candidate unchanged, but reserve it so subsequent entities see it + $reservedIpns[$candidate] = $part; + } + }; + + // 1) Iterate over new entities + foreach ($uow->getScheduledEntityInsertions() as $entity) { + if ($entity instanceof Part) { + $ensureUnique($entity); + } + } + + // 2) Iterate over updates (if IPN changed, ensure uniqueness again) + foreach ($uow->getScheduledEntityUpdates() as $entity) { + if ($entity instanceof Part) { + $ensureUnique($entity); + } + } + } +} 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 5a4ef5bce..35afbaa1a 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', ], @@ -114,7 +120,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/AdminPages/CategoryAdminForm.php b/src/Form/AdminPages/CategoryAdminForm.php index 44c1dede7..489649ede 100644 --- a/src/Form/AdminPages/CategoryAdminForm.php +++ b/src/Form/AdminPages/CategoryAdminForm.php @@ -84,6 +84,17 @@ protected function additionalFormElements(FormBuilderInterface $builder, array $ 'disabled' => !$this->security->isGranted($is_new ? 'create' : 'edit', $entity), ]); + $builder->add('part_ipn_prefix', TextType::class, [ + 'required' => false, + 'empty_data' => '', + 'label' => 'category.edit.part_ipn_prefix', + 'help' => 'category.edit.part_ipn_prefix.help', + 'attr' => [ + 'placeholder' => 'category.edit.part_ipn_prefix.placeholder', + ], + 'disabled' => !$this->security->isGranted($is_new ? 'create' : 'edit', $entity), + ]); + $builder->add('default_description', RichTextEditorType::class, [ 'required' => false, 'empty_data' => '', diff --git a/src/Form/AdminPages/PartCustomStateAdminForm.php b/src/Form/AdminPages/PartCustomStateAdminForm.php new file mode 100644 index 000000000..b8bb2815e --- /dev/null +++ b/src/Form/AdminPages/PartCustomStateAdminForm.php @@ -0,0 +1,27 @@ +. + */ + +declare(strict_types=1); + +namespace App\Form\AdminPages; + +class PartCustomStateAdminForm extends BaseEntityAdminForm +{ +} 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..acfbb1a8e --- /dev/null +++ b/src/Form/Filters/AssemblyFilterType.php @@ -0,0 +1,114 @@ +. + */ +namespace App\Form\Filters; + +use App\DataTables\Filters\AssemblyFilter; +use App\Entity\Attachments\AttachmentType; +use App\Form\Filters\Constraints\DateTimeConstraintType; +use App\Form\Filters\Constraints\NumberConstraintType; +use App\Form\Filters\Constraints\StructuralEntityConstraintType; +use App\Form\Filters\Constraints\TextConstraintType; +use Symfony\Component\Form\AbstractType; +use Symfony\Component\Form\Extension\Core\Type\ResetType; +use Symfony\Component\Form\Extension\Core\Type\SubmitType; +use Symfony\Component\Form\FormBuilderInterface; +use Symfony\Component\OptionsResolver\OptionsResolver; + +class AssemblyFilterType extends AbstractType +{ + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults([ + 'compound' => true, + 'data_class' => AssemblyFilter::class, + 'csrf_protection' => false, + ]); + } + + public function buildForm(FormBuilderInterface $builder, array $options): void + { + /* + * Common tab + */ + + $builder->add('name', TextConstraintType::class, [ + 'label' => 'assembly.filter.name', + ]); + + $builder->add('description', TextConstraintType::class, [ + 'label' => 'assembly.filter.description', + ]); + + $builder->add('comment', TextConstraintType::class, [ + 'label' => 'assembly.filter.comment' + ]); + + /* + * Advanced tab + */ + + $builder->add('dbId', NumberConstraintType::class, [ + 'label' => 'assembly.filter.dbId', + 'min' => 1, + 'step' => 1, + ]); + + $builder->add('ipn', TextConstraintType::class, [ + 'label' => 'assembly.filter.ipn', + ]); + + $builder->add('lastModified', DateTimeConstraintType::class, [ + 'label' => 'lastModified' + ]); + + $builder->add('addedDate', DateTimeConstraintType::class, [ + 'label' => 'createdAt' + ]); + + /** + * Attachments count + */ + $builder->add('attachmentsCount', NumberConstraintType::class, [ + 'label' => 'assembly.filter.attachments_count', + 'step' => 1, + 'min' => 0, + ]); + + $builder->add('attachmentType', StructuralEntityConstraintType::class, [ + 'label' => 'attachment.attachment_type', + 'entity_class' => AttachmentType::class + ]); + + $builder->add('attachmentName', TextConstraintType::class, [ + 'label' => 'assembly.filter.attachmentName', + ]); + + $builder->add('submit', SubmitType::class, [ + 'label' => 'filter.submit', + ]); + + $builder->add('discard', ResetType::class, [ + 'label' => 'filter.discard', + ]); + } +} diff --git a/src/Form/Filters/AttachmentFilterType.php b/src/Form/Filters/AttachmentFilterType.php index 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 c973ad0fb..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', @@ -130,6 +132,7 @@ public function buildForm(FormBuilderInterface $builder, array $options): void LogTargetType::PART_ASSOCIATION => 'part_association.label', LogTargetType::BULK_INFO_PROVIDER_IMPORT_JOB => 'bulk_info_provider_import_job.label', LogTargetType::BULK_INFO_PROVIDER_IMPORT_JOB_PART => 'bulk_info_provider_import_job_part.label', + LogTargetType::PART_CUSTOM_STATE => 'part_custom_state.label', }, ]); diff --git a/src/Form/Filters/PartFilterType.php b/src/Form/Filters/PartFilterType.php index 871f9b074..e101c6351 100644 --- a/src/Form/Filters/PartFilterType.php +++ b/src/Form/Filters/PartFilterType.php @@ -32,6 +32,7 @@ use App\Entity\Parts\Footprint; use App\Entity\Parts\Manufacturer; use App\Entity\Parts\MeasurementUnit; +use App\Entity\Parts\PartCustomState; use App\Entity\Parts\StorageLocation; use App\Entity\Parts\Supplier; use App\Entity\ProjectSystem\Project; @@ -139,6 +140,11 @@ public function buildForm(FormBuilderInterface $builder, array $options): void 'entity_class' => MeasurementUnit::class ]); + $builder->add('partCustomState', StructuralEntityConstraintType::class, [ + 'label' => 'part.edit.partCustomState', + 'entity_class' => PartCustomState::class + ]); + $builder->add('lastModified', DateTimeConstraintType::class, [ 'label' => 'lastModified' ]); diff --git a/src/Form/Part/PartBaseType.php b/src/Form/Part/PartBaseType.php index 0bd3d0e3f..78d653589 100644 --- a/src/Form/Part/PartBaseType.php +++ b/src/Form/Part/PartBaseType.php @@ -30,6 +30,7 @@ use App\Entity\Parts\ManufacturingStatus; use App\Entity\Parts\MeasurementUnit; use App\Entity\Parts\Part; +use App\Entity\Parts\PartCustomState; use App\Entity\PriceInformations\Orderdetail; use App\Form\AttachmentFormType; use App\Form\ParameterType; @@ -41,6 +42,7 @@ use App\Services\InfoProviderSystem\DTOs\PartDetailDTO; use App\Services\LogSystem\EventCommentNeededHelper; use App\Services\LogSystem\EventCommentType; +use App\Settings\MiscSettings\IpnSuggestSettings; use Symfony\Bundle\SecurityBundle\Security; use Symfony\Component\Form\AbstractType; use Symfony\Component\Form\Extension\Core\Type\CheckboxType; @@ -56,8 +58,12 @@ class PartBaseType extends AbstractType { - public function __construct(protected Security $security, protected UrlGeneratorInterface $urlGenerator, protected EventCommentNeededHelper $event_comment_needed_helper) - { + public function __construct( + protected Security $security, + protected UrlGeneratorInterface $urlGenerator, + protected EventCommentNeededHelper $event_comment_needed_helper, + protected IpnSuggestSettings $ipnSuggestSettings, + ) { } public function buildForm(FormBuilderInterface $builder, array $options): void @@ -69,6 +75,38 @@ public function buildForm(FormBuilderInterface $builder, array $options): void /** @var PartDetailDTO|null $dto */ $dto = $options['info_provider_dto']; + $descriptionAttr = [ + 'placeholder' => 'part.edit.description.placeholder', + 'rows' => 2, + ]; + + if ($this->ipnSuggestSettings->useDuplicateDescription) { + // Only add attribute when duplicate description feature is enabled + $descriptionAttr['data-ipn-suggestion'] = 'descriptionField'; + } + + $ipnAttr = [ + 'class' => 'ipn-suggestion-field', + 'data-elements--ipn-suggestion-target' => 'input', + 'autocomplete' => 'off', + ]; + + if ($this->ipnSuggestSettings->regex !== null && $this->ipnSuggestSettings->regex !== '') { + $ipnAttr['pattern'] = $this->ipnSuggestSettings->regex; + $ipnAttr['placeholder'] = $this->ipnSuggestSettings->regex; + } + + $ipnOptions = [ + 'required' => false, + 'empty_data' => null, + 'label' => 'part.edit.ipn', + 'attr' => $ipnAttr, + ]; + + if (isset($ipnAttr['pattern']) && $this->ipnSuggestSettings->regexHelp !== null && $this->ipnSuggestSettings->regexHelp !== '') { + $ipnOptions['help'] = $this->ipnSuggestSettings->regexHelp; + } + //Common section $builder ->add('name', TextType::class, [ @@ -83,10 +121,7 @@ public function buildForm(FormBuilderInterface $builder, array $options): void 'empty_data' => '', 'label' => 'part.edit.description', 'mode' => 'markdown-single_line', - 'attr' => [ - 'placeholder' => 'part.edit.description.placeholder', - 'rows' => 2, - ], + 'attr' => $descriptionAttr, ]) ->add('minAmount', SIUnitType::class, [ 'attr' => [ @@ -104,6 +139,9 @@ public function buildForm(FormBuilderInterface $builder, array $options): void 'disable_not_selectable' => true, //Do not require category for new parts, so that the user must select the category by hand and cannot forget it (the requirement is handled by the constraint in the entity) 'required' => !$new_part, + 'attr' => [ + 'data-ipn-suggestion' => 'categoryField', + ] ]) ->add('footprint', StructuralEntityType::class, [ 'class' => Footprint::class, @@ -171,11 +209,13 @@ public function buildForm(FormBuilderInterface $builder, array $options): void 'disable_not_selectable' => true, 'label' => 'part.edit.partUnit', ]) - ->add('ipn', TextType::class, [ + ->add('partCustomState', StructuralEntityType::class, [ + 'class' => PartCustomState::class, 'required' => false, - 'empty_data' => null, - 'label' => 'part.edit.ipn', - ]); + 'disable_not_selectable' => true, + 'label' => 'part.edit.partCustomState', + ]) + ->add('ipn', TextType::class, $ipnOptions); //Comment section $builder->add('comment', RichTextEditorType::class, [ diff --git a/src/Form/ProjectSystem/ProjectBOMEntryType.php b/src/Form/ProjectSystem/ProjectBOMEntryType.php index cac362fbb..44850c304 100644 --- a/src/Form/ProjectSystem/ProjectBOMEntryType.php +++ b/src/Form/ProjectSystem/ProjectBOMEntryType.php @@ -22,8 +22,6 @@ class ProjectBOMEntryType extends AbstractType public function buildForm(FormBuilderInterface $builder, array $options): void { - - $builder->addEventListener(FormEvents::PRE_SET_DATA, function (PreSetDataEvent $event) { $form = $event->getForm(); /** @var ProjectBOMEntry $data */ @@ -36,11 +34,10 @@ public function buildForm(FormBuilderInterface $builder, array $options): void }); $builder - ->add('part', PartSelectType::class, [ + 'label' => 'project.bom.part', 'required' => false, ]) - ->add('name', TextType::class, [ 'label' => 'project.bom.name', 'required' => false, @@ -77,10 +74,7 @@ public function buildForm(FormBuilderInterface $builder, array $options): void 'required' => false, 'label' => false, 'short' => true, - ]) - - ; - + ]); } public function configureOptions(OptionsResolver $resolver): void diff --git a/src/Form/ProjectSystem/ProjectBuildType.php b/src/Form/ProjectSystem/ProjectBuildType.php index 2b7b52e28..b13dd12f2 100644 --- a/src/Form/ProjectSystem/ProjectBuildType.php +++ b/src/Form/ProjectSystem/ProjectBuildType.php @@ -82,36 +82,35 @@ public function buildForm(FormBuilderInterface $builder, array $options): void //The form is initially empty, we have to define the fields after we know the data $builder->addEventListener(FormEvents::PRE_SET_DATA, function (PreSetDataEvent $event) { $form = $event->getForm(); - /** @var ProjectBuildRequest $build_request */ - $build_request = $event->getData(); + /** @var ProjectBuildRequest $projectBuildRequest */ + $projectBuildRequest = $event->getData(); $form->add('addBuildsToBuildsPart', CheckboxType::class, [ 'label' => 'project.build.add_builds_to_builds_part', 'required' => false, - 'disabled' => !$build_request->getProject()->getBuildPart() instanceof Part, + 'disabled' => !$projectBuildRequest->getProject()->getBuildPart() instanceof Part, ]); - if ($build_request->getProject()->getBuildPart() instanceof Part) { + if ($projectBuildRequest->getProject()->getBuildPart() instanceof Part) { $form->add('buildsPartLot', PartLotSelectType::class, [ 'label' => 'project.build.builds_part_lot', 'required' => false, - 'part' => $build_request->getProject()->getBuildPart(), + 'part' => $projectBuildRequest->getProject()->getBuildPart(), 'placeholder' => 'project.build.buildsPartLot.new_lot' ]); } - foreach ($build_request->getPartBomEntries() as $bomEntry) { + foreach ($projectBuildRequest->getPartBomEntries() as $bomEntry) { //Every part lot has a field to specify the number of parts to take from this lot - foreach ($build_request->getPartLotsForBOMEntry($bomEntry) as $lot) { + foreach ($projectBuildRequest->getPartLotsForBOMEntry($bomEntry) as $lot) { $form->add('lot_' . $lot->getID(), SIUnitType::class, [ 'label' => false, 'measurement_unit' => $bomEntry->getPart()->getPartUnit(), - 'max' => min($build_request->getNeededAmountForBOMEntry($bomEntry), $lot->getAmount()), + 'max' => min($projectBuildRequest->getNeededAmountForBOMEntry($bomEntry), $lot->getAmount()), 'disabled' => !$this->security->isGranted('withdraw', $lot), ]); } } - }); } diff --git a/src/Form/Type/AssemblySelectType.php b/src/Form/Type/AssemblySelectType.php new file mode 100644 index 000000000..10e858f26 --- /dev/null +++ b/src/Form/Type/AssemblySelectType.php @@ -0,0 +1,124 @@ +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, + ]); + + error_log($this->urlGenerator->generate('typeahead_assemblies', ['query' => '__QUERY__'])); + + $resolver->setDefaults([ + 'attr' => [ + 'data-controller' => 'elements--assembly-select', + 'data-autocomplete' => $this->urlGenerator->generate('typeahead_assemblies', ['query' => '__QUERY__']), + 'autocomplete' => 'off', + ], + ]); + + $resolver->setDefaults([ + //Prefill the selected choice with the needed data, so the user can see it without an additional Ajax request + 'choice_attr' => ChoiceList::attr($this, function (?Assembly $assembly) { + if($assembly instanceof Assembly) { + //Determine the picture to show: + $preview_attachment = $this->previewGenerator->getTablePreviewAttachment($assembly); + if ($preview_attachment instanceof Attachment) { + $preview_url = $this->attachmentURLGenerator->getThumbnailURL($preview_attachment, + 'thumbnail_sm'); + } else { + $preview_url = ''; + } + } + + return $assembly instanceof Assembly ? [ + 'data-description' => $assembly->getDescription() ? mb_strimwidth($assembly->getDescription(), 0, 127, '...') : '', + 'data-category' => '', + 'data-footprint' => '', + 'data-image' => $preview_url, + ] : []; + }) + ]); + } + + public function mapDataToForms($data, \Traversable $forms): void + { + $form = current(iterator_to_array($forms, false)); + $form->setData($data); + } + + public function mapFormsToData(\Traversable $forms, &$data): void + { + $form = current(iterator_to_array($forms, false)); + $data = $form->getData(); + } + +} diff --git a/src/Form/Type/DataSourceJsonType.php b/src/Form/Type/DataSourceJsonType.php new file mode 100644 index 000000000..6d11058a2 --- /dev/null +++ b/src/Form/Type/DataSourceJsonType.php @@ -0,0 +1,103 @@ +settings->dataSourceSynonyms; + } + + foreach ($dataSources as $key => $label) { + $initialData = $existingData[$key] ?? $defaultValues[$key] ?? '{}'; + + $builder->add($key, TextareaType::class, [ + 'label' => $label, + 'required' => false, + 'data' => $initialData, + 'attr' => [ + 'rows' => 3, + 'style' => 'font-family: monospace;', + 'placeholder' => sprintf('%s translations in JSON format', ucfirst($key)), + ], + 'constraints' => [ + new Assert\Callback(function ($value, $context) { + if ($value && !static::isValidJson($value)) { + $context->buildViolation('The field must contain valid JSON.')->addViolation(); + } + }), + ], + ]); + } + + $builder->addEventListener(FormEvents::SUBMIT, function (FormEvent $event) use ($defaultValues) { + $data = $event->getData(); + + if (!$data) { + $event->setData($defaultValues); + return; + } + + foreach ($defaultValues as $key => $defaultValue) { + if (empty($data[$key])) { + $data[$key] = $defaultValue; + } else { + $decodedValue = json_decode($data[$key], true); + if (json_last_error() === JSON_ERROR_NONE) { + $data[$key] = json_encode($decodedValue, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE); + } + } + } + + $event->setData($data); + }); + } + + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults([ + 'data_sources' => [], + 'default_values' => [], + ]); + + $resolver->setAllowedTypes('data_sources', 'array'); + $resolver->setAllowedTypes('default_values', 'array'); + } + + /** + * Validates if a string is a valid JSON format. + * + * @param string $json + * @return bool + */ + public static function isValidJson(string $json): bool + { + json_decode($json); + return json_last_error() === JSON_ERROR_NONE; + } +} diff --git a/src/Form/Type/LocaleSelectType.php b/src/Form/Type/LocaleSelectType.php index d47fb57fd..b87932d12 100644 --- a/src/Form/Type/LocaleSelectType.php +++ b/src/Form/Type/LocaleSelectType.php @@ -23,7 +23,7 @@ namespace App\Form\Type; -use Symfony\Component\DependencyInjection\Attribute\Autowire; +use App\Settings\SystemSettings\LocalizationSettings; use Symfony\Component\Form\AbstractType; use Symfony\Component\Form\Extension\Core\Type\LocaleType; use Symfony\Component\OptionsResolver\OptionsResolver; @@ -35,7 +35,7 @@ class LocaleSelectType extends AbstractType { - public function __construct(#[Autowire(param: 'partdb.locale_menu')] private readonly array $preferred_languages) + public function __construct(private LocalizationSettings $localizationSetting) { } @@ -47,7 +47,7 @@ public function getParent(): string public function configureOptions(OptionsResolver $resolver): void { $resolver->setDefaults([ - 'preferred_choices' => $this->preferred_languages, + 'preferred_choices' => array_column($this->localizationSetting->preferredLanguages, 'value'), ]); } } diff --git a/src/Form/Type/PartSelectType.php b/src/Form/Type/PartSelectType.php index 34b8fc7c4..c41d6b8f9 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 @@ -84,7 +84,6 @@ public function configureOptions(OptionsResolver $resolver): void 'data-autocomplete' => $this->urlGenerator->generate('typeahead_parts', ['query' => '__QUERY__']), //Disable browser autocomplete 'autocomplete' => 'off', - ], ]); @@ -103,7 +102,7 @@ public function configureOptions(OptionsResolver $resolver): void } return $part instanceof Part ? [ - 'data-description' => mb_strimwidth($part->getDescription(), 0, 127, '...'), + 'data-description' => $part->getDescription() ? mb_strimwidth($part->getDescription(), 0, 127, '...') : '', 'data-category' => $part->getCategory() instanceof Category ? $part->getCategory()->getName() : '', 'data-footprint' => $part->getFootprint() instanceof Footprint ? $part->getFootprint()->getName() : '', 'data-image' => $preview_url, diff --git a/src/Helpers/Assemblies/AssemblyPartAggregator.php b/src/Helpers/Assemblies/AssemblyPartAggregator.php new file mode 100644 index 000000000..46495935d --- /dev/null +++ b/src/Helpers/Assemblies/AssemblyPartAggregator.php @@ -0,0 +1,273 @@ +. + */ +namespace App\Helpers\Assemblies; + +use App\Entity\AssemblySystem\Assembly; +use App\Entity\AssemblySystem\AssemblyBOMEntry; +use App\Entity\Parts\Part; +use Dompdf\Dompdf; +use Dompdf\Options; +use Twig\Environment; + +class AssemblyPartAggregator +{ + public function __construct(private readonly Environment $twig) + { + } + + /** + * Aggregate the required parts and their total quantities for an assembly. + * + * @param Assembly $assembly The assembly to process. + * @param float $multiplier The quantity multiplier from the parent assembly. + * @return array Array of parts with their aggregated quantities, keyed by Part ID. + */ + public function getAggregatedParts(Assembly $assembly, float $multiplier): array + { + $aggregatedParts = []; + + // Start processing the assembly recursively + $this->processAssembly($assembly, $multiplier, $aggregatedParts); + + // Return the final aggregated list of parts + return $aggregatedParts; + } + + /** + * Recursive helper to process an assembly and all its BOM entries. + * + * @param Assembly $assembly The current assembly to process. + * @param float $multiplier The quantity multiplier from the parent assembly. + * @param array &$aggregatedParts The array to accumulate parts and their quantities. + */ + private function processAssembly(Assembly $assembly, float $multiplier, array &$aggregatedParts): void + { + /** @var AssemblyBOMEntry $bomEntry */ + foreach ($assembly->getBomEntries() as $bomEntry) { + // If the BOM entry refers to a part, add its quantity + if ($bomEntry->getPart() instanceof Part) { + $part = $bomEntry->getPart(); + + if (!isset($aggregatedParts[$part->getId()])) { + $aggregatedParts[$part->getId()] = [ + 'part' => $part, + 'assembly' => $assembly, + 'name' => $bomEntry->getName(), + 'designator' => $bomEntry->getDesignator(), + 'quantity' => $bomEntry->getQuantity(), + 'multiplier' => $multiplier, + ]; + } + } elseif ($bomEntry->getReferencedAssembly() instanceof Assembly) { + // If the BOM entry refers to another assembly, process it recursively + $this->processAssembly($bomEntry->getReferencedAssembly(), $bomEntry->getQuantity(), $aggregatedParts); + } else { + $aggregatedParts[] = [ + 'part' => null, + 'assembly' => $assembly, + 'name' => $bomEntry->getName(), + 'designator' => $bomEntry->getDesignator(), + 'quantity' => $bomEntry->getQuantity(), + 'multiplier' => $multiplier, + ]; + } + } + } + + /** + * Exports a hierarchical Bill of Materials (BOM) for assemblies and parts in a readable format, + * including the multiplier for each part and assembly. + * + * @param Assembly $assembly The root assembly to export. + * @param string $indentationSymbol The symbol used for indentation (e.g., ' '). + * @param int $initialDepth The starting depth for formatting (default: 0). + * @return string Human-readable hierarchical BOM list. + */ + public function exportReadableHierarchy(Assembly $assembly, string $indentationSymbol = ' ', int $initialDepth = 0): string + { + // Start building the hierarchy + $output = ''; + $this->processAssemblyHierarchy($assembly, $initialDepth, 1, $indentationSymbol, $output); + + return $output; + } + + public function exportReadableHierarchyForPdf(array $assemblyHierarchies): string + { + $html = $this->twig->render('assemblies/export_bom_pdf.html.twig', [ + 'assemblies' => $assemblyHierarchies, + ]); + + $options = new Options(); + $options->set('isHtml5ParserEnabled', true); + $options->set('isPhpEnabled', true); + + $dompdf = new Dompdf($options); + $dompdf->loadHtml($html); + $dompdf->setPaper('A4'); + $dompdf->render(); + + $canvas = $dompdf->getCanvas(); + $font = $dompdf->getFontMetrics()->getFont('Arial', 'normal'); + + return $dompdf->output(); + } + + /** + * Recursive method to process assemblies and their parts. + * + * @param Assembly $assembly The current assembly to process. + * @param int $depth The current depth in the hierarchy. + * @param float $parentMultiplier The multiplier inherited from the parent (default is 1 for root). + * @param string $indentationSymbol The symbol used for indentation. + * @param string &$output The cumulative output string. + */ + private function processAssemblyHierarchy(Assembly $assembly, int $depth, float $parentMultiplier, string $indentationSymbol, string &$output): void + { + // Add the current assembly to the output + if ($depth === 0) { + $output .= sprintf( + "%sAssembly: %s [IPN: %s]\n\n", + str_repeat($indentationSymbol, $depth), + $assembly->getName(), + $assembly->getIpn(), + ); + } else { + $output .= sprintf( + "%sAssembly: %s [IPN: %s, Multiplier: %.2f]\n\n", + str_repeat($indentationSymbol, $depth), + $assembly->getName(), + $assembly->getIpn(), + $parentMultiplier + ); + } + + // Gruppiere BOM-Einträge in Kategorien + $parts = []; + $referencedAssemblies = []; + $others = []; + + foreach ($assembly->getBomEntries() as $bomEntry) { + if ($bomEntry->getPart() instanceof Part) { + $parts[] = $bomEntry; + } elseif ($bomEntry->getReferencedAssembly() instanceof Assembly) { + $referencedAssemblies[] = $bomEntry; + } else { + $others[] = $bomEntry; + } + } + + if (!empty($parts)) { + // Process each BOM entry for the current assembly + foreach ($parts as $bomEntry) { + $effectiveQuantity = $bomEntry->getQuantity() * $parentMultiplier; + + $output .= sprintf( + "%sPart: %s [IPN: %s, MPNR: %s, Quantity: %.2f%s, EffectiveQuantity: %.2f]\n", + str_repeat($indentationSymbol, $depth + 1), + $bomEntry->getPart()?->getName(), + $bomEntry->getPart()?->getIpn() ?? '-', + $bomEntry->getPart()?->getManufacturerProductNumber() ?? '-', + $bomEntry->getQuantity(), + $parentMultiplier > 1 ? sprintf(", Multiplier: %.2f", $parentMultiplier) : '', + $effectiveQuantity, + ); + } + + $output .= "\n"; + } + + foreach ($referencedAssemblies as $bomEntry) { + // Add referenced assembly details + $referencedQuantity = $bomEntry->getQuantity() * $parentMultiplier; + + $output .= sprintf( + "%sReferenced Assembly: %s [IPN: %s, Quantity: %.2f%s, EffectiveQuantity: %.2f]\n", + str_repeat($indentationSymbol, $depth + 1), + $bomEntry->getReferencedAssembly()->getName(), + $bomEntry->getReferencedAssembly()->getIpn() ?? '-', + $bomEntry->getQuantity(), + $parentMultiplier > 1 ? sprintf(", Multiplier: %.2f", $parentMultiplier) : '', + $referencedQuantity, + ); + + // Recurse into the referenced assembly + $this->processAssemblyHierarchy( + $bomEntry->getReferencedAssembly(), + $depth + 2, // Increase depth for nested assemblies + $referencedQuantity, // Pass the calculated multiplier + $indentationSymbol, + $output + ); + } + + foreach ($others as $bomEntry) { + $output .= sprintf( + "%sOther: %s [Quantity: %.2f, Multiplier: %.2f]\n", + str_repeat($indentationSymbol, $depth + 1), + $bomEntry->getName(), + $bomEntry->getQuantity(), + $parentMultiplier, + ); + } + } + + public function processAssemblyHierarchyForPdf(Assembly $assembly, int $depth, float $quantity, float $parentMultiplier): array + { + $result = [ + 'name' => $assembly->getName(), + 'ipn' => $assembly->getIpn(), + 'quantity' => $quantity, + 'multiplier' => $depth === 0 ? null : $parentMultiplier, + 'parts' => [], + 'referencedAssemblies' => [], + 'others' => [], + ]; + + foreach ($assembly->getBomEntries() as $bomEntry) { + if ($bomEntry->getPart() instanceof Part) { + $result['parts'][] = [ + 'name' => $bomEntry->getPart()->getName(), + 'ipn' => $bomEntry->getPart()->getIpn(), + 'quantity' => $bomEntry->getQuantity(), + 'effectiveQuantity' => $bomEntry->getQuantity() * $parentMultiplier, + ]; + } elseif ($bomEntry->getReferencedAssembly() instanceof Assembly) { + $result['referencedAssemblies'][] = $this->processAssemblyHierarchyForPdf( + $bomEntry->getReferencedAssembly(), + $depth + 1, + $bomEntry->getQuantity(), + $parentMultiplier * $bomEntry->getQuantity() + ); + } else { + $result['others'][] = [ + 'name' => $bomEntry->getName(), + 'quantity' => $bomEntry->getQuantity(), + 'multiplier' => $parentMultiplier, + ]; + } + } + + return $result; + } +} diff --git a/src/Helpers/Projects/ProjectBuildRequest.php b/src/Helpers/Projects/ProjectBuildRequest.php index 430d37b56..24bb5eb78 100644 --- a/src/Helpers/Projects/ProjectBuildRequest.php +++ b/src/Helpers/Projects/ProjectBuildRequest.php @@ -301,6 +301,4 @@ public function setDontCheckQuantity(bool $dont_check_quantity): ProjectBuildReq $this->dont_check_quantity = $dont_check_quantity; return $this; } - - -} +} \ No newline at end of file diff --git a/src/Repository/AssemblyRepository.php b/src/Repository/AssemblyRepository.php new file mode 100644 index 000000000..eef366905 --- /dev/null +++ b/src/Repository/AssemblyRepository.php @@ -0,0 +1,69 @@ +. + */ + +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'); + + $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 2437e8488..23ad296aa 100644 --- a/src/Repository/DBElementRepository.php +++ b/src/Repository/DBElementRepository.php @@ -154,4 +154,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 edccd74ba..3c83001af 100644 --- a/src/Repository/PartRepository.php +++ b/src/Repository/PartRepository.php @@ -22,17 +22,35 @@ namespace App\Repository; +use App\Entity\Parts\Category; use App\Entity\Parts\Part; use App\Entity\Parts\PartLot; +use App\Settings\MiscSettings\IpnSuggestSettings; use Doctrine\ORM\NonUniqueResultException; use Doctrine\ORM\NoResultException; use Doctrine\ORM\QueryBuilder; +use Symfony\Contracts\Translation\TranslatorInterface; +use Doctrine\ORM\EntityManagerInterface; /** * @extends NamedDBElementRepository */ class PartRepository extends NamedDBElementRepository { + private TranslatorInterface $translator; + private IpnSuggestSettings $ipnSuggestSettings; + + public function __construct( + EntityManagerInterface $em, + TranslatorInterface $translator, + IpnSuggestSettings $ipnSuggestSettings, + ) { + parent::__construct($em, $em->getClassMetadata(Part::class)); + + $this->translator = $translator; + $this->ipnSuggestSettings = $ipnSuggestSettings; + } + /** * Gets the summed up instock of all parts (only parts without a measurement unit). * @@ -84,8 +102,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(category.name, :query) = TRUE') - ->orWhere('ILIKE(footprint.name, :query) = TRUE') - ; + ->orWhere('ILIKE(footprint.name, :query) = TRUE'); $qb->setParameter('query', '%'.$query.'%'); @@ -94,4 +111,240 @@ public function autocompleteSearch(string $query, int $max_limits = 50): array return $qb->getQuery()->getResult(); } + + /** + * Provides IPN (Internal Part Number) suggestions for a given part based on its category, description, + * and configured autocomplete digit length. + * + * This function generates suggestions for common prefixes and incremented prefixes based on + * the part's current category and its hierarchy. If the part is unsaved, a default "n.a." prefix is returned. + * + * @param Part $part The part for which autocomplete suggestions are generated. + * @param string $description description to assist in generating suggestions. + * @param int $suggestPartDigits The number of digits used in autocomplete increments. + * + * @return array An associative array containing the following keys: + * - 'commonPrefixes': List of common prefixes found for the part. + * - 'prefixesPartIncrement': Increments for the generated prefixes, including hierarchical prefixes. + */ + public function autoCompleteIpn(Part $part, string $description, int $suggestPartDigits): array + { + $category = $part->getCategory(); + $ipnSuggestions = ['commonPrefixes' => [], 'prefixesPartIncrement' => []]; + + if (strlen($description) > 150) { + $description = substr($description, 0, 150); + } + + if ($description !== '' && $this->ipnSuggestSettings->useDuplicateDescription) { + // Check if the description is already used in another part, + + $suggestionByDescription = $this->getIpnSuggestByDescription($description); + + if ($suggestionByDescription !== null && $suggestionByDescription !== $part->getIpn() && $part->getIpn() !== null && $part->getIpn() !== '') { + $ipnSuggestions['prefixesPartIncrement'][] = [ + 'title' => $part->getIpn(), + 'description' => $this->translator->trans('part.edit.tab.advanced.ipn.prefix.description.current-increment') + ]; + } + + if ($suggestionByDescription !== null) { + $ipnSuggestions['prefixesPartIncrement'][] = [ + 'title' => $suggestionByDescription, + 'description' => $this->translator->trans('part.edit.tab.advanced.ipn.prefix.description.increment') + ]; + } + } + + // Validate the category and ensure it's an instance of Category + if ($category instanceof Category) { + $currentPath = $category->getPartIpnPrefix(); + $directIpnPrefixEmpty = $category->getPartIpnPrefix() === ''; + $currentPath = $currentPath === '' ? 'n.a.' : $currentPath; + + $increment = $this->generateNextPossiblePartIncrement($currentPath, $part, $suggestPartDigits); + + $ipnSuggestions['commonPrefixes'][] = [ + 'title' => $currentPath . '-', + 'description' => $directIpnPrefixEmpty ? $this->translator->trans('part.edit.tab.advanced.ipn.prefix_empty.direct_category', ['%name%' => $category->getName()]) : $this->translator->trans('part.edit.tab.advanced.ipn.prefix.direct_category') + ]; + + $ipnSuggestions['prefixesPartIncrement'][] = [ + 'title' => $currentPath . '-' . $increment, + 'description' => $directIpnPrefixEmpty ? $this->translator->trans('part.edit.tab.advanced.ipn.prefix_empty.direct_category', ['%name%' => $category->getName()]) : $this->translator->trans('part.edit.tab.advanced.ipn.prefix.direct_category.increment') + ]; + + // Process parent categories + $parentCategory = $category->getParent(); + + while ($parentCategory instanceof Category) { + // Prepend the parent category's prefix to the current path + $currentPath = $parentCategory->getPartIpnPrefix() . '-' . $currentPath; + $currentPath = $parentCategory->getPartIpnPrefix() === '' ? 'n.a.-' . $currentPath : $currentPath; + + $ipnSuggestions['commonPrefixes'][] = [ + 'title' => $currentPath . '-', + 'description' => $this->translator->trans('part.edit.tab.advanced.ipn.prefix.hierarchical.no_increment') + ]; + + $increment = $this->generateNextPossiblePartIncrement($currentPath, $part, $suggestPartDigits); + + $ipnSuggestions['prefixesPartIncrement'][] = [ + 'title' => $currentPath . '-' . $increment, + 'description' => $this->translator->trans('part.edit.tab.advanced.ipn.prefix.hierarchical.increment') + ]; + + // Move to the next parent category + $parentCategory = $parentCategory->getParent(); + } + } elseif ($part->getID() === null) { + $ipnSuggestions['commonPrefixes'][] = [ + 'title' => 'n.a.', + 'description' => $this->translator->trans('part.edit.tab.advanced.ipn.prefix.not_saved') + ]; + } + + return $ipnSuggestions; + } + + /** + * Suggests the next IPN (Internal Part Number) based on the provided part description. + * + * Searches for parts with similar descriptions and retrieves their existing IPNs to calculate the next suggestion. + * Returns null if the description is empty or no suggestion can be generated. + * + * @param string $description The part description to search for. + * + * @return string|null The suggested IPN, or null if no suggestion is possible. + * + * @throws NonUniqueResultException + */ + public function getIpnSuggestByDescription(string $description): ?string + { + if ($description === '') { + return null; + } + + $qb = $this->createQueryBuilder('part'); + + $qb->select('part') + ->where('part.description LIKE :descriptionPattern') + ->setParameter('descriptionPattern', $description.'%') + ->orderBy('part.id', 'ASC'); + + $partsBySameDescription = $qb->getQuery()->getResult(); + $givenIpnsWithSameDescription = []; + + foreach ($partsBySameDescription as $part) { + if ($part->getIpn() === null || $part->getIpn() === '') { + continue; + } + + $givenIpnsWithSameDescription[] = $part->getIpn(); + } + + return $this->getNextIpnSuggestion($givenIpnsWithSameDescription); + } + + /** + * Generates the next possible increment for a part within a given category, while ensuring uniqueness. + * + * This method calculates the next available increment for a part's identifier (`ipn`) based on the current path + * and the number of digits specified for the autocomplete feature. It ensures that the generated identifier + * aligns with the expected length and does not conflict with already existing identifiers in the same category. + * + * @param string $currentPath The base path or prefix for the part's identifier. + * @param Part $currentPart The part entity for which the increment is being generated. + * @param int $suggestPartDigits The number of digits reserved for the increment. + * + * @return string The next possible increment as a zero-padded string. + * + * @throws NonUniqueResultException If the query returns non-unique results. + * @throws NoResultException If the query fails to return a result. + */ + private function generateNextPossiblePartIncrement(string $currentPath, Part $currentPart, int $suggestPartDigits): string + { + $qb = $this->createQueryBuilder('part'); + + $expectedLength = strlen($currentPath) + 1 + $suggestPartDigits; // Path + '-' + $suggestPartDigits digits + + // Fetch all parts in the given category, sorted by their ID in ascending order + $qb->select('part') + ->where('part.ipn LIKE :ipnPattern') + ->andWhere('LENGTH(part.ipn) = :expectedLength') + ->setParameter('ipnPattern', $currentPath . '%') + ->setParameter('expectedLength', $expectedLength) + ->orderBy('part.id', 'ASC'); + + $parts = $qb->getQuery()->getResult(); + + // Collect all used increments in the category + $usedIncrements = []; + foreach ($parts as $part) { + if ($part->getIpn() === null || $part->getIpn() === '') { + continue; + } + + if ($part->getId() === $currentPart->getId() && $currentPart->getID() !== null) { + // Extract and return the current part's increment directly + $incrementPart = substr($part->getIpn(), -$suggestPartDigits); + if (is_numeric($incrementPart)) { + return str_pad((string) $incrementPart, $suggestPartDigits, '0', STR_PAD_LEFT); + } + } + + // Extract last $autocompletePartDigits digits for possible available part increment + $incrementPart = substr($part->getIpn(), -$suggestPartDigits); + if (is_numeric($incrementPart)) { + $usedIncrements[] = (int) $incrementPart; + } + + } + + // Generate the next free $autocompletePartDigits-digit increment + $nextIncrement = 1; // Start at the beginning + + while (in_array($nextIncrement, $usedIncrements, true)) { + $nextIncrement++; + } + + return str_pad((string) $nextIncrement, $suggestPartDigits, '0', STR_PAD_LEFT); + } + + /** + * Generates the next IPN suggestion based on the maximum numeric suffix found in the given IPNs. + * + * The new IPN is constructed using the base format of the first provided IPN, + * incremented by the next free numeric suffix. If no base IPNs are found, + * returns null. + * + * @param array $givenIpns List of IPNs to analyze. + * + * @return string|null The next suggested IPN, or null if no base IPNs can be derived. + */ + private function getNextIpnSuggestion(array $givenIpns): ?string { + $maxSuffix = 0; + + foreach ($givenIpns as $ipn) { + // Check whether the IPN contains a suffix "_ " + if (preg_match('/_(\d+)$/', $ipn, $matches)) { + $suffix = (int)$matches[1]; + if ($suffix > $maxSuffix) { + $maxSuffix = $suffix; // Höchste Nummer speichern + } + } + } + + // Find the basic format (the IPN without suffix) from the first IPN + $baseIpn = $givenIpns[0] ?? ''; + $baseIpn = preg_replace('/_\d+$/', '', $baseIpn); // Remove existing "_ " + + if ($baseIpn === '') { + return null; + } + + // Generate next free possible IPN + return $baseIpn . '_' . ($maxSuffix + 1); + } + } diff --git a/src/Repository/Parts/PartCustomStateRepository.php b/src/Repository/Parts/PartCustomStateRepository.php new file mode 100644 index 000000000..d66221a24 --- /dev/null +++ b/src/Repository/Parts/PartCustomStateRepository.php @@ -0,0 +1,48 @@ +. + */ +namespace App\Repository\Parts; + +use App\Entity\Parts\PartCustomState; +use App\Repository\AbstractPartsContainingRepository; +use InvalidArgumentException; + +class PartCustomStateRepository extends AbstractPartsContainingRepository +{ + public function getParts(object $element, string $nameOrderDirection = "ASC"): array + { + if (!$element instanceof PartCustomState) { + throw new InvalidArgumentException('$element must be an PartCustomState!'); + } + + return $this->getPartsByField($element, $nameOrderDirection, 'partUnit'); + } + + public function getPartsCount(object $element): int + { + if (!$element instanceof PartCustomState) { + throw new InvalidArgumentException('$element must be an PartCustomState!'); + } + + return $this->getPartsCountByField($element, 'partUnit'); + } +} diff --git a/src/Security/Voter/AttachmentVoter.php b/src/Security/Voter/AttachmentVoter.php index bd7ae4df8..c233a236f 100644 --- a/src/Security/Voter/AttachmentVoter.php +++ b/src/Security/Voter/AttachmentVoter.php @@ -22,6 +22,8 @@ namespace App\Security\Voter; +use App\Entity\Attachments\AssemblyAttachment; +use App\Entity\Attachments\PartCustomStateAttachment; use App\Services\UserSystem\VoterHelper; use Symfony\Bundle\SecurityBundle\Security; use App\Entity\Attachments\AttachmentContainingDBElement; @@ -89,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)) { @@ -99,6 +103,8 @@ protected function voteOnAttribute(string $attribute, $subject, TokenInterface $ $param = 'measurement_units'; } elseif (is_a($subject, PartAttachment::class, true)) { $param = 'parts'; + } elseif (is_a($subject, PartCustomStateAttachment::class, true)) { + $param = 'part_custom_states'; } elseif (is_a($subject, StorageLocationAttachment::class, true)) { $param = 'storelocations'; } elseif (is_a($subject, SupplierAttachment::class, true)) { diff --git a/src/Security/Voter/ParameterVoter.php b/src/Security/Voter/ParameterVoter.php index f59bdeaf5..5dc30ea25 100644 --- a/src/Security/Voter/ParameterVoter.php +++ b/src/Security/Voter/ParameterVoter.php @@ -22,6 +22,7 @@ */ namespace App\Security\Voter; +use App\Entity\Parameters\PartCustomStateParameter; use App\Services\UserSystem\VoterHelper; use Symfony\Bundle\SecurityBundle\Security; use App\Entity\Base\AbstractDBElement; @@ -97,6 +98,8 @@ protected function voteOnAttribute(string $attribute, $subject, TokenInterface $ $param = 'measurement_units'; } elseif (is_a($subject, PartParameter::class, true)) { $param = 'parts'; + } elseif (is_a($subject, PartCustomStateParameter::class, true)) { + $param = 'part_custom_states'; } elseif (is_a($subject, StorageLocationParameter::class, true)) { $param = 'storelocations'; } elseif (is_a($subject, SupplierParameter::class, true)) { diff --git a/src/Security/Voter/StructureVoter.php b/src/Security/Voter/StructureVoter.php index ad0299a79..cb05ffdd5 100644 --- a/src/Security/Voter/StructureVoter.php +++ b/src/Security/Voter/StructureVoter.php @@ -22,7 +22,9 @@ namespace App\Security\Voter; +use App\Entity\AssemblySystem\Assembly; use App\Entity\Attachments\AttachmentType; +use App\Entity\Parts\PartCustomState; use App\Entity\ProjectSystem\Project; use App\Entity\Parts\Category; use App\Entity\Parts\Footprint; @@ -47,12 +49,14 @@ 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', Supplier::class => 'suppliers', Currency::class => 'currencies', MeasurementUnit::class => 'measurement_units', + PartCustomState::class => 'part_custom_states', ]; public function __construct(private readonly VoterHelper $helper) 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 9fbc3fe39..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; @@ -30,6 +31,7 @@ use App\Entity\Attachments\CategoryAttachment; use App\Entity\Attachments\CurrencyAttachment; use App\Entity\Attachments\LabelAttachment; +use App\Entity\Attachments\PartCustomStateAttachment; use App\Entity\Attachments\ProjectAttachment; use App\Entity\Attachments\FootprintAttachment; use App\Entity\Attachments\GroupAttachment; @@ -80,10 +82,12 @@ public function __construct( //The mapping used to determine which folder will be used for an attachment type $this->folder_mapping = [ PartAttachment::class => 'part', + PartCustomStateAttachment::class => 'part_custom_state', AttachmentTypeAttachment::class => 'attachment_type', 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/PartPreviewGenerator.php b/src/Services/Attachments/PartPreviewGenerator.php index ba6e5db0d..9aedba743 100644 --- a/src/Services/Attachments/PartPreviewGenerator.php +++ b/src/Services/Attachments/PartPreviewGenerator.php @@ -23,6 +23,7 @@ namespace App\Services\Attachments; use App\Entity\Parts\Footprint; +use App\Entity\Parts\PartCustomState; use App\Entity\ProjectSystem\Project; use App\Entity\Parts\Category; use App\Entity\Parts\StorageLocation; diff --git a/src/Services/EDA/KiCadHelper.php b/src/Services/EDA/KiCadHelper.php index 75c2cc346..4b7c5e5a5 100644 --- a/src/Services/EDA/KiCadHelper.php +++ b/src/Services/EDA/KiCadHelper.php @@ -233,6 +233,10 @@ public function getKiCADPart(Part $part): array } $result["fields"]["Part-DB Unit"] = $this->createField($unit); } + if ($part->getPartCustomState() !== null) { + $customState = $part->getPartCustomState()->getName(); + $result["fields"]["Part-DB Custom state"] = $this->createField($customState); + } if ($part->getMass()) { $result["fields"]["Mass"] = $this->createField($part->getMass() . ' g'); } diff --git a/src/Services/ElementTypeNameGenerator.php b/src/Services/ElementTypeNameGenerator.php index 326707b77..9ef2a063e 100644 --- a/src/Services/ElementTypeNameGenerator.php +++ b/src/Services/ElementTypeNameGenerator.php @@ -37,6 +37,7 @@ use App\Entity\Parts\MeasurementUnit; use App\Entity\Parts\Part; use App\Entity\Parts\PartAssociation; +use App\Entity\Parts\PartCustomState; use App\Entity\Parts\PartLot; use App\Entity\Parts\StorageLocation; use App\Entity\Parts\Supplier; @@ -45,6 +46,8 @@ use App\Entity\PriceInformations\Pricedetail; use App\Entity\ProjectSystem\Project; use App\Entity\ProjectSystem\ProjectBOMEntry; +use App\Entity\AssemblySystem\Assembly; +use App\Entity\AssemblySystem\AssemblyBOMEntry; use App\Entity\UserSystem\Group; use App\Entity\UserSystem\User; use App\Exceptions\EntityNotSupportedException; @@ -66,6 +69,8 @@ public function __construct(protected TranslatorInterface $translator, private r AttachmentType::class => $this->translator->trans('attachment_type.label'), Project::class => $this->translator->trans('project.label'), ProjectBOMEntry::class => $this->translator->trans('project_bom_entry.label'), + Assembly::class => $this->translator->trans('assembly.label'), + AssemblyBOMEntry::class => $this->translator->trans('assembly_bom_entry.label'), Footprint::class => $this->translator->trans('footprint.label'), Manufacturer::class => $this->translator->trans('manufacturer.label'), MeasurementUnit::class => $this->translator->trans('measurement_unit.label'), @@ -83,6 +88,7 @@ public function __construct(protected TranslatorInterface $translator, private r PartAssociation::class => $this->translator->trans('part_association.label'), BulkInfoProviderImportJob::class => $this->translator->trans('bulk_info_provider_import_job.label'), BulkInfoProviderImportJobPart::class => $this->translator->trans('bulk_info_provider_import_job_part.label'), + PartCustomState::class => $this->translator->trans('part_custom_state.label'), ]; } @@ -182,6 +188,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/EntityMergers/Mergers/PartMerger.php b/src/Services/EntityMergers/Mergers/PartMerger.php index 01b53e25e..d1f5c1374 100644 --- a/src/Services/EntityMergers/Mergers/PartMerger.php +++ b/src/Services/EntityMergers/Mergers/PartMerger.php @@ -65,6 +65,7 @@ public function merge(object $target, object $other, array $context = []): Part $this->useOtherValueIfNotNull($target, $other, 'footprint'); $this->useOtherValueIfNotNull($target, $other, 'category'); $this->useOtherValueIfNotNull($target, $other, 'partUnit'); + $this->useOtherValueIfNotNull($target, $other, 'partCustomState'); //We assume that the higher value is the correct one for minimum instock $this->useLargerValue($target, $other, 'minamount'); diff --git a/src/Services/EntityURLGenerator.php b/src/Services/EntityURLGenerator.php index 78db06f07..bdf26fa94 100644 --- a/src/Services/EntityURLGenerator.php +++ b/src/Services/EntityURLGenerator.php @@ -22,11 +22,13 @@ namespace App\Services; +use App\Entity\AssemblySystem\Assembly; use App\Entity\Attachments\Attachment; use App\Entity\Attachments\AttachmentType; use App\Entity\Attachments\PartAttachment; use App\Entity\Base\AbstractDBElement; use App\Entity\Parameters\PartParameter; +use App\Entity\Parts\PartCustomState; use App\Entity\ProjectSystem\Project; use App\Entity\LabelSystem\LabelProfile; use App\Entity\Parts\Category; @@ -98,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', @@ -107,6 +110,7 @@ public function timeTravelURL(AbstractDBElement $entity, \DateTimeInterface $dat MeasurementUnit::class => 'measurement_unit_edit', Group::class => 'group_edit', LabelProfile::class => 'label_profile_edit', + PartCustomState::class => 'part_custom_state_edit', ]; try { @@ -204,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', @@ -213,6 +218,7 @@ public function infoURL(AbstractDBElement $entity): string MeasurementUnit::class => 'measurement_unit_edit', Group::class => 'group_edit', LabelProfile::class => 'label_profile_edit', + PartCustomState::class => 'part_custom_state_edit', ]; return $this->urlGenerator->generate($this->mapToController($map, $entity), ['id' => $entity->getID()]); @@ -234,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', @@ -243,6 +250,7 @@ public function editURL(AbstractDBElement $entity): string MeasurementUnit::class => 'measurement_unit_edit', Group::class => 'group_edit', LabelProfile::class => 'label_profile_edit', + PartCustomState::class => 'part_custom_state_edit', ]; return $this->urlGenerator->generate($this->mapToController($map, $entity), ['id' => $entity->getID()]); @@ -265,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', @@ -274,6 +283,7 @@ public function createURL(AbstractDBElement|string $entity): string MeasurementUnit::class => 'measurement_unit_new', Group::class => 'group_new', LabelProfile::class => 'label_profile_new', + PartCustomState::class => 'part_custom_state_new', ]; return $this->urlGenerator->generate($this->mapToController($map, $entity)); @@ -296,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', @@ -305,6 +316,7 @@ public function cloneURL(AbstractDBElement $entity): string MeasurementUnit::class => 'measurement_unit_clone', Group::class => 'group_clone', LabelProfile::class => 'label_profile_clone', + PartCustomState::class => 'part_custom_state_clone', ]; return $this->urlGenerator->generate($this->mapToController($map, $entity), ['id' => $entity->getID()]); @@ -323,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', @@ -341,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', @@ -350,6 +364,7 @@ public function deleteURL(AbstractDBElement $entity): string MeasurementUnit::class => 'measurement_unit_delete', Group::class => 'group_delete', LabelProfile::class => 'label_profile_delete', + PartCustomState::class => 'part_custom_state_delete', ]; return $this->urlGenerator->generate($this->mapToController($map, $entity), ['id' => $entity->getID()]); diff --git a/src/Services/ImportExportSystem/BOMImporter.php b/src/Services/ImportExportSystem/BOMImporter.php index 862fa463f..fc0791eef 100644 --- a/src/Services/ImportExportSystem/BOMImporter.php +++ b/src/Services/ImportExportSystem/BOMImporter.php @@ -22,21 +22,37 @@ */ namespace App\Services\ImportExportSystem; +use App\Entity\AssemblySystem\Assembly; +use App\Entity\AssemblySystem\AssemblyBOMEntry; +use App\Entity\Parts\Category; +use App\Entity\Parts\Manufacturer; use App\Entity\Parts\Part; use App\Entity\ProjectSystem\Project; use App\Entity\ProjectSystem\ProjectBOMEntry; +use App\Repository\DBElementRepository; +use App\Repository\PartRepository; +use App\Repository\Parts\CategoryRepository; +use App\Repository\Parts\ManufacturerRepository; use Doctrine\ORM\EntityManagerInterface; use InvalidArgumentException; use League\Csv\Reader; use Psr\Log\LoggerInterface; use Symfony\Component\HttpFoundation\File\File; +use Symfony\Component\HttpFoundation\File\UploadedFile; use Symfony\Component\OptionsResolver\OptionsResolver; +use Symfony\Contracts\Translation\TranslatorInterface; +use UnexpectedValueException; +use Symfony\Component\Validator\ConstraintViolation; /** * @see \App\Tests\Services\ImportExportSystem\BOMImporterTest */ class BOMImporter { + private const IMPORT_TYPE_JSON = 'json'; + private const IMPORT_TYPE_CSV = 'csv'; + private const IMPORT_TYPE_KICAD_PCB = 'kicad_pcbnew'; + private const IMPORT_TYPE_KICAD_SCHEMATIC = 'kicad_schematic'; private const MAP_KICAD_PCB_FIELDS = [ 0 => 'Id', @@ -47,17 +63,35 @@ class BOMImporter 5 => 'Supplier and ref', ]; + private readonly PartRepository $partRepository; + + private readonly ManufacturerRepository $manufacturerRepository; + + private readonly CategoryRepository $categoryRepository; + + private readonly DBElementRepository $projectBomEntryRepository; + + private readonly DBElementRepository $assemblyBomEntryRepository; + + private string $jsonRoot = ''; + public function __construct( private readonly EntityManagerInterface $entityManager, private readonly LoggerInterface $logger, - private readonly BOMValidationService $validationService + private readonly BOMValidationService $validationService, + private readonly TranslatorInterface $translator ) { + $this->partRepository = $this->entityManager->getRepository(Part::class); + $this->manufacturerRepository = $this->entityManager->getRepository(Manufacturer::class); + $this->categoryRepository = $this->entityManager->getRepository(Category::class); + $this->projectBomEntryRepository = $this->entityManager->getRepository(ProjectBOMEntry::class); + $this->assemblyBomEntryRepository = $this->entityManager->getRepository(AssemblyBOMEntry::class); } protected function configureOptions(OptionsResolver $resolver): OptionsResolver { $resolver->setRequired('type'); - $resolver->setAllowedValues('type', ['kicad_pcbnew', 'kicad_schematic']); + $resolver->setAllowedValues('type', [self::IMPORT_TYPE_KICAD_PCB, self::IMPORT_TYPE_KICAD_SCHEMATIC, self::IMPORT_TYPE_JSON, self::IMPORT_TYPE_CSV]); // For flexible schematic import with field mapping $resolver->setDefined(['field_mapping', 'field_priorities', 'delimiter']); @@ -73,27 +107,118 @@ 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; + } + + /** + * 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 importFileIntoAssembly(UploadedFile $file, Assembly $assembly, array $options): ImporterResult + { + $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 given file into an array of BOM entries using the given options. - * @return ProjectBOMEntry[] + * 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(File $file, array $options): array + public function fileToBOMEntries(Project|Assembly $importObject, File $file, array $options): array { - return $this->stringToBOMEntries($file->getContent(), $options); + return $this->stringToBOMEntries($importObject, $file->getContent(), $options); + } + + + /** + * Handles the conversion of an uploaded file into an ImporterResult for a given project or assembly. + * + * This method processes the uploaded file by validating its file extension based on the provided import type + * options and then proceeds to convert the file content into an ImporterResult. If the file extension is + * invalid or unsupported, the result will contain a corresponding violation. + * + * @param Project|Assembly $importObject The context of the import operation (either a Project or Assembly). + * @param UploadedFile $file The uploaded file to be processed. + * @param array $options An array of options, expected to include an 'type' key to determine valid file types. + * + * @return ImporterResult An object containing the results of the import process, including any detected violations. + */ + public function fileToImporterResult(Project|Assembly $importObject, UploadedFile $file, array $options): ImporterResult + { + $result = new ImporterResult(); + + //Available file endings depending on the import type + $validExtensions = match ($options['type']) { + self::IMPORT_TYPE_KICAD_PCB => ['kicad_pcb'], + self::IMPORT_TYPE_JSON => ['json'], + self::IMPORT_TYPE_CSV => ['csv'], + default => [], + }; + + //Get the file extension of the uploaded file + $fileExtension = pathinfo($file->getClientOriginalName(), PATHINFO_EXTENSION); + + //Check whether the file extension is valid + if ($validExtensions === []) { + $result->addViolation($this->buildJsonViolation( + 'validator.bom_importer.invalid_import_type', + 'import.type' + )); + + return $result; + } else if (!in_array(strtolower($fileExtension), $validExtensions, true)) { + $result->addViolation($this->buildJsonViolation( + 'validator.bom_importer.invalid_file_extension', + 'file.extension', + $fileExtension, + [ + '%extension%' => $fileExtension, + '%importType%' => $this->translator->trans($importObject instanceof Project ? 'project.bom_import.type.'.$options['type'] : 'assembly.bom_import.type.'.$options['type']), + '%allowedExtensions%' => implode(', ', $validExtensions), + ] + )); + + return $result; + } + + return $this->stringToImporterResult($importObject, $file->getContent(), $options); } /** @@ -115,31 +240,76 @@ 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 Project|Assembly $importObject The object determining the context of the BOM entry (either a Project or Assembly). + * @param string $data The data to import + * @param array $options An array of options + * + * @return ProjectBOMEntry[]|AssemblyBOMEntry[] An array of imported entries */ - public function stringToBOMEntries(string $data, array $options): array + public function stringToBOMEntries(Project|Assembly $importObject, string $data, array $options): array { $resolver = new OptionsResolver(); $resolver = $this->configureOptions($resolver); $options = $resolver->resolve($options); return match ($options['type']) { - 'kicad_pcbnew' => $this->parseKiCADPCB($data), - 'kicad_schematic' => $this->parseKiCADSchematic($data, $options), - default => throw new InvalidArgumentException('Invalid import type!'), + self::IMPORT_TYPE_KICAD_PCB => $this->parseKiCADPCB($data, $importObject)->getBomEntries(), + self::IMPORT_TYPE_KICAD_SCHEMATIC => $this->parseKiCADSchematic($data, $options), + default => throw new InvalidArgumentException($this->translator->trans('validator.bom_importer.invalid_import_type', [], 'validators')), + }; + } + + /** + * 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 Project|Assembly $importObject The object determining the context of the BOM entry (either a Project or Assembly). + * @return ImporterResult The result of the import process, containing the created BOM entries. + * + * @throws UnexpectedValueException If required fields are missing in the provided data. + */ + private function parseKiCADPCB(string $data, Project|Assembly $importObject): ImporterResult { + $result = new ImporterResult(); + $csv = Reader::createFromString($data); $csv->setDelimiter(';'); $csv->setHeaderOffset(0); - $bom_entries = []; - foreach ($csv->getRecords() as $offset => $entry) { //Translate the german field names to english $entry = $this->normalizeColumnNames($entry); @@ -158,16 +328,21 @@ 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; } /** @@ -227,6 +402,545 @@ 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, ';'); + } + + 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 @@ -243,13 +957,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 */ @@ -727,4 +1456,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 70feb8e67..028a537e7 100644 --- a/src/Services/ImportExportSystem/EntityExporter.php +++ b/src/Services/ImportExportSystem/EntityExporter.php @@ -22,8 +22,22 @@ namespace App\Services\ImportExportSystem; +use App\Entity\AssemblySystem\Assembly; +use App\Entity\AssemblySystem\AssemblyBOMEntry; +use App\Entity\Attachments\AttachmentType; use App\Entity\Base\AbstractNamedDBElement; use App\Entity\Base\AbstractStructuralDBElement; +use App\Entity\LabelSystem\LabelProfile; +use App\Entity\Parts\Category; +use App\Entity\Parts\Footprint; +use App\Entity\Parts\Manufacturer; +use App\Entity\Parts\MeasurementUnit; +use App\Entity\Parts\StorageLocation; +use App\Entity\Parts\Supplier; +use App\Entity\PriceInformations\Currency; +use App\Entity\ProjectSystem\Project; +use App\Entity\ProjectSystem\ProjectBOMEntry; +use App\Helpers\Assemblies\AssemblyPartAggregator; use App\Helpers\FilenameSanatizer; use App\Serializer\APIPlatform\SkippableItemNormalizer; use Symfony\Component\OptionsResolver\OptionsResolver; @@ -48,8 +62,10 @@ */ class EntityExporter { - public function __construct(protected SerializerInterface $serializer) - { + public function __construct( + protected SerializerInterface $serializer, + protected AssemblyPartAggregator $partAggregator, private readonly AssemblyPartAggregator $assemblyPartAggregator, + ) { } protected function configureOptions(OptionsResolver $resolver): void @@ -65,6 +81,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']); + } /** @@ -222,15 +242,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 @@ -241,6 +313,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); @@ -277,4 +350,311 @@ 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', 'AssemblyNameHierarchical', 'AssemblyName', + 'AssemblyFullName', + + //BOM relevant attributes + 'Quantity', 'PartId', 'PartName', 'Ipn', 'Manufacturer', 'Mpn', 'Name', 'Designator', + 'Description', 'ReferencedAssemblyId', 'ReferencedAssemblyIpn', + 'ReferencedAssemblyFullName', + ], + 'processEntity' => fn($entity, $depth) => [ + 'Id' => $entity->getId(), + 'ParentId' => $entity->getParent()?->getId() ?? '', + 'Type' => 'assembly', + 'AssemblyIpn' => $entity->getIpn(), + 'AssemblyNameHierarchical' => str_repeat('--', $depth) . ' ' . $entity->getName(), + 'AssemblyName' => $entity->getName(), + 'AssemblyFullName' => $this->getFullName($entity), + + //BOM relevant attributes + 'Quantity' => '-', + 'PartId' => '-', + 'PartName' => '-', + 'Ipn' => '-', + 'Manufacturer' => '-', + 'Mpn' => '-', + 'Name' => '-', + 'Designator' => '-', + 'Description' => '-', + 'ReferencedAssemblyId' => '-', + 'ReferencedAssemblyIpn' => '-', + 'ReferencedAssemblyFullName' => '-', + ], + 'processBomEntries' => fn($entity, $depth) => $this->processBomEntriesWithAggregatedParts($entity, $depth), + ], + Supplier::class => [ + 'header' => ['Id', 'ParentId', 'NameHierarchical', 'Name', 'FullName'], + 'processEntity' => $defaultProcessEntity, + ], + Manufacturer::class => [ + 'header' => ['Id', 'ParentId', 'NameHierarchical', 'Name', 'FullName'], + 'processEntity' => $defaultProcessEntity, + ], + StorageLocation::class => [ + 'header' => ['Id', 'ParentId', 'NameHierarchical', 'Name', 'FullName'], + 'processEntity' => $defaultProcessEntity, + ], + Footprint::class => [ + 'header' => ['Id', 'ParentId', 'NameHierarchical', 'Name', 'FullName'], + 'processEntity' => $defaultProcessEntity, + ], + Currency::class => [ + 'header' => ['Id', 'ParentId', 'NameHierarchical', 'Name', 'FullName'], + 'processEntity' => $defaultProcessEntity, + ], + MeasurementUnit::class => [ + 'header' => ['Id', 'ParentId', 'NameHierarchical', 'Name', 'FullName'], + 'processEntity' => $defaultProcessEntity, + ], + LabelProfile::class => [ + 'header' => ['Id', 'SupportedElement', 'Name'], + 'processEntity' => fn(LabelProfile $entity, $depth) => [ + 'Id' => $entity->getId(), + 'SupportedElement' => $entity->getOptions()->getSupportedElement()->name, + 'Name' => $entity->getName(), + ], + ], + ]; + + //Get configuration for the entity type + $entityConfig = $config[$type] ?? null; + + if (!$entityConfig) { + return ''; + } + + //Initialize CSV data with the header + $csvData = []; + $csvData[] = $entityConfig['header']; + + $relevantEntities = $entities; + + if ($isHierarchical) { + //Filter root entities (those without parents) + $relevantEntities = array_filter($entities, fn($entity) => $entity->getParent() === null); + + if (count($relevantEntities) === 0 && count($entities) > 0) { + //If no root entities are found, then we need to add all entities + + $relevantEntities = $entities; + } + } + + //Sort root entities alphabetically by `name` + usort($relevantEntities, fn($a, $b) => strnatcasecmp($a->getName(), $b->getName())); + + //Recursive function to process an entity and its children + $processEntity = function ($entity, &$csvData, $depth = 0) use (&$processEntity, $entityConfig, $isHierarchical) { + //Add main entity data to CSV + $csvData[] = $entityConfig['processEntity']($entity, $depth); + + //Process BOM entries if applicable + if (isset($entityConfig['processBomEntries'])) { + $bomRows = $entityConfig['processBomEntries']($entity, $depth); + foreach ($bomRows as $bomRow) { + $csvData[] = $bomRow; + } + } + + if ($isHierarchical) { + //Retrieve children, sort alphabetically, then process them + $children = $entity->getChildren()->toArray(); + usort($children, fn($a, $b) => strnatcasecmp($a->getName(), $b->getName())); + foreach ($children as $childEntity) { + $processEntity($childEntity, $csvData, $depth + 1); + } + } + }; + + //Start processing with root entities + foreach ($relevantEntities as $rootEntity) { + $processEntity($rootEntity, $csvData); + } + + //Generate CSV string + $output = ''; + foreach ($csvData as $line) { + $output .= implode(';', $line) . "\n"; // Use a semicolon as the delimiter + } + + return $output; + } + + /** + * Process BOM entries and include aggregated parts as "complete_part_list". + * + * @param Assembly $assembly The assembly being processed. + * @param int $depth The current depth in the hierarchy. + * @return array Processed BOM entries and aggregated parts rows. + */ + private function processBomEntriesWithAggregatedParts(Assembly $assembly, int $depth): array + { + $rows = []; + + /** @var AssemblyBOMEntry $bomEntry */ + foreach ($assembly->getBomEntries() as $bomEntry) { + // Add the BOM entry itself + $rows[] = [ + 'Id' => $assembly->getId(), + 'ParentId' => '', + 'Type' => 'assembly_bom_entry', + 'AssemblyIpn' => $assembly->getIpn(), + 'AssemblyNameHierarchical' => str_repeat('--', $depth) . '> ' . $assembly->getName(), + 'AssemblyName' => $assembly->getName(), + 'AssemblyFullName' => $this->getFullName($assembly), + + //BOM relevant attributes + 'Quantity' => $bomEntry->getQuantity(), + 'PartId' => $bomEntry->getPart()?->getId() ?? '-', + 'PartName' => $bomEntry->getPart()?->getName() ?? '-', + 'Ipn' => $bomEntry->getPart()?->getIpn() ?? '-', + 'Manufacturer' => $bomEntry->getPart()?->getManufacturer()?->getName() ?? '-', + 'Mpn' => $bomEntry->getPart()?->getManufacturerProductNumber() ?? '-', + 'Name' => $bomEntry->getName() ?? '-', + 'Designator' => $bomEntry->getDesignator(), + 'Description' => $bomEntry->getPart()?->getDescription() ?? '-', + 'ReferencedAssemblyId' => $bomEntry->getReferencedAssembly()?->getId() ?? '-', + 'ReferencedAssemblyIpn' => $bomEntry->getReferencedAssembly()?->getIpn() ?? '-', + 'ReferencedAssemblyFullName' => $this->getFullName($bomEntry->getReferencedAssembly() ?? null), + ]; + + // If a referenced assembly exists, add aggregated parts + if ($bomEntry->getReferencedAssembly() instanceof Assembly) { + $referencedAssembly = $bomEntry->getReferencedAssembly(); + + // Get aggregated parts for the referenced assembly + $aggregatedParts = $this->assemblyPartAggregator->getAggregatedParts($referencedAssembly, $bomEntry->getQuantity());; + + foreach ($aggregatedParts as $partData) { + $partAssembly = $partData['assembly'] ?? null; + + $rows[] = [ + 'Id' => $assembly->getId(), + 'ParentId' => '', + 'Type' => 'subassembly_part_list', + 'AssemblyIpn' => $partAssembly ? $partAssembly->getIpn() : '', + 'AssemblyNameHierarchical' => '', + 'AssemblyName' => $partAssembly ? $partAssembly->getName() : '', + 'AssemblyFullName' => $this->getFullName($partAssembly), + + //BOM relevant attributes + 'Quantity' => $partData['quantity'], + 'PartId' => $partData['part']?->getId(), + 'PartName' => $partData['part']?->getName(), + 'Ipn' => $partData['part']?->getIpn(), + 'Manufacturer' => $partData['part']?->getManufacturer()?->getName(), + 'Mpn' => $partData['part']?->getManufacturerProductNumber(), + 'Name' => $partData['name'] ?? '', + 'Designator' => $partData['designator'], + 'Description' => $partData['part']?->getDescription(), + 'ReferencedAssemblyId' => '-', + 'ReferencedAssemblyIpn' => '-', + 'ReferencedAssemblyFullName' => '-', + ]; + } + } + } + + return $rows; + } + + /** + * Constructs the full hierarchical name of an object by traversing + * through its parent objects and concatenating their names using + * a specified separator. + * + * @param AttachmentType|Category|Project|Assembly|Supplier|Manufacturer|StorageLocation|Footprint|Currency|MeasurementUnit|LabelProfile|null $object The object whose full name is to be constructed. If null, the result will be an empty string. + * @param string $separator The string used to separate the names of the objects in the full hierarchy. + * + * @return string The full hierarchical name constructed by concatenating the names of the object and its parents. + */ + private function getFullName(AttachmentType|Category|Project|Assembly|Supplier|Manufacturer|StorageLocation|Footprint|Currency|MeasurementUnit|LabelProfile|null $object, string $separator = '->'): string + { + $fullNameParts = []; + + while ($object !== null) { + array_unshift($fullNameParts, $object->getName()); + $object = $object->getParent(); + } + + return implode($separator, $fullNameParts); + } } diff --git a/src/Services/ImportExportSystem/ImporterResult.php b/src/Services/ImportExportSystem/ImporterResult.php new file mode 100644 index 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/ImportExportSystem/PartKeeprImporter/PKDatastructureImporter.php b/src/Services/ImportExportSystem/PartKeeprImporter/PKDatastructureImporter.php index 1f842c23d..9e674f05c 100644 --- a/src/Services/ImportExportSystem/PartKeeprImporter/PKDatastructureImporter.php +++ b/src/Services/ImportExportSystem/PartKeeprImporter/PKDatastructureImporter.php @@ -29,6 +29,7 @@ use App\Entity\Parts\Footprint; use App\Entity\Parts\Manufacturer; use App\Entity\Parts\MeasurementUnit; +use App\Entity\Parts\PartCustomState; use App\Entity\Parts\StorageLocation; use App\Entity\Parts\Supplier; use Doctrine\ORM\EntityManagerInterface; @@ -148,6 +149,26 @@ public function importPartUnits(array $data): int return is_countable($partunit_data) ? count($partunit_data) : 0; } + public function importPartCustomStates(array $data): int + { + if (!isset($data['partcustomstate'])) { + throw new \RuntimeException('$data must contain a "partcustomstate" key!'); + } + + $partCustomStateData = $data['partcustomstate']; + foreach ($partCustomStateData as $partCustomState) { + $customState = new PartCustomState(); + $customState->setName($partCustomState['name']); + + $this->setIDOfEntity($customState, $partCustomState['id']); + $this->em->persist($customState); + } + + $this->em->flush(); + + return is_countable($partCustomStateData) ? count($partCustomStateData) : 0; + } + public function importCategories(array $data): int { if (!isset($data['partcategory'])) { diff --git a/src/Services/ImportExportSystem/PartKeeprImporter/PKPartImporter.php b/src/Services/ImportExportSystem/PartKeeprImporter/PKPartImporter.php index 80c2dbf77..ab06a1346 100644 --- a/src/Services/ImportExportSystem/PartKeeprImporter/PKPartImporter.php +++ b/src/Services/ImportExportSystem/PartKeeprImporter/PKPartImporter.php @@ -91,6 +91,8 @@ public function importParts(array $data): int $this->setAssociationField($entity, 'partUnit', MeasurementUnit::class, $part['partUnit_id']); } + $this->setAssociationField($entity, 'partCustomState', MeasurementUnit::class, $part['partCustomState_id']); + //Create a part lot to store the stock level and location $lot = new PartLot(); $lot->setAmount((float) ($part['stockLevel'] ?? 0)); diff --git a/src/Services/LabelSystem/SandboxedTwigFactory.php b/src/Services/LabelSystem/SandboxedTwigFactory.php index d6ea69685..d5e09fa54 100644 --- a/src/Services/LabelSystem/SandboxedTwigFactory.php +++ b/src/Services/LabelSystem/SandboxedTwigFactory.php @@ -133,7 +133,7 @@ final class SandboxedTwigFactory Supplier::class => ['getShippingCosts', 'getDefaultCurrency'], Part::class => ['isNeedsReview', 'getTags', 'getMass', 'getIpn', 'getProviderReference', 'getDescription', 'getComment', 'isFavorite', 'getCategory', 'getFootprint', - 'getPartLots', 'getPartUnit', 'useFloatAmount', 'getMinAmount', 'getAmountSum', 'isNotEnoughInstock', 'isAmountUnknown', 'getExpiredAmountSum', + 'getPartLots', 'getPartUnit', 'getPartCustomState', 'useFloatAmount', 'getMinAmount', 'getAmountSum', 'isNotEnoughInstock', 'isAmountUnknown', 'getExpiredAmountSum', 'getManufacturerProductUrl', 'getCustomProductURL', 'getManufacturingStatus', 'getManufacturer', 'getManufacturerProductNumber', 'getOrderdetails', 'isObsolete', 'getParameters', 'getGroupedParameters', diff --git a/src/Services/Trees/ToolsTreeBuilder.php b/src/Services/Trees/ToolsTreeBuilder.php index 036797f61..843aa8f77 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; @@ -29,6 +30,7 @@ use App\Entity\Parts\Manufacturer; use App\Entity\Parts\MeasurementUnit; use App\Entity\Parts\Part; +use App\Entity\Parts\PartCustomState; use App\Entity\Parts\StorageLocation; use App\Entity\Parts\Supplier; use App\Entity\PriceInformations\Currency; @@ -37,6 +39,7 @@ use App\Entity\UserSystem\User; use App\Helpers\Trees\TreeViewNode; use App\Services\Cache\UserCacheKeyGenerator; +use App\Settings\BehaviorSettings\DataSourceSynonymsSettings; use Symfony\Bundle\SecurityBundle\Security; use Symfony\Component\Routing\Generator\UrlGeneratorInterface; use Symfony\Contracts\Cache\ItemInterface; @@ -49,8 +52,14 @@ */ class ToolsTreeBuilder { - public function __construct(protected TranslatorInterface $translator, protected UrlGeneratorInterface $urlGenerator, protected TagAwareCacheInterface $cache, protected UserCacheKeyGenerator $keyGenerator, protected Security $security) - { + public function __construct( + protected TranslatorInterface $translator, + protected UrlGeneratorInterface $urlGenerator, + protected TagAwareCacheInterface $cache, + protected UserCacheKeyGenerator $keyGenerator, + protected Security $security, + protected DataSourceSynonymsSettings $dataSourceSynonymsSettings, + ) { } /** @@ -138,7 +147,7 @@ protected function getToolsNode(): array $this->translator->trans('info_providers.search.title'), $this->urlGenerator->generate('info_providers_search') ))->setIcon('fa-treeview fa-fw fa-solid fa-cloud-arrow-down'); - + $nodes[] = (new TreeViewNode( $this->translator->trans('info_providers.bulk_import.manage_jobs'), $this->urlGenerator->generate('bulk_info_provider_manage') @@ -165,37 +174,43 @@ protected function getEditNodes(): array } if ($this->security->isGranted('read', new Category())) { $nodes[] = (new TreeViewNode( - $this->translator->trans('tree.tools.edit.categories'), + $this->getTranslatedDataSourceOrSynonym('category', 'tree.tools.edit.categories', $this->translator->getLocale()), $this->urlGenerator->generate('category_new') ))->setIcon('fa-fw fa-treeview fa-solid fa-tags'); } if ($this->security->isGranted('read', new Project())) { $nodes[] = (new TreeViewNode( - $this->translator->trans('tree.tools.edit.projects'), + $this->getTranslatedDataSourceOrSynonym('project', 'tree.tools.edit.projects', $this->translator->getLocale()), $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->translator->trans('tree.tools.edit.suppliers'), + $this->getTranslatedDataSourceOrSynonym('supplier', 'tree.tools.edit.suppliers', $this->translator->getLocale()), $this->urlGenerator->generate('supplier_new') ))->setIcon('fa-fw fa-treeview fa-solid fa-truck'); } if ($this->security->isGranted('read', new Manufacturer())) { $nodes[] = (new TreeViewNode( - $this->translator->trans('tree.tools.edit.manufacturer'), + $this->getTranslatedDataSourceOrSynonym('manufacturer', 'tree.tools.edit.manufacturer', $this->translator->getLocale()), $this->urlGenerator->generate('manufacturer_new') ))->setIcon('fa-fw fa-treeview fa-solid fa-industry'); } if ($this->security->isGranted('read', new StorageLocation())) { $nodes[] = (new TreeViewNode( - $this->translator->trans('tree.tools.edit.storelocation'), + $this->getTranslatedDataSourceOrSynonym('storagelocation', 'tree.tools.edit.storelocation', $this->translator->getLocale()), $this->urlGenerator->generate('store_location_new') ))->setIcon('fa-fw fa-treeview fa-solid fa-cube'); } if ($this->security->isGranted('read', new Footprint())) { $nodes[] = (new TreeViewNode( - $this->translator->trans('tree.tools.edit.footprint'), + $this->getTranslatedDataSourceOrSynonym('footprint', 'tree.tools.edit.footprint', $this->translator->getLocale()), $this->urlGenerator->generate('footprint_new') ))->setIcon('fa-fw fa-treeview fa-solid fa-microchip'); } @@ -217,6 +232,12 @@ protected function getEditNodes(): array $this->urlGenerator->generate('label_profile_new') ))->setIcon('fa-fw fa-treeview fa-solid fa-qrcode'); } + if ($this->security->isGranted('read', new PartCustomState())) { + $nodes[] = (new TreeViewNode( + $this->translator->trans('tree.tools.edit.part_custom_state'), + $this->urlGenerator->generate('part_custom_state_new') + ))->setIcon('fa-fw fa-treeview fa-solid fa-tools'); + } if ($this->security->isGranted('create', new Part())) { $nodes[] = (new TreeViewNode( $this->translator->trans('tree.tools.edit.part'), @@ -303,4 +324,24 @@ protected function getSystemNodes(): array return $nodes; } + + protected function getTranslatedDataSourceOrSynonym(string $dataSource, string $translationKey, string $locale): string + { + $currentTranslation = $this->translator->trans($translationKey); + + $synonyms = $this->dataSourceSynonymsSettings->getSynonymsAsArray(); + + // Call alternatives from DataSourcesynonyms (if available) + if (!empty($synonyms[$dataSource][$locale])) { + $alternativeTranslation = $synonyms[$dataSource][$locale]; + + // Use alternative translation when it deviates from the standard translation + if ($alternativeTranslation !== $currentTranslation) { + return $alternativeTranslation; + } + } + + // Otherwise return the standard translation + return $currentTranslation; + } } diff --git a/src/Services/Trees/TreeViewGenerator.php b/src/Services/Trees/TreeViewGenerator.php index 73ffa5baf..47870e0d6 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; @@ -38,6 +39,7 @@ use App\Services\Cache\ElementCacheTagGenerator; use App\Services\Cache\UserCacheKeyGenerator; use App\Services\EntityURLGenerator; +use App\Settings\BehaviorSettings\DataSourceSynonymsSettings; use App\Settings\BehaviorSettings\SidebarSettings; use Doctrine\ORM\EntityManagerInterface; use InvalidArgumentException; @@ -67,6 +69,7 @@ public function __construct( protected TranslatorInterface $translator, private readonly UrlGeneratorInterface $router, private readonly SidebarSettings $sidebarSettings, + protected DataSourceSynonymsSettings $dataSourceSynonymsSettings, ) { $this->rootNodeEnabled = $this->sidebarSettings->rootNodeEnabled; $this->rootNodeExpandedByDefault = $this->sidebarSettings->rootNodeExpanded; @@ -154,6 +157,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); @@ -183,6 +190,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]; } @@ -212,13 +228,16 @@ protected function entityClassToRootNodeHref(string $class): ?string protected function entityClassToRootNodeString(string $class): string { + $locale = $this->translator->getLocale(); + return match ($class) { - Category::class => $this->translator->trans('category.labelp'), - StorageLocation::class => $this->translator->trans('storelocation.labelp'), - Footprint::class => $this->translator->trans('footprint.labelp'), - Manufacturer::class => $this->translator->trans('manufacturer.labelp'), - Supplier::class => $this->translator->trans('supplier.labelp'), - Project::class => $this->translator->trans('project.labelp'), + Category::class => $this->getTranslatedOrSynonym('category', $locale), + StorageLocation::class => $this->getTranslatedOrSynonym('storelocation', $locale), + Footprint::class => $this->getTranslatedOrSynonym('footprint', $locale), + Manufacturer::class => $this->getTranslatedOrSynonym('manufacturer', $locale), + Supplier::class => $this->getTranslatedOrSynonym('supplier', $locale), + Project::class => $this->getTranslatedOrSynonym('project', $locale), + Assembly::class => $this->getTranslatedOrSynonym('assembly', $locale), default => $this->translator->trans('tree.root_node.text'), }; } @@ -233,6 +252,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, }; } @@ -274,4 +294,24 @@ public function getGenericTree(string $class, ?AbstractStructuralDBElement $pare return $repo->getGenericNodeTree($parent); //@phpstan-ignore-line }); } + + protected function getTranslatedOrSynonym(string $key, string $locale): string + { + $currentTranslation = $this->translator->trans($key . '.labelp'); + + $synonyms = $this->dataSourceSynonymsSettings->getSynonymsAsArray(); + + // Call alternatives from DataSourcesynonyms (if available) + if (!empty($synonyms[$key][$locale])) { + $alternativeTranslation = $synonyms[$key][$locale]; + + // Use alternative translation when it deviates from the standard translation + if ($alternativeTranslation !== $currentTranslation) { + return $alternativeTranslation; + } + } + + // Otherwise return the standard translation + return $currentTranslation; + } } diff --git a/src/Services/UserSystem/PermissionPresetsHelper.php b/src/Services/UserSystem/PermissionPresetsHelper.php index 554da8b32..a3ed01b82 100644 --- a/src/Services/UserSystem/PermissionPresetsHelper.php +++ b/src/Services/UserSystem/PermissionPresetsHelper.php @@ -102,6 +102,7 @@ private function admin(HasPermissionsInterface $perm_holder): void $this->permissionResolver->setAllOperationsOfPermission($perm_holder, 'attachment_types', PermissionData::ALLOW); $this->permissionResolver->setAllOperationsOfPermission($perm_holder, 'currencies', PermissionData::ALLOW); $this->permissionResolver->setAllOperationsOfPermission($perm_holder, 'measurement_units', PermissionData::ALLOW); + $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); @@ -131,6 +132,7 @@ private function editor(HasPermissionsInterface $permHolder): HasPermissionsInte $this->permissionResolver->setAllOperationsOfPermissionExcept($permHolder, 'attachment_types', PermissionData::ALLOW, ['import']); $this->permissionResolver->setAllOperationsOfPermissionExcept($permHolder, 'currencies', PermissionData::ALLOW, ['import']); $this->permissionResolver->setAllOperationsOfPermissionExcept($permHolder, 'measurement_units', PermissionData::ALLOW, ['import']); + $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']); 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..02c315b49 --- /dev/null +++ b/src/Settings/BehaviorSettings/AssemblyTableColumns.php @@ -0,0 +1,49 @@ +. + */ + +declare(strict_types=1); + + +namespace App\Settings\BehaviorSettings; + +use Symfony\Contracts\Translation\TranslatableInterface; +use Symfony\Contracts\Translation\TranslatorInterface; + +enum AssemblyTableColumns : string implements TranslatableInterface +{ + + case NAME = "name"; + case ID = "id"; + case IPN = "ipn"; + case DESCRIPTION = "description"; + case REFERENCED_ASSEMBLIES = "referencedAssemblies"; + case ADDED_DATE = "addedDate"; + case LAST_MODIFIED = "lastModified"; + case EDIT = "edit"; + + public function trans(TranslatorInterface $translator, ?string $locale = null): string + { + $key = match($this) { + default => 'assembly.table.' . $this->value, + }; + + return $translator->trans($key, locale: $locale); + } +} diff --git a/src/Settings/BehaviorSettings/DataSourceSynonymsSettings.php b/src/Settings/BehaviorSettings/DataSourceSynonymsSettings.php new file mode 100644 index 000000000..acfb61f49 --- /dev/null +++ b/src/Settings/BehaviorSettings/DataSourceSynonymsSettings.php @@ -0,0 +1,76 @@ + '{"en":"", "de":""}']), + options: ['type' => StringType::class], + formType: DataSourceJsonType::class, + formOptions: [ + 'required' => false, + 'data_sources' => [ + 'category' => new TM("settings.behavior.data_source_synonyms.category"), + 'storagelocation' => new TM("settings.behavior.data_source_synonyms.storagelocation"), + 'footprint' => new TM("settings.behavior.data_source_synonyms.footprint"), + 'manufacturer' => new TM("settings.behavior.data_source_synonyms.manufacturer"), + 'supplier' => new TM("settings.behavior.data_source_synonyms.supplier"), + 'project' => new TM("settings.behavior.data_source_synonyms.project"), + 'assembly' => new TM("settings.behavior.data_source_synonyms.assembly"), + ], + 'default_values' => [ + 'category' => '{"en":"Categories", "de":"Kategorien"}', + 'storagelocation' => '{"en":"Storage locations", "de":"Lagerorte"}', + 'footprint' => '{"en":"Footprints", "de":"Footprints"}', + 'manufacturer' => '{"en":"Manufacturers", "de":"Hersteller"}', + 'supplier' => '{"en":"Suppliers", "de":"Lieferanten"}', + 'project' => '{"en":"Projects", "de":"Projekte"}', + 'assembly' => '{"en":"Assemblies", "de":"Baugruppen"}', + ], + ], + )] + #[Assert\Type('array')] + public array $dataSourceSynonyms = [ + 'category' => '{"en":"Categories", "de":"Kategorien"}', + 'storagelocation' => '{"en":"Storage locations", "de":"Lagerorte"}', + 'footprint' => '{"en":"Footprints", "de":"Footprints"}', + 'manufacturer' => '{"en":"Manufacturers", "de":"Hersteller"}', + 'supplier' => '{"en":"Suppliers", "de":"Lieferanten"}', + 'project' => '{"en":"Projects", "de":"Projekte"}', + 'assembly' => '{"en":"Assemblies", "de":"Baugruppen"}' + ]; + + /** + * Get the synonyms data as a structured array. + * + * @return array> The data source synonyms parsed from JSON to array. + */ + public function getSynonymsAsArray(): array + { + $result = []; + foreach ($this->dataSourceSynonyms as $key => $jsonString) { + $result[$key] = json_decode($jsonString, true, 512, JSON_THROW_ON_ERROR) ?? []; + } + + return $result; + } + +} diff --git a/src/Settings/BehaviorSettings/PartTableColumns.php b/src/Settings/BehaviorSettings/PartTableColumns.php index eea6ad860..c025c9529 100644 --- a/src/Settings/BehaviorSettings/PartTableColumns.php +++ b/src/Settings/BehaviorSettings/PartTableColumns.php @@ -46,6 +46,7 @@ enum PartTableColumns : string implements TranslatableInterface case FAVORITE = "favorite"; case MANUFACTURING_STATUS = "manufacturing_status"; case MPN = "manufacturer_product_number"; + case CUSTOM_PART_STATE = 'partCustomState'; case MASS = "mass"; case TAGS = "tags"; case ATTACHMENTS = "attachments"; @@ -63,4 +64,4 @@ public function trans(TranslatorInterface $translator, ?string $locale = null): return $translator->trans($key, locale: $locale); } -} \ No newline at end of file +} diff --git a/src/Settings/BehaviorSettings/TableSettings.php b/src/Settings/BehaviorSettings/TableSettings.php index b69648769..960db65b5 100644 --- a/src/Settings/BehaviorSettings/TableSettings.php +++ b/src/Settings/BehaviorSettings/TableSettings.php @@ -53,7 +53,6 @@ class TableSettings )] public int $fullDefaultPageSize = 50; - /** @var PartTableColumns[] */ #[SettingsParameter(ArrayType::class, label: new TM("settings.behavior.table.parts_default_columns"), @@ -68,7 +67,38 @@ class TableSettings #[Assert\All([new Assert\Type(PartTableColumns::class)])] public array $partsDefaultColumns = [PartTableColumns::NAME, PartTableColumns::DESCRIPTION, PartTableColumns::CATEGORY, PartTableColumns::FOOTPRINT, PartTableColumns::MANUFACTURER, - PartTableColumns::LOCATION, PartTableColumns::AMOUNT]; + PartTableColumns::LOCATION, PartTableColumns::AMOUNT, PartTableColumns::CUSTOM_PART_STATE]; + + /** @var AssemblyTableColumns[] */ + #[SettingsParameter(ArrayType::class, + label: new TM("settings.behavior.table.assemblies_default_columns"), + description: new TM("settings.behavior.table.assemblies_default_columns.help"), + options: ['type' => EnumType::class, 'options' => ['class' => AssemblyTableColumns::class]], + formType: \Symfony\Component\Form\Extension\Core\Type\EnumType::class, + formOptions: ['class' => AssemblyTableColumns::class, 'multiple' => true, 'ordered' => true], + envVar: "TABLE_ASSEMBLIES_DEFAULT_COLUMNS", envVarMode: EnvVarMode::OVERWRITE, envVarMapper: [self::class, 'mapAssembliesDefaultColumnsEnv'] + )] + #[Assert\NotBlank()] + #[Assert\Unique()] + #[Assert\All([new Assert\Type(AssemblyTableColumns::class)])] + public array $assembliesDefaultColumns = [AssemblyTableColumns::ID, AssemblyTableColumns::IPN, AssemblyTableColumns::NAME, + AssemblyTableColumns::DESCRIPTION, AssemblyTableColumns::REFERENCED_ASSEMBLIES, AssemblyTableColumns::EDIT]; + + /** @var AssemblyBomTableColumns[] */ + #[SettingsParameter(ArrayType::class, + label: new TM("settings.behavior.table.assemblies_bom_default_columns"), + description: new TM("settings.behavior.table.assemblies_bom_default_columns.help"), + options: ['type' => EnumType::class, 'options' => ['class' => AssemblyBomTableColumns::class]], + formType: \Symfony\Component\Form\Extension\Core\Type\EnumType::class, + formOptions: ['class' => AssemblyBomTableColumns::class, 'multiple' => true, 'ordered' => true], + envVar: "TABLE_ASSEMBLIES_BOM_DEFAULT_COLUMNS", envVarMode: EnvVarMode::OVERWRITE, envVarMapper: [self::class, 'mapAssemblyBomsDefaultColumnsEnv'] + )] + #[Assert\NotBlank()] + #[Assert\Unique()] + #[Assert\All([new Assert\Type(AssemblyBomTableColumns::class)])] + + public array $assembliesBomDefaultColumns = [AssemblyBomTableColumns::QUANTITY, AssemblyBomTableColumns::ID, + AssemblyBomTableColumns::IPN, AssemblyBomTableColumns::NAME, AssemblyBomTableColumns::DESCRIPTION]; #[SettingsParameter(label: new TM("settings.behavior.table.preview_image_min_width"), formOptions: ['attr' => ['min' => 1, 'max' => 100]], @@ -101,4 +131,36 @@ 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 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/IpnSuggestSettings.php b/src/Settings/MiscSettings/IpnSuggestSettings.php new file mode 100644 index 000000000..5092dfafe --- /dev/null +++ b/src/Settings/MiscSettings/IpnSuggestSettings.php @@ -0,0 +1,78 @@ +. + */ + +declare(strict_types=1); + + +namespace App\Settings\MiscSettings; + +use App\Settings\SettingsIcon; +use Jbtronics\SettingsBundle\Metadata\EnvVarMode; +use Jbtronics\SettingsBundle\ParameterTypes\StringType; +use Jbtronics\SettingsBundle\Settings\Settings; +use Jbtronics\SettingsBundle\Settings\SettingsParameter; +use Jbtronics\SettingsBundle\Settings\SettingsTrait; +use Symfony\Component\Translation\TranslatableMessage as TM; +use Symfony\Component\Validator\Constraints as Assert; + +#[Settings(label: new TM("settings.misc.ipn_suggest"))] +#[SettingsIcon("fa-list")] +class IpnSuggestSettings +{ + use SettingsTrait; + + #[SettingsParameter( + label: new TM("settings.misc.ipn_suggest.regex"), + options: ['type' => StringType::class], + formOptions: ['attr' => ['placeholder' => '^[A-Za-z0-9]{3,4}(?:-[A-Za-z0-9]{3,4})*-\d{4}$']], + envVar: "IPN_SUGGEST_REGEX", envVarMode: EnvVarMode::OVERWRITE, + )] + public ?string $regex = null; + + #[SettingsParameter( + label: new TM("settings.misc.ipn_suggest.regex_help"), + description: new TM("settings.misc.ipn_suggest.regex_help_description"), + options: ['type' => StringType::class], + formOptions: ['attr' => ['placeholder' => 'Format: 3–4 alphanumeric segments (any number) separated by "-", followed by "-" and 4 digits, e.g., PCOM-RES-0001']], + envVar: "IPN_SUGGEST_REGEX_HELP", envVarMode: EnvVarMode::OVERWRITE, + )] + public ?string $regexHelp = null; + + #[SettingsParameter( + label: new TM("settings.misc.ipn_suggest.autoAppendSuffix"), + envVar: "bool:IPN_AUTO_APPEND_SUFFIX", envVarMode: EnvVarMode::OVERWRITE, + )] + public bool $autoAppendSuffix = true; + + #[SettingsParameter(label: new TM("settings.misc.ipn_suggest.suggestPartDigits"), + description: new TM("settings.misc.ipn_suggest.suggestPartDigits.help"), + formOptions: ['attr' => ['min' => 1, 'max' => 100]], + envVar: "int:IPN_SUGGEST_PART_DIGITS", envVarMode: EnvVarMode::OVERWRITE + )] + #[Assert\Range(min: 1, max: 6)] + public int $suggestPartDigits = 4; + + #[SettingsParameter( + label: new TM("settings.misc.ipn_suggest.useDuplicateDescription"), + envVar: "bool:IPN_USE_DUPLICATE_DESCRIPTION", envVarMode: EnvVarMode::OVERWRITE, + )] + public bool $useDuplicateDescription = false; +} diff --git a/src/Settings/MiscSettings/MiscSettings.php b/src/Settings/MiscSettings/MiscSettings.php index b8a3a73f6..552aa955a 100644 --- a/src/Settings/MiscSettings/MiscSettings.php +++ b/src/Settings/MiscSettings/MiscSettings.php @@ -34,4 +34,10 @@ class MiscSettings #[EmbeddedSettings] public ?ExchangeRateSettings $exchangeRate = null; -} \ No newline at end of file + + #[EmbeddedSettings] + public ?AssemblySettings $assembly = null; + + #[EmbeddedSettings] + public ?IpnSuggestSettings $ipnSuggestSettings = null; +} diff --git a/src/Settings/SystemSettings.php b/src/Settings/SystemSettings.php index 83d00afc0..a81f5871c 100644 --- a/src/Settings/SystemSettings.php +++ b/src/Settings/SystemSettings.php @@ -23,6 +23,7 @@ namespace App\Settings; +use App\Settings\BehaviorSettings\DataSourceSynonymsSettings; use App\Settings\SystemSettings\AttachmentsSettings; use App\Settings\SystemSettings\CustomizationSettings; use App\Settings\SystemSettings\HistorySettings; @@ -37,6 +38,9 @@ class SystemSettings #[EmbeddedSettings()] public ?LocalizationSettings $localization = null; + #[EmbeddedSettings] + public ?DataSourceSynonymsSettings $dataSourceSynonyms = null; + #[EmbeddedSettings()] public ?CustomizationSettings $customization = null; @@ -48,4 +52,4 @@ class SystemSettings #[EmbeddedSettings()] public ?HistorySettings $history = null; -} \ No newline at end of file +} diff --git a/src/Settings/SystemSettings/LocalizationSettings.php b/src/Settings/SystemSettings/LocalizationSettings.php index 434a4e69e..bc52f4880 100644 --- a/src/Settings/SystemSettings/LocalizationSettings.php +++ b/src/Settings/SystemSettings/LocalizationSettings.php @@ -26,6 +26,8 @@ use App\Form\Type\LocaleSelectType; use App\Settings\SettingsIcon; use Jbtronics\SettingsBundle\Metadata\EnvVarMode; +use Jbtronics\SettingsBundle\ParameterTypes\ArrayType; +use Jbtronics\SettingsBundle\ParameterTypes\EnumType; use Jbtronics\SettingsBundle\Settings\Settings; use Jbtronics\SettingsBundle\Settings\SettingsParameter; use Jbtronics\SettingsBundle\Settings\SettingsTrait; @@ -60,4 +62,19 @@ class LocalizationSettings envVar: "string:BASE_CURRENCY", envVarMode: EnvVarMode::OVERWRITE )] public string $baseCurrency = 'EUR'; -} \ No newline at end of file + + /** @var PreferredLocales[] */ + #[SettingsParameter(ArrayType::class, + label: new TM("settings.system.localization.preferred_languages"), + description: new TM("settings.system.localization.preferred_languages.help"), + options: ['type' => EnumType::class, 'options' => ['class' => PreferredLocales::class]], + formType: \Symfony\Component\Form\Extension\Core\Type\EnumType::class, + formOptions: ['class' => PreferredLocales::class, 'multiple' => true, 'ordered' => true] + )] + #[Assert\NotBlank()] + #[Assert\Unique()] + #[Assert\All([new Assert\Type(PreferredLocales::class)])] + public array $preferredLanguages = [PreferredLocales::EN, PreferredLocales::DE, + PreferredLocales::IT, PreferredLocales::FR, PreferredLocales::RU, PreferredLocales::JA, + PreferredLocales::CS, PreferredLocales::DA, PreferredLocales::ZH, PreferredLocales::PL]; +} diff --git a/src/Settings/SystemSettings/PreferredLocales.php b/src/Settings/SystemSettings/PreferredLocales.php new file mode 100644 index 000000000..1fe38a548 --- /dev/null +++ b/src/Settings/SystemSettings/PreferredLocales.php @@ -0,0 +1,37 @@ +. + */ + +declare(strict_types=1); + +namespace App\Settings\SystemSettings; + +enum PreferredLocales: string +{ + case EN = 'en'; + case DE = 'de'; + case IT = 'it'; + case FR = 'fr'; + case RU = 'ru'; + case JA = 'ja'; + case CS = 'cs'; + case DA = 'da'; + case ZH = 'zh'; + case PL = 'pl'; +} diff --git a/src/Twig/DataSourceNameExtension.php b/src/Twig/DataSourceNameExtension.php new file mode 100644 index 000000000..d0d8b4b52 --- /dev/null +++ b/src/Twig/DataSourceNameExtension.php @@ -0,0 +1,43 @@ +translator = $translator; + $this->dataSourceSynonyms = $dataSourceSynonymsSettings->getSynonymsAsArray(); + } + + public function getFunctions(): array + { + return [ + new TwigFunction('get_data_source_name', [$this, 'getDataSourceName']), + ]; + } + + /** + * Based on the locale and data source names, gives the right synonym value back or the default translator value. + */ + public function getDataSourceName(string $dataSourceName, string $defaultKey): string + { + $locale = $this->translator->getLocale(); + + // Use alternative dataSource synonym (if available) + if (isset($this->dataSourceSynonyms[$dataSourceName][$locale])) { + return $this->dataSourceSynonyms[$dataSourceName][$locale]; + } + + // Otherwise return the standard translation + return $this->translator->trans($defaultKey); + } +} diff --git a/src/Twig/EntityExtension.php b/src/Twig/EntityExtension.php index 762ebb094..b757d75e8 100644 --- a/src/Twig/EntityExtension.php +++ b/src/Twig/EntityExtension.php @@ -22,8 +22,10 @@ */ namespace App\Twig; +use App\Entity\AssemblySystem\Assembly; use App\Entity\Attachments\Attachment; use App\Entity\Base\AbstractDBElement; +use App\Entity\Parts\PartCustomState; use App\Entity\ProjectSystem\Project; use App\Entity\LabelSystem\LabelProfile; use App\Entity\Parts\Category; @@ -108,6 +110,7 @@ public function getEntityType(object $entity): ?string Manufacturer::class => 'manufacturer', Category::class => 'category', Project::class => 'device', + Assembly::class => 'assembly', Attachment::class => 'attachment', Supplier::class => 'supplier', User::class => 'user', @@ -115,6 +118,7 @@ public function getEntityType(object $entity): ?string Currency::class => 'currency', MeasurementUnit::class => 'measurement_unit', LabelProfile::class => 'label_profile', + PartCustomState::class => 'part_custom_state', ]; foreach ($map as $class => $type) { 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/src/Validator/Constraints/UniquePartIpnConstraint.php b/src/Validator/Constraints/UniquePartIpnConstraint.php new file mode 100644 index 000000000..ca32f9ef9 --- /dev/null +++ b/src/Validator/Constraints/UniquePartIpnConstraint.php @@ -0,0 +1,22 @@ +entityManager = $entityManager; + $this->ipnSuggestSettings = $ipnSuggestSettings; + } + + public function validate($value, Constraint $constraint) + { + if (null === $value || '' === $value) { + return; + } + + if ($this->ipnSuggestSettings->autoAppendSuffix) { + return; + } + + // Stelle sicher, dass es unser eigenes Constraint ist (wichtig für PHPStan) + if (!$constraint instanceof UniquePartIpnConstraint) { + return; + } + + /** @var Part $currentPart */ + $currentPart = $this->context->getObject(); + + if (!$currentPart instanceof Part) { + return; + } + + $repository = $this->entityManager->getRepository(Part::class); + $existingParts = $repository->findBy(['ipn' => $value]); + + foreach ($existingParts as $existingPart) { + if ($currentPart->getId() !== $existingPart->getId()) { + $this->context->buildViolation($constraint->message) + ->setParameter('{{ value }}', $value) + ->addViolation(); + } + } + } +} diff --git a/templates/_turbo_control.html.twig b/templates/_turbo_control.html.twig index 4c1780386..46637587c 100644 --- a/templates/_turbo_control.html.twig +++ b/templates/_turbo_control.html.twig @@ -22,9 +22,9 @@
    - {% for locale in locale_menu %} + {% for locale in location_settings.preferredLanguages %} - {{ locale|language_name }} ({{ locale|upper }}) + app.request.query.all|merge(app.request.attributes.get('_route_params'))|merge({'_locale': locale.value})) }}"> + {{ locale.value|language_name }} ({{ locale.value|upper }}) {% endfor %} -
    \ No newline at end of file + 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..2e68a3da9 --- /dev/null +++ b/templates/admin/assembly_admin.html.twig @@ -0,0 +1,50 @@ +{% extends "admin/base_admin.html.twig" %} + +{# @var entity App\Entity\AssemblySystem\Assembly #} + +{% block card_title %} + {% set dataSourceName = get_data_source_name('assembly', 'assembly.caption') %} + {% set translatedSource = 'assembly.caption'|trans %} + {% if dataSourceName != translatedSource %}{{ 'datasource.synonym'|trans({'%name%': translatedSource, '%synonym%': dataSourceName}) }}{% else %}{{ translatedSource }}{% endif %} +{% 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/admin/base_admin.html.twig b/templates/admin/base_admin.html.twig index 51790c3c1..b3d5af5f1 100644 --- a/templates/admin/base_admin.html.twig +++ b/templates/admin/base_admin.html.twig @@ -201,4 +201,4 @@
    - {% endblock %} \ No newline at end of file + {% endblock %} diff --git a/templates/admin/category_admin.html.twig b/templates/admin/category_admin.html.twig index 5811640b9..569cd2eb3 100644 --- a/templates/admin/category_admin.html.twig +++ b/templates/admin/category_admin.html.twig @@ -1,7 +1,9 @@ {% extends "admin/base_admin.html.twig" %} {% block card_title %} - {% trans %}category.labelp{% endtrans %} + {% set dataSourceName = get_data_source_name('category', 'category.labelp') %} + {% set translatedSource = 'category.labelp'|trans %} + {% if dataSourceName != translatedSource %}{{ 'datasource.synonym'|trans({'%name%': translatedSource, '%synonym%': dataSourceName}) }}{% else %}{{ translatedSource }}{% endif %} {% endblock %} {% block additional_pills %} @@ -31,6 +33,7 @@
    {{ form_row(form.partname_regex) }} {{ form_row(form.partname_hint) }} + {{ form_row(form.part_ipn_prefix) }}
    {{ form_row(form.default_description) }} {{ form_row(form.default_comment) }} @@ -60,4 +63,4 @@
    {{ form_row(form.eda_info.kicad_symbol) }} -{% endblock %} \ No newline at end of file +{% endblock %} diff --git a/templates/admin/footprint_admin.html.twig b/templates/admin/footprint_admin.html.twig index a2c3e4afd..3099b2078 100644 --- a/templates/admin/footprint_admin.html.twig +++ b/templates/admin/footprint_admin.html.twig @@ -1,7 +1,9 @@ {% extends "admin/base_admin.html.twig" %} {% block card_title %} - {% trans %}footprint.labelp{% endtrans %} + {% set dataSourceName = get_data_source_name('footprint', 'footprint.labelp') %} + {% set translatedSource = 'footprint.labelp'|trans %} + {% if dataSourceName != translatedSource %}{{ 'datasource.synonym'|trans({'%name%': translatedSource, '%synonym%': dataSourceName}) }}{% else %}{{ translatedSource }}{% endif %} {% endblock %} {% block master_picture_block %} @@ -34,4 +36,4 @@ {{ form_row(form.eda_info.kicad_footprint) }} -{% endblock %} \ No newline at end of file +{% endblock %} diff --git a/templates/admin/manufacturer_admin.html.twig b/templates/admin/manufacturer_admin.html.twig index 5db892c04..df597487c 100644 --- a/templates/admin/manufacturer_admin.html.twig +++ b/templates/admin/manufacturer_admin.html.twig @@ -1,7 +1,9 @@ {% extends "admin/base_company_admin.html.twig" %} {% block card_title %} - {% trans %}manufacturer.caption{% endtrans %} + {% set dataSourceName = get_data_source_name('manufacturer', 'manufacturer.caption') %} + {% set translatedSource = 'manufacturer.caption'|trans %} + {% if dataSourceName != translatedSource %}{{ 'datasource.synonym'|trans({'%name%': translatedSource, '%synonym%': dataSourceName}) }}{% else %}{{ translatedSource }}{% endif %} {% endblock %} {% block edit_title %} @@ -10,4 +12,4 @@ {% block new_title %} {% trans %}manufacturer.new{% endtrans %} -{% endblock %} \ No newline at end of file +{% endblock %} diff --git a/templates/admin/part_custom_state_admin.html.twig b/templates/admin/part_custom_state_admin.html.twig new file mode 100644 index 000000000..004ceb657 --- /dev/null +++ b/templates/admin/part_custom_state_admin.html.twig @@ -0,0 +1,14 @@ +{% extends "admin/base_admin.html.twig" %} + +{% block card_title %} + {% trans %}part_custom_state.caption{% endtrans %} +{% endblock %} + +{% block edit_title %} + {% trans %}part_custom_state.edit{% endtrans %}: {{ entity.name }} +{% endblock %} + +{% block new_title %} + {% trans %}part_custom_state.new{% endtrans %} +{% endblock %} + diff --git a/templates/admin/project_admin.html.twig b/templates/admin/project_admin.html.twig index 1a9950691..c4174bc1f 100644 --- a/templates/admin/project_admin.html.twig +++ b/templates/admin/project_admin.html.twig @@ -3,7 +3,9 @@ {# @var entity App\Entity\ProjectSystem\Project #} {% block card_title %} - {% trans %}project.caption{% endtrans %} + {% set dataSourceName = get_data_source_name('project', 'project.caption') %} + {% set translatedSource = 'project.caption'|trans %} + {% if dataSourceName != translatedSource %}{{ 'datasource.synonym'|trans({'%name%': translatedSource, '%synonym%': dataSourceName}) }}{% else %}{{ translatedSource }}{% endif %} {% endblock %} {% block edit_title %} @@ -36,7 +38,7 @@ {% if entity.buildPart %} {{ entity.buildPart.name }} {% else %} - {% trans %}project.edit.associated_build_part.add{% endtrans %} {% endif %}

    {% trans %}project.edit.associated_build.hint{% endtrans %}

    @@ -59,4 +61,4 @@ {% endif %} -{% endblock %} \ No newline at end of file +{% endblock %} diff --git a/templates/admin/storelocation_admin.html.twig b/templates/admin/storelocation_admin.html.twig index c93339dc1..43779e31e 100644 --- a/templates/admin/storelocation_admin.html.twig +++ b/templates/admin/storelocation_admin.html.twig @@ -2,7 +2,9 @@ {% import "label_system/dropdown_macro.html.twig" as dropdown %} {% block card_title %} - {% trans %}storelocation.labelp{% endtrans %} + {% set dataSourceName = get_data_source_name('storagelocation', 'storelocation.labelp') %} + {% set translatedSource = 'storelocation.labelp'|trans %} + {% if dataSourceName != translatedSource %}{{ 'datasource.synonym'|trans({'%name%': translatedSource, '%synonym%': dataSourceName}) }}{% else %}{{ translatedSource }}{% endif %} {% endblock %} {% block additional_controls %} @@ -38,4 +40,4 @@ {% block new_title %} {% trans %}storelocation.new{% endtrans %} -{% endblock %} \ No newline at end of file +{% endblock %} diff --git a/templates/admin/supplier_admin.html.twig b/templates/admin/supplier_admin.html.twig index ce38a5ca4..1898fdf3d 100644 --- a/templates/admin/supplier_admin.html.twig +++ b/templates/admin/supplier_admin.html.twig @@ -1,7 +1,9 @@ {% extends "admin/base_company_admin.html.twig" %} {% block card_title %} - {% trans %}supplier.caption{% endtrans %} + {% set dataSourceName = get_data_source_name('supplier', 'supplier.caption') %} + {% set translatedSource = 'supplier.caption'|trans %} + {% if dataSourceName != translatedSource %}{{ 'datasource.synonym'|trans({'%name%': translatedSource, '%synonym%': dataSourceName}) }}{% else %}{{ translatedSource }}{% endif %} {% endblock %} {% block additional_panes %} @@ -19,4 +21,4 @@ {% block new_title %} {% trans %}supplier.new{% endtrans %} -{% endblock %} \ No newline at end of file +{% 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..15bf5d883 --- /dev/null +++ b/templates/assemblies/export_bom_pdf.html.twig @@ -0,0 +1,103 @@ + + + + Assembly Hierarchy + + + + + +

    Table of Contents

    + + + + + + + + + + + {% for assembly in assemblies %} + + + + + + + {% endfor %} + +
    #Assembly NameIPNSection
    {{ loop.index }}Assembly: {{ assembly.name }}{% if assembly.ipn != '' %}{{ assembly.ipn }}{% else %}-{% endif %}{{ loop.index + 1 }}
    +
    + + +{% for assembly in assemblies %} +
    Assembly: {{ assembly.name }}
    + + + + + + + + + + + + {% for part in assembly.parts %} + + + + + + + + {% endfor %} + {% for other in assembly.others %} + + + + + + + + {% endfor %} + {% for referencedAssembly in assembly.referencedAssemblies %} + + + + + + + + {% endfor %} + +
    NameIPNQuantityMultiplierEffective Quantity
    {{ part.name }}{{ part.ipn }}{{ part.quantity }}{% if assembly.multiplier %}{{ assembly.multiplier }}{% else %}-{% endif %}{{ part.effectiveQuantity }}
    {{ other.name }}{{ other.ipn }}{{ other.quantity }}{{ other.multiplier }}{{ other.effectiveQuantity }}
    {{ referencedAssembly.name }}{{ referencedAssembly.ipn }}{{ referencedAssembly.quantity }}{{ 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..b5a1324d9 --- /dev/null +++ b/templates/assemblies/export_bom_referenced_assembly_pdf.html.twig @@ -0,0 +1,55 @@ +
    +
    Referenced Assembly: {{ assembly.name }} [IPN: {% if assembly.ipn != '' %}{{ assembly.ipn }}{% else %}-{% endif %}, quantity: {{ assembly.quantity }}]
    + + + + + + + + + + + + + + {% for part in assembly.parts %} + + + + + + + + + {% endfor %} + + {% for other in assembly.others %} + + + + + + + + + {% endfor %} + + {% for referencedAssembly in assembly.referencedAssemblies %} + + + + + + + + + {% endfor %} + +
    TypeNameIPNQuantityMultiplierEffective Quantity
    Part{{ part.name }}{{ part.ipn }}{{ part.quantity }}{% if assembly.multiplier %}{{ assembly.multiplier }}{% else %}-{% endif %}{{ part.effectiveQuantity }}
    Other{{ other.name }}-{{ other.quantity }}{{ other.multiplier }}-
    Referenced assembly{{ referencedAssembly.name }}-{{ referencedAssembly.quantity }}{{ referencedAssembly.multiplier }}
    + + + {% 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..6b63bebc7 --- /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 %} +
    +
    +
    +
    +
    +
    +
    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..2cb3636f8 --- /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/components/tree_macros.html.twig b/templates/components/tree_macros.html.twig index 366d42fe8..210a00633 100644 --- a/templates/components/tree_macros.html.twig +++ b/templates/components/tree_macros.html.twig @@ -1,13 +1,16 @@ {% macro sidebar_dropdown() %} + {% set currentLocale = app.request.locale %} + {# Format is [mode, route, label, show_condition] #} {% set data_sources = [ - ['categories', path('tree_category_root'), 'category.labelp', is_granted('@categories.read') and is_granted('@parts.read')], - ['locations', path('tree_location_root'), 'storelocation.labelp', is_granted('@storelocations.read') and is_granted('@parts.read')], - ['footprints', path('tree_footprint_root'), 'footprint.labelp', is_granted('@footprints.read') and is_granted('@parts.read')], - ['manufacturers', path('tree_manufacturer_root'), 'manufacturer.labelp', is_granted('@manufacturers.read') and is_granted('@parts.read')], - ['suppliers', path('tree_supplier_root'), 'supplier.labelp', is_granted('@suppliers.read') and is_granted('@parts.read')], - ['projects', path('tree_device_root'), 'project.labelp', is_granted('@projects.read')], - ['tools', path('tree_tools'), 'tools.label', true], + ['categories', path('tree_category_root'), 'category.labelp', is_granted('@categories.read') and is_granted('@parts.read'), 'category'], + ['locations', path('tree_location_root'), 'storelocation.labelp', is_granted('@storelocations.read') and is_granted('@parts.read'), 'storagelocation'], + ['footprints', path('tree_footprint_root'), 'footprint.labelp', is_granted('@footprints.read') and is_granted('@parts.read'), 'footprint'], + ['manufacturers', path('tree_manufacturer_root'), 'manufacturer.labelp', is_granted('@manufacturers.read') and is_granted('@parts.read'), 'manufacturer'], + ['suppliers', path('tree_supplier_root'), 'supplier.labelp', is_granted('@suppliers.read') and is_granted('@parts.read'), 'supplier'], + ['projects', path('tree_device_root'), 'project.labelp', is_granted('@projects.read'), 'project'], + ['assembly', path('tree_assembly_root'), 'assembly.labelp', is_granted('@assemblies.read'), 'assembly'], + ['tools', path('tree_tools'), 'tools.label', true, 'tool'], ] %} @@ -18,9 +21,9 @@ {% for source in data_sources %} {% if source[3] %} {# show_condition #} -
  • + >{{ get_data_source_name(source[4], source[2]) }} {% endif %} {% endfor %} {% endmacro %} @@ -61,4 +64,4 @@
    -{% endmacro %} \ No newline at end of file +{% endmacro %} diff --git a/templates/form/collection_types_layout_assembly.html.twig b/templates/form/collection_types_layout_assembly.html.twig new file mode 100644 index 000000000..1bd79bb5f --- /dev/null +++ b/templates/form/collection_types_layout_assembly.html.twig @@ -0,0 +1,74 @@ +{% block assembly_bom_entry_collection_widget %} + {% import 'components/collection_type.macro.html.twig' as collection %} +
    + + + + {# expand button #} + + + + {# Remove button #} + + + + + {% for entry in form %} + {{ form_widget(entry) }} + {% endfor %} + +
    {% trans %}assembly.bom.quantity{% endtrans %}{% trans %}assembly.bom.partOrAssembly{% endtrans %}{% trans %}assembly.bom.identifiers{% endtrans %}
    + + +
    + +{% endblock %} + +{% block assembly_bom_entry_widget %} + {% set target_id = 'expand_row-' ~ form.vars.name %} + + {% import 'components/collection_type.macro.html.twig' as collection %} + + + + + + {{ form_widget(form.quantity) }} + {{ form_errors(form.quantity) }} + + + {{ form_row(form.part) }} + {{ form_errors(form.part) }} +
    + {{ form_widget(form.referencedAssembly) }} + {{ form_errors(form.referencedAssembly) }} + + + {{ form_row(form.name) }} + {{ form_errors(form.name) }} +
    + {{ form_row(form.designator) }} + {{ form_errors(form.designator) }} + + + + {{ form_errors(form) }} + + + + + +
    + {{ form_row(form.comment) }} +
    + + +{% endblock %} diff --git a/templates/form/permission_layout.html.twig b/templates/form/permission_layout.html.twig index 166147b4c..95353a1bc 100644 --- a/templates/form/permission_layout.html.twig +++ b/templates/form/permission_layout.html.twig @@ -6,12 +6,36 @@
    {% else %} - {{ form.vars.label | trans }} + def{{ form.vars.label | trans }} {% endif %} @@ -110,4 +134,4 @@ {% endfor %} -{% endblock %} \ No newline at end of file +{% endblock %} diff --git a/templates/helper.twig b/templates/helper.twig index bd1d2aa7a..3ddb4f7fa 100644 --- a/templates/helper.twig +++ b/templates/helper.twig @@ -76,6 +76,21 @@ {% endif %} {% endmacro %} +{% macro assemblies_status_to_badge(status, class="badge") %} + {% if status is not empty %} + {% set color = " bg-secondary" %} + + {% if status == "in_production" %} + {% set color = " bg-success" %} + {% endif %} + + + + {{ ("assembly.status." ~ status) | trans }} + + {% endif %} +{% endmacro %} + {% macro structural_entity_link(entity, link_type = "list_parts") %} {# @var entity \App\Entity\Base\StructuralDBElement #} {% if entity %} @@ -101,6 +116,7 @@ "category": ["fa-solid fa-tags", "category.label"], "currency": ["fa-solid fa-coins", "currency.label"], "device": ["fa-solid fa-archive", "project.label"], + "assembly": ["fa-solid fa-list", "assembly.label"], "footprint": ["fa-solid fa-microchip", "footprint.label"], "group": ["fa-solid fa-users", "group.label"], "label_profile": ["fa-solid fa-qrcode", "label_profile.label"], diff --git a/templates/parts/edit/_advanced.html.twig b/templates/parts/edit/_advanced.html.twig index 12b546abc..991a36ebb 100644 --- a/templates/parts/edit/_advanced.html.twig +++ b/templates/parts/edit/_advanced.html.twig @@ -1,5 +1,16 @@ {{ form_row(form.needsReview) }} {{ form_row(form.favorite) }} {{ form_row(form.mass) }} -{{ form_row(form.ipn) }} -{{ form_row(form.partUnit) }} \ No newline at end of file +
    + {{ form_row(form.ipn) }} +
    +{{ form_row(form.partUnit) }} +{{ form_row(form.partCustomState) }} diff --git a/templates/parts/info/_assemblies.html.twig b/templates/parts/info/_assemblies.html.twig new file mode 100644 index 000000000..d4996c592 --- /dev/null +++ b/templates/parts/info/_assemblies.html.twig @@ -0,0 +1,31 @@ +{% import "components/attachments.macro.html.twig" as attachments %} +{% import "helper.twig" as helper %} + + + + + + + + + + + + + {% for bom_entry in part.assemblyBomEntries %} + {# @var bom_entry App\Entity\Assembly\AssemblyBOMEntry #} + + + {# Name #} + {# Description #} + + + {% endfor %} + +
    {% trans %}entity.info.name{% endtrans %}{% trans %}description.label{% endtrans %}{% trans %}assembly.bom.quantity{% endtrans %}
    {% if bom_entry.assembly.masterPictureAttachment is not null %}{{ attachments.attachment_icon(bom_entry.assembly.masterPictureAttachment, attachment_manager) }}{% endif %}{{ bom_entry.assembly.name }}{{ bom_entry.assembly.description|format_markdown }}{{ bom_entry.quantity | format_amount(part.partUnit) }}
    + + + + {% trans %}part.info.add_part_to_assembly{% endtrans %} + \ No newline at end of file diff --git a/templates/parts/info/_sidebar.html.twig b/templates/parts/info/_sidebar.html.twig index 28eada048..0c353d8f6 100644 --- a/templates/parts/info/_sidebar.html.twig +++ b/templates/parts/info/_sidebar.html.twig @@ -36,6 +36,19 @@ {% endif %} +{% if part.partCustomState is not null %} +
    +
    + {{ part.partCustomState.name }} + + {% if part.partCustomState is not null and part.partCustomState.masterPictureAttachment and attachment_manager.fileExisting(part.partCustomState.masterPictureAttachment) %} +
    + {% trans %}attachment.preview.alt{% endtrans %} + {% endif %} +
    +
    +{% endif %} + {# Favorite Status tag #} {% if part.favorite %}
    @@ -79,4 +92,4 @@
    -{% endif %} \ No newline at end of file +{% endif %} diff --git a/templates/parts/info/show_part_info.html.twig b/templates/parts/info/show_part_info.html.twig index 96b5e2091..cd7b4ce7a 100644 --- a/templates/parts/info/show_part_info.html.twig +++ b/templates/parts/info/show_part_info.html.twig @@ -109,15 +109,20 @@ {% trans %}vendor.partinfo.history{% endtrans %} - {% if part.projectBomEntries is not empty %} - - {% endif %} + +