mirror of
https://github.com/Part-DB/Part-DB-server.git
synced 2025-12-06 02:59:29 +00:00
Compare commits
12 commits
020f9896a7
...
6a5039326c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6a5039326c | ||
|
|
bee1542cce | ||
|
|
95c5ab7b8b | ||
|
|
f184afc918 | ||
|
|
54f318ecac | ||
|
|
5e3bd26e27 | ||
|
|
e53b72a8d1 | ||
|
|
e8ff15ad0f | ||
|
|
ec6b3ae414 | ||
|
|
07e4521c30 | ||
|
|
771857e014 | ||
|
|
14a4f1f437 |
124 changed files with 16995 additions and 11193 deletions
6
.github/workflows/assets_artifact_build.yml
vendored
6
.github/workflows/assets_artifact_build.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
2
.github/workflows/tests.yml
vendored
2
.github/workflows/tests.yml
vendored
|
|
@ -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'
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
250
assets/controllers/elements/ipn_suggestion_controller.js
Normal file
250
assets/controllers/elements/ipn_suggestion_controller.js
Normal 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));
|
||||
};
|
||||
}
|
||||
68
assets/controllers/pages/synonyms_collection_controller.js
Normal file
68
assets/controllers/pages/synonyms_collection_controller.js
Normal 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 || '';
|
||||
}
|
||||
}
|
||||
|
|
@ -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
1316
composer.lock
generated
File diff suppressed because it is too large
Load diff
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -116,6 +116,16 @@ bundled with Part-DB. Set `DATABASE_MYSQL_SSL_VERIFY_CERT` if you want to accept
|
|||
value should be handled as confidential data and not shared publicly.
|
||||
* `SHOW_PART_IMAGE_OVERLAY`: Set to 0 to disable the part image overlay, which appears if you hover over an image in the
|
||||
part image gallery
|
||||
* `IPN_SUGGEST_REGEX`: A global regular expression, that part IPNs have to fullfill. Enforce your own format for your users.
|
||||
* `IPN_SUGGEST_REGEX_HELP`: Define your own user help text for the Regex format specification.
|
||||
* `IPN_AUTO_APPEND_SUFFIX`: When enabled, an incremental suffix will be added to the user input when entering an existing
|
||||
* IPN again upon saving.
|
||||
* `IPN_SUGGEST_PART_DIGITS`: Defines the fixed number of digits used as the increment at the end of an IPN (Internal Part Number).
|
||||
IPN prefixes, maintained within part categories and their hierarchy, form the foundation for suggesting complete IPNs.
|
||||
These suggestions become accessible during IPN input of a part. The constant specifies the digits used to calculate and assign
|
||||
unique increments for parts within a category hierarchy, ensuring consistency and uniqueness in IPN generation.
|
||||
* `IPN_USE_DUPLICATE_DESCRIPTION`: When enabled, the part’s description is used to find existing parts with the same
|
||||
description and to determine the next available IPN by incrementing their numeric suffix for the suggestion list.
|
||||
|
||||
### E-Mail settings (all env only)
|
||||
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
605
migrations/Version20250321075747.php
Normal file
605
migrations/Version20250321075747.php
Normal 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);
|
||||
}
|
||||
}
|
||||
307
migrations/Version20250325073036.php
Normal file
307
migrations/Version20250325073036.php
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
]);
|
||||
}
|
||||
|
||||
|
|
|
|||
83
src/Controller/AdminPages/PartCustomStateController.php
Normal file
83
src/Controller/AdminPages/PartCustomStateController.php
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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'));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
|
|
|
|||
45
src/Entity/Attachments/PartCustomStateAttachment.php
Normal file
45
src/Entity/Attachments/PartCustomStateAttachment.php
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
<?php
|
||||
/**
|
||||
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
|
||||
*
|
||||
* Copyright (C) 2019 - 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;
|
||||
}
|
||||
|
|
@ -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
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
65
src/Entity/Parameters/PartCustomStateParameter.php
Normal file
65
src/Entity/Parameters/PartCustomStateParameter.php
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
<?php
|
||||
/*
|
||||
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
|
||||
*
|
||||
* Copyright (C) 2019 - 2022 Jan Böhmer (https://github.com/jbtronics)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
|
||||
*
|
||||
* Copyright (C) 2019 - 2022 Jan Böhmer (https://github.com/jbtronics)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
namespace App\Entity\Parameters;
|
||||
|
||||
use App\Entity\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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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"])]
|
||||
|
|
|
|||
127
src/Entity/Parts/PartCustomState.php
Normal file
127
src/Entity/Parts/PartCustomState.php
Normal 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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
97
src/EventSubscriber/UserSystem/PartUniqueIpnSubscriber.php
Normal file
97
src/EventSubscriber/UserSystem/PartUniqueIpnSubscriber.php
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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' => '',
|
||||
|
|
|
|||
27
src/Form/AdminPages/PartCustomStateAdminForm.php
Normal file
27
src/Form/AdminPages/PartCustomStateAdminForm.php
Normal 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
|
||||
{
|
||||
}
|
||||
|
|
@ -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',
|
||||
},
|
||||
]);
|
||||
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
]);
|
||||
|
|
|
|||
|
|
@ -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, [
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
142
src/Form/Settings/TypeSynonymRowType.php
Normal file
142
src/Form/Settings/TypeSynonymRowType.php
Normal 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);
|
||||
}
|
||||
}
|
||||
223
src/Form/Settings/TypeSynonymsCollectionType.php
Normal file
223
src/Form/Settings/TypeSynonymsCollectionType.php
Normal 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';
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
48
src/Repository/Parts/PartCustomStateRepository.php
Normal file
48
src/Repository/Parts/PartCustomStateRepository.php
Normal 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');
|
||||
}
|
||||
}
|
||||
|
|
@ -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)) {
|
||||
|
|
|
|||
|
|
@ -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)) {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
);
|
||||
|
|
|
|||
229
src/Services/ElementTypes.php
Normal file
229
src/Services/ElementTypes.php
Normal 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));
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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()]);
|
||||
|
|
|
|||
|
|
@ -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'])) {
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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']);
|
||||
|
||||
|
|
|
|||
|
|
@ -47,6 +47,12 @@ class AppSettings
|
|||
#[EmbeddedSettings()]
|
||||
public ?InfoProviderSettings $infoProviders = null;
|
||||
|
||||
#[EmbeddedSettings]
|
||||
public ?SynonymSettings $synonyms = null;
|
||||
|
||||
#[EmbeddedSettings()]
|
||||
public ?MiscSettings $miscSettings = null;
|
||||
|
||||
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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]],
|
||||
|
|
|
|||
80
src/Settings/MiscSettings/IpnSuggestSettings.php
Normal file
80
src/Settings/MiscSettings/IpnSuggestSettings.php
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
|
||||
*
|
||||
* Copyright (C) 2019 - 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: 3–4 alphanumeric segments (any number) separated by "-", followed by "-" and 4 digits, e.g., PCOM-RES-0001']],
|
||||
envVar: "IPN_SUGGEST_REGEX_HELP", envVarMode: EnvVarMode::OVERWRITE,
|
||||
)]
|
||||
public ?string $regexHelp = null;
|
||||
|
||||
#[SettingsParameter(
|
||||
label: new TM("settings.misc.ipn_suggest.autoAppendSuffix"),
|
||||
envVar: "bool:IPN_AUTO_APPEND_SUFFIX", envVarMode: EnvVarMode::OVERWRITE,
|
||||
)]
|
||||
public bool $autoAppendSuffix = 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;
|
||||
}
|
||||
|
|
@ -35,4 +35,7 @@ class MiscSettings
|
|||
|
||||
#[EmbeddedSettings]
|
||||
public ?ExchangeRateSettings $exchangeRate = null;
|
||||
|
||||
#[EmbeddedSettings]
|
||||
public ?IpnSuggestSettings $ipnSuggestSettings = null;
|
||||
}
|
||||
|
|
|
|||
116
src/Settings/SynonymSettings.php
Normal file
116
src/Settings/SynonymSettings.php
Normal 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,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -33,6 +33,8 @@ class SystemSettings
|
|||
#[EmbeddedSettings()]
|
||||
public ?LocalizationSettings $localization = null;
|
||||
|
||||
|
||||
|
||||
#[EmbeddedSettings()]
|
||||
public ?CustomizationSettings $customization = null;
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
22
src/Validator/Constraints/UniquePartIpnConstraint.php
Normal file
22
src/Validator/Constraints/UniquePartIpnConstraint.php
Normal 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;
|
||||
}
|
||||
}
|
||||
55
src/Validator/Constraints/UniquePartIpnValidator.php
Normal file
55
src/Validator/Constraints/UniquePartIpnValidator.php
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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) }}
|
||||
|
|
|
|||
|
|
@ -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 %}
|
||||
|
|
|
|||
|
|
@ -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 %}
|
||||
|
|
|
|||
|
|
@ -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 %}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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 %}
|
||||
|
|
|
|||
|
|
@ -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 %}
|
||||
|
|
|
|||
|
|
@ -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 %}
|
||||
|
|
|
|||
14
templates/admin/part_custom_state_admin.html.twig
Normal file
14
templates/admin/part_custom_state_admin.html.twig
Normal 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 %}
|
||||
|
||||
|
|
@ -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 %}
|
||||
|
|
|
|||
|
|
@ -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 %}
|
||||
|
|
|
|||
|
|
@ -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 %}
|
||||
|
|
|
|||
|
|
@ -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 %}
|
||||
|
|
|
|||
|
|
@ -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 %}
|
||||
|
|
|
|||
59
templates/form/synonyms_collection.html.twig
Normal file
59
templates/form/synonyms_collection.html.twig
Normal 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 %}
|
||||
26
templates/form/vertical_bootstrap_layout.html.twig
Normal file
26
templates/form/vertical_bootstrap_layout.html.twig
Normal 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 -%}
|
||||
|
|
@ -1,5 +1,16 @@
|
|||
{{ form_row(form.needsReview) }}
|
||||
{{ form_row(form.favorite) }}
|
||||
{{ form_row(form.mass) }}
|
||||
<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) }}
|
||||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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) }}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
69
tests/API/Endpoints/PartCustomStateEndpointTest.php
Normal file
69
tests/API/Endpoints/PartCustomStateEndpointTest.php
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
<?php
|
||||
/*
|
||||
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
|
||||
*
|
||||
* Copyright (C) 2019 - 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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];
|
||||
|
|
|
|||
|
|
@ -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]]']);
|
||||
}
|
||||
}
|
||||
297
tests/Repository/PartRepositoryTest.php
Normal file
297
tests/Repository/PartRepositoryTest.php
Normal 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
Loading…
Add table
Add a link
Reference in a new issue