mirror of
https://github.com/Part-DB/Part-DB-server.git
synced 2026-01-14 22:29:33 +00:00
Merge tag 'v2.1.2' into Buerklin-provider
# Conflicts: # .docker/symfony.conf # VERSION
This commit is contained in:
commit
5b2fc7ef4b
366 changed files with 32347 additions and 19045 deletions
|
|
@ -40,6 +40,7 @@ use App\Entity\Attachments\StorageLocationAttachment;
|
|||
use App\Entity\Attachments\SupplierAttachment;
|
||||
use App\Entity\Attachments\UserAttachment;
|
||||
use App\Exceptions\AttachmentDownloadException;
|
||||
use App\Settings\SystemSettings\AttachmentsSettings;
|
||||
use Hshn\Base64EncodedFile\HttpFoundation\File\Base64EncodedFile;
|
||||
use Hshn\Base64EncodedFile\HttpFoundation\File\UploadedBase64EncodedFile;
|
||||
use const DIRECTORY_SEPARATOR;
|
||||
|
|
@ -64,12 +65,14 @@ class AttachmentSubmitHandler
|
|||
'asp', 'cgi', 'py', 'pl', 'exe', 'aspx', 'js', 'mjs', 'jsp', 'css', 'jar', 'html', 'htm', 'shtm', 'shtml', 'htaccess',
|
||||
'htpasswd', ''];
|
||||
|
||||
public function __construct(protected AttachmentPathResolver $pathResolver, protected bool $allow_attachments_downloads,
|
||||
protected HttpClientInterface $httpClient, protected MimeTypesInterface $mimeTypes,
|
||||
protected FileTypeFilterTools $filterTools, /**
|
||||
* @var string The user configured maximum upload size. This is a string like "10M" or "1G" and will be converted to
|
||||
*/
|
||||
protected string $max_upload_size)
|
||||
public function __construct(
|
||||
protected AttachmentPathResolver $pathResolver,
|
||||
protected HttpClientInterface $httpClient,
|
||||
protected MimeTypesInterface $mimeTypes,
|
||||
protected FileTypeFilterTools $filterTools,
|
||||
protected AttachmentsSettings $settings,
|
||||
protected readonly SVGSanitizer $SVGSanitizer,
|
||||
)
|
||||
{
|
||||
//The mapping used to determine which folder will be used for an attachment type
|
||||
$this->folder_mapping = [
|
||||
|
|
@ -214,6 +217,9 @@ class AttachmentSubmitHandler
|
|||
//Move the attachment files to secure location (and back) if needed
|
||||
$this->moveFile($attachment, $secure_attachment);
|
||||
|
||||
//Sanitize the SVG if needed
|
||||
$this->sanitizeSVGAttachment($attachment);
|
||||
|
||||
//Rename blacklisted (unsecure) files to a better extension
|
||||
$this->renameBlacklistedExtensions($attachment);
|
||||
|
||||
|
|
@ -334,7 +340,7 @@ class AttachmentSubmitHandler
|
|||
protected function downloadURL(Attachment $attachment, bool $secureAttachment): Attachment
|
||||
{
|
||||
//Check if we are allowed to download files
|
||||
if (!$this->allow_attachments_downloads) {
|
||||
if (!$this->settings->allowDownloads) {
|
||||
throw new RuntimeException('Download of attachments is not allowed!');
|
||||
}
|
||||
|
||||
|
|
@ -345,9 +351,28 @@ class AttachmentSubmitHandler
|
|||
$tmp_path = $attachment_folder.DIRECTORY_SEPARATOR.$this->generateAttachmentFilename($attachment, 'tmp');
|
||||
|
||||
try {
|
||||
$response = $this->httpClient->request('GET', $url, [
|
||||
$opts = [
|
||||
'buffer' => false,
|
||||
]);
|
||||
//Use user-agent and other headers to make the server think we are a browser
|
||||
'headers' => [
|
||||
"sec-ch-ua" => "\"Not(A:Brand\";v=\"99\", \"Google Chrome\";v=\"133\", \"Chromium\";v=\"133\"",
|
||||
"sec-ch-ua-mobile" => "?0",
|
||||
"sec-ch-ua-platform" => "\"Windows\"",
|
||||
"user-agent" => "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/133.0.0.0 Safari/537.36",
|
||||
"sec-fetch-site" => "none",
|
||||
"sec-fetch-mode" => "navigate",
|
||||
],
|
||||
|
||||
];
|
||||
$response = $this->httpClient->request('GET', $url, $opts);
|
||||
//Digikey wants TLSv1.3, so try again with that if we get a 403
|
||||
if ($response->getStatusCode() === 403) {
|
||||
$opts['crypto_method'] = STREAM_CRYPTO_METHOD_TLSv1_3_CLIENT;
|
||||
$response = $this->httpClient->request('GET', $url, $opts);
|
||||
}
|
||||
# if you have these changes and downloads still fail, check if it's due to an unknown certificate. Curl by
|
||||
# default uses the systems ca store and that doesn't contain all the intermediate certificates needed to
|
||||
# verify the leafs
|
||||
|
||||
if (200 !== $response->getStatusCode()) {
|
||||
throw new AttachmentDownloadException('Status code: '.$response->getStatusCode());
|
||||
|
|
@ -474,9 +499,37 @@ class AttachmentSubmitHandler
|
|||
$this->max_upload_size_bytes = min(
|
||||
$this->parseFileSizeString(ini_get('post_max_size')),
|
||||
$this->parseFileSizeString(ini_get('upload_max_filesize')),
|
||||
$this->parseFileSizeString($this->max_upload_size),
|
||||
$this->parseFileSizeString($this->settings->maxFileSize)
|
||||
);
|
||||
|
||||
return $this->max_upload_size_bytes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitizes the given SVG file, if the attachment is an internal SVG file.
|
||||
* @param Attachment $attachment
|
||||
* @return Attachment
|
||||
*/
|
||||
public function sanitizeSVGAttachment(Attachment $attachment): Attachment
|
||||
{
|
||||
//We can not do anything on builtins or external ressources
|
||||
if ($attachment->isBuiltIn() || !$attachment->hasInternal()) {
|
||||
return $attachment;
|
||||
}
|
||||
|
||||
//Resolve the path to the file
|
||||
$path = $this->pathResolver->placeholderToRealPath($attachment->getInternalPath());
|
||||
|
||||
//Check if the file exists
|
||||
if (!file_exists($path)) {
|
||||
return $attachment;
|
||||
}
|
||||
|
||||
//Check if the file is an SVG
|
||||
if ($attachment->getExtension() === "svg") {
|
||||
$this->SVGSanitizer->sanitizeFile($path);
|
||||
}
|
||||
|
||||
return $attachment;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -112,12 +112,12 @@ class AttachmentURLGenerator
|
|||
/**
|
||||
* Returns a URL to a thumbnail of the attachment file.
|
||||
* For external files the original URL is returned.
|
||||
* @return string|null The URL or null if the attachment file is not existing
|
||||
* @return string|null The URL or null if the attachment file is not existing or is invalid
|
||||
*/
|
||||
public function getThumbnailURL(Attachment $attachment, string $filter_name = 'thumbnail_sm'): ?string
|
||||
{
|
||||
if (!$attachment->isPicture()) {
|
||||
throw new InvalidArgumentException('Thumbnail creation only works for picture attachments!');
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!$attachment->hasInternal()){
|
||||
|
|
|
|||
58
src/Services/Attachments/SVGSanitizer.php
Normal file
58
src/Services/Attachments/SVGSanitizer.php
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
<?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\Attachments;
|
||||
|
||||
use Rhukster\DomSanitizer\DOMSanitizer;
|
||||
|
||||
class SVGSanitizer
|
||||
{
|
||||
|
||||
/**
|
||||
* Sanitizes the given SVG string by removing any potentially harmful content (like inline scripts).
|
||||
* @param string $input
|
||||
* @return string
|
||||
*/
|
||||
public function sanitizeString(string $input): string
|
||||
{
|
||||
return (new DOMSanitizer(DOMSanitizer::SVG))->sanitize($input);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitizes the given SVG file by removing any potentially harmful content (like inline scripts).
|
||||
* The sanitized content is written back to the file.
|
||||
* @param string $filepath
|
||||
*/
|
||||
public function sanitizeFile(string $filepath): void
|
||||
{
|
||||
//Open the file and read the content
|
||||
$content = file_get_contents($filepath);
|
||||
if ($content === false) {
|
||||
throw new \RuntimeException('Could not read file: ' . $filepath);
|
||||
}
|
||||
//Sanitize the content
|
||||
$sanitizedContent = $this->sanitizeString($content);
|
||||
//Write the sanitized content back to the file
|
||||
file_put_contents($filepath, $sanitizedContent);
|
||||
}
|
||||
}
|
||||
|
|
@ -27,7 +27,9 @@ use App\Entity\Parts\Category;
|
|||
use App\Entity\Parts\Footprint;
|
||||
use App\Entity\Parts\Part;
|
||||
use App\Services\Cache\ElementCacheTagGenerator;
|
||||
use App\Services\EntityURLGenerator;
|
||||
use App\Services\Trees\NodesListBuilder;
|
||||
use App\Settings\MiscSettings\KiCadEDASettings;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
|
||||
|
|
@ -38,16 +40,20 @@ use Symfony\Contracts\Translation\TranslatorInterface;
|
|||
class KiCadHelper
|
||||
{
|
||||
|
||||
/** @var int The maximum level of the shown categories. 0 Means only the top level categories are shown. -1 means only a single one containing */
|
||||
private readonly int $category_depth;
|
||||
|
||||
public function __construct(
|
||||
private readonly NodesListBuilder $nodesListBuilder,
|
||||
private readonly TagAwareCacheInterface $kicadCache,
|
||||
private readonly EntityManagerInterface $em,
|
||||
private readonly ElementCacheTagGenerator $tagGenerator,
|
||||
private readonly UrlGeneratorInterface $urlGenerator,
|
||||
private readonly EntityURLGenerator $entityURLGenerator,
|
||||
private readonly TranslatorInterface $translator,
|
||||
/** The maximum level of the shown categories. 0 Means only the top level categories are shown. -1 means only a single one containing */
|
||||
private readonly int $category_depth,
|
||||
KiCadEDASettings $kiCadEDASettings,
|
||||
) {
|
||||
$this->category_depth = $kiCadEDASettings->categoryDepth;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -64,6 +70,10 @@ class KiCadHelper
|
|||
$secure_class_name = $this->tagGenerator->getElementTypeCacheTag(Category::class);
|
||||
$item->tag($secure_class_name);
|
||||
|
||||
//Invalidate the cache on part changes (as the visibility depends on parts, and the parts can change)
|
||||
$secure_class_name = $this->tagGenerator->getElementTypeCacheTag(Part::class);
|
||||
$item->tag($secure_class_name);
|
||||
|
||||
//If the category depth is smaller than 0, create only one dummy category
|
||||
if ($this->category_depth < 0) {
|
||||
return [
|
||||
|
|
@ -108,6 +118,8 @@ class KiCadHelper
|
|||
$result[] = [
|
||||
'id' => (string)$category->getId(),
|
||||
'name' => $category->getFullPath('/'),
|
||||
//Show the category link as the category description, this also fixes an segfault in KiCad see issue #878
|
||||
'description' => $this->entityURLGenerator->listPartsURL($category),
|
||||
];
|
||||
}
|
||||
|
||||
|
|
@ -229,6 +241,49 @@ class KiCadHelper
|
|||
$result["fields"]["Part-DB IPN"] = $this->createField($part->getIpn());
|
||||
}
|
||||
|
||||
// Add supplier information from orderdetails (include obsolete orderdetails)
|
||||
if ($part->getOrderdetails(false)->count() > 0) {
|
||||
$supplierCounts = [];
|
||||
|
||||
foreach ($part->getOrderdetails(false) as $orderdetail) {
|
||||
if ($orderdetail->getSupplier() !== null && $orderdetail->getSupplierPartNr() !== '') {
|
||||
$supplierName = $orderdetail->getSupplier()->getName();
|
||||
|
||||
$supplierName .= " SPN"; // Append "SPN" to the supplier name to indicate Supplier Part Number
|
||||
|
||||
if (!isset($supplierCounts[$supplierName])) {
|
||||
$supplierCounts[$supplierName] = 0;
|
||||
}
|
||||
$supplierCounts[$supplierName]++;
|
||||
|
||||
// Create field name with sequential number if more than one from same supplier (e.g. "Mouser", "Mouser 2", etc.)
|
||||
$fieldName = $supplierCounts[$supplierName] > 1
|
||||
? $supplierName . ' ' . $supplierCounts[$supplierName]
|
||||
: $supplierName;
|
||||
|
||||
$result["fields"][$fieldName] = $this->createField($orderdetail->getSupplierPartNr());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
//Add fields for KiCost:
|
||||
if ($part->getManufacturer() !== null) {
|
||||
$result["fields"]["manf"] = $this->createField($part->getManufacturer()->getName());
|
||||
}
|
||||
if ($part->getManufacturerProductNumber() !== "") {
|
||||
$result['fields']['manf#'] = $this->createField($part->getManufacturerProductNumber());
|
||||
}
|
||||
|
||||
//For each supplier, add a field with the supplier name and the supplier part number for KiCost
|
||||
if ($part->getOrderdetails(false)->count() > 0) {
|
||||
foreach ($part->getOrderdetails(false) as $orderdetail) {
|
||||
if ($orderdetail->getSupplier() !== null && $orderdetail->getSupplierPartNr() !== '') {
|
||||
$fieldName = mb_strtolower($orderdetail->getSupplier()->getName()) . '#';
|
||||
|
||||
$result["fields"][$fieldName] = $this->createField($orderdetail->getSupplierPartNr());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -156,8 +156,10 @@ class EntityURLGenerator
|
|||
|
||||
public function viewURL(Attachment $entity): string
|
||||
{
|
||||
if ($entity->hasInternal()) {
|
||||
return $this->attachmentURLGenerator->getInternalViewURL($entity);
|
||||
//If the underlying file path is invalid, null gets returned, which is not allowed here.
|
||||
//We still have the chance to use an external path, if it is set.
|
||||
if ($entity->hasInternal() && ($url = $this->attachmentURLGenerator->getInternalViewURL($entity)) !== null) {
|
||||
return $url;
|
||||
}
|
||||
|
||||
if($entity->hasExternal()) {
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ declare(strict_types=1);
|
|||
namespace App\Services\Formatters;
|
||||
|
||||
use App\Entity\PriceInformations\Currency;
|
||||
use App\Settings\SystemSettings\LocalizationSettings;
|
||||
use Locale;
|
||||
use NumberFormatter;
|
||||
|
||||
|
|
@ -30,7 +31,7 @@ class MoneyFormatter
|
|||
{
|
||||
protected string $locale;
|
||||
|
||||
public function __construct(protected string $base_currency)
|
||||
public function __construct(private readonly LocalizationSettings $localizationSettings)
|
||||
{
|
||||
$this->locale = Locale::getDefault();
|
||||
}
|
||||
|
|
@ -45,7 +46,7 @@ class MoneyFormatter
|
|||
*/
|
||||
public function format(string|float $value, ?Currency $currency = null, int $decimals = 5, bool $show_all_digits = false): string
|
||||
{
|
||||
$iso_code = $this->base_currency;
|
||||
$iso_code = $this->localizationSettings->baseCurrency;
|
||||
if ($currency instanceof Currency && ($currency->getIsoCode() !== '')) {
|
||||
$iso_code = $currency->getIsoCode();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -22,10 +22,13 @@ declare(strict_types=1);
|
|||
*/
|
||||
namespace App\Services\ImportExportSystem;
|
||||
|
||||
use App\Entity\Parts\Part;
|
||||
use App\Entity\ProjectSystem\Project;
|
||||
use App\Entity\ProjectSystem\ProjectBOMEntry;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use InvalidArgumentException;
|
||||
use League\Csv\Reader;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Symfony\Component\HttpFoundation\File\File;
|
||||
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||
|
||||
|
|
@ -44,14 +47,25 @@ class BOMImporter
|
|||
5 => 'Supplier and ref',
|
||||
];
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
public function __construct(
|
||||
private readonly EntityManagerInterface $entityManager,
|
||||
private readonly LoggerInterface $logger,
|
||||
private readonly BOMValidationService $validationService
|
||||
) {
|
||||
}
|
||||
|
||||
protected function configureOptions(OptionsResolver $resolver): OptionsResolver
|
||||
{
|
||||
$resolver->setRequired('type');
|
||||
$resolver->setAllowedValues('type', ['kicad_pcbnew']);
|
||||
$resolver->setAllowedValues('type', ['kicad_pcbnew', 'kicad_schematic']);
|
||||
|
||||
// For flexible schematic import with field mapping
|
||||
$resolver->setDefined(['field_mapping', 'field_priorities', 'delimiter']);
|
||||
$resolver->setDefault('delimiter', ',');
|
||||
$resolver->setDefault('field_priorities', []);
|
||||
$resolver->setAllowedTypes('field_mapping', 'array');
|
||||
$resolver->setAllowedTypes('field_priorities', 'array');
|
||||
$resolver->setAllowedTypes('delimiter', 'string');
|
||||
|
||||
return $resolver;
|
||||
}
|
||||
|
|
@ -82,6 +96,23 @@ class BOMImporter
|
|||
return $this->stringToBOMEntries($file->getContent(), $options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate BOM data before importing
|
||||
* @return array Validation result with errors, warnings, and info
|
||||
*/
|
||||
public function validateBOMData(string $data, array $options): array
|
||||
{
|
||||
$resolver = new OptionsResolver();
|
||||
$resolver = $this->configureOptions($resolver);
|
||||
$options = $resolver->resolve($options);
|
||||
|
||||
return match ($options['type']) {
|
||||
'kicad_pcbnew' => $this->validateKiCADPCB($data),
|
||||
'kicad_schematic' => $this->validateKiCADSchematicData($data, $options),
|
||||
default => throw new InvalidArgumentException('Invalid import type!'),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Import string data into an array of BOM entries, which are not yet assigned to a project.
|
||||
* @param string $data The data to import
|
||||
|
|
@ -95,12 +126,13 @@ class BOMImporter
|
|||
$options = $resolver->resolve($options);
|
||||
|
||||
return match ($options['type']) {
|
||||
'kicad_pcbnew' => $this->parseKiCADPCB($data, $options),
|
||||
'kicad_pcbnew' => $this->parseKiCADPCB($data),
|
||||
'kicad_schematic' => $this->parseKiCADSchematic($data, $options),
|
||||
default => throw new InvalidArgumentException('Invalid import type!'),
|
||||
};
|
||||
}
|
||||
|
||||
private function parseKiCADPCB(string $data, array $options = []): array
|
||||
private function parseKiCADPCB(string $data): array
|
||||
{
|
||||
$csv = Reader::createFromString($data);
|
||||
$csv->setDelimiter(';');
|
||||
|
|
@ -113,17 +145,17 @@ class BOMImporter
|
|||
$entry = $this->normalizeColumnNames($entry);
|
||||
|
||||
//Ensure that the entry has all required fields
|
||||
if (!isset ($entry['Designator'])) {
|
||||
throw new \UnexpectedValueException('Designator missing at line '.($offset + 1).'!');
|
||||
if (!isset($entry['Designator'])) {
|
||||
throw new \UnexpectedValueException('Designator missing at line ' . ($offset + 1) . '!');
|
||||
}
|
||||
if (!isset ($entry['Package'])) {
|
||||
throw new \UnexpectedValueException('Package missing at line '.($offset + 1).'!');
|
||||
if (!isset($entry['Package'])) {
|
||||
throw new \UnexpectedValueException('Package missing at line ' . ($offset + 1) . '!');
|
||||
}
|
||||
if (!isset ($entry['Designation'])) {
|
||||
throw new \UnexpectedValueException('Designation missing at line '.($offset + 1).'!');
|
||||
if (!isset($entry['Designation'])) {
|
||||
throw new \UnexpectedValueException('Designation missing at line ' . ($offset + 1) . '!');
|
||||
}
|
||||
if (!isset ($entry['Quantity'])) {
|
||||
throw new \UnexpectedValueException('Quantity missing at line '.($offset + 1).'!');
|
||||
if (!isset($entry['Quantity'])) {
|
||||
throw new \UnexpectedValueException('Quantity missing at line ' . ($offset + 1) . '!');
|
||||
}
|
||||
|
||||
$bom_entry = new ProjectBOMEntry();
|
||||
|
|
@ -138,6 +170,63 @@ class BOMImporter
|
|||
return $bom_entries;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate KiCad PCB data
|
||||
*/
|
||||
private function validateKiCADPCB(string $data): array
|
||||
{
|
||||
$csv = Reader::createFromString($data);
|
||||
$csv->setDelimiter(';');
|
||||
$csv->setHeaderOffset(0);
|
||||
|
||||
$mapped_entries = [];
|
||||
|
||||
foreach ($csv->getRecords() as $offset => $entry) {
|
||||
// Translate the german field names to english
|
||||
$entry = $this->normalizeColumnNames($entry);
|
||||
$mapped_entries[] = $entry;
|
||||
}
|
||||
|
||||
return $this->validationService->validateBOMEntries($mapped_entries);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate KiCad schematic data
|
||||
*/
|
||||
private function validateKiCADSchematicData(string $data, array $options): array
|
||||
{
|
||||
$delimiter = $options['delimiter'] ?? ',';
|
||||
$field_mapping = $options['field_mapping'] ?? [];
|
||||
$field_priorities = $options['field_priorities'] ?? [];
|
||||
|
||||
// Handle potential BOM (Byte Order Mark) at the beginning
|
||||
$data = preg_replace('/^\xEF\xBB\xBF/', '', $data);
|
||||
|
||||
$csv = Reader::createFromString($data);
|
||||
$csv->setDelimiter($delimiter);
|
||||
$csv->setHeaderOffset(0);
|
||||
|
||||
// Handle quoted fields properly
|
||||
$csv->setEscape('\\');
|
||||
$csv->setEnclosure('"');
|
||||
|
||||
$mapped_entries = [];
|
||||
|
||||
foreach ($csv->getRecords() as $offset => $entry) {
|
||||
// Apply field mapping to translate column names
|
||||
$mapped_entry = $this->applyFieldMapping($entry, $field_mapping, $field_priorities);
|
||||
|
||||
// Extract footprint package name if it contains library prefix
|
||||
if (isset($mapped_entry['Package']) && str_contains($mapped_entry['Package'], ':')) {
|
||||
$mapped_entry['Package'] = explode(':', $mapped_entry['Package'], 2)[1];
|
||||
}
|
||||
|
||||
$mapped_entries[] = $mapped_entry;
|
||||
}
|
||||
|
||||
return $this->validationService->validateBOMEntries($mapped_entries, $options);
|
||||
}
|
||||
|
||||
/**
|
||||
* This function uses the order of the fields in the CSV files to make them locale independent.
|
||||
* @param array $entry
|
||||
|
|
@ -160,4 +249,482 @@ class BOMImporter
|
|||
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse KiCad schematic BOM with flexible field mapping
|
||||
*/
|
||||
private function parseKiCADSchematic(string $data, array $options = []): array
|
||||
{
|
||||
$delimiter = $options['delimiter'] ?? ',';
|
||||
$field_mapping = $options['field_mapping'] ?? [];
|
||||
$field_priorities = $options['field_priorities'] ?? [];
|
||||
|
||||
// Handle potential BOM (Byte Order Mark) at the beginning
|
||||
$data = preg_replace('/^\xEF\xBB\xBF/', '', $data);
|
||||
|
||||
$csv = Reader::createFromString($data);
|
||||
$csv->setDelimiter($delimiter);
|
||||
$csv->setHeaderOffset(0);
|
||||
|
||||
// Handle quoted fields properly
|
||||
$csv->setEscape('\\');
|
||||
$csv->setEnclosure('"');
|
||||
|
||||
$bom_entries = [];
|
||||
$entries_by_key = []; // Track entries by name+part combination
|
||||
$mapped_entries = []; // Collect all mapped entries for validation
|
||||
|
||||
foreach ($csv->getRecords() as $offset => $entry) {
|
||||
// Apply field mapping to translate column names
|
||||
$mapped_entry = $this->applyFieldMapping($entry, $field_mapping, $field_priorities);
|
||||
|
||||
// Extract footprint package name if it contains library prefix
|
||||
if (isset($mapped_entry['Package']) && str_contains($mapped_entry['Package'], ':')) {
|
||||
$mapped_entry['Package'] = explode(':', $mapped_entry['Package'], 2)[1];
|
||||
}
|
||||
|
||||
$mapped_entries[] = $mapped_entry;
|
||||
}
|
||||
|
||||
// Validate all entries before processing
|
||||
$validation_result = $this->validationService->validateBOMEntries($mapped_entries, $options);
|
||||
|
||||
// Log validation results
|
||||
$this->logger->info('BOM import validation completed', [
|
||||
'total_entries' => $validation_result['total_entries'],
|
||||
'valid_entries' => $validation_result['valid_entries'],
|
||||
'invalid_entries' => $validation_result['invalid_entries'],
|
||||
'error_count' => count($validation_result['errors']),
|
||||
'warning_count' => count($validation_result['warnings']),
|
||||
]);
|
||||
|
||||
// If there are validation errors, throw an exception with detailed messages
|
||||
if (!empty($validation_result['errors'])) {
|
||||
$error_message = $this->validationService->getErrorMessage($validation_result);
|
||||
throw new \UnexpectedValueException("BOM import validation failed:\n" . $error_message);
|
||||
}
|
||||
|
||||
// Process validated entries
|
||||
foreach ($mapped_entries as $offset => $mapped_entry) {
|
||||
|
||||
// Set name - prefer MPN, fall back to Value, then default format
|
||||
$mpn = trim($mapped_entry['MPN'] ?? '');
|
||||
$designation = trim($mapped_entry['Designation'] ?? '');
|
||||
$value = trim($mapped_entry['Value'] ?? '');
|
||||
|
||||
// Use the first non-empty value, or 'Unknown Component' if all are empty
|
||||
$name = '';
|
||||
if (!empty($mpn)) {
|
||||
$name = $mpn;
|
||||
} elseif (!empty($designation)) {
|
||||
$name = $designation;
|
||||
} elseif (!empty($value)) {
|
||||
$name = $value;
|
||||
} else {
|
||||
$name = 'Unknown Component';
|
||||
}
|
||||
|
||||
if (isset($mapped_entry['Package']) && !empty(trim($mapped_entry['Package']))) {
|
||||
$name .= ' (' . trim($mapped_entry['Package']) . ')';
|
||||
}
|
||||
|
||||
// Set mountnames and quantity
|
||||
// The Designator field contains comma-separated mount names for all instances
|
||||
$designator = trim($mapped_entry['Designator']);
|
||||
$quantity = (float) $mapped_entry['Quantity'];
|
||||
|
||||
// Get mountnames array (validation already ensured they match quantity)
|
||||
$mountnames_array = array_map('trim', explode(',', $designator));
|
||||
|
||||
// Try to link existing Part-DB part if ID is provided
|
||||
$part = null;
|
||||
if (isset($mapped_entry['Part-DB ID']) && !empty($mapped_entry['Part-DB ID'])) {
|
||||
$partDbId = (int) $mapped_entry['Part-DB ID'];
|
||||
$existingPart = $this->entityManager->getRepository(Part::class)->find($partDbId);
|
||||
|
||||
if ($existingPart) {
|
||||
$part = $existingPart;
|
||||
// Update name with actual part name
|
||||
$name = $existingPart->getName();
|
||||
}
|
||||
}
|
||||
|
||||
// Create unique key for this entry (name + part ID)
|
||||
$entry_key = $name . '|' . ($part ? $part->getID() : 'null');
|
||||
|
||||
// Check if we already have an entry with the same name and part
|
||||
if (isset($entries_by_key[$entry_key])) {
|
||||
// Merge with existing entry
|
||||
$existing_entry = $entries_by_key[$entry_key];
|
||||
|
||||
// Combine mountnames
|
||||
$existing_mountnames = $existing_entry->getMountnames();
|
||||
$combined_mountnames = $existing_mountnames . ',' . $designator;
|
||||
$existing_entry->setMountnames($combined_mountnames);
|
||||
|
||||
// Add quantities
|
||||
$existing_quantity = $existing_entry->getQuantity();
|
||||
$existing_entry->setQuantity($existing_quantity + $quantity);
|
||||
|
||||
$this->logger->info('Merged duplicate BOM entry', [
|
||||
'name' => $name,
|
||||
'part_id' => $part ? $part->getID() : null,
|
||||
'original_quantity' => $existing_quantity,
|
||||
'added_quantity' => $quantity,
|
||||
'new_quantity' => $existing_quantity + $quantity,
|
||||
'original_mountnames' => $existing_mountnames,
|
||||
'added_mountnames' => $designator,
|
||||
]);
|
||||
|
||||
continue; // Skip creating new entry
|
||||
}
|
||||
|
||||
// Create new BOM entry
|
||||
$bom_entry = new ProjectBOMEntry();
|
||||
$bom_entry->setName($name);
|
||||
$bom_entry->setMountnames($designator);
|
||||
$bom_entry->setQuantity($quantity);
|
||||
|
||||
if ($part) {
|
||||
$bom_entry->setPart($part);
|
||||
}
|
||||
|
||||
// Set comment with additional info
|
||||
$comment_parts = [];
|
||||
if (isset($mapped_entry['Value']) && $mapped_entry['Value'] !== ($mapped_entry['MPN'] ?? '')) {
|
||||
$comment_parts[] = 'Value: ' . $mapped_entry['Value'];
|
||||
}
|
||||
if (isset($mapped_entry['MPN'])) {
|
||||
$comment_parts[] = 'MPN: ' . $mapped_entry['MPN'];
|
||||
}
|
||||
if (isset($mapped_entry['Manufacturer'])) {
|
||||
$comment_parts[] = 'Manf: ' . $mapped_entry['Manufacturer'];
|
||||
}
|
||||
if (isset($mapped_entry['LCSC'])) {
|
||||
$comment_parts[] = 'LCSC: ' . $mapped_entry['LCSC'];
|
||||
}
|
||||
if (isset($mapped_entry['Supplier and ref'])) {
|
||||
$comment_parts[] = $mapped_entry['Supplier and ref'];
|
||||
}
|
||||
|
||||
if ($part) {
|
||||
$comment_parts[] = "Part-DB ID: " . $part->getID();
|
||||
} elseif (isset($mapped_entry['Part-DB ID']) && !empty($mapped_entry['Part-DB ID'])) {
|
||||
$comment_parts[] = "Part-DB ID: " . $mapped_entry['Part-DB ID'] . " (NOT FOUND)";
|
||||
}
|
||||
|
||||
$bom_entry->setComment(implode(', ', $comment_parts));
|
||||
|
||||
$bom_entries[] = $bom_entry;
|
||||
$entries_by_key[$entry_key] = $bom_entry;
|
||||
}
|
||||
|
||||
return $bom_entries;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all available field mapping targets with descriptions
|
||||
*/
|
||||
public function getAvailableFieldTargets(): array
|
||||
{
|
||||
$targets = [
|
||||
'Designator' => [
|
||||
'label' => 'Designator',
|
||||
'description' => 'Component reference designators (e.g., R1, C2, U3)',
|
||||
'required' => true,
|
||||
'multiple' => false,
|
||||
],
|
||||
'Quantity' => [
|
||||
'label' => 'Quantity',
|
||||
'description' => 'Number of components',
|
||||
'required' => true,
|
||||
'multiple' => false,
|
||||
],
|
||||
'Designation' => [
|
||||
'label' => 'Designation',
|
||||
'description' => 'Component designation/part number',
|
||||
'required' => false,
|
||||
'multiple' => true,
|
||||
],
|
||||
'Value' => [
|
||||
'label' => 'Value',
|
||||
'description' => 'Component value (e.g., 10k, 100nF)',
|
||||
'required' => false,
|
||||
'multiple' => true,
|
||||
],
|
||||
'Package' => [
|
||||
'label' => 'Package',
|
||||
'description' => 'Component package/footprint',
|
||||
'required' => false,
|
||||
'multiple' => true,
|
||||
],
|
||||
'MPN' => [
|
||||
'label' => 'MPN',
|
||||
'description' => 'Manufacturer Part Number',
|
||||
'required' => false,
|
||||
'multiple' => true,
|
||||
],
|
||||
'Manufacturer' => [
|
||||
'label' => 'Manufacturer',
|
||||
'description' => 'Component manufacturer name',
|
||||
'required' => false,
|
||||
'multiple' => true,
|
||||
],
|
||||
'Part-DB ID' => [
|
||||
'label' => 'Part-DB ID',
|
||||
'description' => 'Existing Part-DB part ID for linking',
|
||||
'required' => false,
|
||||
'multiple' => false,
|
||||
],
|
||||
'Comment' => [
|
||||
'label' => 'Comment',
|
||||
'description' => 'Additional component information',
|
||||
'required' => false,
|
||||
'multiple' => true,
|
||||
],
|
||||
];
|
||||
|
||||
// Add dynamic supplier fields based on available suppliers in the database
|
||||
$suppliers = $this->entityManager->getRepository(\App\Entity\Parts\Supplier::class)->findAll();
|
||||
foreach ($suppliers as $supplier) {
|
||||
$supplierName = $supplier->getName();
|
||||
$targets[$supplierName . ' SPN'] = [
|
||||
'label' => $supplierName . ' SPN',
|
||||
'description' => "Supplier part number for {$supplierName}",
|
||||
'required' => false,
|
||||
'multiple' => true,
|
||||
'supplier_id' => $supplier->getID(),
|
||||
];
|
||||
}
|
||||
|
||||
return $targets;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get suggested field mappings based on common field names
|
||||
*/
|
||||
public function getSuggestedFieldMapping(array $detected_fields): array
|
||||
{
|
||||
$suggestions = [];
|
||||
|
||||
$field_patterns = [
|
||||
'Part-DB ID' => ['part-db id', 'partdb_id', 'part_db_id', 'db_id', 'partdb'],
|
||||
'Designator' => ['reference', 'ref', 'designator', 'component', 'comp'],
|
||||
'Quantity' => ['qty', 'quantity', 'count', 'number', 'amount'],
|
||||
'Value' => ['value', 'val', 'component_value'],
|
||||
'Designation' => ['designation', 'part_number', 'partnumber', 'part'],
|
||||
'Package' => ['footprint', 'package', 'housing', 'fp'],
|
||||
'MPN' => ['mpn', 'part_number', 'partnumber', 'manf#', 'mfr_part_number', 'manufacturer_part'],
|
||||
'Manufacturer' => ['manufacturer', 'manf', 'mfr', 'brand', 'vendor'],
|
||||
'Comment' => ['comment', 'comments', 'note', 'notes', 'description'],
|
||||
];
|
||||
|
||||
// Add supplier-specific patterns
|
||||
$suppliers = $this->entityManager->getRepository(\App\Entity\Parts\Supplier::class)->findAll();
|
||||
foreach ($suppliers as $supplier) {
|
||||
$supplierName = $supplier->getName();
|
||||
$supplierLower = strtolower($supplierName);
|
||||
|
||||
// Create patterns for each supplier
|
||||
$field_patterns[$supplierName . ' SPN'] = [
|
||||
$supplierLower,
|
||||
$supplierLower . '#',
|
||||
$supplierLower . '_part',
|
||||
$supplierLower . '_number',
|
||||
$supplierLower . 'pn',
|
||||
$supplierLower . '_spn',
|
||||
$supplierLower . ' spn',
|
||||
// Common abbreviations
|
||||
$supplierLower === 'mouser' ? 'mouser' : null,
|
||||
$supplierLower === 'digikey' ? 'dk' : null,
|
||||
$supplierLower === 'farnell' ? 'farnell' : null,
|
||||
$supplierLower === 'rs' ? 'rs' : null,
|
||||
$supplierLower === 'lcsc' ? 'lcsc' : null,
|
||||
];
|
||||
|
||||
// Remove null values
|
||||
$field_patterns[$supplierName . ' SPN'] = array_filter($field_patterns[$supplierName . ' SPN'], fn($value) => $value !== null);
|
||||
}
|
||||
|
||||
foreach ($detected_fields as $field) {
|
||||
$field_lower = strtolower(trim($field));
|
||||
|
||||
foreach ($field_patterns as $target => $patterns) {
|
||||
foreach ($patterns as $pattern) {
|
||||
if (str_contains($field_lower, $pattern)) {
|
||||
$suggestions[$field] = $target;
|
||||
break 2; // Break both loops
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $suggestions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate field mapping configuration
|
||||
*/
|
||||
public function validateFieldMapping(array $field_mapping, array $detected_fields): array
|
||||
{
|
||||
$errors = [];
|
||||
$warnings = [];
|
||||
$available_targets = $this->getAvailableFieldTargets();
|
||||
|
||||
// Check for required fields
|
||||
$mapped_targets = array_values($field_mapping);
|
||||
$required_fields = ['Designator', 'Quantity'];
|
||||
|
||||
foreach ($required_fields as $required) {
|
||||
if (!in_array($required, $mapped_targets, true)) {
|
||||
$errors[] = "Required field '{$required}' is not mapped from any CSV column.";
|
||||
}
|
||||
}
|
||||
|
||||
// Check for invalid target fields
|
||||
foreach ($field_mapping as $csv_field => $target) {
|
||||
if (!empty($target) && !isset($available_targets[$target])) {
|
||||
$errors[] = "Invalid target field '{$target}' for CSV field '{$csv_field}'.";
|
||||
}
|
||||
}
|
||||
|
||||
// Check for unmapped fields (warnings)
|
||||
$unmapped_fields = array_diff($detected_fields, array_keys($field_mapping));
|
||||
if (!empty($unmapped_fields)) {
|
||||
$warnings[] = "The following CSV fields are not mapped: " . implode(', ', $unmapped_fields);
|
||||
}
|
||||
|
||||
return [
|
||||
'errors' => $errors,
|
||||
'warnings' => $warnings,
|
||||
'is_valid' => empty($errors),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply field mapping with support for multiple fields and priority
|
||||
*/
|
||||
private function applyFieldMapping(array $entry, array $field_mapping, array $field_priorities = []): array
|
||||
{
|
||||
$mapped = [];
|
||||
$field_groups = [];
|
||||
|
||||
// Group fields by target with priority information
|
||||
foreach ($field_mapping as $csv_field => $target) {
|
||||
if (!empty($target)) {
|
||||
if (!isset($field_groups[$target])) {
|
||||
$field_groups[$target] = [];
|
||||
}
|
||||
$priority = $field_priorities[$csv_field] ?? 10;
|
||||
$field_groups[$target][] = [
|
||||
'field' => $csv_field,
|
||||
'priority' => $priority,
|
||||
'value' => $entry[$csv_field] ?? ''
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
// Process each target field
|
||||
foreach ($field_groups as $target => $field_data) {
|
||||
// Sort by priority (lower number = higher priority)
|
||||
usort($field_data, function ($a, $b) {
|
||||
return $a['priority'] <=> $b['priority'];
|
||||
});
|
||||
|
||||
$values = [];
|
||||
$non_empty_values = [];
|
||||
|
||||
// Collect all non-empty values for this target
|
||||
foreach ($field_data as $data) {
|
||||
$value = trim($data['value']);
|
||||
if (!empty($value)) {
|
||||
$non_empty_values[] = $value;
|
||||
}
|
||||
$values[] = $value;
|
||||
}
|
||||
|
||||
// Use the first non-empty value (highest priority)
|
||||
if (!empty($non_empty_values)) {
|
||||
$mapped[$target] = $non_empty_values[0];
|
||||
|
||||
// If multiple non-empty values exist, add alternatives to comment
|
||||
if (count($non_empty_values) > 1) {
|
||||
$mapped[$target . '_alternatives'] = array_slice($non_empty_values, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $mapped;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect available fields in CSV data for field mapping UI
|
||||
*/
|
||||
public function detectFields(string $data, ?string $delimiter = null): array
|
||||
{
|
||||
if ($delimiter === null) {
|
||||
// Detect delimiter by counting occurrences in the first row (header)
|
||||
$delimiters = [',', ';', "\t"];
|
||||
$lines = explode("\n", $data, 2);
|
||||
$header_line = $lines[0] ?? '';
|
||||
$delimiter_counts = [];
|
||||
foreach ($delimiters as $delim) {
|
||||
$delimiter_counts[$delim] = substr_count($header_line, $delim);
|
||||
}
|
||||
// Choose the delimiter with the highest count, default to comma if all are zero
|
||||
$max_count = max($delimiter_counts);
|
||||
$delimiter = array_search($max_count, $delimiter_counts, true);
|
||||
if ($max_count === 0 || $delimiter === false) {
|
||||
$delimiter = ',';
|
||||
}
|
||||
}
|
||||
// Handle potential BOM (Byte Order Mark) at the beginning
|
||||
$data = preg_replace('/^\xEF\xBB\xBF/', '', $data);
|
||||
|
||||
// Get first line only for header detection
|
||||
$lines = explode("\n", $data);
|
||||
$header_line = trim($lines[0] ?? '');
|
||||
|
||||
|
||||
// Simple manual parsing for header detection
|
||||
// This handles quoted CSV fields better than the library for detection
|
||||
$fields = [];
|
||||
$current_field = '';
|
||||
$in_quotes = false;
|
||||
$quote_char = '"';
|
||||
|
||||
for ($i = 0; $i < strlen($header_line); $i++) {
|
||||
$char = $header_line[$i];
|
||||
|
||||
if ($char === $quote_char && !$in_quotes) {
|
||||
$in_quotes = true;
|
||||
} elseif ($char === $quote_char && $in_quotes) {
|
||||
// Check for escaped quote (double quote)
|
||||
if ($i + 1 < strlen($header_line) && $header_line[$i + 1] === $quote_char) {
|
||||
$current_field .= $quote_char;
|
||||
$i++; // Skip next quote
|
||||
} else {
|
||||
$in_quotes = false;
|
||||
}
|
||||
} elseif ($char === $delimiter && !$in_quotes) {
|
||||
$fields[] = trim($current_field);
|
||||
$current_field = '';
|
||||
} else {
|
||||
$current_field .= $char;
|
||||
}
|
||||
}
|
||||
|
||||
// Add the last field
|
||||
if ($current_field !== '') {
|
||||
$fields[] = trim($current_field);
|
||||
}
|
||||
|
||||
// Clean up headers - remove quotes and trim whitespace
|
||||
$headers = array_map(function ($header) {
|
||||
return trim($header, '"\'');
|
||||
}, $fields);
|
||||
|
||||
|
||||
return array_values($headers);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
476
src/Services/ImportExportSystem/BOMValidationService.php
Normal file
476
src/Services/ImportExportSystem/BOMValidationService.php
Normal file
|
|
@ -0,0 +1,476 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/*
|
||||
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
|
||||
*
|
||||
* Copyright (C) 2019 - 2023 Jan Böhmer (https://github.com/jbtronics)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
namespace App\Services\ImportExportSystem;
|
||||
|
||||
use App\Entity\Parts\Part;
|
||||
use App\Entity\ProjectSystem\ProjectBOMEntry;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Contracts\Translation\TranslatorInterface;
|
||||
|
||||
/**
|
||||
* Service for validating BOM import data with comprehensive validation rules
|
||||
* and user-friendly error messages.
|
||||
*/
|
||||
class BOMValidationService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly EntityManagerInterface $entityManager,
|
||||
private readonly TranslatorInterface $translator
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Validation result structure
|
||||
*/
|
||||
public static function createValidationResult(): array
|
||||
{
|
||||
return [
|
||||
'errors' => [],
|
||||
'warnings' => [],
|
||||
'info' => [],
|
||||
'is_valid' => true,
|
||||
'total_entries' => 0,
|
||||
'valid_entries' => 0,
|
||||
'invalid_entries' => 0,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a single BOM entry with comprehensive checks
|
||||
*/
|
||||
public function validateBOMEntry(array $mapped_entry, int $line_number, array $options = []): array
|
||||
{
|
||||
$result = [
|
||||
'line_number' => $line_number,
|
||||
'errors' => [],
|
||||
'warnings' => [],
|
||||
'info' => [],
|
||||
'is_valid' => true,
|
||||
];
|
||||
|
||||
// Run all validation rules
|
||||
$this->validateRequiredFields($mapped_entry, $result);
|
||||
$this->validateDesignatorFormat($mapped_entry, $result);
|
||||
$this->validateQuantityFormat($mapped_entry, $result);
|
||||
$this->validateDesignatorQuantityMatch($mapped_entry, $result);
|
||||
$this->validatePartDBLink($mapped_entry, $result);
|
||||
$this->validateComponentName($mapped_entry, $result);
|
||||
$this->validatePackageFormat($mapped_entry, $result);
|
||||
$this->validateNumericFields($mapped_entry, $result);
|
||||
|
||||
$result['is_valid'] = empty($result['errors']);
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate multiple BOM entries and provide summary
|
||||
*/
|
||||
public function validateBOMEntries(array $mapped_entries, array $options = []): array
|
||||
{
|
||||
$result = self::createValidationResult();
|
||||
$result['total_entries'] = count($mapped_entries);
|
||||
|
||||
$line_results = [];
|
||||
$all_errors = [];
|
||||
$all_warnings = [];
|
||||
$all_info = [];
|
||||
|
||||
foreach ($mapped_entries as $index => $entry) {
|
||||
$line_number = $index + 1;
|
||||
$line_result = $this->validateBOMEntry($entry, $line_number, $options);
|
||||
|
||||
$line_results[] = $line_result;
|
||||
|
||||
if ($line_result['is_valid']) {
|
||||
$result['valid_entries']++;
|
||||
} else {
|
||||
$result['invalid_entries']++;
|
||||
}
|
||||
|
||||
// Collect all messages
|
||||
$all_errors = array_merge($all_errors, $line_result['errors']);
|
||||
$all_warnings = array_merge($all_warnings, $line_result['warnings']);
|
||||
$all_info = array_merge($all_info, $line_result['info']);
|
||||
}
|
||||
|
||||
// Add summary messages
|
||||
$this->addSummaryMessages($result, $all_errors, $all_warnings, $all_info);
|
||||
|
||||
$result['errors'] = $all_errors;
|
||||
$result['warnings'] = $all_warnings;
|
||||
$result['info'] = $all_info;
|
||||
$result['line_results'] = $line_results;
|
||||
$result['is_valid'] = empty($all_errors);
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate required fields are present
|
||||
*/
|
||||
private function validateRequiredFields(array $entry, array &$result): void
|
||||
{
|
||||
$required_fields = ['Designator', 'Quantity'];
|
||||
|
||||
foreach ($required_fields as $field) {
|
||||
if (!isset($entry[$field]) || trim($entry[$field]) === '') {
|
||||
$result['errors'][] = $this->translator->trans('project.bom_import.validation.errors.required_field_missing', [
|
||||
'%line%' => $result['line_number'],
|
||||
'%field%' => $field
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate designator format and content
|
||||
*/
|
||||
private function validateDesignatorFormat(array $entry, array &$result): void
|
||||
{
|
||||
if (!isset($entry['Designator']) || trim($entry['Designator']) === '') {
|
||||
return; // Already handled by required fields validation
|
||||
}
|
||||
|
||||
$designator = trim($entry['Designator']);
|
||||
$mountnames = array_map('trim', explode(',', $designator));
|
||||
|
||||
// Remove empty entries
|
||||
$mountnames = array_filter($mountnames, fn($name) => !empty($name));
|
||||
|
||||
if (empty($mountnames)) {
|
||||
$result['errors'][] = $this->translator->trans('project.bom_import.validation.errors.no_valid_designators', [
|
||||
'%line%' => $result['line_number']
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate each mountname format (allow 1-2 uppercase letters, followed by 1+ digits)
|
||||
$invalid_mountnames = [];
|
||||
foreach ($mountnames as $mountname) {
|
||||
if (!preg_match('/^[A-Z]{1,2}[0-9]+$/', $mountname)) {
|
||||
$invalid_mountnames[] = $mountname;
|
||||
}
|
||||
}
|
||||
|
||||
if (!empty($invalid_mountnames)) {
|
||||
$result['warnings'][] = $this->translator->trans('project.bom_import.validation.warnings.unusual_designator_format', [
|
||||
'%line%' => $result['line_number'],
|
||||
'%designators%' => implode(', ', $invalid_mountnames)
|
||||
]);
|
||||
}
|
||||
|
||||
// Check for duplicate mountnames within the same line
|
||||
$duplicates = array_diff_assoc($mountnames, array_unique($mountnames));
|
||||
if (!empty($duplicates)) {
|
||||
$result['errors'][] = $this->translator->trans('project.bom_import.validation.errors.duplicate_designators', [
|
||||
'%line%' => $result['line_number'],
|
||||
'%designators%' => implode(', ', array_unique($duplicates))
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate quantity format and value
|
||||
*/
|
||||
private function validateQuantityFormat(array $entry, array &$result): void
|
||||
{
|
||||
if (!isset($entry['Quantity']) || trim($entry['Quantity']) === '') {
|
||||
return; // Already handled by required fields validation
|
||||
}
|
||||
|
||||
$quantity_str = trim($entry['Quantity']);
|
||||
|
||||
// Check if it's a valid number
|
||||
if (!is_numeric($quantity_str)) {
|
||||
$result['errors'][] = $this->translator->trans('project.bom_import.validation.errors.invalid_quantity', [
|
||||
'%line%' => $result['line_number'],
|
||||
'%quantity%' => $quantity_str
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
$quantity = (float) $quantity_str;
|
||||
|
||||
// Check for reasonable quantity values
|
||||
if ($quantity <= 0) {
|
||||
$result['errors'][] = $this->translator->trans('project.bom_import.validation.errors.quantity_zero_or_negative', [
|
||||
'%line%' => $result['line_number'],
|
||||
'%quantity%' => $quantity_str
|
||||
]);
|
||||
} elseif ($quantity > 10000) {
|
||||
$result['warnings'][] = $this->translator->trans('project.bom_import.validation.warnings.quantity_unusually_high', [
|
||||
'%line%' => $result['line_number'],
|
||||
'%quantity%' => $quantity_str
|
||||
]);
|
||||
}
|
||||
|
||||
// Check if quantity is a whole number when it should be
|
||||
if (isset($entry['Designator'])) {
|
||||
$designator = trim($entry['Designator']);
|
||||
$mountnames = array_map('trim', explode(',', $designator));
|
||||
$mountnames = array_filter($mountnames, fn($name) => !empty($name));
|
||||
|
||||
if (count($mountnames) > 0 && $quantity != (int) $quantity) {
|
||||
$result['warnings'][] = $this->translator->trans('project.bom_import.validation.warnings.quantity_not_whole_number', [
|
||||
'%line%' => $result['line_number'],
|
||||
'%quantity%' => $quantity_str,
|
||||
'%count%' => count($mountnames)
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate that designator count matches quantity
|
||||
*/
|
||||
private function validateDesignatorQuantityMatch(array $entry, array &$result): void
|
||||
{
|
||||
if (!isset($entry['Designator']) || !isset($entry['Quantity'])) {
|
||||
return; // Already handled by required fields validation
|
||||
}
|
||||
|
||||
$designator = trim($entry['Designator']);
|
||||
$quantity_str = trim($entry['Quantity']);
|
||||
|
||||
if (!is_numeric($quantity_str)) {
|
||||
return; // Already handled by quantity validation
|
||||
}
|
||||
|
||||
$mountnames = array_map('trim', explode(',', $designator));
|
||||
$mountnames = array_filter($mountnames, fn($name) => !empty($name));
|
||||
$mountnames_count = count($mountnames);
|
||||
$quantity = (float) $quantity_str;
|
||||
|
||||
if ($mountnames_count !== (int) $quantity) {
|
||||
$result['errors'][] = $this->translator->trans('project.bom_import.validation.errors.quantity_designator_mismatch', [
|
||||
'%line%' => $result['line_number'],
|
||||
'%quantity%' => $quantity_str,
|
||||
'%count%' => $mountnames_count,
|
||||
'%designators%' => $designator
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate Part-DB ID link
|
||||
*/
|
||||
private function validatePartDBLink(array $entry, array &$result): void
|
||||
{
|
||||
if (!isset($entry['Part-DB ID']) || trim($entry['Part-DB ID']) === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
$part_db_id = trim($entry['Part-DB ID']);
|
||||
|
||||
if (!is_numeric($part_db_id)) {
|
||||
$result['errors'][] = $this->translator->trans('project.bom_import.validation.errors.invalid_partdb_id', [
|
||||
'%line%' => $result['line_number'],
|
||||
'%id%' => $part_db_id
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
$part_id = (int) $part_db_id;
|
||||
|
||||
if ($part_id <= 0) {
|
||||
$result['errors'][] = $this->translator->trans('project.bom_import.validation.errors.partdb_id_zero_or_negative', [
|
||||
'%line%' => $result['line_number'],
|
||||
'%id%' => $part_id
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if part exists in database
|
||||
$existing_part = $this->entityManager->getRepository(Part::class)->find($part_id);
|
||||
if (!$existing_part) {
|
||||
$result['warnings'][] = $this->translator->trans('project.bom_import.validation.warnings.partdb_id_not_found', [
|
||||
'%line%' => $result['line_number'],
|
||||
'%id%' => $part_id
|
||||
]);
|
||||
} else {
|
||||
$result['info'][] = $this->translator->trans('project.bom_import.validation.info.partdb_link_success', [
|
||||
'%line%' => $result['line_number'],
|
||||
'%name%' => $existing_part->getName(),
|
||||
'%id%' => $part_id
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate component name/designation
|
||||
*/
|
||||
private function validateComponentName(array $entry, array &$result): void
|
||||
{
|
||||
$name_fields = ['MPN', 'Designation', 'Value'];
|
||||
$has_name = false;
|
||||
|
||||
foreach ($name_fields as $field) {
|
||||
if (isset($entry[$field]) && trim($entry[$field]) !== '') {
|
||||
$has_name = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!$has_name) {
|
||||
$result['warnings'][] = $this->translator->trans('project.bom_import.validation.warnings.no_component_name', [
|
||||
'%line%' => $result['line_number']
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate package format
|
||||
*/
|
||||
private function validatePackageFormat(array $entry, array &$result): void
|
||||
{
|
||||
if (!isset($entry['Package']) || trim($entry['Package']) === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
$package = trim($entry['Package']);
|
||||
|
||||
// Check for common package format issues
|
||||
if (strlen($package) > 100) {
|
||||
$result['warnings'][] = $this->translator->trans('project.bom_import.validation.warnings.package_name_too_long', [
|
||||
'%line%' => $result['line_number'],
|
||||
'%package%' => $package
|
||||
]);
|
||||
}
|
||||
|
||||
// Check for library prefixes (KiCad format)
|
||||
if (str_contains($package, ':')) {
|
||||
$result['info'][] = $this->translator->trans('project.bom_import.validation.info.library_prefix_detected', [
|
||||
'%line%' => $result['line_number'],
|
||||
'%package%' => $package
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate numeric fields
|
||||
*/
|
||||
private function validateNumericFields(array $entry, array &$result): void
|
||||
{
|
||||
$numeric_fields = ['Quantity', 'Part-DB ID'];
|
||||
|
||||
foreach ($numeric_fields as $field) {
|
||||
if (isset($entry[$field]) && trim($entry[$field]) !== '') {
|
||||
$value = trim($entry[$field]);
|
||||
if (!is_numeric($value)) {
|
||||
$result['errors'][] = $this->translator->trans('project.bom_import.validation.errors.non_numeric_field', [
|
||||
'%line%' => $result['line_number'],
|
||||
'%field%' => $field,
|
||||
'%value%' => $value
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add summary messages to validation result
|
||||
*/
|
||||
private function addSummaryMessages(array &$result, array $errors, array $warnings, array $info): void
|
||||
{
|
||||
$total_entries = $result['total_entries'];
|
||||
$valid_entries = $result['valid_entries'];
|
||||
$invalid_entries = $result['invalid_entries'];
|
||||
|
||||
// Add summary info
|
||||
if ($total_entries > 0) {
|
||||
$result['info'][] = $this->translator->trans('project.bom_import.validation.info.import_summary', [
|
||||
'%total%' => $total_entries,
|
||||
'%valid%' => $valid_entries,
|
||||
'%invalid%' => $invalid_entries
|
||||
]);
|
||||
}
|
||||
|
||||
// Add error summary
|
||||
if (!empty($errors)) {
|
||||
$error_count = count($errors);
|
||||
$result['errors'][] = $this->translator->trans('project.bom_import.validation.errors.summary', [
|
||||
'%count%' => $error_count
|
||||
]);
|
||||
}
|
||||
|
||||
// Add warning summary
|
||||
if (!empty($warnings)) {
|
||||
$warning_count = count($warnings);
|
||||
$result['warnings'][] = $this->translator->trans('project.bom_import.validation.warnings.summary', [
|
||||
'%count%' => $warning_count
|
||||
]);
|
||||
}
|
||||
|
||||
// Add success message if all entries are valid
|
||||
if ($total_entries > 0 && $invalid_entries === 0) {
|
||||
$result['info'][] = $this->translator->trans('project.bom_import.validation.info.all_valid');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user-friendly error message for a validation result
|
||||
*/
|
||||
public function getErrorMessage(array $validation_result): string
|
||||
{
|
||||
if ($validation_result['is_valid']) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$messages = [];
|
||||
|
||||
if (!empty($validation_result['errors'])) {
|
||||
$messages[] = 'Errors:';
|
||||
foreach ($validation_result['errors'] as $error) {
|
||||
$messages[] = '• ' . $error;
|
||||
}
|
||||
}
|
||||
|
||||
if (!empty($validation_result['warnings'])) {
|
||||
$messages[] = 'Warnings:';
|
||||
foreach ($validation_result['warnings'] as $warning) {
|
||||
$messages[] = '• ' . $warning;
|
||||
}
|
||||
}
|
||||
|
||||
return implode("\n", $messages);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get validation statistics
|
||||
*/
|
||||
public function getValidationStats(array $validation_result): array
|
||||
{
|
||||
return [
|
||||
'total_entries' => $validation_result['total_entries'] ?? 0,
|
||||
'valid_entries' => $validation_result['valid_entries'] ?? 0,
|
||||
'invalid_entries' => $validation_result['invalid_entries'] ?? 0,
|
||||
'error_count' => count($validation_result['errors'] ?? []),
|
||||
'warning_count' => count($validation_result['warnings'] ?? []),
|
||||
'info_count' => count($validation_result['info'] ?? []),
|
||||
'success_rate' => $validation_result['total_entries'] > 0
|
||||
? round(($validation_result['valid_entries'] / $validation_result['total_entries']) * 100, 1)
|
||||
: 0,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -137,7 +137,7 @@ class EntityExporter
|
|||
$options = [
|
||||
'format' => $request->get('format') ?? 'json',
|
||||
'level' => $request->get('level') ?? 'extended',
|
||||
'include_children' => $request->request->getBoolean('include_children') ?? false,
|
||||
'include_children' => $request->request->getBoolean('include_children'),
|
||||
];
|
||||
|
||||
if (!is_array($entities)) {
|
||||
|
|
|
|||
|
|
@ -57,6 +57,7 @@ class EntityImporter
|
|||
/**
|
||||
* Creates many entries at once, based on a (text) list of name.
|
||||
* The created entities are not persisted to database yet, so you have to do it yourself.
|
||||
* It returns all entities in the hierachy chain (even if they are already persisted).
|
||||
*
|
||||
* @template T of AbstractNamedDBElement
|
||||
* @param string $lines The list of names seperated by \n
|
||||
|
|
@ -132,32 +133,38 @@ class EntityImporter
|
|||
//We can only use the getNewEntityFromPath function, if the repository is a StructuralDBElementRepository
|
||||
if ($repo instanceof StructuralDBElementRepository) {
|
||||
$entities = $repo->getNewEntityFromPath($new_path);
|
||||
$entity = end($entities);
|
||||
if ($entity === false) {
|
||||
if ($entities === []) {
|
||||
throw new InvalidArgumentException('getNewEntityFromPath returned an empty array!');
|
||||
}
|
||||
} else { //Otherwise just create a new entity
|
||||
$entity = new $class_name;
|
||||
$entity->setName($name);
|
||||
$entities = [$entity];
|
||||
}
|
||||
|
||||
|
||||
//Validate entity
|
||||
$tmp = $this->validator->validate($entity);
|
||||
//If no error occured, write entry to DB:
|
||||
if (0 === count($tmp)) {
|
||||
$valid_entities[] = $entity;
|
||||
} else { //Otherwise log error
|
||||
$errors[] = [
|
||||
'entity' => $entity,
|
||||
'violations' => $tmp,
|
||||
];
|
||||
foreach ($entities as $entity) {
|
||||
$tmp = $this->validator->validate($entity);
|
||||
//If no error occured, write entry to DB:
|
||||
if (0 === count($tmp)) {
|
||||
$valid_entities[] = $entity;
|
||||
} else { //Otherwise log error
|
||||
$errors[] = [
|
||||
'entity' => $entity,
|
||||
'violations' => $tmp,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
$last_element = $entity;
|
||||
$last_element = end($entities);
|
||||
if ($last_element === false) {
|
||||
$last_element = null;
|
||||
}
|
||||
}
|
||||
|
||||
return $valid_entities;
|
||||
//Only return objects once
|
||||
return array_values(array_unique($valid_entities));
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -35,6 +35,7 @@ use App\Entity\Parts\Supplier;
|
|||
use App\Entity\PriceInformations\Currency;
|
||||
use App\Entity\PriceInformations\Orderdetail;
|
||||
use App\Entity\PriceInformations\Pricedetail;
|
||||
use App\Settings\SystemSettings\LocalizationSettings;
|
||||
use Brick\Math\BigDecimal;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Component\Intl\Currencies;
|
||||
|
|
@ -47,7 +48,7 @@ class PKPartImporter
|
|||
{
|
||||
use PKImportHelperTrait;
|
||||
|
||||
public function __construct(EntityManagerInterface $em, PropertyAccessorInterface $propertyAccessor, private readonly string $base_currency)
|
||||
public function __construct(EntityManagerInterface $em, PropertyAccessorInterface $propertyAccessor, private readonly LocalizationSettings $localizationSettings)
|
||||
{
|
||||
$this->em = $em;
|
||||
$this->propertyAccessor = $propertyAccessor;
|
||||
|
|
@ -210,7 +211,7 @@ class PKPartImporter
|
|||
$currency_iso_code = strtoupper($currency_iso_code);
|
||||
|
||||
//We do not have a currency for the base currency to be consistent with prices without currencies
|
||||
if ($currency_iso_code === $this->base_currency) {
|
||||
if ($currency_iso_code === $this->localizationSettings->baseCurrency) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -43,6 +43,7 @@ use App\Services\InfoProviderSystem\DTOs\ParameterDTO;
|
|||
use App\Services\InfoProviderSystem\DTOs\PartDetailDTO;
|
||||
use App\Services\InfoProviderSystem\DTOs\PriceDTO;
|
||||
use App\Services\InfoProviderSystem\DTOs\PurchaseInfoDTO;
|
||||
use App\Settings\SystemSettings\LocalizationSettings;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
|
||||
/**
|
||||
|
|
@ -54,8 +55,11 @@ final class DTOtoEntityConverter
|
|||
private const TYPE_DATASHEETS_NAME = 'Datasheet';
|
||||
private const TYPE_IMAGE_NAME = 'Image';
|
||||
|
||||
public function __construct(private readonly EntityManagerInterface $em, private readonly string $base_currency)
|
||||
private readonly string $base_currency;
|
||||
|
||||
public function __construct(private readonly EntityManagerInterface $em, LocalizationSettings $localizationSettings)
|
||||
{
|
||||
$this->base_currency = $localizationSettings->baseCurrency;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\InfoProviderSystem;
|
||||
|
||||
use App\Entity\Parts\Manufacturer;
|
||||
|
|
@ -74,4 +76,4 @@ final class ExistingPartFinder
|
|||
|
||||
return $qb->getQuery()->getResult();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ use App\Services\InfoProviderSystem\DTOs\PriceDTO;
|
|||
use App\Services\InfoProviderSystem\DTOs\PurchaseInfoDTO;
|
||||
use App\Services\InfoProviderSystem\DTOs\SearchResultDTO;
|
||||
use App\Services\OAuth\OAuthTokenManager;
|
||||
use App\Settings\InfoProviderSystem\DigikeySettings;
|
||||
use Symfony\Contracts\HttpClient\HttpClientInterface;
|
||||
|
||||
class DigikeyProvider implements InfoProviderInterface
|
||||
|
|
@ -55,17 +56,16 @@ class DigikeyProvider implements InfoProviderInterface
|
|||
];
|
||||
|
||||
public function __construct(HttpClientInterface $httpClient, private readonly OAuthTokenManager $authTokenManager,
|
||||
private readonly string $currency, private readonly string $clientId,
|
||||
private readonly string $language, private readonly string $country)
|
||||
private readonly DigikeySettings $settings,)
|
||||
{
|
||||
//Create the HTTP client with some default options
|
||||
$this->digikeyClient = $httpClient->withOptions([
|
||||
"base_uri" => self::BASE_URI,
|
||||
"headers" => [
|
||||
"X-DIGIKEY-Client-Id" => $clientId,
|
||||
"X-DIGIKEY-Locale-Site" => $this->country,
|
||||
"X-DIGIKEY-Locale-Language" => $this->language,
|
||||
"X-DIGIKEY-Locale-Currency" => $this->currency,
|
||||
"X-DIGIKEY-Client-Id" => $this->settings->clientId,
|
||||
"X-DIGIKEY-Locale-Site" => $this->settings->country,
|
||||
"X-DIGIKEY-Locale-Language" => $this->settings->language,
|
||||
"X-DIGIKEY-Locale-Currency" => $this->settings->currency,
|
||||
"X-DIGIKEY-Customer-Id" => 0,
|
||||
]
|
||||
]);
|
||||
|
|
@ -78,7 +78,8 @@ class DigikeyProvider implements InfoProviderInterface
|
|||
'description' => 'This provider uses the DigiKey API to search for parts.',
|
||||
'url' => 'https://www.digikey.com/',
|
||||
'oauth_app_name' => self::OAUTH_APP_NAME,
|
||||
'disabled_help' => 'Set the PROVIDER_DIGIKEY_CLIENT_ID and PROVIDER_DIGIKEY_SECRET env option and connect OAuth to enable.'
|
||||
'disabled_help' => 'Set the Client ID and Secret in provider settings and connect OAuth to enable.',
|
||||
'settings_class' => DigikeySettings::class,
|
||||
];
|
||||
}
|
||||
|
||||
|
|
@ -101,19 +102,22 @@ class DigikeyProvider implements InfoProviderInterface
|
|||
public function isActive(): bool
|
||||
{
|
||||
//The client ID has to be set and a token has to be available (user clicked connect)
|
||||
return $this->clientId !== '' && $this->authTokenManager->hasToken(self::OAUTH_APP_NAME);
|
||||
return $this->settings->clientId !== null && $this->settings->clientId !== '' && $this->authTokenManager->hasToken(self::OAUTH_APP_NAME);
|
||||
}
|
||||
|
||||
public function searchByKeyword(string $keyword): array
|
||||
{
|
||||
$request = [
|
||||
'Keywords' => $keyword,
|
||||
'RecordCount' => 50,
|
||||
'RecordStartPosition' => 0,
|
||||
'ExcludeMarketPlaceProducts' => 'true',
|
||||
'Limit' => 50,
|
||||
'Offset' => 0,
|
||||
'FilterOptionsRequest' => [
|
||||
'MarketPlaceFilter' => 'ExcludeMarketPlace',
|
||||
],
|
||||
];
|
||||
|
||||
$response = $this->digikeyClient->request('POST', '/Search/v3/Products/Keyword', [
|
||||
//$response = $this->digikeyClient->request('POST', '/Search/v3/Products/Keyword', [
|
||||
$response = $this->digikeyClient->request('POST', '/products/v4/search/keyword', [
|
||||
'json' => $request,
|
||||
'auth_bearer' => $this->authTokenManager->getAlwaysValidTokenString(self::OAUTH_APP_NAME)
|
||||
]);
|
||||
|
|
@ -124,18 +128,21 @@ class DigikeyProvider implements InfoProviderInterface
|
|||
$result = [];
|
||||
$products = $response_array['Products'];
|
||||
foreach ($products as $product) {
|
||||
$result[] = new SearchResultDTO(
|
||||
provider_key: $this->getProviderKey(),
|
||||
provider_id: $product['DigiKeyPartNumber'],
|
||||
name: $product['ManufacturerPartNumber'],
|
||||
description: $product['DetailedDescription'] ?? $product['ProductDescription'],
|
||||
category: $this->getCategoryString($product),
|
||||
manufacturer: $product['Manufacturer']['Value'] ?? null,
|
||||
mpn: $product['ManufacturerPartNumber'],
|
||||
preview_image_url: $product['PrimaryPhoto'] ?? null,
|
||||
manufacturing_status: $this->productStatusToManufacturingStatus($product['ProductStatus']),
|
||||
provider_url: $product['ProductUrl'],
|
||||
);
|
||||
foreach ($product['ProductVariations'] as $variation) {
|
||||
$result[] = new SearchResultDTO(
|
||||
provider_key: $this->getProviderKey(),
|
||||
provider_id: $variation['DigiKeyProductNumber'],
|
||||
name: $product['ManufacturerProductNumber'],
|
||||
description: $product['Description']['DetailedDescription'] ?? $product['Description']['ProductDescription'],
|
||||
category: $this->getCategoryString($product),
|
||||
manufacturer: $product['Manufacturer']['Name'] ?? null,
|
||||
mpn: $product['ManufacturerProductNumber'],
|
||||
preview_image_url: $product['PhotoUrl'] ?? null,
|
||||
manufacturing_status: $this->productStatusToManufacturingStatus($product['ProductStatus']['Id']),
|
||||
provider_url: $product['ProductUrl'],
|
||||
footprint: $variation['PackageType']['Name'], //Use the footprint field, to show the user the package type (Tape & Reel, etc., as digikey has many different package types)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return $result;
|
||||
|
|
@ -143,62 +150,79 @@ class DigikeyProvider implements InfoProviderInterface
|
|||
|
||||
public function getDetails(string $id): PartDetailDTO
|
||||
{
|
||||
$response = $this->digikeyClient->request('GET', '/Search/v3/Products/' . urlencode($id), [
|
||||
$response = $this->digikeyClient->request('GET', '/products/v4/search/' . urlencode($id) . '/productdetails', [
|
||||
'auth_bearer' => $this->authTokenManager->getAlwaysValidTokenString(self::OAUTH_APP_NAME)
|
||||
]);
|
||||
|
||||
$product = $response->toArray();
|
||||
$response_array = $response->toArray();
|
||||
$product = $response_array['Product'];
|
||||
|
||||
$footprint = null;
|
||||
$parameters = $this->parametersToDTOs($product['Parameters'] ?? [], $footprint);
|
||||
$media = $this->mediaToDTOs($product['MediaLinks']);
|
||||
$media = $this->mediaToDTOs($id);
|
||||
|
||||
// Get the price_breaks of the selected variation
|
||||
$price_breaks = [];
|
||||
foreach ($product['ProductVariations'] as $variation) {
|
||||
if ($variation['DigiKeyProductNumber'] == $id) {
|
||||
$price_breaks = $variation['StandardPricing'] ?? [];
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return new PartDetailDTO(
|
||||
provider_key: $this->getProviderKey(),
|
||||
provider_id: $product['DigiKeyPartNumber'],
|
||||
name: $product['ManufacturerPartNumber'],
|
||||
description: $product['DetailedDescription'] ?? $product['ProductDescription'],
|
||||
provider_id: $id,
|
||||
name: $product['ManufacturerProductNumber'],
|
||||
description: $product['Description']['DetailedDescription'] ?? $product['Description']['ProductDescription'],
|
||||
category: $this->getCategoryString($product),
|
||||
manufacturer: $product['Manufacturer']['Value'] ?? null,
|
||||
mpn: $product['ManufacturerPartNumber'],
|
||||
preview_image_url: $product['PrimaryPhoto'] ?? null,
|
||||
manufacturing_status: $this->productStatusToManufacturingStatus($product['ProductStatus']),
|
||||
manufacturer: $product['Manufacturer']['Name'] ?? null,
|
||||
mpn: $product['ManufacturerProductNumber'],
|
||||
preview_image_url: $product['PhotoUrl'] ?? null,
|
||||
manufacturing_status: $this->productStatusToManufacturingStatus($product['ProductStatus']['Id']),
|
||||
provider_url: $product['ProductUrl'],
|
||||
footprint: $footprint,
|
||||
datasheets: $media['datasheets'],
|
||||
images: $media['images'],
|
||||
parameters: $parameters,
|
||||
vendor_infos: $this->pricingToDTOs($product['StandardPricing'] ?? [], $product['DigiKeyPartNumber'], $product['ProductUrl']),
|
||||
vendor_infos: $this->pricingToDTOs($price_breaks, $id, $product['ProductUrl']),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts the product status from the Digikey API to the manufacturing status used in Part-DB
|
||||
* @param string|null $dk_status
|
||||
* @param int|null $dk_status
|
||||
* @return ManufacturingStatus|null
|
||||
*/
|
||||
private function productStatusToManufacturingStatus(?string $dk_status): ?ManufacturingStatus
|
||||
private function productStatusToManufacturingStatus(?int $dk_status): ?ManufacturingStatus
|
||||
{
|
||||
// The V4 can use strings to get the status, but if you have changed the PROVIDER_DIGIKEY_LANGUAGE it will not match.
|
||||
// Using the Id instead which should be fixed.
|
||||
//
|
||||
// The API is not well documented and the ID are not there yet, so were extracted using "trial and error".
|
||||
// The 'Preliminary' id was not found in several categories so I was unable to extract it. Disabled for now.
|
||||
return match ($dk_status) {
|
||||
null => null,
|
||||
'Active' => ManufacturingStatus::ACTIVE,
|
||||
'Obsolete' => ManufacturingStatus::DISCONTINUED,
|
||||
'Discontinued at Digi-Key', 'Last Time Buy' => ManufacturingStatus::EOL,
|
||||
'Not For New Designs' => ManufacturingStatus::NRFND,
|
||||
'Preliminary' => ManufacturingStatus::ANNOUNCED,
|
||||
0 => ManufacturingStatus::ACTIVE,
|
||||
1 => ManufacturingStatus::DISCONTINUED,
|
||||
2, 4 => ManufacturingStatus::EOL,
|
||||
7 => ManufacturingStatus::NRFND,
|
||||
//'Preliminary' => ManufacturingStatus::ANNOUNCED,
|
||||
default => ManufacturingStatus::NOT_SET,
|
||||
};
|
||||
}
|
||||
|
||||
private function getCategoryString(array $product): string
|
||||
{
|
||||
$category = $product['Category']['Value'];
|
||||
$sub_category = $product['Family']['Value'];
|
||||
$category = $product['Category']['Name'];
|
||||
$sub_category = current($product['Category']['ChildCategories']);
|
||||
|
||||
//Replace the ' - ' category separator with ' -> '
|
||||
$sub_category = str_replace(' - ', ' -> ', $sub_category);
|
||||
if ($sub_category) {
|
||||
//Replace the ' - ' category separator with ' -> '
|
||||
$category = $category . ' -> ' . str_replace(' - ', ' -> ', $sub_category["Name"]);
|
||||
}
|
||||
|
||||
return $category . ' -> ' . $sub_category;
|
||||
return $category;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -215,18 +239,18 @@ class DigikeyProvider implements InfoProviderInterface
|
|||
|
||||
foreach ($parameters as $parameter) {
|
||||
if ($parameter['ParameterId'] === 1291) { //Meaning "Manufacturer given footprint"
|
||||
$footprint_name = $parameter['Value'];
|
||||
$footprint_name = $parameter['ValueText'];
|
||||
}
|
||||
|
||||
if (in_array(trim((string) $parameter['Value']), ['', '-'], true)) {
|
||||
if (in_array(trim((string) $parameter['ValueText']), ['', '-'], true)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
//If the parameter was marked as text only, then we do not try to parse it as a numerical value
|
||||
if (in_array($parameter['ParameterId'], self::TEXT_ONLY_PARAMETERS, true)) {
|
||||
$results[] = new ParameterDTO(name: $parameter['Parameter'], value_text: $parameter['Value']);
|
||||
$results[] = new ParameterDTO(name: $parameter['ParameterText'], value_text: $parameter['ValueText']);
|
||||
} else { //Otherwise try to parse it as a numerical value
|
||||
$results[] = ParameterDTO::parseValueIncludingUnit($parameter['Parameter'], $parameter['Value']);
|
||||
$results[] = ParameterDTO::parseValueIncludingUnit($parameter['ParameterText'], $parameter['ValueText']);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -245,7 +269,7 @@ class DigikeyProvider implements InfoProviderInterface
|
|||
$prices = [];
|
||||
|
||||
foreach ($price_breaks as $price_break) {
|
||||
$prices[] = new PriceDTO(minimum_discount_amount: $price_break['BreakQuantity'], price: (string) $price_break['UnitPrice'], currency_iso_code: $this->currency);
|
||||
$prices[] = new PriceDTO(minimum_discount_amount: $price_break['BreakQuantity'], price: (string) $price_break['UnitPrice'], currency_iso_code: $this->settings->currency);
|
||||
}
|
||||
|
||||
return [
|
||||
|
|
@ -254,16 +278,22 @@ class DigikeyProvider implements InfoProviderInterface
|
|||
}
|
||||
|
||||
/**
|
||||
* @param array $media_links
|
||||
* @param string $id The Digikey product number, to get the media for
|
||||
* @return FileDTO[][]
|
||||
* @phpstan-return array<string, FileDTO[]>
|
||||
*/
|
||||
private function mediaToDTOs(array $media_links): array
|
||||
private function mediaToDTOs(string $id): array
|
||||
{
|
||||
$datasheets = [];
|
||||
$images = [];
|
||||
|
||||
foreach ($media_links as $media_link) {
|
||||
$response = $this->digikeyClient->request('GET', '/products/v4/search/' . urlencode($id) . '/media', [
|
||||
'auth_bearer' => $this->authTokenManager->getAlwaysValidTokenString(self::OAUTH_APP_NAME)
|
||||
]);
|
||||
|
||||
$media_array = $response->toArray();
|
||||
|
||||
foreach ($media_array['MediaLinks'] as $media_link) {
|
||||
$file = new FileDTO(url: $media_link['Url'], name: $media_link['Title']);
|
||||
|
||||
switch ($media_link['MediaType']) {
|
||||
|
|
|
|||
|
|
@ -29,6 +29,8 @@ use App\Services\InfoProviderSystem\DTOs\ParameterDTO;
|
|||
use App\Services\InfoProviderSystem\DTOs\PartDetailDTO;
|
||||
use App\Services\InfoProviderSystem\DTOs\PriceDTO;
|
||||
use App\Services\InfoProviderSystem\DTOs\PurchaseInfoDTO;
|
||||
use App\Settings\InfoProviderSystem\Element14Settings;
|
||||
use Composer\CaBundle\CaBundle;
|
||||
use Symfony\Contracts\HttpClient\HttpClientInterface;
|
||||
|
||||
class Element14Provider implements InfoProviderInterface
|
||||
|
|
@ -43,9 +45,19 @@ class Element14Provider implements InfoProviderInterface
|
|||
private const COMPLIANCE_ATTRIBUTES = ['euEccn', 'hazardous', 'MSL', 'productTraceability', 'rohsCompliant',
|
||||
'rohsPhthalatesCompliant', 'SVHC', 'tariffCode', 'usEccn', 'hazardCode'];
|
||||
|
||||
public function __construct(private readonly HttpClientInterface $element14Client, private readonly string $api_key, private readonly string $store_id)
|
||||
{
|
||||
private readonly HttpClientInterface $element14Client;
|
||||
|
||||
public function __construct(HttpClientInterface $element14Client, private readonly Element14Settings $settings)
|
||||
{
|
||||
/* We use the mozilla CA from the composer ca bundle directly, as some debian systems seems to have problems
|
||||
* with the SSL.COM CA, element14 uses. See https://github.com/Part-DB/Part-DB-server/issues/866
|
||||
*
|
||||
* This is a workaround until the issue is resolved in debian (or never).
|
||||
* As this only affects this provider, this should have no negative impact and the CA bundle is still secure.
|
||||
*/
|
||||
$this->element14Client = $element14Client->withOptions([
|
||||
'cafile' => CaBundle::getBundledCaBundlePath(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function getProviderInfo(): array
|
||||
|
|
@ -54,7 +66,8 @@ class Element14Provider implements InfoProviderInterface
|
|||
'name' => 'Farnell element14',
|
||||
'description' => 'This provider uses the Farnell element14 API to search for parts.',
|
||||
'url' => 'https://www.element14.com/',
|
||||
'disabled_help' => 'Configure the API key in the PROVIDER_ELEMENT14_KEY environment variable to enable.'
|
||||
'disabled_help' => 'Configure the API key in the provider settings to enable.',
|
||||
'settings_class' => Element14Settings::class,
|
||||
];
|
||||
}
|
||||
|
||||
|
|
@ -65,7 +78,7 @@ class Element14Provider implements InfoProviderInterface
|
|||
|
||||
public function isActive(): bool
|
||||
{
|
||||
return $this->api_key !== '';
|
||||
return $this->settings->apiKey !== null && trim($this->settings->apiKey) !== '';
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -77,11 +90,11 @@ class Element14Provider implements InfoProviderInterface
|
|||
$response = $this->element14Client->request('GET', self::ENDPOINT_URL, [
|
||||
'query' => [
|
||||
'term' => $term,
|
||||
'storeInfo.id' => $this->store_id,
|
||||
'storeInfo.id' => $this->settings->storeId,
|
||||
'resultsSettings.offset' => 0,
|
||||
'resultsSettings.numberOfResults' => self::NUMBER_OF_RESULTS,
|
||||
'resultsSettings.responseGroup' => 'large',
|
||||
'callInfo.apiKey' => $this->api_key,
|
||||
'callInfo.apiKey' => $this->settings->apiKey,
|
||||
'callInfo.responseDataFormat' => 'json',
|
||||
'versionNumber' => self::API_VERSION_NUMBER,
|
||||
],
|
||||
|
|
@ -149,7 +162,7 @@ class Element14Provider implements InfoProviderInterface
|
|||
$locale = 'en_US';
|
||||
}
|
||||
|
||||
return 'https://' . $this->store_id . '/productimages/standard/' . $locale . $image['baseName'];
|
||||
return 'https://' . $this->settings->storeId . '/productimages/standard/' . $locale . $image['baseName'];
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -184,7 +197,7 @@ class Element14Provider implements InfoProviderInterface
|
|||
public function getUsedCurrency(): string
|
||||
{
|
||||
//Decide based on the shop ID
|
||||
return match ($this->store_id) {
|
||||
return match ($this->settings->storeId) {
|
||||
'bg.farnell.com', 'at.farnell.com', 'si.farnell.com', 'sk.farnell.com', 'ro.farnell.com', 'pt.farnell.com', 'nl.farnell.com', 'be.farnell.com', 'lv.farnell.com', 'lt.farnell.com', 'it.farnell.com', 'fr.farnell.com', 'fi.farnell.com', 'ee.farnell.com', 'es.farnell.com', 'ie.farnell.com', 'cpcireland.farnell.com', 'de.farnell.com' => 'EUR',
|
||||
'cz.farnell.com' => 'CZK',
|
||||
'dk.farnell.com' => 'DKK',
|
||||
|
|
@ -211,7 +224,7 @@ class Element14Provider implements InfoProviderInterface
|
|||
'tw.element14.com' => 'TWD',
|
||||
'kr.element14.com' => 'KRW',
|
||||
'vn.element14.com' => 'VND',
|
||||
default => throw new \RuntimeException('Unknown store ID: ' . $this->store_id)
|
||||
default => throw new \RuntimeException('Unknown store ID: ' . $this->settings->storeId)
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -296,4 +309,4 @@ class Element14Provider implements InfoProviderInterface
|
|||
ProviderCapabilities::DATASHEET,
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -39,8 +39,9 @@ interface InfoProviderInterface
|
|||
* - url?: The url of the provider (e.g. "https://www.digikey.com")
|
||||
* - disabled_help?: A help text which is shown when the provider is disabled, explaining how to enable it
|
||||
* - oauth_app_name?: The name of the OAuth app which is used for authentication (e.g. "ip_digikey_oauth"). If this is set a connect button will be shown
|
||||
* - settings_class?: The class name of the settings class which contains the settings for this provider (e.g. "App\Settings\InfoProviderSettings\DigikeySettings"). If this is set a link to the settings will be shown
|
||||
*
|
||||
* @phpstan-return array{ name: string, description?: string, logo?: string, url?: string, disabled_help?: string, oauth_app_name?: string }
|
||||
* @phpstan-return array{ name: string, description?: string, logo?: string, url?: string, disabled_help?: string, oauth_app_name?: string, settings_class?: class-string }
|
||||
*/
|
||||
public function getProviderInfo(): array;
|
||||
|
||||
|
|
@ -78,4 +79,4 @@ interface InfoProviderInterface
|
|||
* @return ProviderCapabilities[]
|
||||
*/
|
||||
public function getCapabilities(): array;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ use App\Services\InfoProviderSystem\DTOs\ParameterDTO;
|
|||
use App\Services\InfoProviderSystem\DTOs\PartDetailDTO;
|
||||
use App\Services\InfoProviderSystem\DTOs\PriceDTO;
|
||||
use App\Services\InfoProviderSystem\DTOs\PurchaseInfoDTO;
|
||||
use App\Settings\InfoProviderSystem\LCSCSettings;
|
||||
use Symfony\Component\HttpFoundation\Cookie;
|
||||
use Symfony\Contracts\HttpClient\HttpClientInterface;
|
||||
|
||||
|
|
@ -39,7 +40,7 @@ class LCSCProvider implements InfoProviderInterface
|
|||
|
||||
public const DISTRIBUTOR_NAME = 'LCSC';
|
||||
|
||||
public function __construct(private readonly HttpClientInterface $lcscClient, private readonly string $currency, private readonly bool $enabled = true)
|
||||
public function __construct(private readonly HttpClientInterface $lcscClient, private readonly LCSCSettings $settings)
|
||||
{
|
||||
|
||||
}
|
||||
|
|
@ -50,7 +51,8 @@ class LCSCProvider implements InfoProviderInterface
|
|||
'name' => 'LCSC',
|
||||
'description' => 'This provider uses the (unofficial) LCSC API to search for parts.',
|
||||
'url' => 'https://www.lcsc.com/',
|
||||
'disabled_help' => 'Set PROVIDER_LCSC_ENABLED to 1 (or true) in your environment variable config.'
|
||||
'disabled_help' => 'Enable this provider in the provider settings.',
|
||||
'settings_class' => LCSCSettings::class,
|
||||
];
|
||||
}
|
||||
|
||||
|
|
@ -62,7 +64,7 @@ class LCSCProvider implements InfoProviderInterface
|
|||
// This provider is always active
|
||||
public function isActive(): bool
|
||||
{
|
||||
return $this->enabled;
|
||||
return $this->settings->enabled;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -73,7 +75,7 @@ class LCSCProvider implements InfoProviderInterface
|
|||
{
|
||||
$response = $this->lcscClient->request('GET', self::ENDPOINT_URL . "/product/detail", [
|
||||
'headers' => [
|
||||
'Cookie' => new Cookie('currencyCode', $this->currency)
|
||||
'Cookie' => new Cookie('currencyCode', $this->settings->currency)
|
||||
],
|
||||
'query' => [
|
||||
'productCode' => $id,
|
||||
|
|
@ -121,11 +123,11 @@ class LCSCProvider implements InfoProviderInterface
|
|||
*/
|
||||
private function queryByTerm(string $term): array
|
||||
{
|
||||
$response = $this->lcscClient->request('GET', self::ENDPOINT_URL . "/search/global", [
|
||||
$response = $this->lcscClient->request('POST', self::ENDPOINT_URL . "/search/v2/global", [
|
||||
'headers' => [
|
||||
'Cookie' => new Cookie('currencyCode', $this->currency)
|
||||
'Cookie' => new Cookie('currencyCode', $this->settings->currency)
|
||||
],
|
||||
'query' => [
|
||||
'json' => [
|
||||
'keyword' => $term,
|
||||
],
|
||||
]);
|
||||
|
|
@ -163,6 +165,9 @@ class LCSCProvider implements InfoProviderInterface
|
|||
if ($field === null) {
|
||||
return null;
|
||||
}
|
||||
// Replace "range" indicators with mathematical tilde symbols
|
||||
// so they don't get rendered as strikethrough by Markdown
|
||||
$field = preg_replace("/~/", "\u{223c}", $field);
|
||||
|
||||
return strip_tags($field);
|
||||
}
|
||||
|
|
@ -195,9 +200,6 @@ class LCSCProvider implements InfoProviderInterface
|
|||
$category = $product['parentCatalogName'] ?? null;
|
||||
if (isset($product['catalogName'])) {
|
||||
$category = ($category ?? '') . ' -> ' . $product['catalogName'];
|
||||
|
||||
// Replace the / with a -> for better readability
|
||||
$category = str_replace('/', ' -> ', $category);
|
||||
}
|
||||
|
||||
return new PartDetailDTO(
|
||||
|
|
@ -273,7 +275,7 @@ class LCSCProvider implements InfoProviderInterface
|
|||
'kr.' => 'DKK',
|
||||
'₹' => 'INR',
|
||||
//Fallback to the configured currency
|
||||
default => $this->currency,
|
||||
default => $this->settings->currency,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -37,6 +37,7 @@ use App\Services\InfoProviderSystem\DTOs\FileDTO;
|
|||
use App\Services\InfoProviderSystem\DTOs\PartDetailDTO;
|
||||
use App\Services\InfoProviderSystem\DTOs\PriceDTO;
|
||||
use App\Services\InfoProviderSystem\DTOs\PurchaseInfoDTO;
|
||||
use App\Settings\InfoProviderSystem\MouserSettings;
|
||||
use Symfony\Contracts\HttpClient\HttpClientInterface;
|
||||
use Symfony\Contracts\HttpClient\ResponseInterface;
|
||||
|
||||
|
|
@ -50,10 +51,7 @@ class MouserProvider implements InfoProviderInterface
|
|||
|
||||
public function __construct(
|
||||
private readonly HttpClientInterface $mouserClient,
|
||||
private readonly string $api_key,
|
||||
private readonly string $language,
|
||||
private readonly string $options,
|
||||
private readonly int $search_limit
|
||||
private readonly MouserSettings $settings,
|
||||
) {
|
||||
}
|
||||
|
||||
|
|
@ -63,7 +61,8 @@ class MouserProvider implements InfoProviderInterface
|
|||
'name' => 'Mouser',
|
||||
'description' => 'This provider uses the Mouser API to search for parts.',
|
||||
'url' => 'https://www.mouser.com/',
|
||||
'disabled_help' => 'Configure the API key in the PROVIDER_MOUSER_KEY environment variable to enable.'
|
||||
'disabled_help' => 'Configure the API key in the provider settings to enable.',
|
||||
'settings_class' => MouserSettings::class
|
||||
];
|
||||
}
|
||||
|
||||
|
|
@ -74,7 +73,7 @@ class MouserProvider implements InfoProviderInterface
|
|||
|
||||
public function isActive(): bool
|
||||
{
|
||||
return $this->api_key !== '';
|
||||
return $this->settings->apiKey !== '' && $this->settings->apiKey !== null;
|
||||
}
|
||||
|
||||
public function searchByKeyword(string $keyword): array
|
||||
|
|
@ -94,6 +93,7 @@ class MouserProvider implements InfoProviderInterface
|
|||
From the startingRecord, the number of records specified will be returned up to the end of the recordset.
|
||||
This is useful for paging through the complete recordset of parts matching keyword.
|
||||
|
||||
|
||||
searchOptions string
|
||||
Optional.
|
||||
If not provided, the default is None.
|
||||
|
|
@ -119,15 +119,15 @@ class MouserProvider implements InfoProviderInterface
|
|||
|
||||
$response = $this->mouserClient->request('POST', self::ENDPOINT_URL."/keyword", [
|
||||
'query' => [
|
||||
'apiKey' => $this->api_key,
|
||||
'apiKey' => $this->settings->apiKey
|
||||
],
|
||||
'json' => [
|
||||
'SearchByKeywordRequest' => [
|
||||
'keyword' => $keyword,
|
||||
'records' => $this->search_limit, //self::NUMBER_OF_RESULTS,
|
||||
'records' => $this->settings->searchLimit, //self::NUMBER_OF_RESULTS,
|
||||
'startingRecord' => 0,
|
||||
'searchOptions' => $this->options,
|
||||
'searchWithYourSignUpLanguage' => $this->language,
|
||||
'searchOptions' => $this->settings->searchOption->value,
|
||||
'searchWithYourSignUpLanguage' => $this->settings->searchWithSignUpLanguage ? 'true' : 'false',
|
||||
]
|
||||
],
|
||||
]);
|
||||
|
|
@ -160,7 +160,7 @@ class MouserProvider implements InfoProviderInterface
|
|||
|
||||
$response = $this->mouserClient->request('POST', self::ENDPOINT_URL."/partnumber", [
|
||||
'query' => [
|
||||
'apiKey' => $this->api_key,
|
||||
'apiKey' => $this->settings->apiKey,
|
||||
],
|
||||
'json' => [
|
||||
'SearchByPartRequest' => [
|
||||
|
|
@ -176,11 +176,16 @@ class MouserProvider implements InfoProviderInterface
|
|||
throw new \RuntimeException('No part found with ID '.$id);
|
||||
}
|
||||
|
||||
//Manually filter out the part with the correct ID
|
||||
$tmp = array_filter($tmp, fn(PartDetailDTO $part) => $part->provider_id === $id);
|
||||
if (count($tmp) === 0) {
|
||||
throw new \RuntimeException('No part found with ID '.$id);
|
||||
}
|
||||
if (count($tmp) > 1) {
|
||||
throw new \RuntimeException('Multiple parts found with ID '.$id . ' ('.count($tmp).' found). This is basically a bug in Mousers API response. See issue #616.');
|
||||
throw new \RuntimeException('Multiple parts found with ID '.$id);
|
||||
}
|
||||
|
||||
return $tmp[0];
|
||||
return reset($tmp);
|
||||
}
|
||||
|
||||
public function getCapabilities(): array
|
||||
|
|
@ -341,4 +346,4 @@ class MouserProvider implements InfoProviderInterface
|
|||
|
||||
return $tmp;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -88,6 +88,8 @@ use App\Services\InfoProviderSystem\DTOs\PartDetailDTO;
|
|||
use App\Services\InfoProviderSystem\DTOs\PriceDTO;
|
||||
use App\Services\InfoProviderSystem\DTOs\PurchaseInfoDTO;
|
||||
use App\Services\InfoProviderSystem\DTOs\ParameterDTO;
|
||||
use App\Settings\InfoProviderSystem\OEMSecretsSettings;
|
||||
use App\Settings\InfoProviderSystem\OEMSecretsSortMode;
|
||||
use Symfony\Contracts\HttpClient\HttpClientInterface;
|
||||
use Psr\Cache\CacheItemPoolInterface;
|
||||
|
||||
|
|
@ -99,12 +101,7 @@ class OEMSecretsProvider implements InfoProviderInterface
|
|||
|
||||
public function __construct(
|
||||
private readonly HttpClientInterface $oemsecretsClient,
|
||||
private readonly string $api_key,
|
||||
private readonly string $country_code,
|
||||
private readonly string $currency,
|
||||
private readonly string $zero_price,
|
||||
private readonly string $set_param,
|
||||
private readonly string $sort_criteria,
|
||||
private readonly OEMSecretsSettings $settings,
|
||||
private readonly CacheItemPoolInterface $partInfoCache
|
||||
)
|
||||
{
|
||||
|
|
@ -249,7 +246,8 @@ class OEMSecretsProvider implements InfoProviderInterface
|
|||
'name' => 'OEMSecrets',
|
||||
'description' => 'This provider uses the OEMSecrets API to search for parts.',
|
||||
'url' => 'https://www.oemsecrets.com/',
|
||||
'disabled_help' => 'Configure the API key in the PROVIDER_OEMSECRETS_KEY environment variable to enable.'
|
||||
'disabled_help' => 'Configure the API key in the provider settings to enable.',
|
||||
'settings_class' => OEMSecretsSettings::class
|
||||
];
|
||||
}
|
||||
/**
|
||||
|
|
@ -268,7 +266,7 @@ class OEMSecretsProvider implements InfoProviderInterface
|
|||
*/
|
||||
public function isActive(): bool
|
||||
{
|
||||
return $this->api_key !== '';
|
||||
return $this->settings->apiKey !== null && $this->settings->apiKey !== '';
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -288,18 +286,18 @@ class OEMSecretsProvider implements InfoProviderInterface
|
|||
public function searchByKeyword(string $keyword): array
|
||||
{
|
||||
/*
|
||||
oemsecrets Part Search API 3.0.1
|
||||
oemsecrets Part Search API 3.0.1
|
||||
|
||||
"https://oemsecretsapi.com/partsearch?
|
||||
searchTerm=BC547
|
||||
&apiKey=icawpb0bspoo2c6s64uv4vpdfp2vgr7e27bxw0yct2bzh87mpl027x353uelpq2x
|
||||
¤cy=EUR
|
||||
&countryCode=IT"
|
||||
|
||||
&countryCode=IT"
|
||||
|
||||
partsearch description:
|
||||
Use the Part Search API to find distributor data for a full or partial manufacturer
|
||||
Use the Part Search API to find distributor data for a full or partial manufacturer
|
||||
part number including part details, pricing, compliance and inventory.
|
||||
|
||||
|
||||
Required Parameter Format Description
|
||||
searchTerm string Part number you are searching for
|
||||
apiKey string Your unique API key provided to you by OEMsecrets
|
||||
|
|
@ -307,14 +305,14 @@ class OEMSecretsProvider implements InfoProviderInterface
|
|||
Additional Parameter Format Description
|
||||
countryCode string The country you want to output for
|
||||
currency string / array The currency you want the prices to be displayed as
|
||||
|
||||
|
||||
To display the output for GB and to view prices in USD, add [ countryCode=GB ] and [ currency=USD ]
|
||||
as seen below:
|
||||
oemsecretsapi.com/partsearch?apiKey=abcexampleapikey123&searchTerm=bd04&countryCode=GB¤cy=USD
|
||||
|
||||
|
||||
To view prices in both USD and GBP add [ currency[]=USD¤cy[]=GBP ]
|
||||
oemsecretsapi.com/partsearch?searchTerm=bd04&apiKey=abcexampleapikey123¤cy[]=USD¤cy[]=GBP
|
||||
|
||||
|
||||
*/
|
||||
|
||||
|
||||
|
|
@ -324,9 +322,9 @@ class OEMSecretsProvider implements InfoProviderInterface
|
|||
$response = $this->oemsecretsClient->request('GET', self::ENDPOINT_URL, [
|
||||
'query' => [
|
||||
'searchTerm' => $keyword,
|
||||
'apiKey' => $this->api_key,
|
||||
'currency' => $this->currency,
|
||||
'countryCode' => $this->country_code,
|
||||
'apiKey' => $this->settings->apiKey,
|
||||
'currency' => $this->settings->currency,
|
||||
'countryCode' => $this->settings->country,
|
||||
],
|
||||
]);
|
||||
|
||||
|
|
@ -533,7 +531,7 @@ class OEMSecretsProvider implements InfoProviderInterface
|
|||
|
||||
// Extract prices
|
||||
$priceDTOs = $this->getPrices($product);
|
||||
if (empty($priceDTOs) && (int)$this->zero_price === 0) {
|
||||
if (empty($priceDTOs) && !$this->settings->keepZeroPrices) {
|
||||
return null; // Skip products without valid prices
|
||||
}
|
||||
|
||||
|
|
@ -557,7 +555,7 @@ class OEMSecretsProvider implements InfoProviderInterface
|
|||
}
|
||||
|
||||
$imagesResults[$provider_id] = $this->getImages($product, $imagesResults[$provider_id] ?? []);
|
||||
if ($this->set_param == 1) {
|
||||
if ($this->settings->parseParams) {
|
||||
$parametersResults[$provider_id] = $this->getParameters($product, $parametersResults[$provider_id] ?? []);
|
||||
} else {
|
||||
$parametersResults[$provider_id] = [];
|
||||
|
|
@ -582,7 +580,7 @@ class OEMSecretsProvider implements InfoProviderInterface
|
|||
$regionB = $this->countryCodeToRegionMap[$countryCodeB] ?? '';
|
||||
|
||||
// If the map is empty or doesn't contain the key for $this->country_code, assign a placeholder region.
|
||||
$regionForEnvCountry = $this->countryCodeToRegionMap[$this->country_code] ?? '';
|
||||
$regionForEnvCountry = $this->countryCodeToRegionMap[$this->settings->country] ?? '';
|
||||
|
||||
// Convert to string before comparison to avoid mixed types
|
||||
$countryCodeA = (string) $countryCodeA;
|
||||
|
|
@ -599,9 +597,9 @@ class OEMSecretsProvider implements InfoProviderInterface
|
|||
}
|
||||
|
||||
// Step 1: country_code from the environment
|
||||
if ($countryCodeA === $this->country_code && $countryCodeB !== $this->country_code) {
|
||||
if ($countryCodeA === $this->settings->country && $countryCodeB !== $this->settings->country) {
|
||||
return -1;
|
||||
} elseif ($countryCodeA !== $this->country_code && $countryCodeB === $this->country_code) {
|
||||
} elseif ($countryCodeA !== $this->settings->country && $countryCodeB === $this->settings->country) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
|
|
@ -681,8 +679,8 @@ class OEMSecretsProvider implements InfoProviderInterface
|
|||
|
||||
if (is_array($prices)) {
|
||||
// Step 1: Check if prices exist in the preferred currency
|
||||
if (isset($prices[$this->currency]) && is_array($prices[$this->currency])) {
|
||||
$priceDetails = $prices[$this->currency];
|
||||
if (isset($prices[$this->settings->currency]) && is_array($prices[$this->settings->currency])) {
|
||||
$priceDetails = $prices[$this->$this->settings->currency];
|
||||
foreach ($priceDetails as $priceDetail) {
|
||||
if (
|
||||
is_array($priceDetail) &&
|
||||
|
|
@ -694,7 +692,7 @@ class OEMSecretsProvider implements InfoProviderInterface
|
|||
$priceDTOs[] = new PriceDTO(
|
||||
minimum_discount_amount: (float)$priceDetail['unit_break'],
|
||||
price: (string)$priceDetail['unit_price'],
|
||||
currency_iso_code: $this->currency,
|
||||
currency_iso_code: $this->settings->currency,
|
||||
includes_tax: false,
|
||||
price_related_quantity: 1.0
|
||||
);
|
||||
|
|
@ -1293,7 +1291,7 @@ class OEMSecretsProvider implements InfoProviderInterface
|
|||
private function sortResultsData(array &$resultsData, string $searchKeyword): void
|
||||
{
|
||||
// If the SORT_CRITERIA is not 'C' or 'M', do not sort
|
||||
if ($this->sort_criteria !== 'C' && $this->sort_criteria !== 'M') {
|
||||
if ($this->settings->sortMode !== OEMSecretsSortMode::COMPLETENESS && $this->settings->sortMode !== OEMSecretsSortMode::MANUFACTURER) {
|
||||
return;
|
||||
}
|
||||
usort($resultsData, function ($a, $b) use ($searchKeyword) {
|
||||
|
|
@ -1332,9 +1330,9 @@ class OEMSecretsProvider implements InfoProviderInterface
|
|||
}
|
||||
|
||||
// Final sorting: by completeness or manufacturer, if necessary
|
||||
if ($this->sort_criteria === 'C') {
|
||||
if ($this->settings->sortMode === OEMSecretsSortMode::COMPLETENESS) {
|
||||
return $this->compareByCompleteness($a, $b);
|
||||
} elseif ($this->sort_criteria === 'M') {
|
||||
} elseif ($this->settings->sortMode === OEMSecretsSortMode::MANUFACTURER) {
|
||||
return strcasecmp($a->manufacturer, $b->manufacturer);
|
||||
}
|
||||
|
||||
|
|
@ -1468,4 +1466,4 @@ class OEMSecretsProvider implements InfoProviderInterface
|
|||
return $url;
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ use App\Services\InfoProviderSystem\DTOs\PartDetailDTO;
|
|||
use App\Services\InfoProviderSystem\DTOs\PriceDTO;
|
||||
use App\Services\InfoProviderSystem\DTOs\PurchaseInfoDTO;
|
||||
use App\Services\OAuth\OAuthTokenManager;
|
||||
use App\Settings\InfoProviderSystem\OctopartSettings;
|
||||
use Psr\Cache\CacheItemPoolInterface;
|
||||
use Symfony\Component\HttpClient\HttpOptions;
|
||||
use Symfony\Contracts\HttpClient\HttpClientInterface;
|
||||
|
|
@ -114,9 +115,8 @@ class OctopartProvider implements InfoProviderInterface
|
|||
|
||||
public function __construct(private readonly HttpClientInterface $httpClient,
|
||||
private readonly OAuthTokenManager $authTokenManager, private readonly CacheItemPoolInterface $partInfoCache,
|
||||
private readonly string $clientId, private readonly string $secret,
|
||||
private readonly string $currency, private readonly string $country,
|
||||
private readonly int $search_limit, private readonly bool $onlyAuthorizedSellers)
|
||||
private readonly OctopartSettings $settings,
|
||||
)
|
||||
{
|
||||
|
||||
}
|
||||
|
|
@ -170,7 +170,8 @@ class OctopartProvider implements InfoProviderInterface
|
|||
'name' => 'Octopart',
|
||||
'description' => 'This provider uses the Nexar/Octopart API to search for parts on Octopart.',
|
||||
'url' => 'https://www.octopart.com/',
|
||||
'disabled_help' => 'Set the PROVIDER_OCTOPART_CLIENT_ID and PROVIDER_OCTOPART_SECRET env option.'
|
||||
'disabled_help' => 'Set the Client ID and Secret in provider settings.',
|
||||
'settings_class' => OctopartSettings::class
|
||||
];
|
||||
}
|
||||
|
||||
|
|
@ -183,7 +184,8 @@ class OctopartProvider implements InfoProviderInterface
|
|||
{
|
||||
//The client ID has to be set and a token has to be available (user clicked connect)
|
||||
//return /*!empty($this->clientId) && */ $this->authTokenManager->hasToken(self::OAUTH_APP_NAME);
|
||||
return $this->clientId !== '' && $this->secret !== '';
|
||||
return $this->settings->clientId !== null && $this->settings->clientId !== ''
|
||||
&& $this->settings->secret !== null && $this->settings->secret !== '';
|
||||
}
|
||||
|
||||
private function mapLifeCycleStatus(?string $value): ?ManufacturingStatus
|
||||
|
|
@ -337,7 +339,7 @@ class OctopartProvider implements InfoProviderInterface
|
|||
) {
|
||||
hits
|
||||
results {
|
||||
part
|
||||
part
|
||||
%s
|
||||
}
|
||||
}
|
||||
|
|
@ -347,10 +349,10 @@ class OctopartProvider implements InfoProviderInterface
|
|||
|
||||
$result = $this->makeGraphQLCall($graphQL, [
|
||||
'keyword' => $keyword,
|
||||
'limit' => $this->search_limit,
|
||||
'currency' => $this->currency,
|
||||
'country' => $this->country,
|
||||
'authorizedOnly' => $this->onlyAuthorizedSellers,
|
||||
'limit' => $this->settings->searchLimit,
|
||||
'currency' => $this->settings->currency,
|
||||
'country' => $this->settings->country,
|
||||
'authorizedOnly' => $this->settings->onlyAuthorizedSellers,
|
||||
]);
|
||||
|
||||
$tmp = [];
|
||||
|
|
@ -383,9 +385,9 @@ class OctopartProvider implements InfoProviderInterface
|
|||
|
||||
$result = $this->makeGraphQLCall($graphql, [
|
||||
'ids' => [$id],
|
||||
'currency' => $this->currency,
|
||||
'country' => $this->country,
|
||||
'authorizedOnly' => $this->onlyAuthorizedSellers,
|
||||
'currency' => $this->settings->currency,
|
||||
'country' => $this->settings->country,
|
||||
'authorizedOnly' => $this->settings->onlyAuthorizedSellers,
|
||||
]);
|
||||
|
||||
$tmp = $this->partResultToDTO($result['data']['supParts'][0]);
|
||||
|
|
@ -403,4 +405,4 @@ class OctopartProvider implements InfoProviderInterface
|
|||
ProviderCapabilities::PRICE,
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ use App\Services\InfoProviderSystem\DTOs\PartDetailDTO;
|
|||
use App\Services\InfoProviderSystem\DTOs\PriceDTO;
|
||||
use App\Services\InfoProviderSystem\DTOs\PurchaseInfoDTO;
|
||||
use App\Services\InfoProviderSystem\DTOs\SearchResultDTO;
|
||||
use App\Settings\InfoProviderSystem\PollinSettings;
|
||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||
use Symfony\Component\DomCrawler\Crawler;
|
||||
use Symfony\Contracts\HttpClient\HttpClientInterface;
|
||||
|
|
@ -39,8 +40,7 @@ class PollinProvider implements InfoProviderInterface
|
|||
{
|
||||
|
||||
public function __construct(private readonly HttpClientInterface $client,
|
||||
#[Autowire(env: 'bool:PROVIDER_POLLIN_ENABLED')]
|
||||
private readonly bool $enabled = true,
|
||||
private readonly PollinSettings $settings,
|
||||
)
|
||||
{
|
||||
}
|
||||
|
|
@ -49,9 +49,10 @@ class PollinProvider implements InfoProviderInterface
|
|||
{
|
||||
return [
|
||||
'name' => 'Pollin',
|
||||
'description' => 'Webscrapping from pollin.de to get part information',
|
||||
'url' => 'https://www.reichelt.de/',
|
||||
'disabled_help' => 'Set PROVIDER_POLLIN_ENABLED env to 1'
|
||||
'description' => 'Webscraping from pollin.de to get part information',
|
||||
'url' => 'https://www.pollin.de/',
|
||||
'disabled_help' => 'Enable the provider in provider settings',
|
||||
'settings_class' => PollinSettings::class,
|
||||
];
|
||||
}
|
||||
|
||||
|
|
@ -62,7 +63,7 @@ class PollinProvider implements InfoProviderInterface
|
|||
|
||||
public function isActive(): bool
|
||||
{
|
||||
return $this->enabled;
|
||||
return $this->settings->enabled;
|
||||
}
|
||||
|
||||
public function searchByKeyword(string $keyword): array
|
||||
|
|
@ -157,7 +158,8 @@ class PollinProvider implements InfoProviderInterface
|
|||
category: $this->parseCategory($dom),
|
||||
manufacturer: $dom->filter('meta[property="product:brand"]')->count() > 0 ? $dom->filter('meta[property="product:brand"]')->attr('content') : null,
|
||||
preview_image_url: $dom->filter('meta[property="og:image"]')->attr('content'),
|
||||
manufacturing_status: $this->mapAvailability($dom->filter('link[itemprop="availability"]')->attr('href')),
|
||||
//TODO: Find another way to determine the manufacturing status, as the itemprop="availability" is often is not existing anymore in the page
|
||||
//manufacturing_status: $this->mapAvailability($dom->filter('link[itemprop="availability"]')->attr('href')),
|
||||
provider_url: $productPageUrl,
|
||||
notes: $this->parseNotes($dom),
|
||||
datasheets: $this->parseDatasheets($dom),
|
||||
|
|
@ -215,7 +217,7 @@ class PollinProvider implements InfoProviderInterface
|
|||
private function parseNotes(Crawler $dom): string
|
||||
{
|
||||
//Concat product highlights and product description
|
||||
return $dom->filter('div.product-detail-top-features')->html() . '<br><br>' . $dom->filter('div.product-detail-description-text')->html();
|
||||
return $dom->filter('div.product-detail-top-features')->html('') . '<br><br>' . $dom->filter('div.product-detail-description-text')->html('');
|
||||
}
|
||||
|
||||
private function parsePrices(Crawler $dom): array
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ use App\Services\InfoProviderSystem\DTOs\PartDetailDTO;
|
|||
use App\Services\InfoProviderSystem\DTOs\PriceDTO;
|
||||
use App\Services\InfoProviderSystem\DTOs\PurchaseInfoDTO;
|
||||
use App\Services\InfoProviderSystem\DTOs\SearchResultDTO;
|
||||
use App\Settings\InfoProviderSystem\ReicheltSettings;
|
||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||
use Symfony\Component\DomCrawler\Crawler;
|
||||
use Symfony\Contracts\HttpClient\HttpClientInterface;
|
||||
|
|
@ -39,16 +40,7 @@ class ReicheltProvider implements InfoProviderInterface
|
|||
public const DISTRIBUTOR_NAME = "Reichelt";
|
||||
|
||||
public function __construct(private readonly HttpClientInterface $client,
|
||||
#[Autowire(env: "bool:PROVIDER_REICHELT_ENABLED")]
|
||||
private readonly bool $enabled = true,
|
||||
#[Autowire(env: "PROVIDER_REICHELT_LANGUAGE")]
|
||||
private readonly string $language = "en",
|
||||
#[Autowire(env: "PROVIDER_REICHELT_COUNTRY")]
|
||||
private readonly string $country = "DE",
|
||||
#[Autowire(env: "PROVIDER_REICHELT_INCLUDE_VAT")]
|
||||
private readonly bool $includeVAT = false,
|
||||
#[Autowire(env: "PROVIDER_REICHELT_CURRENCY")]
|
||||
private readonly string $currency = "EUR",
|
||||
private readonly ReicheltSettings $settings,
|
||||
)
|
||||
{
|
||||
}
|
||||
|
|
@ -57,9 +49,10 @@ class ReicheltProvider implements InfoProviderInterface
|
|||
{
|
||||
return [
|
||||
'name' => 'Reichelt',
|
||||
'description' => 'Webscrapping from reichelt.com to get part information',
|
||||
'description' => 'Webscraping from reichelt.com to get part information',
|
||||
'url' => 'https://www.reichelt.com/',
|
||||
'disabled_help' => 'Set PROVIDER_REICHELT_ENABLED env to 1'
|
||||
'disabled_help' => 'Enable provider in provider settings.',
|
||||
'settings_class' => ReicheltSettings::class,
|
||||
];
|
||||
}
|
||||
|
||||
|
|
@ -70,7 +63,7 @@ class ReicheltProvider implements InfoProviderInterface
|
|||
|
||||
public function isActive(): bool
|
||||
{
|
||||
return $this->enabled;
|
||||
return $this->settings->enabled;
|
||||
}
|
||||
|
||||
public function searchByKeyword(string $keyword): array
|
||||
|
|
@ -121,8 +114,8 @@ class ReicheltProvider implements InfoProviderInterface
|
|||
sprintf(
|
||||
'https://www.reichelt.com/?ACTION=514&id=74&article=%s&LANGUAGE=%s&CCOUNTRY=%s',
|
||||
$id,
|
||||
strtoupper($this->language),
|
||||
strtoupper($this->country)
|
||||
strtoupper($this->settings->language),
|
||||
strtoupper($this->settings->country)
|
||||
)
|
||||
);
|
||||
$json = $response->toArray();
|
||||
|
|
@ -133,8 +126,8 @@ class ReicheltProvider implements InfoProviderInterface
|
|||
|
||||
$response = $this->client->request('GET', $productPage, [
|
||||
'query' => [
|
||||
'CCTYPE' => $this->includeVAT ? 'private' : 'business',
|
||||
'currency' => $this->currency,
|
||||
'CCTYPE' => $this->settings->includeVAT ? 'private' : 'business',
|
||||
'currency' => $this->settings->currency,
|
||||
],
|
||||
]);
|
||||
$html = $response->getContent();
|
||||
|
|
@ -158,7 +151,7 @@ class ReicheltProvider implements InfoProviderInterface
|
|||
distributor_name: self::DISTRIBUTOR_NAME,
|
||||
order_number: $json[0]['article_artnr'],
|
||||
prices: array_merge(
|
||||
[new PriceDTO(1.0, $priceString, $currency, $this->includeVAT)]
|
||||
[new PriceDTO(1.0, $priceString, $currency, $this->settings->includeVAT)]
|
||||
, $this->parseBatchPrices($dom, $currency)),
|
||||
product_url: $productPage
|
||||
);
|
||||
|
|
@ -218,7 +211,7 @@ class ReicheltProvider implements InfoProviderInterface
|
|||
//Strip any non-numeric characters
|
||||
$priceString = preg_replace('/[^0-9.]/', '', $priceString);
|
||||
|
||||
$prices[] = new PriceDTO($minAmount, $priceString, $currency, $this->includeVAT);
|
||||
$prices[] = new PriceDTO($minAmount, $priceString, $currency, $this->settings->includeVAT);
|
||||
});
|
||||
|
||||
return $prices;
|
||||
|
|
@ -270,7 +263,7 @@ class ReicheltProvider implements InfoProviderInterface
|
|||
private function getBaseURL(): string
|
||||
{
|
||||
//Without the trailing slash
|
||||
return 'https://www.reichelt.com/' . strtolower($this->country) . '/' . strtolower($this->language);
|
||||
return 'https://www.reichelt.com/' . strtolower($this->settings->country) . '/' . strtolower($this->settings->language);
|
||||
}
|
||||
|
||||
public function getCapabilities(): array
|
||||
|
|
@ -282,4 +275,4 @@ class ReicheltProvider implements InfoProviderInterface
|
|||
ProviderCapabilities::PRICE,
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ declare(strict_types=1);
|
|||
|
||||
namespace App\Services\InfoProviderSystem\Providers;
|
||||
|
||||
use App\Settings\InfoProviderSystem\TMESettings;
|
||||
use Symfony\Contracts\HttpClient\HttpClientInterface;
|
||||
use Symfony\Contracts\HttpClient\ResponseInterface;
|
||||
|
||||
|
|
@ -30,15 +31,15 @@ class TMEClient
|
|||
{
|
||||
public const BASE_URI = 'https://api.tme.eu';
|
||||
|
||||
public function __construct(private readonly HttpClientInterface $tmeClient, private readonly string $token, private readonly string $secret)
|
||||
public function __construct(private readonly HttpClientInterface $tmeClient, private readonly TMESettings $settings)
|
||||
{
|
||||
|
||||
}
|
||||
|
||||
public function makeRequest(string $action, array $parameters): ResponseInterface
|
||||
{
|
||||
$parameters['Token'] = $this->token;
|
||||
$parameters['ApiSignature'] = $this->getSignature($action, $parameters, $this->secret);
|
||||
$parameters['Token'] = $this->settings->apiToken;
|
||||
$parameters['ApiSignature'] = $this->getSignature($action, $parameters, $this->settings->apiSecret);
|
||||
|
||||
return $this->tmeClient->request('POST', $this->getUrlForAction($action), [
|
||||
'body' => $parameters,
|
||||
|
|
@ -47,7 +48,7 @@ class TMEClient
|
|||
|
||||
public function isUsable(): bool
|
||||
{
|
||||
return $this->token !== '' && $this->secret !== '';
|
||||
return !($this->settings->apiToken === null || $this->settings->apiSecret === null);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -58,7 +59,7 @@ class TMEClient
|
|||
public function isUsingPrivateToken(): bool
|
||||
{
|
||||
//Private tokens are longer than anonymous ones (50 instead of 45 characters)
|
||||
return strlen($this->token) > 45;
|
||||
return strlen($this->settings->apiToken ?? '') > 45;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -93,4 +94,4 @@ class TMEClient
|
|||
|
||||
return $params;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -30,24 +30,21 @@ use App\Services\InfoProviderSystem\DTOs\PartDetailDTO;
|
|||
use App\Services\InfoProviderSystem\DTOs\PriceDTO;
|
||||
use App\Services\InfoProviderSystem\DTOs\PurchaseInfoDTO;
|
||||
use App\Services\InfoProviderSystem\DTOs\SearchResultDTO;
|
||||
use App\Settings\InfoProviderSystem\TMESettings;
|
||||
|
||||
class TMEProvider implements InfoProviderInterface
|
||||
{
|
||||
|
||||
private const VENDOR_NAME = 'TME';
|
||||
|
||||
/** @var bool If true, the prices are gross prices. If false, the prices are net prices. */
|
||||
private readonly bool $get_gross_prices;
|
||||
|
||||
public function __construct(private readonly TMEClient $tmeClient, private readonly string $country,
|
||||
private readonly string $language, private readonly string $currency,
|
||||
bool $get_gross_prices)
|
||||
public function __construct(private readonly TMEClient $tmeClient, private readonly TMESettings $settings)
|
||||
{
|
||||
//If we have a private token, set get_gross_prices to false, as it is automatically determined by the account type then
|
||||
if ($this->tmeClient->isUsingPrivateToken()) {
|
||||
$this->get_gross_prices = false;
|
||||
} else {
|
||||
$this->get_gross_prices = $get_gross_prices;
|
||||
$this->get_gross_prices = $this->settings->grossPrices;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -57,7 +54,8 @@ class TMEProvider implements InfoProviderInterface
|
|||
'name' => 'TME',
|
||||
'description' => 'This provider uses the API of TME (Transfer Multipart).',
|
||||
'url' => 'https://tme.eu/',
|
||||
'disabled_help' => 'Configure the PROVIDER_TME_KEY and PROVIDER_TME_SECRET environment variables to use this provider.'
|
||||
'disabled_help' => 'Configure the API Token and secret in provider settings to use this provider.',
|
||||
'settings_class' => TMESettings::class
|
||||
];
|
||||
}
|
||||
|
||||
|
|
@ -74,8 +72,8 @@ class TMEProvider implements InfoProviderInterface
|
|||
public function searchByKeyword(string $keyword): array
|
||||
{
|
||||
$response = $this->tmeClient->makeRequest('Products/Search', [
|
||||
'Country' => $this->country,
|
||||
'Language' => $this->language,
|
||||
'Country' => $this->settings->country,
|
||||
'Language' => $this->settings->language,
|
||||
'SearchPlain' => $keyword,
|
||||
]);
|
||||
|
||||
|
|
@ -104,8 +102,8 @@ class TMEProvider implements InfoProviderInterface
|
|||
public function getDetails(string $id): PartDetailDTO
|
||||
{
|
||||
$response = $this->tmeClient->makeRequest('Products/GetProducts', [
|
||||
'Country' => $this->country,
|
||||
'Language' => $this->language,
|
||||
'Country' => $this->settings->country,
|
||||
'Language' => $this->settings->language,
|
||||
'SymbolList' => [$id],
|
||||
]);
|
||||
|
||||
|
|
@ -149,8 +147,8 @@ class TMEProvider implements InfoProviderInterface
|
|||
public function getFiles(string $id): array
|
||||
{
|
||||
$response = $this->tmeClient->makeRequest('Products/GetProductsFiles', [
|
||||
'Country' => $this->country,
|
||||
'Language' => $this->language,
|
||||
'Country' => $this->settings->country,
|
||||
'Language' => $this->settings->language,
|
||||
'SymbolList' => [$id],
|
||||
]);
|
||||
|
||||
|
|
@ -191,9 +189,9 @@ class TMEProvider implements InfoProviderInterface
|
|||
public function getVendorInfo(string $id, ?string $productURL = null): PurchaseInfoDTO
|
||||
{
|
||||
$response = $this->tmeClient->makeRequest('Products/GetPricesAndStocks', [
|
||||
'Country' => $this->country,
|
||||
'Language' => $this->language,
|
||||
'Currency' => $this->currency,
|
||||
'Country' => $this->settings->country,
|
||||
'Language' => $this->settings->language,
|
||||
'Currency' => $this->settings->currency,
|
||||
'GrossPrices' => $this->get_gross_prices,
|
||||
'SymbolList' => [$id],
|
||||
]);
|
||||
|
|
@ -234,8 +232,8 @@ class TMEProvider implements InfoProviderInterface
|
|||
public function getParameters(string $id, string|null &$footprint_name = null): array
|
||||
{
|
||||
$response = $this->tmeClient->makeRequest('Products/GetParameters', [
|
||||
'Country' => $this->country,
|
||||
'Language' => $this->language,
|
||||
'Country' => $this->settings->country,
|
||||
'Language' => $this->settings->language,
|
||||
'SymbolList' => [$id],
|
||||
]);
|
||||
|
||||
|
|
@ -298,4 +296,4 @@ class TMEProvider implements InfoProviderInterface
|
|||
ProviderCapabilities::PRICE,
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -42,6 +42,7 @@ declare(strict_types=1);
|
|||
namespace App\Services\LabelSystem;
|
||||
|
||||
use App\Entity\LabelSystem\LabelProcessMode;
|
||||
use App\Settings\SystemSettings\CustomizationSettings;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
use App\Entity\Contracts\NamedElementInterface;
|
||||
use App\Entity\LabelSystem\LabelOptions;
|
||||
|
|
@ -60,7 +61,7 @@ final class LabelHTMLGenerator
|
|||
private readonly LabelBarcodeGenerator $barcodeGenerator,
|
||||
private readonly SandboxedTwigFactory $sandboxedTwigProvider,
|
||||
private readonly Security $security,
|
||||
private readonly string $partdb_title)
|
||||
private readonly CustomizationSettings $customizationSettings,)
|
||||
{
|
||||
}
|
||||
|
||||
|
|
@ -88,7 +89,8 @@ final class LabelHTMLGenerator
|
|||
'page' => $page,
|
||||
'last_page' => count($elements),
|
||||
'user' => $current_user,
|
||||
'install_title' => $this->partdb_title,
|
||||
'install_title' => $this->customizationSettings->instanceName,
|
||||
'partdb_title' => $this->customizationSettings->instanceName,
|
||||
'paper_width' => $options->getWidth(),
|
||||
'paper_height' => $options->getHeight(),
|
||||
]
|
||||
|
|
|
|||
|
|
@ -63,12 +63,24 @@ final class BarcodeProvider implements PlaceholderProviderInterface
|
|||
return $this->barcodeGenerator->generateHTMLBarcode($label_options, $label_target);
|
||||
}
|
||||
|
||||
if ('[[BARCODE_DATAMATRIX]]' === $placeholder) {
|
||||
$label_options = new LabelOptions();
|
||||
$label_options->setBarcodeType(BarcodeType::DATAMATRIX);
|
||||
return $this->barcodeGenerator->generateHTMLBarcode($label_options, $label_target);
|
||||
}
|
||||
|
||||
if ('[[BARCODE_C39]]' === $placeholder) {
|
||||
$label_options = new LabelOptions();
|
||||
$label_options->setBarcodeType(BarcodeType::CODE39);
|
||||
return $this->barcodeGenerator->generateHTMLBarcode($label_options, $label_target);
|
||||
}
|
||||
|
||||
if ('[[BARCODE_C93]]' === $placeholder) {
|
||||
$label_options = new LabelOptions();
|
||||
$label_options->setBarcodeType(BarcodeType::CODE93);
|
||||
return $this->barcodeGenerator->generateHTMLBarcode($label_options, $label_target);
|
||||
}
|
||||
|
||||
if ('[[BARCODE_C128]]' === $placeholder) {
|
||||
$label_options = new LabelOptions();
|
||||
$label_options->setBarcodeType(BarcodeType::CODE128);
|
||||
|
|
|
|||
|
|
@ -41,6 +41,7 @@ declare(strict_types=1);
|
|||
|
||||
namespace App\Services\LabelSystem\PlaceholderProviders;
|
||||
|
||||
use App\Settings\SystemSettings\CustomizationSettings;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
use App\Entity\UserSystem\User;
|
||||
use DateTime;
|
||||
|
|
@ -54,14 +55,18 @@ use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
|
|||
*/
|
||||
final class GlobalProviders implements PlaceholderProviderInterface
|
||||
{
|
||||
public function __construct(private readonly string $partdb_title, private readonly Security $security, private readonly UrlGeneratorInterface $url_generator)
|
||||
public function __construct(
|
||||
private readonly Security $security,
|
||||
private readonly UrlGeneratorInterface $url_generator,
|
||||
private CustomizationSettings $customizationSettings,
|
||||
)
|
||||
{
|
||||
}
|
||||
|
||||
public function replace(string $placeholder, object $label_target, array $options = []): ?string
|
||||
{
|
||||
if ('[[INSTALL_NAME]]' === $placeholder) {
|
||||
return $this->partdb_title;
|
||||
return $this->customizationSettings->instanceName;
|
||||
}
|
||||
|
||||
$user = $this->security->getUser();
|
||||
|
|
|
|||
|
|
@ -46,7 +46,9 @@ use App\Entity\Parts\Manufacturer;
|
|||
use App\Entity\Parts\Footprint;
|
||||
use App\Entity\Parts\Part;
|
||||
use App\Services\Formatters\SIFormatter;
|
||||
use Parsedown;
|
||||
use League\CommonMark\Environment\Environment;
|
||||
use League\CommonMark\Extension\InlinesOnly\InlinesOnlyExtension;
|
||||
use League\CommonMark\MarkdownConverter;
|
||||
use Symfony\Contracts\Translation\TranslatorInterface;
|
||||
|
||||
/**
|
||||
|
|
@ -54,8 +56,13 @@ use Symfony\Contracts\Translation\TranslatorInterface;
|
|||
*/
|
||||
final class PartProvider implements PlaceholderProviderInterface
|
||||
{
|
||||
private readonly MarkdownConverter $inlineConverter;
|
||||
|
||||
public function __construct(private readonly SIFormatter $siFormatter, private readonly TranslatorInterface $translator)
|
||||
{
|
||||
$environment = new Environment();
|
||||
$environment->addExtension(new InlinesOnlyExtension());
|
||||
$this->inlineConverter = new MarkdownConverter($environment);
|
||||
}
|
||||
|
||||
public function replace(string $placeholder, object $part, array $options = []): ?string
|
||||
|
|
@ -112,22 +119,20 @@ final class PartProvider implements PlaceholderProviderInterface
|
|||
return $this->translator->trans($part->getManufacturingStatus()->toTranslationKey());
|
||||
}
|
||||
|
||||
$parsedown = new Parsedown();
|
||||
|
||||
if ('[[DESCRIPTION]]' === $placeholder) {
|
||||
return $parsedown->line($part->getDescription());
|
||||
return trim($this->inlineConverter->convert($part->getDescription())->getContent());
|
||||
}
|
||||
|
||||
if ('[[DESCRIPTION_T]]' === $placeholder) {
|
||||
return strip_tags((string) $parsedown->line($part->getDescription()));
|
||||
return trim(strip_tags($this->inlineConverter->convert($part->getDescription())->getContent()));
|
||||
}
|
||||
|
||||
if ('[[COMMENT]]' === $placeholder) {
|
||||
return $parsedown->line($part->getComment());
|
||||
return trim($this->inlineConverter->convert($part->getComment())->getContent());
|
||||
}
|
||||
|
||||
if ('[[COMMENT_T]]' === $placeholder) {
|
||||
return strip_tags((string) $parsedown->line($part->getComment()));
|
||||
return trim(strip_tags($this->inlineConverter->convert($part->getComment())->getContent()));
|
||||
}
|
||||
|
||||
return null;
|
||||
|
|
|
|||
|
|
@ -22,37 +22,25 @@ declare(strict_types=1);
|
|||
*/
|
||||
namespace App\Services\LogSystem;
|
||||
|
||||
use App\Settings\SystemSettings\HistorySettings;
|
||||
|
||||
/**
|
||||
* This service is used to check if a log change comment is needed for a given operation type.
|
||||
* It is configured using the "enforce_change_comments_for" config parameter.
|
||||
* @see \App\Tests\Services\LogSystem\EventCommentNeededHelperTest
|
||||
*/
|
||||
class EventCommentNeededHelper
|
||||
final class EventCommentNeededHelper
|
||||
{
|
||||
final public const VALID_OPERATION_TYPES = [
|
||||
'part_edit',
|
||||
'part_create',
|
||||
'part_delete',
|
||||
'part_stock_operation',
|
||||
'datastructure_edit',
|
||||
'datastructure_create',
|
||||
'datastructure_delete',
|
||||
];
|
||||
|
||||
public function __construct(protected array $enforce_change_comments_for)
|
||||
public function __construct(private readonly HistorySettings $settings)
|
||||
{
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a log change comment is needed for the given operation type
|
||||
*/
|
||||
public function isCommentNeeded(string $comment_type): bool
|
||||
public function isCommentNeeded(EventCommentType $comment_type): bool
|
||||
{
|
||||
//Check if the comment type is valid
|
||||
if (! in_array($comment_type, self::VALID_OPERATION_TYPES, true)) {
|
||||
throw new \InvalidArgumentException('The comment type "'.$comment_type.'" is not valid!');
|
||||
}
|
||||
|
||||
return in_array($comment_type, $this->enforce_change_comments_for, true);
|
||||
return in_array($comment_type, $this->settings->enforceComments, true);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
47
src/Services/LogSystem/EventCommentType.php
Normal file
47
src/Services/LogSystem/EventCommentType.php
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
<?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\Services\LogSystem;
|
||||
|
||||
use Symfony\Contracts\Translation\TranslatableInterface;
|
||||
use Symfony\Contracts\Translation\TranslatorInterface;
|
||||
|
||||
/**
|
||||
* This enum represents the different types of event comments that could be required, by the system.
|
||||
* They are almost only useful when working with the EventCommentNeededHelper service.
|
||||
*/
|
||||
enum EventCommentType: string implements TranslatableInterface
|
||||
{
|
||||
case PART_EDIT = 'part_edit';
|
||||
case PART_CREATE = 'part_create';
|
||||
case PART_DELETE = 'part_delete';
|
||||
case PART_STOCK_OPERATION = 'part_stock_operation';
|
||||
case DATASTRUCTURE_EDIT = 'datastructure_edit';
|
||||
case DATASTRUCTURE_CREATE = 'datastructure_create';
|
||||
case DATASTRUCTURE_DELETE = 'datastructure_delete';
|
||||
|
||||
public function trans(TranslatorInterface $translator, ?string $locale = null): string
|
||||
{
|
||||
return $translator->trans('settings.system.history.enforceComments.type.' . $this->value, locale: $locale);
|
||||
}
|
||||
}
|
||||
|
|
@ -22,6 +22,7 @@ declare(strict_types=1);
|
|||
*/
|
||||
namespace App\Services\Parts;
|
||||
|
||||
use App\Entity\Parts\StorageLocation;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
use App\Entity\Parts\Category;
|
||||
use App\Entity\Parts\Footprint;
|
||||
|
|
@ -35,6 +36,9 @@ use InvalidArgumentException;
|
|||
use Symfony\Component\HttpFoundation\RedirectResponse;
|
||||
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
|
||||
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
|
||||
use Symfony\Contracts\Translation\TranslatableInterface;
|
||||
|
||||
use function Symfony\Component\Translation\t;
|
||||
|
||||
final class PartsTableActionHandler
|
||||
{
|
||||
|
|
@ -61,8 +65,9 @@ final class PartsTableActionHandler
|
|||
/**
|
||||
* @param Part[] $selected_parts
|
||||
* @return RedirectResponse|null Returns a redirect response if the user should be redirected to another page, otherwise null
|
||||
* //@param-out list<array{'part': Part, 'message': string|TranslatableInterface}>|array<void> $errors
|
||||
*/
|
||||
public function handleAction(string $action, array $selected_parts, ?int $target_id, ?string $redirect_url = null): ?RedirectResponse
|
||||
public function handleAction(string $action, array $selected_parts, ?int $target_id, ?string $redirect_url = null, array &$errors = []): ?RedirectResponse
|
||||
{
|
||||
if ($action === 'add_to_project') {
|
||||
return new RedirectResponse(
|
||||
|
|
@ -161,6 +166,29 @@ implode(',', array_map(static fn (PartLot $lot) => $lot->getID(), $part->getPart
|
|||
$this->denyAccessUnlessGranted('@measurement_units.read');
|
||||
$part->setPartUnit(null === $target_id ? null : $this->entityManager->find(MeasurementUnit::class, $target_id));
|
||||
break;
|
||||
case 'change_location':
|
||||
$this->denyAccessUnlessGranted('@storelocations.read');
|
||||
//Retrieve the first part lot and set the location for it
|
||||
$part_lots = $part->getPartLots();
|
||||
if ($part_lots->count() > 0) {
|
||||
if ($part_lots->count() > 1) {
|
||||
$errors[] = [
|
||||
'part' => $part,
|
||||
'message' => t('parts.table.action_handler.error.part_lots_multiple'),
|
||||
];
|
||||
break;
|
||||
}
|
||||
|
||||
$part_lot = $part_lots->first();
|
||||
$part_lot->setStorageLocation(null === $target_id ? null : $this->entityManager->find(StorageLocation::class, $target_id));
|
||||
} else { //Create a new part lot if there are none
|
||||
$part_lot = new PartLot();
|
||||
$part_lot->setPart($part);
|
||||
$part_lot->setInstockUnknown(true); //We do not know how many parts are in stock, so we set it to true
|
||||
$part_lot->setStorageLocation(null === $target_id ? null : $this->entityManager->find(StorageLocation::class, $target_id));
|
||||
$this->entityManager->persist($part_lot);
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
throw new InvalidArgumentException('The given action is unknown! ('.$action.')');
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ namespace App\Services\Parts;
|
|||
use App\Entity\Parts\Part;
|
||||
use App\Entity\PriceInformations\Currency;
|
||||
use App\Entity\PriceInformations\Pricedetail;
|
||||
use App\Settings\SystemSettings\LocalizationSettings;
|
||||
use Brick\Math\BigDecimal;
|
||||
use Brick\Math\RoundingMode;
|
||||
use Doctrine\ORM\PersistentCollection;
|
||||
|
|
@ -39,7 +40,7 @@ class PricedetailHelper
|
|||
{
|
||||
protected string $locale;
|
||||
|
||||
public function __construct(protected string $base_currency)
|
||||
public function __construct()
|
||||
{
|
||||
$this->locale = Locale::getDefault();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,12 +23,14 @@ declare(strict_types=1);
|
|||
|
||||
namespace App\Services\System;
|
||||
|
||||
use App\Settings\SystemSettings\CustomizationSettings;
|
||||
|
||||
/**
|
||||
* Helper service to retrieve the banner of this Part-DB installation
|
||||
*/
|
||||
class BannerHelper
|
||||
{
|
||||
public function __construct(private readonly string $project_dir, private readonly string $partdb_banner)
|
||||
public function __construct(private readonly CustomizationSettings $customizationSettings)
|
||||
{
|
||||
|
||||
}
|
||||
|
|
@ -39,18 +41,6 @@ class BannerHelper
|
|||
*/
|
||||
public function getBanner(): string
|
||||
{
|
||||
$banner = $this->partdb_banner;
|
||||
if ($banner === '') {
|
||||
$banner_path = $this->project_dir
|
||||
.DIRECTORY_SEPARATOR.'config'.DIRECTORY_SEPARATOR.'banner.md';
|
||||
|
||||
$tmp = file_get_contents($banner_path);
|
||||
if (false === $tmp) {
|
||||
throw new \RuntimeException('The banner file could not be read.');
|
||||
}
|
||||
$banner = $tmp;
|
||||
}
|
||||
|
||||
return $banner;
|
||||
return $this->customizationSettings->banner ?? "";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ declare(strict_types=1);
|
|||
|
||||
namespace App\Services\System;
|
||||
|
||||
use App\Settings\SystemSettings\PrivacySettings;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Shivas\VersioningBundle\Service\VersionManagerInterface;
|
||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||
|
|
@ -43,7 +44,7 @@ class UpdateAvailableManager
|
|||
|
||||
public function __construct(private readonly HttpClientInterface $httpClient,
|
||||
private readonly CacheInterface $updateCache, private readonly VersionManagerInterface $versionManager,
|
||||
private readonly bool $check_for_updates, private readonly LoggerInterface $logger,
|
||||
private readonly PrivacySettings $privacySettings, private readonly LoggerInterface $logger,
|
||||
#[Autowire(param: 'kernel.debug')] private readonly bool $is_dev_mode)
|
||||
{
|
||||
|
||||
|
|
@ -83,7 +84,7 @@ class UpdateAvailableManager
|
|||
public function isUpdateAvailable(): bool
|
||||
{
|
||||
//If we don't want to check for updates, we can return false
|
||||
if (!$this->check_for_updates) {
|
||||
if (!$this->privacySettings->checkForUpdates) {
|
||||
return false;
|
||||
}
|
||||
|
||||
|
|
@ -101,7 +102,7 @@ class UpdateAvailableManager
|
|||
private function getLatestVersionInfo(): array
|
||||
{
|
||||
//If we don't want to check for updates, we can return dummy data
|
||||
if (!$this->check_for_updates) {
|
||||
if (!$this->privacySettings->checkForUpdates) {
|
||||
return [
|
||||
'version' => '0.0.1',
|
||||
'url' => 'update-checking-disabled'
|
||||
|
|
|
|||
|
|
@ -23,13 +23,16 @@ declare(strict_types=1);
|
|||
namespace App\Services\Tools;
|
||||
|
||||
use App\Entity\PriceInformations\Currency;
|
||||
use App\Settings\SystemSettings\LocalizationSettings;
|
||||
use Brick\Math\BigDecimal;
|
||||
use Brick\Math\RoundingMode;
|
||||
use Exchanger\Exception\UnsupportedCurrencyPairException;
|
||||
use Exchanger\Exception\UnsupportedExchangeQueryException;
|
||||
use Swap\Swap;
|
||||
|
||||
class ExchangeRateUpdater
|
||||
{
|
||||
public function __construct(private readonly string $base_currency, private readonly Swap $swap)
|
||||
public function __construct(private LocalizationSettings $localizationSettings, private readonly Swap $swap)
|
||||
{
|
||||
}
|
||||
|
||||
|
|
@ -38,15 +41,21 @@ class ExchangeRateUpdater
|
|||
*/
|
||||
public function update(Currency $currency): Currency
|
||||
{
|
||||
//Currency pairs are always in the format "BASE/QUOTE"
|
||||
$rate = $this->swap->latest($this->base_currency.'/'.$currency->getIsoCode());
|
||||
//The rate says how many quote units are worth one base unit
|
||||
//So we need to invert it to get the exchange rate
|
||||
try {
|
||||
//Try it in the direction QUOTE/BASE first, as most providers provide rates in this direction
|
||||
$rate = $this->swap->latest($currency->getIsoCode().'/'.$this->localizationSettings->baseCurrency);
|
||||
$effective_rate = BigDecimal::of($rate->getValue());
|
||||
} catch (UnsupportedCurrencyPairException|UnsupportedExchangeQueryException $exception) {
|
||||
//Otherwise try to get it inverse and calculate it ourselfes, from the format "BASE/QUOTE"
|
||||
$rate = $this->swap->latest($this->localizationSettings->baseCurrency.'/'.$currency->getIsoCode());
|
||||
//The rate says how many quote units are worth one base unit
|
||||
//So we need to invert it to get the exchange rate
|
||||
|
||||
$rate_bd = BigDecimal::of($rate->getValue());
|
||||
$rate_inverted = BigDecimal::one()->dividedBy($rate_bd, Currency::PRICE_SCALE, RoundingMode::HALF_UP);
|
||||
$rate_bd = BigDecimal::of($rate->getValue());
|
||||
$effective_rate = BigDecimal::one()->dividedBy($rate_bd, Currency::PRICE_SCALE, RoundingMode::HALF_UP);
|
||||
}
|
||||
|
||||
$currency->setExchangeRate($rate_inverted);
|
||||
$currency->setExchangeRate($effective_rate);
|
||||
|
||||
return $currency;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -289,6 +289,13 @@ class ToolsTreeBuilder
|
|||
))->setIcon('fa-fw fa-treeview fa-solid fa-database');
|
||||
}
|
||||
|
||||
if ($this->security->isGranted('@config.change_system_settings')) {
|
||||
$nodes[] = (new TreeViewNode(
|
||||
$this->translator->trans('tree.tools.system.settings'),
|
||||
$this->urlGenerator->generate('system_settings')
|
||||
))->setIcon('fa fa-fw fa-gears fa-solid');
|
||||
}
|
||||
|
||||
return $nodes;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -38,6 +38,7 @@ use App\Repository\StructuralDBElementRepository;
|
|||
use App\Services\Cache\ElementCacheTagGenerator;
|
||||
use App\Services\Cache\UserCacheKeyGenerator;
|
||||
use App\Services\EntityURLGenerator;
|
||||
use App\Settings\BehaviorSettings\SidebarSettings;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use InvalidArgumentException;
|
||||
use RecursiveIteratorIterator;
|
||||
|
|
@ -53,6 +54,10 @@ use function count;
|
|||
*/
|
||||
class TreeViewGenerator
|
||||
{
|
||||
|
||||
private readonly bool $rootNodeExpandedByDefault;
|
||||
private readonly bool $rootNodeEnabled;
|
||||
|
||||
public function __construct(
|
||||
protected EntityURLGenerator $urlGenerator,
|
||||
protected EntityManagerInterface $em,
|
||||
|
|
@ -61,10 +66,10 @@ class TreeViewGenerator
|
|||
protected UserCacheKeyGenerator $keyGenerator,
|
||||
protected TranslatorInterface $translator,
|
||||
private readonly UrlGeneratorInterface $router,
|
||||
protected bool $rootNodeExpandedByDefault,
|
||||
protected bool $rootNodeEnabled,
|
||||
|
||||
private readonly SidebarSettings $sidebarSettings,
|
||||
) {
|
||||
$this->rootNodeEnabled = $this->sidebarSettings->rootNodeEnabled;
|
||||
$this->rootNodeExpandedByDefault = $this->sidebarSettings->rootNodeExpanded;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -174,10 +179,7 @@ class TreeViewGenerator
|
|||
}
|
||||
|
||||
if (($mode === 'list_parts_root' || $mode === 'devices') && $this->rootNodeEnabled) {
|
||||
//We show the root node as a link to the list of all parts
|
||||
$show_all_parts_url = $this->router->generate('parts_show_all');
|
||||
|
||||
$root_node = new TreeViewNode($this->entityClassToRootNodeString($class), $show_all_parts_url, $generic);
|
||||
$root_node = new TreeViewNode($this->entityClassToRootNodeString($class), $this->entityClassToRootNodeHref($class), $generic);
|
||||
$root_node->setExpanded($this->rootNodeExpandedByDefault);
|
||||
$root_node->setIcon($this->entityClassToRootNodeIcon($class));
|
||||
|
||||
|
|
@ -187,6 +189,27 @@ class TreeViewGenerator
|
|||
return array_merge($head, $generic);
|
||||
}
|
||||
|
||||
protected function entityClassToRootNodeHref(string $class): ?string
|
||||
{
|
||||
//If the root node should redirect to the new entity page, we return the URL for the new entity.
|
||||
if ($this->sidebarSettings->rootNodeRedirectsToNewEntity) {
|
||||
return match ($class) {
|
||||
Category::class => $this->router->generate('category_new'),
|
||||
StorageLocation::class => $this->router->generate('store_location_new'),
|
||||
Footprint::class => $this->router->generate('footprint_new'),
|
||||
Manufacturer::class => $this->router->generate('manufacturer_new'),
|
||||
Supplier::class => $this->router->generate('supplier_new'),
|
||||
Project::class => $this->router->generate('project_new'),
|
||||
default => null,
|
||||
};
|
||||
}
|
||||
|
||||
return match ($class) {
|
||||
Project::class => $this->router->generate('project_new'),
|
||||
default => $this->router->generate('parts_show_all')
|
||||
};
|
||||
}
|
||||
|
||||
protected function entityClassToRootNodeString(string $class): string
|
||||
{
|
||||
return match ($class) {
|
||||
|
|
|
|||
|
|
@ -105,6 +105,9 @@ class PermissionPresetsHelper
|
|||
$this->permissionResolver->setAllOperationsOfPermission($perm_holder, 'suppliers', PermissionData::ALLOW);
|
||||
$this->permissionResolver->setAllOperationsOfPermission($perm_holder, 'projects', PermissionData::ALLOW);
|
||||
|
||||
//Allow to change system settings
|
||||
$this->permissionResolver->setPermission($perm_holder, 'config', 'change_system_settings', PermissionData::ALLOW);
|
||||
|
||||
//Allow to manage Oauth tokens
|
||||
$this->permissionResolver->setPermission($perm_holder, 'system', 'manage_oauth_tokens', PermissionData::ALLOW);
|
||||
//Allow to show updates
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ use App\Entity\Attachments\UserAttachment;
|
|||
use App\Entity\UserSystem\User;
|
||||
use App\Services\Attachments\AttachmentSubmitHandler;
|
||||
use App\Services\Attachments\AttachmentURLGenerator;
|
||||
use App\Settings\SystemSettings\PrivacySettings;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Component\Asset\Packages;
|
||||
use Symfony\Component\HttpFoundation\File\UploadedFile;
|
||||
|
|
@ -42,7 +43,7 @@ class UserAvatarHelper
|
|||
public const IMG_DEFAULT_AVATAR_PATH = 'img/default_avatar.svg';
|
||||
|
||||
public function __construct(
|
||||
private readonly bool $use_gravatar,
|
||||
private readonly PrivacySettings $privacySettings,
|
||||
private readonly Packages $packages,
|
||||
private readonly AttachmentURLGenerator $attachmentURLGenerator,
|
||||
private readonly EntityManagerInterface $entityManager,
|
||||
|
|
@ -65,7 +66,7 @@ class UserAvatarHelper
|
|||
}
|
||||
|
||||
//If not check if gravatar is enabled (then use gravatar URL)
|
||||
if ($this->use_gravatar) {
|
||||
if ($this->privacySettings->useGravatar) {
|
||||
return $this->getGravatar($user, 200); //200px wide picture
|
||||
}
|
||||
|
||||
|
|
@ -82,7 +83,7 @@ class UserAvatarHelper
|
|||
}
|
||||
|
||||
//If not check if gravatar is enabled (then use gravatar URL)
|
||||
if ($this->use_gravatar) {
|
||||
if ($this->privacySettings->useGravatar) {
|
||||
return $this->getGravatar($user, 50); //50px wide picture
|
||||
}
|
||||
|
||||
|
|
@ -99,7 +100,7 @@ class UserAvatarHelper
|
|||
}
|
||||
|
||||
//If not check if gravatar is enabled (then use gravatar URL)
|
||||
if ($this->use_gravatar) {
|
||||
if ($this->privacySettings->useGravatar) {
|
||||
return $this->getGravatar($user, 150);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -28,6 +28,9 @@ use App\Repository\UserRepository;
|
|||
use App\Security\ApiTokenAuthenticatedToken;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
|
||||
use Symfony\Component\Security\Core\Authorization\Voter\Vote;
|
||||
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
|
||||
use Symfony\Contracts\Translation\TranslatorInterface;
|
||||
|
||||
/**
|
||||
* @see \App\Tests\Services\UserSystem\VoterHelperTest
|
||||
|
|
@ -35,10 +38,14 @@ use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
|
|||
final class VoterHelper
|
||||
{
|
||||
private readonly UserRepository $userRepository;
|
||||
private readonly array $permissionStructure;
|
||||
|
||||
public function __construct(private readonly PermissionManager $permissionManager, private readonly EntityManagerInterface $entityManager)
|
||||
public function __construct(private readonly PermissionManager $permissionManager,
|
||||
private readonly TranslatorInterface $translator,
|
||||
private readonly EntityManagerInterface $entityManager)
|
||||
{
|
||||
$this->userRepository = $this->entityManager->getRepository(User::class);
|
||||
$this->permissionStructure = $this->permissionManager->getPermissionStructure();
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -47,11 +54,16 @@ final class VoterHelper
|
|||
* @param TokenInterface $token The token to check
|
||||
* @param string $permission The permission to check
|
||||
* @param string $operation The operation to check
|
||||
* @param Vote|null $vote The vote object to add reasons to (optional). If null, no reasons are added.
|
||||
* @return bool
|
||||
*/
|
||||
public function isGranted(TokenInterface $token, string $permission, string $operation): bool
|
||||
public function isGranted(TokenInterface $token, string $permission, string $operation, ?Vote $vote = null): bool
|
||||
{
|
||||
return $this->isGrantedTrinary($token, $permission, $operation) ?? false;
|
||||
$tmp = $this->isGrantedTrinary($token, $permission, $operation) ?? false;
|
||||
if ($tmp === false) {
|
||||
$this->addReason($vote, $permission, $operation);
|
||||
}
|
||||
return $tmp;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -124,4 +136,17 @@ final class VoterHelper
|
|||
{
|
||||
return $this->permissionManager->isValidOperation($permission, $operation);
|
||||
}
|
||||
}
|
||||
|
||||
public function addReason(?Vote $voter, string $permission, $operation): void
|
||||
{
|
||||
if ($voter !== null) {
|
||||
$voter->addReason(sprintf("User does not have permission %s -> %s -> %s (%s.%s).",
|
||||
$this->translator->trans('perm.group.'.($this->permissionStructure['perms'][$permission]['group'] ?? 'unknown') ),
|
||||
$this->translator->trans($this->permissionStructure['perms'][$permission]['label'] ?? $permission),
|
||||
$this->translator->trans($this->permissionStructure['perms'][$permission]['operations'][$operation]['label'] ?? $operation),
|
||||
$permission,
|
||||
$operation
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue