Compare commits

...

12 commits

Author SHA1 Message Date
Jan Böhmer
6a5039326c New translations validators.en.xlf (English) 2025-11-12 22:31:26 +01:00
Jan Böhmer
bee1542cce New translations messages.en.xlf (English) 2025-11-12 22:31:25 +01:00
Jan Böhmer
95c5ab7b8b Removed unnecessary polyfills
Some checks failed
Build assets artifact / Build assets artifact (push) Has been cancelled
Docker Image Build / docker (push) Has been cancelled
Docker Image Build (FrankenPHP) / docker (push) Has been cancelled
Static analysis / Static analysis (push) Has been cancelled
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.2, mysql) (push) Has been cancelled
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.3, mysql) (push) Has been cancelled
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.4, mysql) (push) Has been cancelled
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.5, mysql) (push) Has been cancelled
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.2, postgres) (push) Has been cancelled
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.3, postgres) (push) Has been cancelled
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.4, postgres) (push) Has been cancelled
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.5, postgres) (push) Has been cancelled
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.2, sqlite) (push) Has been cancelled
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.3, sqlite) (push) Has been cancelled
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.4, sqlite) (push) Has been cancelled
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.5, sqlite) (push) Has been cancelled
2025-11-12 21:54:41 +01:00
Jan Böhmer
f184afc918 Updated dependencies 2025-11-12 21:49:44 +01:00
web-devinition.de
54f318ecac
Implemented the ability to set user-defined synonyms/labels for internal element types
* Implementiere bevorzugte Sprachauswahl und Datenquellen-Synonyme

Die Spracheinstellungen/System-Settings wurden um die Möglichkeit ergänzt, bevorzugte Sprachen für die Dropdown-Menüs festzulegen. Zudem wurde ein Datenquellen-Synonymsystem implementiert, um benutzerfreundlichere Bezeichnungen anzuzeigen und zu personalisieren.

* Anpassung aus Analyse

* Entferne alten JSON-basierten Datenquellen-Synonym-Handler

Die Verwaltung der Datenquellen-Synonyme wurde überarbeitet, um ein flexibleres und strukturiertes Konzept zu ermöglichen. Der bestehende JSON-basierte Ansatz wurde durch eine neue Service-basierte Architektur ersetzt, die eine bessere Handhabung und Erweiterbarkeit erlaubt.

* Ermögliche Rückgabe aller möglichen Sprachoptionen in Verbindung mit den vom Nutzer freigeschalteten.

* Removed unnecessary service definition

The tag is applied via autoconfiguration

* Use default translations for the NotBlank constraint

* Started refactoring ElementTypeNameGenerator

* Made ElementTypeNameGenerator class readonly

* Modified form to work properly with new datastructure

* Made the form more beautiful and space saving

* Made synonym form even more space saving

* Allow to define overrides for any element label there is

* Use defined synonyms in ElementTypeNameGenerator

* Use ElementTypeNameGenerator where possible

* Register synonyms for element types as global translation parameters

* Revert changes done to permission layout

* Use new synonym system for admin page titles

* Removed now unnecessary services

* Reworked settings name and translation

* Renamed all files to Synonyms

* Removed unnecessary translations

* Removed unnecessary translations

* Fixed duplicate check

* Renamed synoynms translations

* Use our synonyms for permission translations

* Fixed phpstan issue

* Added tests

---------

Co-authored-by: Marcel Diegelmann <marcel.diegelmann@gmail.com>
Co-authored-by: Jan Böhmer <mail@jan-boehmer.de>
2025-11-12 21:35:02 +01:00
Jan Böhmer
5e3bd26e27
New Crowdin updates (#1105)
* New translations validators.en.xlf (French)

* New translations security.en.xlf (French)

* New translations messages.en.xlf (French)

* New translations messages.en.xlf (French)
2025-11-12 21:34:05 +01:00
Jan Böhmer
e53b72a8d1 Merge remote-tracking branch 'origin/master'
Some checks failed
Build assets artifact / Build assets artifact (push) Has been cancelled
Docker Image Build / docker (push) Has been cancelled
Docker Image Build (FrankenPHP) / docker (push) Has been cancelled
Static analysis / Static analysis (push) Has been cancelled
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.2, mysql) (push) Has been cancelled
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.3, mysql) (push) Has been cancelled
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.4, mysql) (push) Has been cancelled
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.5, mysql) (push) Has been cancelled
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.2, postgres) (push) Has been cancelled
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.3, postgres) (push) Has been cancelled
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.4, postgres) (push) Has been cancelled
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.5, postgres) (push) Has been cancelled
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.2, sqlite) (push) Has been cancelled
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.3, sqlite) (push) Has been cancelled
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.4, sqlite) (push) Has been cancelled
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.5, sqlite) (push) Has been cancelled
2025-11-03 00:37:00 +01:00
Jan Böhmer
e8ff15ad0f Updated dependencies 2025-11-03 00:36:56 +01:00
dependabot[bot]
ec6b3ae414
Bump actions/setup-node from 5 to 6 (#1086)
Bumps [actions/setup-node](https://github.com/actions/setup-node) from 5 to 6.
- [Release notes](https://github.com/actions/setup-node/releases)
- [Commits](https://github.com/actions/setup-node/compare/v5...v6)

---
updated-dependencies:
- dependency-name: actions/setup-node
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-11-03 00:35:12 +01:00
dependabot[bot]
07e4521c30
Bump actions/upload-artifact from 4 to 5 (#1091)
Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 4 to 5.
- [Release notes](https://github.com/actions/upload-artifact/releases)
- [Commits](https://github.com/actions/upload-artifact/compare/v4...v5)

---
updated-dependencies:
- dependency-name: actions/upload-artifact
  dependency-version: '5'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-11-03 00:35:03 +01:00
web-devinition.de
771857e014
Added feature for part IPN suggest with category prefixes (#1054)
* Erweiterungstätigkeiten zur IPN-Vorschlagsliste anhand von Präfixen aus den Kategorien

* Umstellung Migrationen bzgl. Multi-Plattform-Support.
Zunächst MySQL, SQLite Statements integrieren.

* Postgre Statements integrieren

* SQL-Formatierung in Migration verbessern

* Erweitere IPN-Suggest um Bauteilbeschreibung.

Die Implementierung berücksichtigt nun zusätzlich die Bauteilbeschreibung zu maximal 150 Zeichen Länge für die Generierung von IPN-Vorschlägen und Inkrementen.

* Anpassungen aus Analyse vornehmen

* IPN-Validierung für Parts überarbeiten

* IPN-Vorschlagslogik um Konfiguration erweitert

* Anpassungen aus phpstan Analyse

* IPN-Vorschlagslogik erweitert und Bauteil-IPN vereindeutigt

Die IPN-Logik wurde um eine Konfiguration zur automatischen Suffix-Anfügung und die Berücksichtigung von doppelten Beschreibungen bei Bedarf ergänzt. Zudem wurde das Datenmodell angepasst, um eine eindeutige Speicherung der IPN zu gewährleisten.

* Regex-Konfigurationsmöglichkeit für IPN-Vorschläge einführen

Die Einstellungen für die IPN-Vorschlagslogik wurden um eine Regex-Validierung und eine Hilfetext-Konfiguration erweitert. Tests und Änderungen an den Formularoptionen wurden implementiert.

* Match range assert and form limits in suggestPartDigits

* Keep existing behavior with autoAppend suffix by default

* Show the regex hint in the browser validation notice.

* Improved translations

* Removed unnecessary service definition

* Removed german comments

---------

Co-authored-by: Marcel Diegelmann <marcel.diegelmann@gmail.com>
Co-authored-by: Jan Böhmer <mail@jan-boehmer.de>
2025-11-03 00:31:47 +01:00
web-devinition.de
14a4f1f437
Added custom part status (#1053)
Some checks failed
Build assets artifact / Build assets artifact (push) Has been cancelled
Docker Image Build / docker (push) Has been cancelled
Docker Image Build (FrankenPHP) / docker (push) Has been cancelled
Static analysis / Static analysis (push) Has been cancelled
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.2, mysql) (push) Has been cancelled
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.3, mysql) (push) Has been cancelled
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.4, mysql) (push) Has been cancelled
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.5, mysql) (push) Has been cancelled
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.2, postgres) (push) Has been cancelled
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.3, postgres) (push) Has been cancelled
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.4, postgres) (push) Has been cancelled
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.5, postgres) (push) Has been cancelled
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.2, sqlite) (push) Has been cancelled
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.3, sqlite) (push) Has been cancelled
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.4, sqlite) (push) Has been cancelled
PHPUnit Tests / PHPUnit and coverage Test (PHP 8.5, sqlite) (push) Has been cancelled
* Benutzerdefinierten Bauteilstatus einführen

* PartCustomStateController hinzufügen

* Umstellung Migrationen bzgl. Multi-Plattform-Support.
Zunächst MySQL, SQLite Statements integrieren.

* Postgre Statements integrieren

* Semikolon in Migration entfernen

* Migration für PartCustomState aktualisieren

* Benutzerdefinierten Bauteilstatus in TableSettings aufnehmen

* PartCustomStateControllerTest: Attribute für PHPUnit-Gruppen umgestellt

* PartCustomState: Mapping für Parameter korrigieren

* PartCustomState: Darstellung und Zuordnung von Anhängen ergänzt

Die Sidebar wurde um die Anzeige des benutzerdefinierten Bauteilstatus erweitert, inklusive Vorschaubild, sofern vorhanden.

* Migrationen zusammenführen

* PartCustomState: Anpassungen bzgl. Tests

* PartCustomStateEndpoint hinzufügen

* Made custom part states plural for consistency with other entity captions

* Fixed phpunit error

* Fixed phpstan issues

---------

Co-authored-by: Marcel Diegelmann <marcel.diegelmann@gmail.com>
Co-authored-by: Jan Böhmer <mail@jan-boehmer.de>
2025-10-27 21:58:16 +01:00
124 changed files with 16995 additions and 11193 deletions

View file

@ -60,7 +60,7 @@ jobs:
${{ runner.os }}-yarn-
- name: Setup node
uses: actions/setup-node@v5
uses: actions/setup-node@v6
with:
node-version: '20'
@ -80,13 +80,13 @@ jobs:
run: zip -r /tmp/partdb_assets.zip public/build/ vendor/
- name: Upload assets artifact
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v5
with:
name: Only dependencies and built assets
path: /tmp/partdb_assets.zip
- name: Upload full artifact
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v5
with:
name: Full Part-DB including dependencies and built assets
path: /tmp/partdb_with_assets.zip

View file

@ -104,7 +104,7 @@ jobs:
run: composer install --prefer-dist --no-progress
- name: Setup node
uses: actions/setup-node@v5
uses: actions/setup-node@v6
with:
node-version: '20'

View file

@ -106,6 +106,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;

View file

@ -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`
<section class="aa-Source">
<div class="aa-SourceHeader">
<span class="aa-SourceHeaderTitle">${title}</span>
<div class="aa-SourceHeaderLine"></div>
</div>
</section>
`;
},
partIncrementHeader({ title, html }) {
return html`
<section class="aa-Source">
<div class="aa-SourceHeader">
<span class="aa-SourceHeaderTitle">${title}</span>
<div class="aa-SourceHeaderLine"></div>
</div>
</section>
`;
},
list({ html }) {
return html`
<ul class="aa-List" role="listbox"></ul>
`;
},
item({ suggestion, description, html }) {
return html`
<li class="aa-Item" role="option" data-suggestion="${suggestion}" aria-selected="false">
<div class="aa-ItemWrapper">
<div class="aa-ItemContent">
<div class="aa-ItemIcon aa-ItemIcon--noBorder">
<svg viewBox="0 0 24 24" fill="currentColor">
<path d="M12 21c4.971 0 9-4.029 9-9s-4.029-9-9-9-9 4.029-9 9 4.029 9 9 9z"></path>
</svg>
</div>
<div class="aa-ItemContentBody">
<div class="aa-ItemContentTitle">${suggestion}</div>
<div class="aa-ItemContentDescription">${description}</div>
</div>
</div>
</div>
</li>
`;
},
};
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));
};
}

View file

@ -0,0 +1,68 @@
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2022 Jan Böhmer (https://github.com/jbtronics)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { Controller } from '@hotwired/stimulus';
export default class extends Controller {
static targets = ['items'];
static values = {
prototype: String,
prototypeName: { type: String, default: '__name__' },
index: { type: Number, default: 0 },
};
connect() {
if (!this.hasIndexValue || Number.isNaN(this.indexValue)) {
this.indexValue = this.itemsTarget?.children.length || 0;
}
}
add(event) {
event.preventDefault();
const encodedProto = this.prototypeValue || '';
const placeholder = this.prototypeNameValue || '__name__';
if (!encodedProto || !this.itemsTarget) return;
const protoHtml = this._decodeHtmlAttribute(encodedProto);
const idx = this.indexValue;
const html = protoHtml.replace(new RegExp(placeholder, 'g'), String(idx));
const wrapper = document.createElement('div');
wrapper.innerHTML = html;
const newItem = wrapper.firstElementChild;
if (newItem) {
this.itemsTarget.appendChild(newItem);
this.indexValue = idx + 1;
}
}
remove(event) {
event.preventDefault();
const row = event.currentTarget.closest('.tc-item');
if (row) row.remove();
}
_decodeHtmlAttribute(str) {
const tmp = document.createElement('textarea');
tmp.innerHTML = str;
return tmp.value || tmp.textContent || '';
}
}

View file

@ -118,6 +118,13 @@
"symfony/stopwatch": "7.3.*",
"symfony/web-profiler-bundle": "7.3.*"
},
"replace": {
"symfony/polyfill-mbstring": "*",
"symfony/polyfill-php74": "*",
"symfony/polyfill-php80": "*",
"symfony/polyfill-php81": "*",
"symfony/polyfill-php82": "*"
},
"suggest": {
"ext-bcmath": "Used to improve price calculation performance",
"ext-gmp": "Used to improve price calculation performanice"

1316
composer.lock generated

File diff suppressed because it is too large Load diff

View file

@ -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:

View file

@ -1,6 +1,6 @@
twig:
default_path: '%kernel.project_dir%/templates'
form_themes: ['bootstrap_5_horizontal_layout.html.twig', 'form/extended_bootstrap_layout.html.twig', 'form/permission_layout.html.twig', 'form/filter_types_layout.html.twig']
form_themes: ['bootstrap_5_horizontal_layout.html.twig', 'form/extended_bootstrap_layout.html.twig', 'form/permission_layout.html.twig', 'form/filter_types_layout.html.twig', 'form/synonyms_collection.html.twig']
paths:
'%kernel.project_dir%/assets/css': css

View file

@ -18,13 +18,13 @@ perms: # Here comes a list with all Permission names (they have a perm_[name] co
parts: # e.g. this maps to perms_parts in User/Group database
group: "data"
label: "perm.parts"
label: "{{part}}"
operations: # Here are all possible operations are listed => the op name is mapped to bit value
read:
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"
@ -71,7 +71,7 @@ perms: # Here comes a list with all Permission names (they have a perm_[name] co
storelocations: &PART_CONTAINING
label: "perm.storelocations"
label: "{{storage_location}}"
group: "data"
operations:
read:
@ -103,35 +103,39 @@ perms: # Here comes a list with all Permission names (they have a perm_[name] co
footprints:
<<: *PART_CONTAINING
label: "perm.part.footprints"
label: "{{footprint}}"
categories:
<<: *PART_CONTAINING
label: "perm.part.categories"
label: "{{category}}"
suppliers:
<<: *PART_CONTAINING
label: "perm.part.supplier"
label: "{{supplier}}"
manufacturers:
<<: *PART_CONTAINING
label: "perm.part.manufacturers"
label: "{{manufacturer}}"
projects:
<<: *PART_CONTAINING
label: "perm.projects"
label: "{{project}}"
attachment_types:
<<: *PART_CONTAINING
label: "perm.part.attachment_types"
label: "{{attachment_type}}"
currencies:
<<: *PART_CONTAINING
label: "perm.currencies"
label: "{{currency}}"
measurement_units:
<<: *PART_CONTAINING
label: "perm.measurement_units"
label: "{{measurement_unit}}"
part_custom_states:
<<: *PART_CONTAINING
label: "{{part_custom_state}}"
tools:
label: "perm.part.tools"

View file

@ -231,6 +231,16 @@ 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 }
# 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

View file

@ -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 parts 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)
@ -136,7 +146,7 @@ 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`.
### History/Eventlog-related settings

View file

@ -0,0 +1,605 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use App\Migration\AbstractMultiPlatformMigration;
use Doctrine\DBAL\Schema\Schema;
final class Version20250321075747 extends AbstractMultiPlatformMigration
{
public function getDescription(): string
{
return 'Create entity table for custom part states and add custom state to parts';
}
public function mySQLUp(Schema $schema): void
{
$this->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);
}
}

View file

@ -0,0 +1,307 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use App\Migration\AbstractMultiPlatformMigration;
use Doctrine\DBAL\Schema\Schema;
final class Version20250325073036 extends AbstractMultiPlatformMigration
{
public function getDescription(): string
{
return 'Add part_ipn_prefix column to categories table and remove unique constraint from parts table';
}
public function mySQLUp(Schema $schema): void
{
$this->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);
}
}

View file

@ -121,6 +121,11 @@ class ImportPartKeeprCommand extends Command
$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);

View file

@ -232,6 +232,7 @@ abstract class BaseAdminController extends AbstractController
'timeTravel' => $timeTravel_timestamp,
'repo' => $repo,
'partsContainingElement' => $repo instanceof PartsContainingRepositoryInterface,
'showParameters' => !($this instanceof PartCustomStateController),
]);
}
@ -382,6 +383,7 @@ abstract class BaseAdminController extends AbstractController
'import_form' => $import_form,
'mass_creation_form' => $mass_creation_form,
'route_base' => $this->route_base,
'showParameters' => !($this instanceof PartCustomStateController),
]);
}

View file

@ -0,0 +1,83 @@
<?php
/**
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2022 Jan Böhmer (https://github.com/jbtronics)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
declare(strict_types=1);
namespace App\Controller\AdminPages;
use App\Entity\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);
}
}

View file

@ -47,6 +47,7 @@ use App\Services\Parts\PartLotWithdrawAddHelper;
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 +75,7 @@ final class PartController extends AbstractController
private readonly EntityManagerInterface $em,
private readonly EventCommentHelper $commentHelper,
private readonly PartInfoSettings $partInfoSettings,
private readonly IpnSuggestSettings $ipnSuggestSettings,
) {
}
@ -444,10 +446,13 @@ final class PartController extends AbstractController
$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 +462,6 @@ final class PartController extends AbstractController
);
}
#[Route(path: '/{id}/add_withdraw', name: 'part_add_withdraw', methods: ['POST'])]
public function withdrawAddHandler(Part $part, Request $request, EntityManagerInterface $em, PartLotWithdrawAddHelper $withdrawAddHelper): Response
{

View file

@ -64,7 +64,7 @@ class SettingsController extends AbstractController
$this->settingsManager->save($settings);
//It might be possible, that the tree settings have changed, so clear the cache
$cache->invalidateTags(['tree_treeview', 'sidebar_tree_update']);
$cache->invalidateTags(['tree_tools', 'tree_treeview', 'sidebar_tree_update', 'synonyms']);
$this->addFlash('success', t('settings.flash.saved'));
}

View file

@ -23,6 +23,7 @@ declare(strict_types=1);
namespace App\Controller;
use App\Entity\Parameters\AbstractParameter;
use App\Settings\MiscSettings\IpnSuggestSettings;
use Symfony\Component\HttpFoundation\Response;
use App\Entity\Attachments\Attachment;
use App\Entity\Parts\Category;
@ -60,8 +61,11 @@ use Symfony\Component\Serializer\Serializer;
#[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')]
@ -183,4 +187,30 @@ class TypeaheadController extends AbstractController
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);
}
}

View file

@ -24,6 +24,7 @@ namespace App\DataFixtures;
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 @@ class DataStructureFixtures extends Fixture implements DependentFixtureInterface
{
//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);

View file

@ -41,6 +41,7 @@ use App\Entity\Parts\Category;
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 @@ class PartFilter implements FilterInterface
$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');

View file

@ -174,6 +174,19 @@ final class PartsDataTable implements DataTableTypeInterface
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'),
])
@ -309,6 +322,7 @@ final class PartsDataTable implements DataTableTypeInterface
->addSelect('footprint')
->addSelect('manufacturer')
->addSelect('partUnit')
->addSelect('partCustomState')
->addSelect('master_picture_attachment')
->addSelect('footprint_attachment')
->addSelect('partLots')
@ -327,6 +341,7 @@ final class PartsDataTable implements DataTableTypeInterface
->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 +359,7 @@ final class PartsDataTable implements DataTableTypeInterface
->addGroupBy('suppliers')
->addGroupBy('attachments')
->addGroupBy('partUnit')
->addGroupBy('partCustomState')
->addGroupBy('parameters');
//Get the results in the same order as the IDs were passed
@ -415,6 +431,10 @@ final class PartsDataTable implements DataTableTypeInterface
$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

View file

@ -97,7 +97,7 @@ use function in_array;
#[DiscriminatorMap(typeProperty: '_type', mapping: self::API_DISCRIMINATOR_MAP)]
abstract class Attachment extends AbstractNamedDBElement
{
private const ORM_DISCRIMINATOR_MAP = ['Part' => PartAttachment::class, 'Device' => ProjectAttachment::class,
private const ORM_DISCRIMINATOR_MAP = ['Part' => PartAttachment::class, 'PartCustomState' => PartCustomStateAttachment::class, 'Device' => ProjectAttachment::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,
"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];

View file

@ -0,0 +1,45 @@
<?php
/**
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2022 Jan Böhmer (https://github.com/jbtronics)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
declare(strict_types=1);
namespace App\Entity\Attachments;
use App\Entity\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<PartCustomState>
*/
#[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;
}

View file

@ -33,6 +33,7 @@ use App\Entity\Attachments\LabelAttachment;
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\StorageLocationAttachment;
use App\Entity\Attachments\SupplierAttachment;
@ -40,6 +41,7 @@ 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\Parts\Footprint;
@ -68,7 +70,41 @@ use Symfony\Component\Serializer\Annotation\Groups;
* 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,
'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,
'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
{

View file

@ -46,6 +46,7 @@ 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 +59,8 @@ use App\Entity\Attachments\UserAttachment;
use App\Entity\Base\AbstractDBElement;
use App\Entity\Contracts\LogWithEventUndoInterface;
use App\Entity\Contracts\NamedElementInterface;
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;
@ -158,6 +161,7 @@ class CollectionElementDeleted extends AbstractLogEntry implements LogWithEventU
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()),
};
}
@ -173,6 +177,7 @@ class CollectionElementDeleted extends AbstractLogEntry implements LogWithEventU
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,

View file

@ -34,6 +34,7 @@ use App\Entity\Parts\Manufacturer;
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;
@ -71,6 +72,7 @@ enum LogTargetType: int
case PART_ASSOCIATION = 20;
case BULK_INFO_PROVIDER_IMPORT_JOB = 21;
case BULK_INFO_PROVIDER_IMPORT_JOB_PART = 22;
case PART_CUSTOM_STATE = 23;
/**
* Returns the class name of the target type or null if the target type is NONE.
@ -102,6 +104,7 @@ enum LogTargetType: int
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
};
}

View file

@ -73,7 +73,8 @@ use function sprintf;
#[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,
12 => PartCustomStateParameter::class])]
#[ORM\Table('parameters')]
#[ORM\Index(columns: ['name'], name: 'parameter_name_idx')]
#[ORM\Index(columns: ['param_group'], name: 'parameter_group_idx')]
@ -105,7 +106,7 @@ abstract class AbstractParameter extends AbstractNamedDBElement implements Uniqu
"AttachmentType" => AttachmentTypeParameter::class, "Category" => CategoryParameter::class, "Currency" => CurrencyParameter::class,
"Project" => ProjectParameter::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.

View file

@ -0,0 +1,65 @@
<?php
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2022 Jan Böhmer (https://github.com/jbtronics)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
declare(strict_types=1);
/**
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2022 Jan Böhmer (https://github.com/jbtronics)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
namespace App\Entity\Parameters;
use App\Entity\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;
}

View file

@ -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 @@ class Category extends AbstractPartsContainingDBElement
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;

View file

@ -61,7 +61,6 @@ use Doctrine\Common\Collections\ArrayCollection;
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 +74,6 @@ use Symfony\Component\Validator\Context\ExecutionContextInterface;
* @extends AttachmentContainingDBElement<PartAttachment>
* @template-use ParametersTrait<PartParameter>
*/
#[UniqueEntity(fields: ['ipn'], message: 'part.ipn.must_be_unique')]
#[ORM\Entity(repositoryClass: PartRepository::class)]
#[ORM\EntityListeners([TreeCacheInvalidationListener::class])]
#[ORM\Table('`parts`')]
@ -107,7 +105,7 @@ use Symfony\Component\Validator\Context\ExecutionContextInterface;
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"])]

View file

@ -0,0 +1,127 @@
<?php
/**
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2022 Jan Böhmer (https://github.com/jbtronics)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
declare(strict_types=1);
namespace App\Entity\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<PartCustomStateAttachment,PartCustomStateParameter>
*/
#[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<int, PartCustomStateAttachment>
*/
#[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<int, PartCustomStateParameter>
*/
#[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();
}
}

View file

@ -23,12 +23,14 @@ declare(strict_types=1);
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.
@ -64,6 +66,7 @@ trait AdvancedPropertyTrait
#[Groups(['extended', 'full', 'import', 'part:read', 'part:write'])]
#[ORM\Column(type: Types::STRING, length: 100, unique: true, 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 @@ trait AdvancedPropertyTrait
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;
}
}

View file

@ -0,0 +1,93 @@
<?php
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2025 Jan Böhmer (https://github.com/jbtronics)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
declare(strict_types=1);
namespace App\EventListener;
use App\Services\ElementTypeNameGenerator;
use App\Services\ElementTypes;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
use Symfony\Component\HttpKernel\Event\RequestEvent;
use Symfony\Component\Translation\Translator;
use Symfony\Contracts\Cache\CacheInterface;
use Symfony\Contracts\Cache\ItemInterface;
use Symfony\Contracts\Cache\TagAwareCacheInterface;
use Symfony\Contracts\Translation\TranslatorInterface;
#[AsEventListener]
readonly class RegisterSynonymsAsTranslationParametersListener
{
private Translator $translator;
public function __construct(
#[Autowire(service: 'translator.default')] TranslatorInterface $translator,
private TagAwareCacheInterface $cache,
private ElementTypeNameGenerator $typeNameGenerator)
{
if (!$translator instanceof Translator) {
throw new \RuntimeException('Translator must be an instance of Symfony\Component\Translation\Translator or this listener cannot be used.');
}
$this->translator = $translator;
}
public function getSynonymPlaceholders(): array
{
return $this->cache->get('partdb_synonym_placeholders', function (ItemInterface $item) {
$item->tag('synonyms');
$placeholders = [];
//Generate a placeholder for each element type
foreach (ElementTypes::cases() as $elementType) {
//We have a placeholder for singular
$placeholders['{' . $elementType->value . '}'] = $this->typeNameGenerator->typeLabel($elementType);
//We have a placeholder for plural
$placeholders['{{' . $elementType->value . '}}'] = $this->typeNameGenerator->typeLabelPlural($elementType);
//And we have lowercase versions for both
$placeholders['[' . $elementType->value . ']'] = mb_strtolower($this->typeNameGenerator->typeLabel($elementType));
$placeholders['[[' . $elementType->value . ']]'] = mb_strtolower($this->typeNameGenerator->typeLabelPlural($elementType));
}
return $placeholders;
});
}
public function __invoke(RequestEvent $event): void
{
//If we already added the parameters, skip adding them again
if (isset($this->translator->getGlobalParameters()['@@partdb_synonyms_registered@@'])) {
return;
}
//Register all placeholders for synonyms
$placeholders = $this->getSynonymPlaceholders();
foreach ($placeholders as $key => $value) {
$this->translator->addGlobalParameter($key, $value);
}
//Register the marker parameter to avoid double registration
$this->translator->addGlobalParameter('@@partdb_synonyms_registered@@', 'registered');
}
}

View file

@ -0,0 +1,97 @@
<?php
namespace App\EventSubscriber\UserSystem;
use App\Entity\Parts\Part;
use App\Settings\MiscSettings\IpnSuggestSettings;
use Doctrine\Common\EventSubscriber;
use Doctrine\ORM\Events;
use Doctrine\ORM\Event\OnFlushEventArgs;
class PartUniqueIpnSubscriber implements EventSubscriber
{
public function __construct(
private IpnSuggestSettings $ipnSuggestSettings
) {
}
public function getSubscribedEvents(): array
{
return [
Events::onFlush,
];
}
public function onFlush(OnFlushEventArgs $args): void
{
if (!$this->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);
}
}
}
}

View file

@ -84,6 +84,17 @@ class CategoryAdminForm extends BaseEntityAdminForm
'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' => '',

View file

@ -0,0 +1,27 @@
<?php
/**
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2022 Jan Böhmer (https://github.com/jbtronics)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
declare(strict_types=1);
namespace App\Form\AdminPages;
class PartCustomStateAdminForm extends BaseEntityAdminForm
{
}

View file

@ -130,6 +130,7 @@ class LogFilterType extends AbstractType
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',
},
]);

View file

@ -32,6 +32,7 @@ use App\Entity\Parts\Category;
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 @@ class PartFilterType extends AbstractType
'entity_class' => MeasurementUnit::class
]);
$builder->add('partCustomState', StructuralEntityConstraintType::class, [
'label' => 'part.edit.partCustomState',
'entity_class' => PartCustomState::class
]);
$builder->add('lastModified', DateTimeConstraintType::class, [
'label' => 'lastModified'
]);

View file

@ -30,6 +30,7 @@ use App\Entity\Parts\Manufacturer;
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\Form\Type\StructuralEntityType;
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 @@ use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
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,39 @@ class PartBaseType extends AbstractType
/** @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;
$ipnAttr['title'] = $this->ipnSuggestSettings->regexHelp;
}
$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 +122,7 @@ class PartBaseType extends AbstractType
'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 +140,9 @@ class PartBaseType extends AbstractType
'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 +210,13 @@ class PartBaseType extends AbstractType
'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, [

View file

@ -21,12 +21,11 @@
declare(strict_types=1);
namespace App\Form\Type;
namespace App\Form\Settings;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\LanguageType;
use Symfony\Component\Form\Extension\Core\Type\LocaleType;
use Symfony\Component\Intl\Languages;
use Symfony\Component\OptionsResolver\OptionsResolver;

View file

@ -0,0 +1,142 @@
<?php
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2024 Jan Böhmer (https://github.com/jbtronics)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
declare(strict_types=1);
namespace App\Form\Settings;
use App\Services\ElementTypes;
use App\Settings\SystemSettings\LocalizationSettings;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\EnumType;
use Symfony\Component\Form\Extension\Core\Type\LocaleType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\Intl\Locales;
use Symfony\Component\Validator\Constraints as Assert;
/**
* A single translation row: data source + language + translations (singular/plural).
*/
class TypeSynonymRowType extends AbstractType
{
private const PREFERRED_TYPES = [
ElementTypes::CATEGORY,
ElementTypes::STORAGE_LOCATION,
ElementTypes::FOOTPRINT,
ElementTypes::MANUFACTURER,
ElementTypes::SUPPLIER,
ElementTypes::PROJECT,
];
public function __construct(
private readonly LocalizationSettings $localizationSettings,
#[Autowire(param: 'partdb.locale_menu')] private readonly array $preferredLanguagesParam,
) {
}
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder
->add('dataSource', EnumType::class, [
'class' => ElementTypes::class,
'label' => false,
'required' => true,
'constraints' => [
new Assert\NotBlank(),
],
'row_attr' => ['class' => 'mb-0'],
'attr' => ['class' => 'form-select-sm'],
'preferred_choices' => self::PREFERRED_TYPES
])
->add('locale', LocaleType::class, [
'label' => false,
'required' => true,
// Restrict to languages configured in the language menu: disable ChoiceLoader and provide explicit choices
'choice_loader' => null,
'choices' => $this->buildLocaleChoices(true),
'preferred_choices' => $this->getPreferredLocales(),
'constraints' => [
new Assert\NotBlank(),
],
'row_attr' => ['class' => 'mb-0'],
'attr' => ['class' => 'form-select-sm']
])
->add('translation_singular', TextType::class, [
'label' => false,
'required' => true,
'empty_data' => '',
'constraints' => [
new Assert\NotBlank(),
],
'row_attr' => ['class' => 'mb-0'],
'attr' => ['class' => 'form-select-sm']
])
->add('translation_plural', TextType::class, [
'label' => false,
'required' => true,
'empty_data' => '',
'constraints' => [
new Assert\NotBlank(),
],
'row_attr' => ['class' => 'mb-0'],
'attr' => ['class' => 'form-select-sm']
]);
}
/**
* Returns only locales configured in the language menu (settings) or falls back to the parameter.
* Format: ['German (DE)' => 'de', ...]
*/
private function buildLocaleChoices(bool $returnPossible = false): array
{
$locales = $this->getPreferredLocales();
if ($returnPossible) {
$locales = $this->getPossibleLocales();
}
$choices = [];
foreach ($locales as $code) {
$label = Locales::getName($code);
$choices[$label . ' (' . strtoupper($code) . ')'] = $code;
}
return $choices;
}
/**
* Source of allowed locales:
* 1) LocalizationSettings->languageMenuEntries (if set)
* 2) Fallback: parameter partdb.locale_menu
*/
private function getPreferredLocales(): array
{
$fromSettings = $this->localizationSettings->languageMenuEntries ?? [];
return !empty($fromSettings) ? array_values($fromSettings) : array_values($this->preferredLanguagesParam);
}
private function getPossibleLocales(): array
{
return array_values($this->preferredLanguagesParam);
}
}

View file

@ -0,0 +1,223 @@
<?php
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2025 Jan Böhmer (https://github.com/jbtronics)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
declare(strict_types=1);
namespace App\Form\Settings;
use App\Services\ElementTypes;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\CallbackTransformer;
use Symfony\Component\Form\Extension\Core\Type\CollectionType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\Form\FormError;
use Symfony\Component\Form\FormEvent;
use Symfony\Component\Form\FormEvents;
use Symfony\Component\Intl\Locales;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Contracts\Translation\TranslatorInterface;
/**
* Flat collection of translation rows.
* View data: list [{dataSource, locale, translation_singular, translation_plural}, ...]
* Model data: same structure (list). Optionally expands a nested map to a list.
*/
class TypeSynonymsCollectionType extends AbstractType
{
public function __construct(private readonly TranslatorInterface $translator)
{
}
private function flattenStructure(array $modelValue): array
{
//If the model is already flattened, return as is
if (array_is_list($modelValue)) {
return $modelValue;
}
$out = [];
foreach ($modelValue as $dataSource => $locales) {
if (!is_array($locales)) {
continue;
}
foreach ($locales as $locale => $translations) {
if (!is_array($translations)) {
continue;
}
$out[] = [
//Convert string to enum value
'dataSource' => ElementTypes::from($dataSource),
'locale' => $locale,
'translation_singular' => $translations['singular'] ?? '',
'translation_plural' => $translations['plural'] ?? '',
];
}
}
return $out;
}
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder->addEventListener(FormEvents::PRE_SET_DATA, function (FormEvent $event): void {
//Flatten the structure
$data = $event->getData();
$event->setData($this->flattenStructure($data));
});
$builder->addModelTransformer(new CallbackTransformer(
// Model -> View
$this->flattenStructure(...),
// View -> Model (keep list; let existing behavior unchanged)
function (array $viewValue) {
//Turn our flat list back into the structured array
$out = [];
foreach ($viewValue as $row) {
if (!is_array($row)) {
continue;
}
$dataSource = $row['dataSource'] ?? null;
$locale = $row['locale'] ?? null;
$translation_singular = $row['translation_singular'] ?? null;
$translation_plural = $row['translation_plural'] ?? null;
if ($dataSource === null ||
!is_string($locale) || $locale === ''
) {
continue;
}
$out[$dataSource->value][$locale] = [
'singular' => is_string($translation_singular) ? $translation_singular : '',
'plural' => is_string($translation_plural) ? $translation_plural : '',
];
}
return $out;
}
));
// Validation and normalization (duplicates + sorting) during SUBMIT
$builder->addEventListener(FormEvents::SUBMIT, function (FormEvent $event): void {
$form = $event->getForm();
$rows = $event->getData();
if (!is_array($rows)) {
return;
}
// Duplicate check: (dataSource, locale) must be unique
$seen = [];
$hasDuplicate = false;
foreach ($rows as $idx => $row) {
if (!is_array($row)) {
continue;
}
$ds = $row['dataSource'] ?? null;
$loc = $row['locale'] ?? null;
if ($ds !== null && is_string($loc) && $loc !== '') {
$key = $ds->value . '|' . $loc;
if (isset($seen[$key])) {
$hasDuplicate = true;
if ($form->has((string)$idx)) {
$child = $form->get((string)$idx);
if ($child->has('dataSource')) {
$child->get('dataSource')->addError(
new FormError($this->translator->trans(
'settings.synonyms.type_synonyms.collection_type.duplicate',
[], 'validators'
))
);
}
if ($child->has('locale')) {
$child->get('locale')->addError(
new FormError($this->translator->trans(
'settings.synonyms.type_synonyms.collection_type.duplicate',
[], 'validators'
))
);
}
}
} else {
$seen[$key] = true;
}
}
}
if ($hasDuplicate) {
return;
}
// Overall sort: first by dataSource key, then by localized language name
$sortable = $rows;
usort($sortable, static function ($a, $b) {
$aDs = $a['dataSource']->value ?? '';
$bDs = $b['dataSource']->value ?? '';
$cmpDs = strcasecmp($aDs, $bDs);
if ($cmpDs !== 0) {
return $cmpDs;
}
$aLoc = (string)($a['locale'] ?? '');
$bLoc = (string)($b['locale'] ?? '');
$aName = Locales::getName($aLoc);
$bName = Locales::getName($bLoc);
return strcasecmp($aName, $bName);
});
$event->setData($sortable);
});
}
public function configureOptions(OptionsResolver $resolver): void
{
// Defaults for the collection and entry type
$resolver->setDefaults([
'entry_type' => TypeSynonymRowType::class,
'allow_add' => true,
'allow_delete' => true,
'by_reference' => false,
'required' => false,
'prototype' => true,
'empty_data' => [],
'entry_options' => ['label' => false],
]);
}
public function getParent(): ?string
{
return CollectionType::class;
}
public function getBlockPrefix(): string
{
return 'type_synonyms_collection';
}
}

View file

@ -30,7 +30,6 @@ use Symfony\Component\OptionsResolver\OptionsResolver;
/**
* A locale select field that uses the preferred languages from the configuration.
*/
class LocaleSelectType extends AbstractType
{

View file

@ -22,17 +22,35 @@ declare(strict_types=1);
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<Part>
*/
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 @@ class PartRepository extends NamedDBElementRepository
->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 @@ class PartRepository extends NamedDBElementRepository
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 "_ <number>"
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 "_ <number>"
if ($baseIpn === '') {
return null;
}
// Generate next free possible IPN
return $baseIpn . '_' . ($maxSuffix + 1);
}
}

View file

@ -0,0 +1,48 @@
<?php
declare(strict_types=1);
/**
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2022 Jan Böhmer (https://github.com/jbtronics)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
namespace App\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');
}
}

View file

@ -22,6 +22,7 @@ declare(strict_types=1);
namespace App\Security\Voter;
use App\Entity\Attachments\PartCustomStateAttachment;
use App\Services\UserSystem\VoterHelper;
use Symfony\Bundle\SecurityBundle\Security;
use App\Entity\Attachments\AttachmentContainingDBElement;
@ -99,6 +100,8 @@ final class AttachmentVoter extends Voter
$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)) {

View file

@ -22,6 +22,7 @@ declare(strict_types=1);
*/
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 @@ final class ParameterVoter extends Voter
$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)) {

View file

@ -23,6 +23,7 @@ declare(strict_types=1);
namespace App\Security\Voter;
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;
@ -53,6 +54,7 @@ final class StructureVoter extends Voter
Supplier::class => 'suppliers',
Currency::class => 'currencies',
MeasurementUnit::class => 'measurement_units',
PartCustomState::class => 'part_custom_states',
];
public function __construct(private readonly VoterHelper $helper)

View file

@ -30,6 +30,7 @@ use App\Entity\Attachments\AttachmentUpload;
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,6 +81,7 @@ class AttachmentSubmitHandler
//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',

View file

@ -23,6 +23,7 @@ declare(strict_types=1);
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;

View file

@ -233,6 +233,10 @@ class KiCadHelper
}
$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');
}

View file

@ -24,66 +24,31 @@ namespace App\Services;
use App\Entity\Attachments\Attachment;
use App\Entity\Attachments\AttachmentContainingDBElement;
use App\Entity\Attachments\AttachmentType;
use App\Entity\Base\AbstractDBElement;
use App\Entity\Contracts\NamedElementInterface;
use App\Entity\InfoProviderSystem\BulkInfoProviderImportJob;
use App\Entity\InfoProviderSystem\BulkInfoProviderImportJobPart;
use App\Entity\LabelSystem\LabelProfile;
use App\Entity\Parameters\AbstractParameter;
use App\Entity\Parts\Category;
use App\Entity\Parts\Footprint;
use App\Entity\Parts\Manufacturer;
use App\Entity\Parts\MeasurementUnit;
use App\Entity\Parts\Part;
use App\Entity\Parts\PartAssociation;
use App\Entity\Parts\PartLot;
use App\Entity\Parts\StorageLocation;
use App\Entity\Parts\Supplier;
use App\Entity\PriceInformations\Currency;
use App\Entity\PriceInformations\Orderdetail;
use App\Entity\PriceInformations\Pricedetail;
use App\Entity\ProjectSystem\Project;
use App\Entity\ProjectSystem\ProjectBOMEntry;
use App\Entity\UserSystem\Group;
use App\Entity\UserSystem\User;
use App\Exceptions\EntityNotSupportedException;
use App\Settings\SynonymSettings;
use Symfony\Contracts\Translation\TranslatorInterface;
/**
* @see \App\Tests\Services\ElementTypeNameGeneratorTest
*/
class ElementTypeNameGenerator
final readonly class ElementTypeNameGenerator
{
protected array $mapping;
public function __construct(protected TranslatorInterface $translator, private readonly EntityURLGenerator $entityURLGenerator)
public function __construct(
private TranslatorInterface $translator,
private EntityURLGenerator $entityURLGenerator,
private SynonymSettings $synonymsSettings,
)
{
//Child classes has to become before parent classes
$this->mapping = [
Attachment::class => $this->translator->trans('attachment.label'),
Category::class => $this->translator->trans('category.label'),
AttachmentType::class => $this->translator->trans('attachment_type.label'),
Project::class => $this->translator->trans('project.label'),
ProjectBOMEntry::class => $this->translator->trans('project_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'),
Part::class => $this->translator->trans('part.label'),
PartLot::class => $this->translator->trans('part_lot.label'),
StorageLocation::class => $this->translator->trans('storelocation.label'),
Supplier::class => $this->translator->trans('supplier.label'),
Currency::class => $this->translator->trans('currency.label'),
Orderdetail::class => $this->translator->trans('orderdetail.label'),
Pricedetail::class => $this->translator->trans('pricedetail.label'),
Group::class => $this->translator->trans('group.label'),
User::class => $this->translator->trans('user.label'),
AbstractParameter::class => $this->translator->trans('parameter.label'),
LabelProfile::class => $this->translator->trans('label_profile.label'),
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'),
];
}
/**
@ -97,27 +62,69 @@ class ElementTypeNameGenerator
* @return string the localized label for the entity type
*
* @throws EntityNotSupportedException when the passed entity is not supported
* @deprecated Use label() instead
*/
public function getLocalizedTypeLabel(object|string $entity): string
{
$class = is_string($entity) ? $entity : $entity::class;
//Check if we have a direct array entry for our entity class, then we can use it
if (isset($this->mapping[$class])) {
return $this->mapping[$class];
return $this->typeLabel($entity);
}
//Otherwise iterate over array and check for inheritance (needed when the proxy element from doctrine are passed)
foreach ($this->mapping as $class_to_check => $translation) {
if (is_a($entity, $class_to_check, true)) {
return $translation;
private function resolveSynonymLabel(ElementTypes $type, ?string $locale, bool $plural): ?string
{
$locale ??= $this->translator->getLocale();
if ($this->synonymsSettings->isSynonymDefinedForType($type)) {
if ($plural) {
$syn = $this->synonymsSettings->getPluralSynonymForType($type, $locale);
} else {
$syn = $this->synonymsSettings->getSingularSynonymForType($type, $locale);
}
if ($syn === null) {
//Try to fall back to english
if ($plural) {
$syn = $this->synonymsSettings->getPluralSynonymForType($type, 'en');
} else {
$syn = $this->synonymsSettings->getSingularSynonymForType($type, 'en');
}
}
//When nothing was found throw an exception
throw new EntityNotSupportedException(sprintf('No localized label for the element with type %s was found!', is_object($entity) ? $entity::class : (string) $entity));
return $syn;
}
return null;
}
/**
* Gets a localized label for the type of the entity. If user defined synonyms are defined,
* these are used instead of the default labels.
* @param object|string $entity
* @param string|null $locale
* @return string
*/
public function typeLabel(object|string $entity, ?string $locale = null): string
{
$type = ElementTypes::fromValue($entity);
return $this->resolveSynonymLabel($type, $locale, false)
?? $this->translator->trans($type->getDefaultLabelKey(), locale: $locale);
}
/**
* Similar to label(), but returns the plural version of the label.
* @param object|string $entity
* @param string|null $locale
* @return string
*/
public function typeLabelPlural(object|string $entity, ?string $locale = null): string
{
$type = ElementTypes::fromValue($entity);
return $this->resolveSynonymLabel($type, $locale, true)
?? $this->translator->trans($type->getDefaultPluralLabelKey(), locale: $locale);
}
/**
* Returns a string like in the format ElementType: ElementName.
* For example this could be something like: "Part: BC547".
@ -132,7 +139,7 @@ class ElementTypeNameGenerator
*/
public function getTypeNameCombination(NamedElementInterface $entity, bool $use_html = false): string
{
$type = $this->getLocalizedTypeLabel($entity);
$type = $this->typeLabel($entity);
if ($use_html) {
return '<i>' . $type . ':</i> ' . htmlspecialchars($entity->getName());
}
@ -142,7 +149,7 @@ class ElementTypeNameGenerator
/**
* Returns a HTML formatted label for the given enitity in the format "Type: Name" (on elements with a name) and
* Returns a HTML formatted label for the given entity in the format "Type: Name" (on elements with a name) and
* "Type: ID" (on elements without a name). If possible the value is given as a link to the element.
* @param AbstractDBElement $entity The entity for which the label should be generated
* @param bool $include_associated If set to true, the associated entity (like the part belonging to a part lot) is included in the label to give further information
@ -163,7 +170,7 @@ class ElementTypeNameGenerator
} else { //Target does not have a name
$tmp = sprintf(
'<i>%s</i>: %s',
$this->getLocalizedTypeLabel($entity),
$this->typeLabel($entity),
$entity->getID()
);
}
@ -207,7 +214,7 @@ class ElementTypeNameGenerator
{
return sprintf(
'<i>%s</i>: %s [%s]',
$this->getLocalizedTypeLabel($class),
$this->typeLabel($class),
$id,
$this->translator->trans('log.target_deleted')
);

View file

@ -0,0 +1,229 @@
<?php
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2025 Jan Böhmer (https://github.com/jbtronics)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
declare(strict_types=1);
namespace App\Services;
use App\Entity\Attachments\Attachment;
use App\Entity\Attachments\AttachmentType;
use App\Entity\InfoProviderSystem\BulkInfoProviderImportJob;
use App\Entity\InfoProviderSystem\BulkInfoProviderImportJobPart;
use App\Entity\LabelSystem\LabelProfile;
use App\Entity\Parameters\AbstractParameter;
use App\Entity\Parts\Category;
use App\Entity\Parts\Footprint;
use App\Entity\Parts\Manufacturer;
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;
use App\Entity\PriceInformations\Currency;
use App\Entity\PriceInformations\Orderdetail;
use App\Entity\PriceInformations\Pricedetail;
use App\Entity\ProjectSystem\Project;
use App\Entity\ProjectSystem\ProjectBOMEntry;
use App\Entity\UserSystem\Group;
use App\Entity\UserSystem\User;
use App\Exceptions\EntityNotSupportedException;
use Symfony\Contracts\Translation\TranslatableInterface;
use Symfony\Contracts\Translation\TranslatorInterface;
enum ElementTypes: string implements TranslatableInterface
{
case ATTACHMENT = "attachment";
case CATEGORY = "category";
case ATTACHMENT_TYPE = "attachment_type";
case PROJECT = "project";
case PROJECT_BOM_ENTRY = "project_bom_entry";
case FOOTPRINT = "footprint";
case MANUFACTURER = "manufacturer";
case MEASUREMENT_UNIT = "measurement_unit";
case PART = "part";
case PART_LOT = "part_lot";
case STORAGE_LOCATION = "storage_location";
case SUPPLIER = "supplier";
case CURRENCY = "currency";
case ORDERDETAIL = "orderdetail";
case PRICEDETAIL = "pricedetail";
case GROUP = "group";
case USER = "user";
case PARAMETER = "parameter";
case LABEL_PROFILE = "label_profile";
case PART_ASSOCIATION = "part_association";
case BULK_INFO_PROVIDER_IMPORT_JOB = "bulk_info_provider_import_job";
case BULK_INFO_PROVIDER_IMPORT_JOB_PART = "bulk_info_provider_import_job_part";
case PART_CUSTOM_STATE = "part_custom_state";
//Child classes has to become before parent classes
private const CLASS_MAPPING = [
Attachment::class => self::ATTACHMENT,
Category::class => self::CATEGORY,
AttachmentType::class => self::ATTACHMENT_TYPE,
Project::class => self::PROJECT,
ProjectBOMEntry::class => self::PROJECT_BOM_ENTRY,
Footprint::class => self::FOOTPRINT,
Manufacturer::class => self::MANUFACTURER,
MeasurementUnit::class => self::MEASUREMENT_UNIT,
Part::class => self::PART,
PartLot::class => self::PART_LOT,
StorageLocation::class => self::STORAGE_LOCATION,
Supplier::class => self::SUPPLIER,
Currency::class => self::CURRENCY,
Orderdetail::class => self::ORDERDETAIL,
Pricedetail::class => self::PRICEDETAIL,
Group::class => self::GROUP,
User::class => self::USER,
AbstractParameter::class => self::PARAMETER,
LabelProfile::class => self::LABEL_PROFILE,
PartAssociation::class => self::PART_ASSOCIATION,
BulkInfoProviderImportJob::class => self::BULK_INFO_PROVIDER_IMPORT_JOB,
BulkInfoProviderImportJobPart::class => self::BULK_INFO_PROVIDER_IMPORT_JOB_PART,
PartCustomState::class => self::PART_CUSTOM_STATE,
];
/**
* Gets the default translation key for the label of the element type (singular form).
*/
public function getDefaultLabelKey(): string
{
return match ($this) {
self::ATTACHMENT => 'attachment.label',
self::CATEGORY => 'category.label',
self::ATTACHMENT_TYPE => 'attachment_type.label',
self::PROJECT => 'project.label',
self::PROJECT_BOM_ENTRY => 'project_bom_entry.label',
self::FOOTPRINT => 'footprint.label',
self::MANUFACTURER => 'manufacturer.label',
self::MEASUREMENT_UNIT => 'measurement_unit.label',
self::PART => 'part.label',
self::PART_LOT => 'part_lot.label',
self::STORAGE_LOCATION => 'storelocation.label',
self::SUPPLIER => 'supplier.label',
self::CURRENCY => 'currency.label',
self::ORDERDETAIL => 'orderdetail.label',
self::PRICEDETAIL => 'pricedetail.label',
self::GROUP => 'group.label',
self::USER => 'user.label',
self::PARAMETER => 'parameter.label',
self::LABEL_PROFILE => 'label_profile.label',
self::PART_ASSOCIATION => 'part_association.label',
self::BULK_INFO_PROVIDER_IMPORT_JOB => 'bulk_info_provider_import_job.label',
self::BULK_INFO_PROVIDER_IMPORT_JOB_PART => 'bulk_info_provider_import_job_part.label',
self::PART_CUSTOM_STATE => 'part_custom_state.label',
};
}
public function getDefaultPluralLabelKey(): string
{
return match ($this) {
self::ATTACHMENT => 'attachment.labelp',
self::CATEGORY => 'category.labelp',
self::ATTACHMENT_TYPE => 'attachment_type.labelp',
self::PROJECT => 'project.labelp',
self::PROJECT_BOM_ENTRY => 'project_bom_entry.labelp',
self::FOOTPRINT => 'footprint.labelp',
self::MANUFACTURER => 'manufacturer.labelp',
self::MEASUREMENT_UNIT => 'measurement_unit.labelp',
self::PART => 'part.labelp',
self::PART_LOT => 'part_lot.labelp',
self::STORAGE_LOCATION => 'storelocation.labelp',
self::SUPPLIER => 'supplier.labelp',
self::CURRENCY => 'currency.labelp',
self::ORDERDETAIL => 'orderdetail.labelp',
self::PRICEDETAIL => 'pricedetail.labelp',
self::GROUP => 'group.labelp',
self::USER => 'user.labelp',
self::PARAMETER => 'parameter.labelp',
self::LABEL_PROFILE => 'label_profile.labelp',
self::PART_ASSOCIATION => 'part_association.labelp',
self::BULK_INFO_PROVIDER_IMPORT_JOB => 'bulk_info_provider_import_job.labelp',
self::BULK_INFO_PROVIDER_IMPORT_JOB_PART => 'bulk_info_provider_import_job_part.labelp',
self::PART_CUSTOM_STATE => 'part_custom_state.labelp',
};
}
/**
* Used to get a user-friendly representation of the object that can be translated.
* For this the singular default label key is used.
* @param TranslatorInterface $translator
* @param string|null $locale
* @return string
*/
public function trans(TranslatorInterface $translator, ?string $locale = null): string
{
return $translator->trans($this->getDefaultLabelKey(), locale: $locale);
}
/**
* Determines the ElementType from a value, which can either be an enum value, an ElementTypes instance, a class name or an object instance.
* @param string|object $value
* @return self
*/
public static function fromValue(string|object $value): self
{
if ($value instanceof self) {
return $value;
}
if (is_object($value)) {
return self::fromClass($value);
}
//Otherwise try to parse it as enum value first
$enumValue = self::tryFrom($value);
//Otherwise try to get it from class name
return $enumValue ?? self::fromClass($value);
}
/**
* Determines the ElementType from a class name or object instance.
* @param string|object $class
* @throws EntityNotSupportedException if the class is not supported
* @return self
*/
public static function fromClass(string|object $class): self
{
if (is_object($class)) {
$className = get_class($class);
} else {
$className = $class;
}
if (array_key_exists($className, self::CLASS_MAPPING)) {
return self::CLASS_MAPPING[$className];
}
//Otherwise we need to check for inheritance
foreach (self::CLASS_MAPPING as $entityClass => $elementType) {
if (is_a($className, $entityClass, true)) {
return $elementType;
}
}
throw new EntityNotSupportedException(sprintf('No localized label for the element with type %s was found!', $className));
}
}

View file

@ -65,6 +65,7 @@ class PartMerger implements EntityMergerInterface
$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');

View file

@ -27,6 +27,7 @@ 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;
@ -107,6 +108,7 @@ class EntityURLGenerator
MeasurementUnit::class => 'measurement_unit_edit',
Group::class => 'group_edit',
LabelProfile::class => 'label_profile_edit',
PartCustomState::class => 'part_custom_state_edit',
];
try {
@ -213,6 +215,7 @@ class EntityURLGenerator
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()]);
@ -243,6 +246,7 @@ class EntityURLGenerator
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()]);
@ -274,6 +278,7 @@ class EntityURLGenerator
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));
@ -305,6 +310,7 @@ class EntityURLGenerator
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()]);
@ -350,6 +356,7 @@ class EntityURLGenerator
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()]);

View file

@ -29,6 +29,7 @@ use App\Entity\Parts\Category;
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 @@ class PKDatastructureImporter
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'])) {

View file

@ -91,6 +91,8 @@ class PKPartImporter
$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));

View file

@ -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',

View file

@ -29,6 +29,7 @@ use App\Entity\Parts\Footprint;
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 +38,7 @@ use App\Entity\UserSystem\Group;
use App\Entity\UserSystem\User;
use App\Helpers\Trees\TreeViewNode;
use App\Services\Cache\UserCacheKeyGenerator;
use App\Services\ElementTypeNameGenerator;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
use Symfony\Contracts\Cache\ItemInterface;
@ -49,8 +51,14 @@ use Symfony\Contracts\Translation\TranslatorInterface;
*/
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,
private readonly ElementTypeNameGenerator $elementTypeNameGenerator,
) {
}
/**
@ -159,64 +167,70 @@ class ToolsTreeBuilder
if ($this->security->isGranted('read', new AttachmentType())) {
$nodes[] = (new TreeViewNode(
$this->translator->trans('tree.tools.edit.attachment_types'),
$this->elementTypeNameGenerator->typeLabelPlural(AttachmentType::class),
$this->urlGenerator->generate('attachment_type_new')
))->setIcon('fa-fw fa-treeview fa-solid fa-file-alt');
}
if ($this->security->isGranted('read', new Category())) {
$nodes[] = (new TreeViewNode(
$this->translator->trans('tree.tools.edit.categories'),
$this->elementTypeNameGenerator->typeLabelPlural(Category::class),
$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->elementTypeNameGenerator->typeLabelPlural(Project::class),
$this->urlGenerator->generate('project_new')
))->setIcon('fa-fw fa-treeview fa-solid fa-archive');
}
if ($this->security->isGranted('read', new Supplier())) {
$nodes[] = (new TreeViewNode(
$this->translator->trans('tree.tools.edit.suppliers'),
$this->elementTypeNameGenerator->typeLabelPlural(Supplier::class),
$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->elementTypeNameGenerator->typeLabelPlural(Manufacturer::class),
$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->elementTypeNameGenerator->typeLabelPlural(StorageLocation::class),
$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->elementTypeNameGenerator->typeLabelPlural(Footprint::class),
$this->urlGenerator->generate('footprint_new')
))->setIcon('fa-fw fa-treeview fa-solid fa-microchip');
}
if ($this->security->isGranted('read', new Currency())) {
$nodes[] = (new TreeViewNode(
$this->translator->trans('tree.tools.edit.currency'),
$this->elementTypeNameGenerator->typeLabelPlural(Currency::class),
$this->urlGenerator->generate('currency_new')
))->setIcon('fa-fw fa-treeview fa-solid fa-coins');
}
if ($this->security->isGranted('read', new MeasurementUnit())) {
$nodes[] = (new TreeViewNode(
$this->translator->trans('tree.tools.edit.measurement_unit'),
$this->elementTypeNameGenerator->typeLabelPlural(MeasurementUnit::class),
$this->urlGenerator->generate('measurement_unit_new')
))->setIcon('fa-fw fa-treeview fa-solid fa-balance-scale');
}
if ($this->security->isGranted('read', new LabelProfile())) {
$nodes[] = (new TreeViewNode(
$this->translator->trans('tree.tools.edit.label_profile'),
$this->elementTypeNameGenerator->typeLabelPlural(LabelProfile::class),
$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->elementTypeNameGenerator->typeLabelPlural(PartCustomState::class),
$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'),

View file

@ -34,9 +34,9 @@ use App\Entity\ProjectSystem\Project;
use App\Helpers\Trees\TreeViewNode;
use App\Helpers\Trees\TreeViewNodeIterator;
use App\Repository\NamedDBElementRepository;
use App\Repository\StructuralDBElementRepository;
use App\Services\Cache\ElementCacheTagGenerator;
use App\Services\Cache\UserCacheKeyGenerator;
use App\Services\ElementTypeNameGenerator;
use App\Services\EntityURLGenerator;
use App\Settings\BehaviorSettings\SidebarSettings;
use Doctrine\ORM\EntityManagerInterface;
@ -67,6 +67,7 @@ class TreeViewGenerator
protected TranslatorInterface $translator,
private readonly UrlGeneratorInterface $router,
private readonly SidebarSettings $sidebarSettings,
private readonly ElementTypeNameGenerator $elementTypeNameGenerator
) {
$this->rootNodeEnabled = $this->sidebarSettings->rootNodeEnabled;
$this->rootNodeExpandedByDefault = $this->sidebarSettings->rootNodeExpanded;
@ -212,15 +213,7 @@ class TreeViewGenerator
protected function entityClassToRootNodeString(string $class): string
{
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'),
default => $this->translator->trans('tree.root_node.text'),
};
return $this->elementTypeNameGenerator->typeLabelPlural($class);
}
protected function entityClassToRootNodeIcon(string $class): ?string

View file

@ -102,6 +102,7 @@ class PermissionPresetsHelper
$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 @@ class PermissionPresetsHelper
$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']);

View file

@ -47,6 +47,12 @@ class AppSettings
#[EmbeddedSettings()]
public ?InfoProviderSettings $infoProviders = null;
#[EmbeddedSettings]
public ?SynonymSettings $synonyms = null;
#[EmbeddedSettings()]
public ?MiscSettings $miscSettings = null;
}

View file

@ -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";

View file

@ -68,7 +68,7 @@ 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];
#[SettingsParameter(label: new TM("settings.behavior.table.preview_image_min_width"),
formOptions: ['attr' => ['min' => 1, 'max' => 100]],

View file

@ -0,0 +1,80 @@
<?php
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2024 Jan Böhmer (https://github.com/jbtronics)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
declare(strict_types=1);
namespace App\Settings\MiscSettings;
use App\Settings\SettingsIcon;
use Jbtronics\SettingsBundle\Metadata\EnvVarMode;
use Jbtronics\SettingsBundle\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"),
description: new TM("settings.misc.ipn_suggest.regex.help"),
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: 34 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 = false;
#[SettingsParameter(label: new TM("settings.misc.ipn_suggest.suggestPartDigits"),
description: new TM("settings.misc.ipn_suggest.suggestPartDigits.help"),
formOptions: ['attr' => ['min' => 1, 'max' => 8]],
envVar: "int:IPN_SUGGEST_PART_DIGITS", envVarMode: EnvVarMode::OVERWRITE
)]
#[Assert\Range(min: 1, max: 8)]
public int $suggestPartDigits = 4;
#[SettingsParameter(
label: new TM("settings.misc.ipn_suggest.useDuplicateDescription"),
description: new TM("settings.misc.ipn_suggest.useDuplicateDescription.help"),
envVar: "bool:IPN_USE_DUPLICATE_DESCRIPTION", envVarMode: EnvVarMode::OVERWRITE,
)]
public bool $useDuplicateDescription = false;
}

View file

@ -35,4 +35,7 @@ class MiscSettings
#[EmbeddedSettings]
public ?ExchangeRateSettings $exchangeRate = null;
#[EmbeddedSettings]
public ?IpnSuggestSettings $ipnSuggestSettings = null;
}

View file

@ -0,0 +1,116 @@
<?php
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2025 Jan Böhmer (https://github.com/jbtronics)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
declare(strict_types=1);
namespace App\Settings;
use App\Form\Settings\TypeSynonymsCollectionType;
use App\Services\ElementTypes;
use Jbtronics\SettingsBundle\ParameterTypes\ArrayType;
use Jbtronics\SettingsBundle\ParameterTypes\SerializeType;
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.synonyms"), description: "settings.synonyms.help")]
#[SettingsIcon("fa-language")]
class SynonymSettings
{
use SettingsTrait;
#[SettingsParameter(
ArrayType::class,
label: new TM("settings.synonyms.type_synonyms"),
description: new TM("settings.synonyms.type_synonyms.help"),
options: ['type' => SerializeType::class],
formType: TypeSynonymsCollectionType::class,
formOptions: [
'required' => false,
],
)]
#[Assert\Type('array')]
#[Assert\All([new Assert\Type('array')])]
/**
* @var array<string, array<string, array{singular: string, plural: string}>> $typeSynonyms
* An array of the form: [
* 'category' => [
* 'en' => ['singular' => 'Category', 'plural' => 'Categories'],
* 'de' => ['singular' => 'Kategorie', 'plural' => 'Kategorien'],
* ],
* 'manufacturer' => [
* 'en' => ['singular' => 'Manufacturer', 'plural' =>'Manufacturers'],
* ],
* ]
*/
public array $typeSynonyms = [];
/**
* Checks if there is any synonym defined for the given type (no matter which language).
* @param ElementTypes $type
* @return bool
*/
public function isSynonymDefinedForType(ElementTypes $type): bool
{
return isset($this->typeSynonyms[$type->value]) && count($this->typeSynonyms[$type->value]) > 0;
}
/**
* Returns the singular synonym for the given type and locale, or null if none is defined.
* @param ElementTypes $type
* @param string $locale
* @return string|null
*/
public function getSingularSynonymForType(ElementTypes $type, string $locale): ?string
{
return $this->typeSynonyms[$type->value][$locale]['singular'] ?? null;
}
/**
* Returns the plural synonym for the given type and locale, or null if none is defined.
* @param ElementTypes $type
* @param string|null $locale
* @return string|null
*/
public function getPluralSynonymForType(ElementTypes $type, ?string $locale): ?string
{
return $this->typeSynonyms[$type->value][$locale]['plural']
?? $this->typeSynonyms[$type->value][$locale]['singular']
?? null;
}
/**
* Sets a synonym for the given type and locale.
* @param ElementTypes $type
* @param string $locale
* @param string $singular
* @param string $plural
* @return void
*/
public function setSynonymForType(ElementTypes $type, string $locale, string $singular, string $plural): void
{
$this->typeSynonyms[$type->value][$locale] = [
'singular' => $singular,
'plural' => $plural,
];
}
}

View file

@ -23,7 +23,7 @@ declare(strict_types=1);
namespace App\Settings\SystemSettings;
use App\Form\Type\LanguageMenuEntriesType;
use App\Form\Settings\LanguageMenuEntriesType;
use App\Form\Type\LocaleSelectType;
use App\Settings\SettingsIcon;
use Jbtronics\SettingsBundle\Metadata\EnvVarMode;

View file

@ -33,6 +33,8 @@ class SystemSettings
#[EmbeddedSettings()]
public ?LocalizationSettings $localization = null;
#[EmbeddedSettings()]
public ?CustomizationSettings $customization = null;

View file

@ -24,6 +24,7 @@ namespace App\Twig;
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;
@ -75,6 +76,8 @@ final class EntityExtension extends AbstractExtension
/* Gets a human readable label for the type of the given entity */
new TwigFunction('entity_type_label', fn(object|string $entity): string => $this->nameGenerator->getLocalizedTypeLabel($entity)),
new TwigFunction('type_label', fn(object|string $entity): string => $this->nameGenerator->typeLabel($entity)),
new TwigFunction('type_label_p', fn(object|string $entity): string => $this->nameGenerator->typeLabelPlural($entity)),
];
}
@ -115,6 +118,7 @@ final class EntityExtension extends AbstractExtension
Currency::class => 'currency',
MeasurementUnit::class => 'measurement_unit',
LabelProfile::class => 'label_profile',
PartCustomState::class => 'part_custom_state',
];
foreach ($map as $class => $type) {

View file

@ -0,0 +1,22 @@
<?php
namespace App\Validator\Constraints;
use Attribute;
use Symfony\Component\Validator\Constraint;
#[Attribute(Attribute::TARGET_CLASS | Attribute::TARGET_PROPERTY)]
class UniquePartIpnConstraint extends Constraint
{
public string $message = 'part.ipn.must_be_unique';
public function getTargets(): string|array
{
return [self::CLASS_CONSTRAINT, self::PROPERTY_CONSTRAINT];
}
public function validatedBy(): string
{
return UniquePartIpnValidator::class;
}
}

View file

@ -0,0 +1,55 @@
<?php
namespace App\Validator\Constraints;
use App\Entity\Parts\Part;
use App\Settings\MiscSettings\IpnSuggestSettings;
use Symfony\Component\Validator\Constraint;
use Symfony\Component\Validator\ConstraintValidator;
use Doctrine\ORM\EntityManagerInterface;
class UniquePartIpnValidator extends ConstraintValidator
{
private EntityManagerInterface $entityManager;
private IpnSuggestSettings $ipnSuggestSettings;
public function __construct(EntityManagerInterface $entityManager, IpnSuggestSettings $ipnSuggestSettings)
{
$this->entityManager = $entityManager;
$this->ipnSuggestSettings = $ipnSuggestSettings;
}
public function validate($value, Constraint $constraint): void
{
if (null === $value || '' === $value) {
return;
}
//If the autoAppendSuffix option is enabled, the IPN becomes unique automatically later
if ($this->ipnSuggestSettings->autoAppendSuffix) {
return;
}
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();
}
}
}
}

View file

@ -592,12 +592,6 @@
"symfony/polyfill-intl-normalizer": {
"version": "v1.17.0"
},
"symfony/polyfill-mbstring": {
"version": "v1.10.0"
},
"symfony/polyfill-php80": {
"version": "v1.17.0"
},
"symfony/process": {
"version": "v4.2.3"
},

View file

@ -86,7 +86,7 @@
<li class="nav-item">
<a data-bs-toggle="tab" class="nav-link link-anchor" href="#attachments">{% trans %}admin.attachments{% endtrans %}</a>
</li>
{% if entity.parameters is defined %}
{% if entity.parameters is defined and showParameters == true %}
<li class="nav-item">
<a data-bs-toggle="tab" class="nav-link link-anchor" href="#parameters">{% trans %}admin.parameters{% endtrans %}</a>
</li>

View file

@ -1,7 +1,7 @@
{% extends "admin/base_admin.html.twig" %}
{% block card_title %}
<i class="fas fa-tags fa-fw"></i> {% trans %}category.labelp{% endtrans %}
<i class="fas fa-tags fa-fw"></i> {{ type_label_p(entity) }}
{% endblock %}
{% block additional_pills %}
@ -31,6 +31,7 @@
<hr>
{{ form_row(form.partname_regex) }}
{{ form_row(form.partname_hint) }}
{{ form_row(form.part_ipn_prefix) }}
<hr>
{{ form_row(form.default_description) }}
{{ form_row(form.default_comment) }}

View file

@ -3,7 +3,7 @@
{% import "vars.macro.twig" as vars %}
{% block card_title %}
<i class="fa-solid fa-coins"></i> {% trans %}currency.caption{% endtrans %}
<i class="fa-solid fa-coins"></i> {{ type_label_p(entity) }}
{% endblock %}
{% block additional_controls %}

View file

@ -1,7 +1,7 @@
{% extends "admin/base_admin.html.twig" %}
{% block card_title %}
<i class="fas fa-microchip fa-fw"></i> {% trans %}footprint.labelp{% endtrans %}
<i class="fas fa-microchip fa-fw"></i> {{ type_label_p(entity) }}
{% endblock %}
{% block master_picture_block %}

View file

@ -1,7 +1,7 @@
{% extends "admin/base_admin.html.twig" %}
{% block card_title %}
<i class="fas fa-users fa-fw"></i> {% trans %}group.edit.caption{% endtrans %}
<i class="fas fa-users fa-fw"></i> {{ type_label_p(entity) }}
{% endblock %}

View file

@ -1,7 +1,7 @@
{% extends "admin/base_admin.html.twig" %}
{% block card_title %}
<i class="fas fa-qrcode fa-fw"></i> {% trans %}label_profile.caption{% endtrans %}
<i class="fas fa-qrcode fa-fw"></i> {{ type_label_p(entity) }}
{% endblock %}
{% block additional_pills %}

View file

@ -1,7 +1,7 @@
{% extends "admin/base_company_admin.html.twig" %}
{% block card_title %}
<i class="fas fa-industry fa-fw"></i> {% trans %}manufacturer.caption{% endtrans %}
<i class="fas fa-industry fa-fw"></i> {{ type_label_p(entity) }}
{% endblock %}
{% block edit_title %}

View file

@ -1,7 +1,7 @@
{% extends "admin/base_admin.html.twig" %}
{% block card_title %}
<i class="fas fa-balance-scale fa-fw"></i> {% trans %}measurement_unit.caption{% endtrans %}
<i class="fas fa-balance-scale fa-fw"></i> {{ type_label_p(entity) }}
{% endblock %}
{% block edit_title %}

View file

@ -0,0 +1,14 @@
{% extends "admin/base_admin.html.twig" %}
{% block card_title %}
<i class="fas fa-balance-scale fa-tools"></i> {{ type_label_p(entity) }}
{% endblock %}
{% block edit_title %}
{% trans %}part_custom_state.edit{% endtrans %}: {{ entity.name }}
{% endblock %}
{% block new_title %}
{% trans %}part_custom_state.new{% endtrans %}
{% endblock %}

View file

@ -3,7 +3,7 @@
{# @var entity App\Entity\ProjectSystem\Project #}
{% block card_title %}
<i class="fas fa-archive fa-fw"></i> {% trans %}project.caption{% endtrans %}
<i class="fas fa-archive fa-fw"></i> {{ type_label_p(entity) }}
{% endblock %}
{% block edit_title %}

View file

@ -2,7 +2,7 @@
{% import "label_system/dropdown_macro.html.twig" as dropdown %}
{% block card_title %}
<i class="fas fa-cube fa-fw"></i> {% trans %}storelocation.labelp{% endtrans %}
<i class="fas fa-cube fa-fw"></i> {{ type_label_p(entity) }}
{% endblock %}
{% block additional_controls %}

View file

@ -1,7 +1,7 @@
{% extends "admin/base_company_admin.html.twig" %}
{% block card_title %}
<i class="fas fa-truck fa-fw"></i> {% trans %}supplier.caption{% endtrans %}
<i class="fas fa-truck fa-fw"></i> {{ type_label_p(entity) }}
{% endblock %}
{% block additional_panes %}

View file

@ -5,7 +5,7 @@
{# @var entity \App\Entity\UserSystem\User #}
{% block card_title %}
<i class="fas fa-user fa-fw"></i> {% trans %}user.edit.caption{% endtrans %}
<i class="fas fa-user fa-fw"></i> {{ type_label_p(entity) }}
{% endblock %}
{% block comment %}{% endblock %}

View file

@ -1,13 +1,15 @@
{% 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@@', is_granted('@categories.read') and is_granted('@parts.read')],
['locations', path('tree_location_root'), '@storage_location@@', is_granted('@storelocations.read') and is_granted('@parts.read'), ],
['footprints', path('tree_footprint_root'), '@footprint@@', is_granted('@footprints.read') and is_granted('@parts.read')],
['manufacturers', path('tree_manufacturer_root'), '@manufacturer@@', is_granted('@manufacturers.read') and is_granted('@parts.read'), 'manufacturer'],
['suppliers', path('tree_supplier_root'), '@supplier@@', is_granted('@suppliers.read') and is_granted('@parts.read'), 'supplier'],
['projects', path('tree_device_root'), '@project@@', is_granted('@projects.read'), 'project'],
['tools', path('tree_tools'), 'tools.label', true, 'tool'],
] %}
<li class="dropdown-header">{% trans %}actions{% endtrans %}</li>
@ -18,9 +20,20 @@
{% for source in data_sources %}
{% if source[3] %} {# show_condition #}
<li><button class="tree-btns dropdown-item" data-mode="{{ source[0] }}" data-url="{{ source[1] }}" data-text="{{ source[2] | trans }}"
<li>
{% if source[2] starts with '@' %}
{% set label = type_label_p(source[2]|replace({'@': ''})) %}
{% else %}
{% set label = source[2]|trans %}
{% endif %}
<button class="tree-btns dropdown-item"
data-mode="{{ source[0] }}"
data-url="{{ source[1] }}"
data-text="{{ label }}"
{{ stimulus_action('elements/sidebar_tree', 'changeDataSource') }}
>{{ source[2] | trans }}</button></li>
>{{ label }}</button>
</li>
{% endif %}
{% endfor %}
{% endmacro %}

View file

@ -0,0 +1,59 @@
{% macro renderForm(child) %}
<div class="tc-item mt-1 px-2 pb-1 border-bottom">
{% form_theme child "form/vertical_bootstrap_layout.html.twig" %}
<div class="row">
<div class="col">{{ form_row(child.dataSource) }}</div>
<div class="col">{{ form_row(child.locale) }}</div>
<div class="col">{{ form_row(child.translation_singular) }}</div>
<div class="col">{{ form_row(child.translation_plural) }}</div>
<div class="col">
<button type="button" class="btn btn-outline-danger btn-sm tc-remove" {{ stimulus_action('pages/synonyms_collection', 'remove' )}}>
<i class="fa fa-trash"></i> {{ 'settings.synonyms.type_synonym.remove_entry'|trans }}
</button>
</div>
</div>
</div>
{% endmacro %}
{% block type_synonyms_collection_widget %}
{% set _attrs = attr|default({}) %}
{% set _attrs = _attrs|merge({
class: (_attrs.class|default('') ~ ' type_synonyms_collection-widget')|trim
}) %}
{% set has_proto = prototype is defined %}
{% if has_proto %}
{% set __proto %}
{{- _self.renderForm(prototype) -}}
{% endset %}
{% set _proto_html = __proto|e('html_attr') %}
{% set _proto_name = form.vars.prototype_name|default('__name__') %}
{% set _index = form|length %}
{% endif %}
<div
{{ stimulus_controller('pages/synonyms_collection', {
prototype: has_proto ? _proto_html : '',
prototypeName: has_proto ? _proto_name : '__name__',
index: has_proto ? _index : (form|length)
}) }}
{{ block('widget_container_attributes')|raw }}{% for k,v in _attrs %} {{ k }}="{{ v }}"{% endfor %}
>
<div class="row">
<div class="col text-center"><strong>{% trans%}settings.synonyms.type_synonym.type{% endtrans%}</strong></div>
<div class="col text-center"><strong>{% trans%}settings.synonyms.type_synonym.language{% endtrans%}</strong></div>
<div class="col text-center"><strong>{% trans%}settings.synonyms.type_synonym.translation_singular{% endtrans%}</strong></div>
<div class="col text-center"><strong>{% trans%}settings.synonyms.type_synonym.translation_plural{% endtrans%}</strong></div>
<div class="col text-center"></div>
</div>
<div class="tc-items" {{ stimulus_target('pages/synonyms_collection', 'items') }}>
{% for child in form %}
{{ _self.renderForm(child) }}
{% endfor %}
</div>
<button type="button" class="btn btn-outline-primary btn-sm mt-2 tc-add" {{ stimulus_action('pages/synonyms_collection', 'add')}}>
<i class="fa fa-plus"></i> {{ 'settings.synonyms.type_synonym.add_entry'|trans }}
</button>
</div>
{% endblock %}

View file

@ -0,0 +1,26 @@
{% extends 'bootstrap_5_layout.html.twig' %}
{%- block choice_widget_collapsed -%}
{# Only add the BS5 form-select class if we dont use bootstrap-selectpicker #}
{# {% if attr["data-controller"] is defined and attr["data-controller"] not in ["elements--selectpicker"] %}
{%- set attr = attr|merge({class: (attr.class|default('') ~ ' form-select')|trim}) -%}
{% else %}
{# If it is an selectpicker add form-control class to fill whole width
{%- set attr = attr|merge({class: (attr.class|default('') ~ ' form-control')|trim}) -%}
{% endif %}
#}
{%- set attr = attr|merge({class: (attr.class|default('') ~ ' form-select')|trim}) -%}
{# If no data-controller was explictly defined add data-controller=elements--select #}
{% if attr["data-controller"] is not defined %}
{%- set attr = attr|merge({"data-controller": "elements--select"}) -%}
{% if attr["data-empty-message"] is not defined %}
{%- set attr = attr|merge({"data-empty-message": ("selectpicker.nothing_selected"|trans)}) -%}
{% endif %}
{% endif %}
{{- block("choice_widget_collapsed", "bootstrap_base_layout.html.twig") -}}
{%- endblock choice_widget_collapsed -%}

View file

@ -1,5 +1,16 @@
{{ form_row(form.needsReview) }}
{{ form_row(form.favorite) }}
{{ form_row(form.mass) }}
{{ form_row(form.ipn) }}
<div {{ stimulus_controller('elements/ipn_suggestion', {
partId: part.id,
partCategoryId: part.category ? part.category.id : null,
partDescription: part.description,
suggestions: ipnSuggestions,
'commonSectionHeader': 'part.edit.tab.advanced.ipn.commonSectionHeader'|trans,
'partIncrementHeader': 'part.edit.tab.advanced.ipn.partIncrementHeader'|trans,
'suggestUrl': url('ipn_suggestions')
}) }}>
{{ form_row(form.ipn) }}
</div>
{{ form_row(form.partUnit) }}
{{ form_row(form.partCustomState) }}

View file

@ -36,6 +36,19 @@
</div>
{% endif %}
{% if part.partCustomState is not null %}
<div class="mt-1">
<h6>
<span class="badge bg-primary" title="{% trans %}part_custom_state.caption{% endtrans %}"><i class="fas fa-tools fa-fw"></i> {{ part.partCustomState.name }}</span>
{% if part.partCustomState is not null and part.partCustomState.masterPictureAttachment and attachment_manager.fileExisting(part.partCustomState.masterPictureAttachment) %}
<br/>
<img class="img-fluid img-thumbnail thumbnail-sm" src="{{ attachment_thumbnail(part.partCustomState.masterPictureAttachment, 'thumbnail_md') }}" alt="{% trans %}attachment.preview.alt{% endtrans %}" />
{% endif %}
</h6>
</div>
{% endif %}
{# Favorite Status tag #}
{% if part.favorite %}
<div class="mt-1">

View file

@ -61,6 +61,7 @@
{{ form_row(filterForm.favorite) }}
{{ form_row(filterForm.needsReview) }}
{{ form_row(filterForm.measurementUnit) }}
{{ form_row(filterForm.partCustomState) }}
{{ form_row(filterForm.mass) }}
{{ form_row(filterForm.dbId) }}
{{ form_row(filterForm.ipn) }}

View file

@ -36,7 +36,7 @@
{% for section_widget in tab_widget %}
{% set settings_object = section_widget.vars.value %}
{% if section_widget.vars.compound ?? false %}
{% if section_widget.vars.embedded_settings_metadata is defined %} {# Check if we have nested embedded settings or not #}
<fieldset>
<legend class="offset-3">
<i class="fa-solid {{ settings_icon(settings_object)|default('fa-sliders') }} fa-fw"></i>

View file

@ -0,0 +1,69 @@
<?php
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2024 Jan Böhmer (https://github.com/jbtronics)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
declare(strict_types=1);
namespace App\Tests\API\Endpoints;
class PartCustomStateEndpointTest extends CrudEndpointTestCase
{
protected function getBasePath(): string
{
return '/api/part_custom_states';
}
public function testGetCollection(): void
{
$this->_testGetCollection();
self::assertJsonContains([
'hydra:totalItems' => 7,
]);
}
public function testGetItem(): void
{
$this->_testGetItem(1);
$this->_testGetItem(2);
$this->_testGetItem(3);
}
public function testCreateItem(): void
{
$this->_testPostItem([
'name' => 'Test API',
'parent' => '/api/part_custom_states/1',
]);
}
public function testUpdateItem(): void
{
$this->_testPatchItem(5, [
'name' => 'Updated',
'parent' => '/api/part_custom_states/2',
]);
}
public function testDeleteItem(): void
{
$this->_testDeleteItem(4);
}
}

View file

@ -0,0 +1,34 @@
<?php
/**
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2020 Jan Böhmer (https://github.com/jbtronics)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
declare(strict_types=1);
namespace App\Tests\Controller\AdminPages;
use App\Entity\Parts\PartCustomState;
use PHPUnit\Framework\Attributes\Group;
#[Group('slow')]
#[Group('DB')]
class PartCustomStateControllerTest extends AbstractAdminController
{
protected static string $base_path = '/en/part_custom_state';
protected static string $entity_class = PartCustomState::class;
}

View file

@ -29,6 +29,7 @@ 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;
@ -38,6 +39,7 @@ use App\Entity\Attachments\PartAttachment;
use App\Entity\Attachments\StorageLocationAttachment;
use App\Entity\Attachments\SupplierAttachment;
use App\Entity\Attachments\UserAttachment;
use App\Entity\Parts\PartCustomState;
use App\Entity\ProjectSystem\Project;
use App\Entity\Parts\Category;
use App\Entity\Parts\Footprint;
@ -86,6 +88,7 @@ class AttachmentTest extends TestCase
yield [ManufacturerAttachment::class, Manufacturer::class];
yield [MeasurementUnitAttachment::class, MeasurementUnit::class];
yield [PartAttachment::class, Part::class];
yield [PartCustomStateAttachment::class, PartCustomState::class];
yield [StorageLocationAttachment::class, StorageLocation::class];
yield [SupplierAttachment::class, Supplier::class];
yield [UserAttachment::class, User::class];

View file

@ -0,0 +1,49 @@
<?php
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2025 Jan Böhmer (https://github.com/jbtronics)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
namespace App\Tests\EventListener;
use App\EventListener\RegisterSynonymsAsTranslationParametersListener;
use PHPUnit\Framework\TestCase;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
class RegisterSynonymsAsTranslationParametersTest extends KernelTestCase
{
private RegisterSynonymsAsTranslationParametersListener $listener;
public function setUp(): void
{
self::bootKernel();
$this->listener = self::getContainer()->get(RegisterSynonymsAsTranslationParametersListener::class);
}
public function testGetSynonymPlaceholders(): void
{
$placeholders = $this->listener->getSynonymPlaceholders();
$this->assertIsArray($placeholders);
$this->assertSame('Part', $placeholders['{part}']);
$this->assertSame('Parts', $placeholders['{{part}}']);
//Lowercase versions:
$this->assertSame('part', $placeholders['[part]']);
$this->assertSame('parts', $placeholders['[[part]]']);
}
}

View file

@ -0,0 +1,297 @@
<?php
/**
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2020 Jan Böhmer (https://github.com/jbtronics)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
declare(strict_types=1);
namespace App\Tests\Repository;
use App\Entity\Parts\Category;
use App\Entity\Parts\Part;
use App\Settings\MiscSettings\IpnSuggestSettings;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\Mapping\ClassMetadata;
use PHPUnit\Framework\TestCase;
use Doctrine\ORM\Query;
use Doctrine\ORM\QueryBuilder;
use Symfony\Contracts\Translation\TranslatorInterface;
use App\Repository\PartRepository;
final class PartRepositoryTest extends TestCase
{
public function test_autocompleteSearch_builds_expected_query_without_db(): void
{
$qb = $this->getMockBuilder(QueryBuilder::class)
->disableOriginalConstructor()
->onlyMethods([
'select', 'leftJoin', 'where', 'orWhere',
'setParameter', 'setMaxResults', 'orderBy', 'getQuery'
])->getMock();
$qb->expects(self::once())->method('select')->with('part')->willReturnSelf();
$qb->expects(self::exactly(2))->method('leftJoin')->with($this->anything(), $this->anything())->willReturnSelf();
$qb->expects(self::atLeastOnce())->method('where')->with($this->anything())->willReturnSelf();
$qb->method('orWhere')->with($this->anything())->willReturnSelf();
$searchQuery = 'res';
$qb->expects(self::once())->method('setParameter')->with('query', '%'.$searchQuery.'%')->willReturnSelf();
$qb->expects(self::once())->method('setMaxResults')->with(10)->willReturnSelf();
$qb->expects(self::once())->method('orderBy')->with('NATSORT(part.name)', 'ASC')->willReturnSelf();
$emMock = $this->createMock(EntityManagerInterface::class);
$classMetadata = new ClassMetadata(Part::class);
$emMock->method('getClassMetadata')->with(Part::class)->willReturn($classMetadata);
$translatorMock = $this->createMock(TranslatorInterface::class);
$ipnSuggestSettings = $this->createMock(IpnSuggestSettings::class);
$repo = $this->getMockBuilder(PartRepository::class)
->setConstructorArgs([$emMock, $translatorMock, $ipnSuggestSettings])
->onlyMethods(['createQueryBuilder'])
->getMock();
$repo->expects(self::once())
->method('createQueryBuilder')
->with('part')
->willReturn($qb);
$part = new Part(); // create found part, because it is not saved in DB
$part->setName('Resistor');
$queryMock = $this->getMockBuilder(Query::class)
->disableOriginalConstructor()
->onlyMethods(['getResult'])
->getMock();
$queryMock->expects(self::once())->method('getResult')->willReturn([$part]);
$qb->method('getQuery')->willReturn($queryMock);
$result = $repo->autocompleteSearch($searchQuery, 10);
// Check one part found and returned
self::assertIsArray($result);
self::assertCount(1, $result);
self::assertSame($part, $result[0]);
}
public function test_autoCompleteIpn_with_unsaved_part_and_category_without_part_description(): void
{
$qb = $this->getMockBuilder(QueryBuilder::class)
->disableOriginalConstructor()
->onlyMethods([
'select', 'leftJoin', 'where', 'andWhere', 'orWhere',
'setParameter', 'setMaxResults', 'orderBy', 'getQuery'
])->getMock();
$qb->method('select')->willReturnSelf();
$qb->method('leftJoin')->willReturnSelf();
$qb->method('where')->willReturnSelf();
$qb->method('andWhere')->willReturnSelf();
$qb->method('orWhere')->willReturnSelf();
$qb->method('setParameter')->willReturnSelf();
$qb->method('setMaxResults')->willReturnSelf();
$qb->method('orderBy')->willReturnSelf();
$emMock = $this->createMock(EntityManagerInterface::class);
$classMetadata = new ClassMetadata(Part::class);
$emMock->method('getClassMetadata')->with(Part::class)->willReturn($classMetadata);
$translatorMock = $this->createMock(TranslatorInterface::class);
$translatorMock->method('trans')
->willReturnCallback(static function (string $id, array $parameters = [], ?string $domain = null, ?string $locale = null): string {
return $id;
});
$ipnSuggestSettings = $this->createMock(IpnSuggestSettings::class);
$ipnSuggestSettings->suggestPartDigits = 4;
$ipnSuggestSettings->useDuplicateDescription = false;
$repo = $this->getMockBuilder(PartRepository::class)
->setConstructorArgs([$emMock, $translatorMock, $ipnSuggestSettings])
->onlyMethods(['createQueryBuilder'])
->getMock();
$repo->expects(self::atLeastOnce())
->method('createQueryBuilder')
->with('part')
->willReturn($qb);
$queryMock = $this->getMockBuilder(Query::class)
->disableOriginalConstructor()
->onlyMethods(['getResult'])
->getMock();
$categoryParent = new Category();
$categoryParent->setName('Passive components');
$categoryParent->setPartIpnPrefix('PCOM');
$categoryChild = new Category();
$categoryChild->setName('Resistors');
$categoryChild->setPartIpnPrefix('RES');
$categoryChild->setParent($categoryParent);
$partForSuggestGeneration = new Part(); // create found part, because it is not saved in DB
$partForSuggestGeneration->setIpn('RES-0001');
$partForSuggestGeneration->setCategory($categoryChild);
$queryMock->method('getResult')->willReturn([$partForSuggestGeneration]);
$qb->method('getQuery')->willReturn($queryMock);
$suggestions = $repo->autoCompleteIpn($partForSuggestGeneration, '', 4);
// Check structure available
self::assertIsArray($suggestions);
self::assertArrayHasKey('commonPrefixes', $suggestions);
self::assertArrayHasKey('prefixesPartIncrement', $suggestions);
self::assertNotEmpty($suggestions['commonPrefixes']);
self::assertNotEmpty($suggestions['prefixesPartIncrement']);
// Check expected values
self::assertSame('RES-', $suggestions['commonPrefixes'][0]['title']);
self::assertSame('part.edit.tab.advanced.ipn.prefix.direct_category', $suggestions['commonPrefixes'][0]['description']);
self::assertSame('PCOM-RES-', $suggestions['commonPrefixes'][1]['title']);
self::assertSame('part.edit.tab.advanced.ipn.prefix.hierarchical.no_increment', $suggestions['commonPrefixes'][1]['description']);
self::assertSame('RES-0002', $suggestions['prefixesPartIncrement'][0]['title']); // next possible free increment for given part category
self::assertSame('part.edit.tab.advanced.ipn.prefix.direct_category.increment', $suggestions['prefixesPartIncrement'][0]['description']);
self::assertSame('PCOM-RES-0002', $suggestions['prefixesPartIncrement'][1]['title']); // next possible free increment for given part category
self::assertSame('part.edit.tab.advanced.ipn.prefix.hierarchical.increment', $suggestions['prefixesPartIncrement'][1]['description']);
}
public function test_autoCompleteIpn_with_unsaved_part_and_category_with_part_description(): void
{
$qb = $this->getMockBuilder(QueryBuilder::class)
->disableOriginalConstructor()
->onlyMethods([
'select', 'leftJoin', 'where', 'andWhere', 'orWhere',
'setParameter', 'setMaxResults', 'orderBy', 'getQuery'
])->getMock();
$qb->method('select')->willReturnSelf();
$qb->method('leftJoin')->willReturnSelf();
$qb->method('where')->willReturnSelf();
$qb->method('andWhere')->willReturnSelf();
$qb->method('orWhere')->willReturnSelf();
$qb->method('setParameter')->willReturnSelf();
$qb->method('setMaxResults')->willReturnSelf();
$qb->method('orderBy')->willReturnSelf();
$emMock = $this->createMock(EntityManagerInterface::class);
$classMetadata = new ClassMetadata(Part::class);
$emMock->method('getClassMetadata')->with(Part::class)->willReturn($classMetadata);
$translatorMock = $this->createMock(TranslatorInterface::class);
$translatorMock->method('trans')
->willReturnCallback(static function (string $id, array $parameters = [], ?string $domain = null, ?string $locale = null): string {
return $id;
});
$ipnSuggestSettings = $this->createMock(IpnSuggestSettings::class);
$ipnSuggestSettings->suggestPartDigits = 4;
$ipnSuggestSettings->useDuplicateDescription = false;
$repo = $this->getMockBuilder(PartRepository::class)
->setConstructorArgs([$emMock, $translatorMock, $ipnSuggestSettings])
->onlyMethods(['createQueryBuilder'])
->getMock();
$repo->expects(self::atLeastOnce())
->method('createQueryBuilder')
->with('part')
->willReturn($qb);
$queryMock = $this->getMockBuilder(Query::class)
->disableOriginalConstructor()
->onlyMethods(['getResult'])
->getMock();
$categoryParent = new Category();
$categoryParent->setName('Passive components');
$categoryParent->setPartIpnPrefix('PCOM');
$categoryChild = new Category();
$categoryChild->setName('Resistors');
$categoryChild->setPartIpnPrefix('RES');
$categoryChild->setParent($categoryParent);
$partForSuggestGeneration = new Part(); // create found part, because it is not saved in DB
$partForSuggestGeneration->setCategory($categoryChild);
$partForSuggestGeneration->setIpn('1810-1679_1');
$partForSuggestGeneration->setDescription('NETWORK-RESISTOR 4 0 OHM +5PCT 0.063W TKF SMT');
$queryMock->method('getResult')->willReturn([$partForSuggestGeneration]);
$qb->method('getQuery')->willReturn($queryMock);
$suggestions = $repo->autoCompleteIpn($partForSuggestGeneration, 'NETWORK-RESISTOR 4 0 OHM +5PCT 0.063W TKF SMT', 4);
// Check structure available
self::assertIsArray($suggestions);
self::assertArrayHasKey('commonPrefixes', $suggestions);
self::assertArrayHasKey('prefixesPartIncrement', $suggestions);
self::assertNotEmpty($suggestions['commonPrefixes']);
self::assertCount(2, $suggestions['commonPrefixes']);
self::assertNotEmpty($suggestions['prefixesPartIncrement']);
self::assertCount(2, $suggestions['prefixesPartIncrement']);
// Check expected values without any increment, for user to decide
self::assertSame('RES-', $suggestions['commonPrefixes'][0]['title']);
self::assertSame('part.edit.tab.advanced.ipn.prefix.direct_category', $suggestions['commonPrefixes'][0]['description']);
self::assertSame('PCOM-RES-', $suggestions['commonPrefixes'][1]['title']);
self::assertSame('part.edit.tab.advanced.ipn.prefix.hierarchical.no_increment', $suggestions['commonPrefixes'][1]['description']);
// Check expected values with next possible increment at category level
self::assertSame('RES-0001', $suggestions['prefixesPartIncrement'][0]['title']); // next possible free increment for given part category
self::assertSame('part.edit.tab.advanced.ipn.prefix.direct_category.increment', $suggestions['prefixesPartIncrement'][0]['description']);
self::assertSame('PCOM-RES-0001', $suggestions['prefixesPartIncrement'][1]['title']); // next possible free increment for given part category
self::assertSame('part.edit.tab.advanced.ipn.prefix.hierarchical.increment', $suggestions['prefixesPartIncrement'][1]['description']);
$ipnSuggestSettings->useDuplicateDescription = true;
$suggestionsWithSameDescription = $repo->autoCompleteIpn($partForSuggestGeneration, 'NETWORK-RESISTOR 4 0 OHM +5PCT 0.063W TKF SMT', 4);
// Check structure available
self::assertIsArray($suggestionsWithSameDescription);
self::assertArrayHasKey('commonPrefixes', $suggestionsWithSameDescription);
self::assertArrayHasKey('prefixesPartIncrement', $suggestionsWithSameDescription);
self::assertNotEmpty($suggestionsWithSameDescription['commonPrefixes']);
self::assertCount(2, $suggestionsWithSameDescription['commonPrefixes']);
self::assertNotEmpty($suggestionsWithSameDescription['prefixesPartIncrement']);
self::assertCount(4, $suggestionsWithSameDescription['prefixesPartIncrement']);
// Check expected values without any increment, for user to decide
self::assertSame('RES-', $suggestionsWithSameDescription['commonPrefixes'][0]['title']);
self::assertSame('part.edit.tab.advanced.ipn.prefix.direct_category', $suggestionsWithSameDescription['commonPrefixes'][0]['description']);
self::assertSame('PCOM-RES-', $suggestionsWithSameDescription['commonPrefixes'][1]['title']);
self::assertSame('part.edit.tab.advanced.ipn.prefix.hierarchical.no_increment', $suggestionsWithSameDescription['commonPrefixes'][1]['description']);
// Check expected values with next possible increment at part description level
self::assertSame('1810-1679_1', $suggestionsWithSameDescription['prefixesPartIncrement'][0]['title']); // current given value
self::assertSame('part.edit.tab.advanced.ipn.prefix.description.current-increment', $suggestionsWithSameDescription['prefixesPartIncrement'][0]['description']);
self::assertSame('1810-1679_2', $suggestionsWithSameDescription['prefixesPartIncrement'][1]['title']); // next possible value
self::assertSame('part.edit.tab.advanced.ipn.prefix.description.increment', $suggestionsWithSameDescription['prefixesPartIncrement'][1]['description']);
// Check expected values with next possible increment at category level
self::assertSame('RES-0001', $suggestionsWithSameDescription['prefixesPartIncrement'][2]['title']); // next possible free increment for given part category
self::assertSame('part.edit.tab.advanced.ipn.prefix.direct_category.increment', $suggestionsWithSameDescription['prefixesPartIncrement'][2]['description']);
self::assertSame('PCOM-RES-0001', $suggestionsWithSameDescription['prefixesPartIncrement'][3]['title']); // next possible free increment for given part category
self::assertSame('part.edit.tab.advanced.ipn.prefix.hierarchical.increment', $suggestionsWithSameDescription['prefixesPartIncrement'][3]['description']);
}
}

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