mirror of
https://github.com/Part-DB/Part-DB-server.git
synced 2025-12-10 13:09:30 +00:00
Added feature for part IPN suggest with category prefixes (#1054)
* Erweiterungstätigkeiten zur IPN-Vorschlagsliste anhand von Präfixen aus den Kategorien * Umstellung Migrationen bzgl. Multi-Plattform-Support. Zunächst MySQL, SQLite Statements integrieren. * Postgre Statements integrieren * SQL-Formatierung in Migration verbessern * Erweitere IPN-Suggest um Bauteilbeschreibung. Die Implementierung berücksichtigt nun zusätzlich die Bauteilbeschreibung zu maximal 150 Zeichen Länge für die Generierung von IPN-Vorschlägen und Inkrementen. * Anpassungen aus Analyse vornehmen * IPN-Validierung für Parts überarbeiten * IPN-Vorschlagslogik um Konfiguration erweitert * Anpassungen aus phpstan Analyse * IPN-Vorschlagslogik erweitert und Bauteil-IPN vereindeutigt Die IPN-Logik wurde um eine Konfiguration zur automatischen Suffix-Anfügung und die Berücksichtigung von doppelten Beschreibungen bei Bedarf ergänzt. Zudem wurde das Datenmodell angepasst, um eine eindeutige Speicherung der IPN zu gewährleisten. * Regex-Konfigurationsmöglichkeit für IPN-Vorschläge einführen Die Einstellungen für die IPN-Vorschlagslogik wurden um eine Regex-Validierung und eine Hilfetext-Konfiguration erweitert. Tests und Änderungen an den Formularoptionen wurden implementiert. * Match range assert and form limits in suggestPartDigits * Keep existing behavior with autoAppend suffix by default * Show the regex hint in the browser validation notice. * Improved translations * Removed unnecessary service definition * Removed german comments --------- Co-authored-by: Marcel Diegelmann <marcel.diegelmann@gmail.com> Co-authored-by: Jan Böhmer <mail@jan-boehmer.de>
This commit is contained in:
parent
14a4f1f437
commit
771857e014
34 changed files with 2791 additions and 115 deletions
|
|
@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue