Merge branch 'master' into l10n_master

This commit is contained in:
Jan Böhmer 2026-04-15 23:37:13 +02:00 committed by GitHub
commit fe8dfe61b4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
71 changed files with 5746 additions and 4072 deletions

4
.env
View file

@ -121,6 +121,10 @@ SAML_SP_PRIVATE_KEY="MIIE..."
# In demo mode things it is not possible for a user to change his password and his settings.
DEMO_MODE=0
# When this is set to 1, users can make Part-DB directly download a file specified as a URL from the local network and create it as a local file.
# This allows users access to all resources available in the local network, which could be a security risk, so use this only if you trust your users and have a secure local network.
ALLOW_ATTACHMENT_DOWNLOADS_FROM_LOCALNETWORK=0
# Change this to true, if no url rewriting (like mod_rewrite for Apache) is available
# In that case all URL contains the index.php front controller in URL
NO_URL_REWRITE_AVAILABLE=0

View file

@ -67,7 +67,7 @@ jobs:
- name: Setup node
uses: actions/setup-node@v6
with:
node-version: '20'
node-version: '22'
- name: Install yarn dependencies
run: yarn install

View file

@ -106,7 +106,7 @@ jobs:
- name: Setup node
uses: actions/setup-node@v6
with:
node-version: '20'
node-version: '22'
- name: Install yarn dependencies
run: yarn install
@ -129,7 +129,7 @@ jobs:
run: ./bin/phpunit --coverage-clover=coverage.xml
- name: Upload coverage
uses: codecov/codecov-action@v5
uses: codecov/codecov-action@v6
with:
env_vars: PHP_VERSION,DB_TYPE
token: ${{ secrets.CODECOV_TOKEN }}

13
.gitignore vendored
View file

@ -25,6 +25,10 @@
uploads/*
!uploads/.keep
# Some people use Certbot or similar tools to make SSL certificates.
# Also see https://www.rfc-editor.org/rfc/rfc5785
public/.well-known/
# Do not keep cache files
.php_cs.cache
.phpcs-cache
@ -50,4 +54,11 @@ phpstan.neon
###< phpstan/phpstan ###
.claude/
CLAUDE.md
CLAUDE.md
.codex
migrations/.codex
docker-data/
scripts/
db/
docker-compose.yaml

View file

@ -62,7 +62,7 @@ RUN yarn build
RUN yarn cache clean && rm -rf node_modules/
# FrankenPHP base stage
FROM dunglas/frankenphp:1-php8.4 AS frankenphp_upstream
FROM dunglas/frankenphp:1-php8.4-bookworm AS frankenphp_upstream
ARG TARGETARCH
RUN --mount=type=cache,id=apt-cache-$TARGETARCH,target=/var/cache/apt \
--mount=type=cache,id=apt-lists-$TARGETARCH,target=/var/lib/apt/lists \

View file

@ -74,11 +74,11 @@ Part-DB is also used by small companies and universities for managing their inve
## Requirements
* A **web server** (like Apache2 or nginx) that is capable of
running [Symfony 6](https://symfony.com/doc/current/reference/requirements.html),
running [Symfony 7](https://symfony.com/doc/current/reference/requirements.html),
this includes a minimum PHP version of **PHP 8.2**
* A **MySQL** (at least 5.7) /**MariaDB** (at least 10.4) database server, or **PostgreSQL** 10+ if you do not want to use SQLite.
* Shell access to your server is highly recommended!
* For building the client-side assets **yarn** and **nodejs** (>= 20.0) is needed.
* For building the client-side assets **yarn** and **nodejs** (>= 22.0) is needed.
## Installation

View file

@ -1 +1 @@
2.9.1
2.10.0

1466
composer.lock generated

File diff suppressed because it is too large Load diff

View file

@ -56,6 +56,7 @@ doctrine:
natsort: App\Doctrine\Functions\Natsort
array_position: App\Doctrine\Functions\ArrayPosition
ilike: App\Doctrine\Functions\ILike
si_value_sort: App\Doctrine\Functions\SiValueSort
when@test:
doctrine:

View file

@ -105,6 +105,8 @@ parameters:
env(DATABASE_EMULATE_NATURAL_SORT): 0
env(ALLOW_ATTACHMENT_DOWNLOADS_FROM_LOCALNETWORK): 0
######################################################################################################################
# Bulk Info Provider Import Configuration
######################################################################################################################

View file

@ -1550,7 +1550,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
* template_parameters?: array{ // Default parameters to be passed to the template
* className?: scalar|Param|null, // Default class attribute to apply to the root table elements // Default: "table table-bordered"
* columnFilter?: "thead"|"tfoot"|"both"|Param|null, // If and where to enable the DataTables Filter module // Default: null
* ...<mixed>
* ...<string, mixed>
* },
* translation_domain?: scalar|Param|null, // Default translation domain to be used // Default: "messages"
* }
@ -1705,14 +1705,14 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
* use_underscore?: bool|Param, // Default: true
* unordered_list_markers?: list<scalar|Param|null>,
* },
* ...<mixed>
* ...<string, mixed>
* },
* }
* @psalm-type GregwarCaptchaConfig = array{
* length?: scalar|Param|null, // Default: 5
* width?: scalar|Param|null, // Default: 130
* height?: scalar|Param|null, // Default: 50
* font?: scalar|Param|null, // Default: "C:\\Users\\mail\\Documents\\PHP\\Part-DB-server\\vendor\\gregwar\\captcha-bundle\\DependencyInjection/../Generator/Font/captcha.ttf"
* font?: scalar|Param|null, // Default: "/home/jan/php/Part-DB-server/vendor/gregwar/captcha-bundle/DependencyInjection/../Generator/Font/captcha.ttf"
* keep_value?: scalar|Param|null, // Default: false
* charset?: scalar|Param|null, // Default: "abcdefhjkmnprstuvwxyz23456789"
* as_file?: scalar|Param|null, // Default: false
@ -2493,7 +2493,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
* max_header_length?: int|Param, // Max header length supported by the cache server. // Default: 7500
* request_options?: mixed, // To pass options to the client charged with the request. // Default: []
* purger?: scalar|Param|null, // Specify a purger to use (available values: "api_platform.http_cache.purger.varnish.ban", "api_platform.http_cache.purger.varnish.xkey", "api_platform.http_cache.purger.souin"). // Default: "api_platform.http_cache.purger.varnish"
* xkey?: array{ // Deprecated: The "xkey" configuration is deprecated, use your own purger to customize surrogate keys or the appropriate paramters.
* xkey?: array{ // Deprecated: The "xkey" configuration is deprecated, use your own purger to customize surrogate keys or the appropriate parameters.
* glue?: scalar|Param|null, // xkey glue between keys // Default: " "
* },
* },
@ -2649,7 +2649,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
* cast_fn?: mixed,
* default?: mixed,
* filter_class?: mixed,
* ...<mixed>
* ...<string, mixed>
* }>,
* strict_query_parameter_validation?: mixed,
* hide_hydra_operation?: mixed,
@ -2669,7 +2669,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
* name?: mixed,
* allow_create?: mixed,
* item_uri_template?: mixed,
* ...<mixed>
* ...<string, mixed>
* },
* }
* @psalm-type ConfigType = array{

View file

@ -86,6 +86,7 @@ bundled with Part-DB. Set `DATABASE_MYSQL_SSL_VERIFY_CERT` if you want to accept
* `ATTACHMENT_DOWNLOAD_BY_DEFAULT`: When this is set to 1, the "download external file" checkbox is checked by default
when adding a new attachment. Otherwise, it is unchecked by default. Use this if you wanna download all attachments
locally by default. Attachment download is only possible, when `ALLOW_ATTACHMENT_DOWNLOADS` is set to 1.
* `ALLOW_ATTACHMENT_DOWNLOADS_FROM_LOCALNETWORK` (default `0`): When this is set to 1, users can make Part-DB directly download a file specified as a URL from the local network and create it as a local file. This allows users access to all resources available in the local network, which could be a security risk, so use this only if you trust your users and have a secure local network.
* `ATTACHMENT_SHOW_HTML_FILES`: When enabled, user uploaded HTML attachments can be viewed directly in the browser.
Many potential malicious functions are restricted, still this is a potential security risk and should only be enabled,
if you trust the users who can upload files. When set to 0, HTML files are rendered as plain text.

View file

@ -95,6 +95,11 @@ services:
docker-compose up -d
```
{: .warning }
> If you run a root console inside the docker container, and wanna execute commands on the webserver behalf, be sure to use `sudo -E` command (with the `-E` flag) to preserve env variables from the current shell.
> Otherwise Part-DB console might use the wrong configuration to execute commands.
6. Create the initial database with
```bash

View file

@ -18,7 +18,7 @@ fulfilled by the official Part-DB docker image.*
Part-DB 2.0 requires at least PHP 8.2 (newer versions are recommended). So if your existing Part-DB installation is still
running PHP 8.1, you will have to upgrade your PHP version first.
The minimum required version of node.js is now 20.0 or newer, so if you are using 18.0, you will have to upgrade it too.
The minimum required version of node.js is now 22.0 or newer, so if you are using 18.0, you will have to upgrade it too.
Most distributions should have the possibility to get backports for PHP 8.4 and modern nodejs, so you should be able to
easily upgrade your system to the new requirements. Otherwise, you can use the official Part-DB docker image, which
@ -60,6 +60,8 @@ The `php bin/console partdb:backup` command can help you with this.
If you want to change them, you must migrate them to the settings interface as described below.
### Docker installation
**When running the console commands from inside a docker container's shell as root, be sure to use `sudo -E` to preserve the environment variables, so that they are correctly passed to the command.**
1. Make a backup of your existing Part-DB installation, including the database, data directories and the configuration files and the file where you configure the docker environment variables.
2. Stop the existing Part-DB container with `docker compose down`
3. Ensure that your docker compose file uses the new latest images (either `latest` or `2` tag).

View file

@ -67,6 +67,7 @@ You can define this on a per-part basis using the KiCad symbol and KiCad footpri
For example, to configure the values for a BC547 transistor you would put `Transistor_BJT:BC547` in the part's KiCad symbol field to give it the right schematic symbol in Eeschema and `Package_TO_SOT_THT:TO-92` to give it the right footprint in Pcbnew.
If you type in a character, you will get an autocomplete list of all symbols and footprints available in the KiCad standard library. You can also input your own value.
If you want to keep custom suggestions across updates, open the server settings page and use the "Autocomplete settings" page. There you can edit `public/kicad/footprints_custom.txt` and `public/kicad/symbols_custom.txt` and enable the "Use custom autocomplete lists" option to use those files instead of the autogenerated defaults.
### Parts and category visibility

View file

@ -9,16 +9,16 @@
"@symfony/stimulus-bridge": "^4.0.0",
"@symfony/ux-translator": "file:vendor/symfony/ux-translator/assets",
"@symfony/ux-turbo": "file:vendor/symfony/ux-turbo/assets",
"@symfony/webpack-encore": "^5.1.0",
"@symfony/webpack-encore": "^6.0.0",
"bootstrap": "^5.1.3",
"core-js": "^3.38.0",
"intl-messageformat": "^10.2.5",
"intl-messageformat": "^10.5.11",
"jquery": "^3.5.1",
"popper.js": "^1.14.7",
"regenerator-runtime": "^0.13.9",
"regenerator-runtime": "^0.14.1",
"webpack": "^5.74.0",
"webpack-bundle-analyzer": "^5.1.1",
"webpack-cli": "^5.1.0",
"webpack-cli": "^6.0.0",
"webpack-notifier": "^1.15.0"
},
"license": "AGPL-3.0-or-later",
@ -30,14 +30,14 @@
"build": "encore production --progress"
},
"engines": {
"node": ">=20.0.0"
"node": ">=22.0.0"
},
"dependencies": {
"@algolia/autocomplete-js": "^1.17.0",
"@algolia/autocomplete-plugin-recent-searches": "^1.17.0",
"@algolia/autocomplete-theme-classic": "^1.17.0",
"@ckeditor/ckeditor5-dev-translations": "^43.0.1",
"@ckeditor/ckeditor5-dev-utils": "^43.0.1",
"@ckeditor/ckeditor5-dev-translations": "^53",
"@ckeditor/ckeditor5-dev-utils": "^53",
"@jbtronics/bs-treeview": "^1.0.1",
"@part-db/html5-qrcode": "^4.0.0",
"@zxcvbn-ts/core": "^3.0.2",
@ -69,11 +69,11 @@
"marked": "^17.0.1",
"marked-gfm-heading-id": "^4.1.1",
"marked-mangle": "^1.0.1",
"pdfmake": "^0.2.2",
"pdfmake": "^0.3.7",
"stimulus-use": "^0.52.0",
"tom-select": "^2.1.0",
"ts-loader": "^9.2.6",
"typescript": "^5.7.2"
"typescript": "^6.0.2"
},
"resolutions": {
"jquery": "^3.5.1"

View file

@ -59,6 +59,9 @@ parameters:
- '#expects .*PartParameter, .*AbstractParameter given.#'
- '#Part::getParameters\(\) should return .*AbstractParameter#'
# Fix some weird issue with how covariance with collections is solved
- '#Method App\\Entity\\Base\\AbstractStructuralDBElement::getParameters\(\) should return Doctrine\\Common\\Collections\\Collection<int, App\\Entity\\Parameters\\AbstractParameter> but returns#'
# Ignore doctrine type mapping mismatch
- '#Property .* type mapping mismatch: property can contain .* but database expects .*#'
@ -70,3 +73,6 @@ parameters:
- message: '#Access to an undefined property Brick\\Schema\\Interfaces\\#'
path: src/Services/InfoProviderSystem/Providers/GenericWebProvider.php
-
identifier: nullCoalesce.property

3
public/kicad/.gitignore vendored Normal file
View file

@ -0,0 +1,3 @@
# They are user generated and should not be tracked by git
footprints_custom.txt
symbols_custom.txt

View file

@ -1,4 +1,4 @@
# Generated on Mon Mar 9 04:23:25 UTC 2026
# Generated on Mon Apr 13 05:19:27 UTC 2026
# This file contains all footprints available in the offical KiCAD library
Audio_Module:Reverb_BTDR-1H
Audio_Module:Reverb_BTDR-1V

View file

@ -1,4 +1,4 @@
# Generated on Mon Mar 9 04:24:12 UTC 2026
# Generated on Mon Apr 13 05:20:06 UTC 2026
# This file contains all symbols available in the offical KiCAD library
4xxx:14528
4xxx:14529
@ -899,6 +899,7 @@ Amplifier_Buffer:BUF634AxD
Amplifier_Buffer:BUF634AxDDA
Amplifier_Buffer:BUF634AxDRB
Amplifier_Buffer:BUF634U
Amplifier_Buffer:BUF802
Amplifier_Buffer:EL2001CN
Amplifier_Buffer:LH0002H
Amplifier_Buffer:LM6321H
@ -1667,7 +1668,6 @@ Analog_ADC:CA3300
Analog_ADC:HX711
Analog_ADC:ICL7106CPL
Analog_ADC:ICL7107CPL
Analog_ADC:INA234AxYBJ
Analog_ADC:LTC1406CGN
Analog_ADC:LTC1406IGN
Analog_ADC:LTC1594CS
@ -2198,6 +2198,7 @@ Audio:WM8731SEDS
Audio:YM2149
Audio:YM2612
Audio:YM3438
Auxiliary_Items:Generic_Outline
Auxiliary_Items:Jumper_Shunt
Auxiliary_Items:MountingScrew
Battery_Management:ADP5063
@ -2254,6 +2255,11 @@ Battery_Management:BQ76200PW
Battery_Management:BQ76920PW
Battery_Management:BQ76930DBT
Battery_Management:BQ76940DBT
Battery_Management:BQ7695201PFBR
Battery_Management:BQ7695202PFBR
Battery_Management:BQ7695203PFBR
Battery_Management:BQ7695204PFBR
Battery_Management:BQ76952PFBR
Battery_Management:BQ78350DBT
Battery_Management:BQ78350DBT-R1
Battery_Management:CN3063
@ -2763,6 +2769,8 @@ Connector:DIN41612_02x32_AC
Connector:DIN41612_02x32_AE
Connector:DIN41612_02x32_ZB
Connector:DIN41612_03x32_C_Split
Connector:DP_Sink
Connector:DP_Source
Connector:DVI-D_Dual_Link
Connector:DVI-I_Dual_Link
Connector:ExpressCard
@ -2901,6 +2909,7 @@ Connector:TestPoint_Alt
Connector:TestPoint_Flag
Connector:TestPoint_Probe
Connector:TestPoint_Small
Connector:TestPoint_Square
Connector:UEXT_Host
Connector:UEXT_Slave
Connector:USB3_A
@ -7772,6 +7781,7 @@ FPGA_Lattice:ICE40HX1K-TQ144
FPGA_Lattice:ICE40HX4K-BG121
FPGA_Lattice:ICE40HX4K-TQ144
FPGA_Lattice:ICE40HX8K-BG121
FPGA_Lattice:ICE40LP384-SG32
FPGA_Lattice:ICE40UL1K-SWG16
FPGA_Lattice:ICE40UP5K-SG48ITR
FPGA_Lattice:ICE5LP1K-SG48
@ -15731,6 +15741,7 @@ Power_Management:RT9742AGJ5F
Power_Management:RT9742ANGJ5F
Power_Management:RT9742BGJ5F
Power_Management:RT9742BNGJ5F
Power_Management:RT9742SNGV
Power_Management:SN6505ADBV
Power_Management:SN6505BDBV
Power_Management:SN6507DGQ
@ -18692,6 +18703,7 @@ Regulator_Linear:TPS7A0530PDBZ
Regulator_Linear:TPS7A0531PDBV
Regulator_Linear:TPS7A0533PDBV
Regulator_Linear:TPS7A0533PDBZ
Regulator_Linear:TPS7A20xxxDBV
Regulator_Linear:TPS7A20xxxDQN
Regulator_Linear:TPS7A3301RGW
Regulator_Linear:TPS7A39
@ -20301,7 +20313,6 @@ Sensor:BME280
Sensor:BME680
Sensor:CHT11
Sensor:DHT11
Sensor:INA260
Sensor:LTC2990
Sensor:MAX30102
Sensor:Nuclear-Radiation_Detector
@ -20588,9 +20599,12 @@ Sensor_Energy:INA219BxD
Sensor_Energy:INA219BxDCN
Sensor_Energy:INA226
Sensor_Energy:INA228
Sensor_Energy:INA229
Sensor_Energy:INA233
Sensor_Energy:INA234AxYBJ
Sensor_Energy:INA237
Sensor_Energy:INA238
Sensor_Energy:INA260
Sensor_Energy:LTC4151xMS
Sensor_Energy:MCP39F521
Sensor_Energy:PAC1931x-xJ6CX
@ -20872,6 +20886,7 @@ Sensor_Proximity:BPR-105
Sensor_Proximity:BPR-105F
Sensor_Proximity:BPR-205
Sensor_Proximity:CNY70
Sensor_Proximity:FDC1004DGS
Sensor_Proximity:GP2S700HCP
Sensor_Proximity:ITR1201SR10AR
Sensor_Proximity:ITR8307
@ -21791,6 +21806,7 @@ Transistor_BJT:Q_NPN_Darlington_ECBC
Transistor_BJT:Q_NPN_EBC
Transistor_BJT:Q_NPN_ECB
Transistor_BJT:Q_NPN_ECBC
Transistor_BJT:Q_PNP_ACAB
Transistor_BJT:Q_PNP_BCE
Transistor_BJT:Q_PNP_BCEC
Transistor_BJT:Q_PNP_BEC

View file

@ -201,6 +201,10 @@ class BackupCommand extends Command
$config_dir = $this->project_dir.'/config';
$zip->addFile($config_dir.'/parameters.yaml', 'config/parameters.yaml');
$zip->addFile($config_dir.'/banner.md', 'config/banner.md');
//Add kicad custom footprints and symbols files
$zip->addFile($this->project_dir . '/public/kicad/footprints_custom.txt', 'public/kicad/footprints_custom.txt');
$zip->addFile($this->project_dir . '/public/kicad/symbols_custom.txt', 'public/kicad/symbols_custom.txt');
}
protected function backupAttachments(ZipFile $zip, SymfonyStyle $io): void

View file

@ -56,13 +56,16 @@ class LoadFixturesCommand extends Command
}
$factory = new ResetAutoIncrementPurgerFactory();
$purger = $factory->createForEntityManager(null, $this->entityManager);
//Use truncate purging to fix compatibility with postgresql
$purger = $factory->createForEntityManager(null, $this->entityManager, purgeWithTruncate: true);
$purger->purge();
//Afterwards run the load fixtures command as normal, but with the --append option
$new_input = new ArrayInput([
'command' => 'doctrine:fixtures:load',
'--purge-with-truncate' => true,
'--append' => true,
]);
@ -70,4 +73,4 @@ class LoadFixturesCommand extends Command
return $returnCode ?? Command::FAILURE;
}
}
}

View file

@ -0,0 +1,88 @@
<?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\Controller;
use App\Form\Settings\KicadListEditorType;
use App\Settings\MiscSettings\KiCadEDASettings;
use App\Services\EDA\KicadListFileManager;
use Jbtronics\SettingsBundle\Exception\SettingsNotValidException;
use Jbtronics\SettingsBundle\Manager\SettingsManagerInterface;
use RuntimeException;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use function Symfony\Component\Translation\t;
final class KicadListEditorController extends AbstractController
{
public function __construct(
private readonly SettingsManagerInterface $settingsManager,
) {
}
#[Route('/settings/misc/kicad-lists', name: 'settings_kicad_lists')]
public function __invoke(Request $request, KicadListFileManager $fileManager): Response
{
$this->denyAccessUnlessGranted('IS_AUTHENTICATED_FULLY');
$this->denyAccessUnlessGranted('@config.change_system_settings');
/** @var KiCadEDASettings $settings */
$settings = $this->settingsManager->createTemporaryCopy(KiCadEDASettings::class);
$form = $this->createForm(KicadListEditorType::class, [
'useCustomList' => $settings->useCustomList,
'customFootprints' => $fileManager->getCustomFootprintsContent(),
'customSymbols' => $fileManager->getCustomSymbolsContent(),
], [
'default_footprints' => $fileManager->getFootprintsContent(),
'default_symbols' => $fileManager->getSymbolsContent(),
]);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$data = $form->getData();
try {
$fileManager->saveCustom($data['customFootprints'], $data['customSymbols']);
$settings->useCustomList = (bool) $data['useCustomList'];
$this->settingsManager->mergeTemporaryCopy($settings);
$this->settingsManager->save($settings);
$this->addFlash('success', t('settings.flash.saved'));
return $this->redirectToRoute('settings_kicad_lists');
} catch (RuntimeException|SettingsNotValidException $exception) {
$this->addFlash('error', $exception->getMessage());
}
}
if ($form->isSubmitted() && !$form->isValid()) {
$this->addFlash('error', t('settings.flash.invalid'));
}
return $this->render('settings/kicad_list_editor.html.twig', [
'form' => $form,
]);
}
}

View file

@ -69,10 +69,13 @@ class ProjectController extends AbstractController
return $table->getResponse();
}
$number_of_builds = max(1, $request->query->getInt('n', 1));
return $this->render('projects/info/info.html.twig', [
'buildHelper' => $buildHelper,
'datatable' => $table,
'project' => $project,
'number_of_builds' => $number_of_builds,
]);
}

View file

@ -44,6 +44,7 @@ class SettingsController extends AbstractController
public function systemSettings(Request $request, TagAwareCacheInterface $cache): Response
{
$this->denyAccessUnlessGranted('@config.change_system_settings');
$this->denyAccessUnlessGranted('IS_AUTHENTICATED_FULLY');
//Create a clone of the settings object
$settings = $this->settingsManager->createTemporaryCopy(AppSettings::class);

View file

@ -38,6 +38,7 @@ use App\DataTables\Filters\PartFilter;
use App\DataTables\Filters\PartSearchFilter;
use App\DataTables\Helpers\ColumnSortHelper;
use App\DataTables\Helpers\PartDataTableHelper;
use App\Doctrine\Functions\SiValueSort;
use App\Doctrine\Helpers\FieldHelper;
use App\Entity\Parts\ManufacturingStatus;
use App\Entity\Parts\Part;
@ -118,6 +119,18 @@ final class PartsDataTable implements DataTableTypeInterface
'render' => fn($value, Part $context) => $this->partDataTableHelper->renderName($context),
'orderField' => 'NATSORT(part.name)'
])
->add('si_value', TextColumn::class, [
'label' => $this->translator->trans('part.table.si_value'),
'render' => function ($value, Part $context): string {
$siValue = SiValueSort::sqliteSiValue($context->getName());
if ($siValue !== null) {
//Output it as scientific number with a big E
return htmlspecialchars(sprintf('%G', $siValue));
}
return '';
},
'orderField' => 'SI_VALUE_SORT(part.name)',
])
->add('id', TextColumn::class, [
'label' => $this->translator->trans('part.table.id'),
])
@ -484,6 +497,19 @@ final class PartsDataTable implements DataTableTypeInterface
//$builder->addGroupBy('_bulkImportJob');
}
//When sorting by SI value, add NATSORT as a secondary sort so that parts without
//an SI-prefixed value fall back to natural string ordering seamlessly.
$orderByParts = $builder->getDQLPart('orderBy');
foreach ($orderByParts as $orderBy) {
foreach ($orderBy->getParts() as $part) {
if (str_contains($part, 'SI_VALUE_SORT')) {
$direction = str_contains($part, 'DESC') ? 'DESC' : 'ASC';
$builder->addOrderBy('NATSORT(part.name)', $direction);
break 2;
}
}
}
return $builder;
}

View file

@ -1,8 +1,5 @@
<?php
declare(strict_types=1);
/*
/**
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2022 Jan Böhmer (https://github.com/jbtronics)
@ -20,23 +17,31 @@ declare(strict_types=1);
* 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\DataTables;
use App\DataTables\Adapters\TwoStepORMAdapter;
use App\DataTables\Column\EntityColumn;
use App\DataTables\Column\EnumColumn;
use App\DataTables\Column\LocaleDateTimeColumn;
use App\DataTables\Column\MarkdownColumn;
use App\DataTables\Helpers\PartDataTableHelper;
use App\Entity\Attachments\Attachment;
use App\Entity\Parts\Part;
use App\Doctrine\Helpers\FieldHelper;
use App\Entity\Parts\ManufacturingStatus;
use App\Entity\Parts\Part;
use App\Entity\ProjectSystem\ProjectBOMEntry;
use App\Services\ElementTypeNameGenerator;
use App\Services\EntityURLGenerator;
use App\Services\Formatters\AmountFormatter;
use App\Services\Formatters\MoneyFormatter;
use App\Services\ProjectSystem\ProjectBuildHelper;
use Brick\Math\RoundingMode;
use Doctrine\ORM\AbstractQuery;
use Doctrine\ORM\Query;
use Doctrine\ORM\QueryBuilder;
use Omines\DataTablesBundle\Adapter\Doctrine\ORM\SearchCriteriaProvider;
use Omines\DataTablesBundle\Adapter\Doctrine\ORMAdapter;
use Omines\DataTablesBundle\Column\TextColumn;
use Omines\DataTablesBundle\DataTable;
use Omines\DataTablesBundle\DataTableTypeInterface;
@ -44,9 +49,14 @@ use Symfony\Contracts\Translation\TranslatorInterface;
class ProjectBomEntriesDataTable implements DataTableTypeInterface
{
public function __construct(protected TranslatorInterface $translator, protected PartDataTableHelper $partDataTableHelper,
protected EntityURLGenerator $entityURLGenerator, protected AmountFormatter $amountFormatter)
{
public function __construct(
protected EntityURLGenerator $entityURLGenerator,
protected TranslatorInterface $translator,
protected AmountFormatter $amountFormatter,
protected PartDataTableHelper $partDataTableHelper,
protected ProjectBuildHelper $projectBuildHelper,
protected MoneyFormatter $moneyFormatter,
) {
}
@ -62,7 +72,7 @@ class ProjectBomEntriesDataTable implements DataTableTypeInterface
return '';
}
return $this->partDataTableHelper->renderPicture($context->getPart());
},
}
])
->add('id', TextColumn::class, [
@ -133,23 +143,24 @@ class ProjectBomEntriesDataTable implements DataTableTypeInterface
->add('category', EntityColumn::class, [
'label' => $this->translator->trans('part.table.category'),
'property' => 'part.category',
'orderField' => 'NATSORT(category.name)',
'orderField' => 'NATSORT(category.name)'
])
->add('footprint', EntityColumn::class, [
'property' => 'part.footprint',
'label' => $this->translator->trans('part.table.footprint'),
'orderField' => 'NATSORT(footprint.name)',
'orderField' => 'NATSORT(footprint.name)'
])
->add('manufacturer', EntityColumn::class, [
'property' => 'part.manufacturer',
'label' => $this->translator->trans('part.table.manufacturer'),
'orderField' => 'NATSORT(manufacturer.name)',
'orderField' => 'NATSORT(manufacturer.name)'
])
->add('manufacturing_status', EnumColumn::class, [
'label' => $this->translator->trans('part.table.manufacturingStatus'),
'data' => static fn(ProjectBOMEntry $context): ?ManufacturingStatus => $context->getPart()?->getManufacturingStatus(),
'data' => static fn(ProjectBOMEntry $context): ?ManufacturingStatus => $context->getPart()?->getManufacturingStatus(),
'orderField' => 'part.manufacturing_status',
'class' => ManufacturingStatus::class,
'render' => function (?ManufacturingStatus $status, ProjectBOMEntry $context): string {
if ($status === null) {
@ -183,8 +194,10 @@ class ProjectBomEntriesDataTable implements DataTableTypeInterface
return '';
}
])
->add('storageLocations', TextColumn::class, [
'label' => 'part.table.storeLocations',
->add('storelocation', TextColumn::class, [
'label' => $this->translator->trans('part.table.storeLocations'),
//We need to use a aggregate function to get the first store location, as we have a one-to-many relation
'orderField' => 'NATSORT(MIN(_storelocations.name))',
'visible' => false,
'render' => function ($value, ProjectBOMEntry $context) {
if ($context->getPart() !== null) {
@ -194,6 +207,27 @@ class ProjectBomEntriesDataTable implements DataTableTypeInterface
return '';
}
])
->add('price', TextColumn::class, [
'label' => 'project.bom.price',
'visible' => false,
'render' => function ($value, ProjectBOMEntry $context) {
$price = $this->projectBuildHelper->getEntryUnitPrice($context);
return $this->moneyFormatter->format($price->toScale(2, RoundingMode::UP)->toFloat(), null, 2, true);
},
])
->add('ext_price', TextColumn::class, [
'label' => 'project.bom.ext_price',
'visible' => false,
'render' => function ($value, ProjectBOMEntry $context) {
$price = $this->projectBuildHelper->getEntryUnitPrice($context);
return $this->moneyFormatter->format(
$price->multipliedBy($context->getQuantity())->toScale(2, RoundingMode::UP)->toFloat(),
null,
2,
true
);
},
])
->add('addedDate', LocaleDateTimeColumn::class, [
'label' => $this->translator->trans('part.table.addedDate'),
@ -207,11 +241,13 @@ class ProjectBomEntriesDataTable implements DataTableTypeInterface
$dataTable->addOrderBy('name', DataTable::SORT_ASCENDING);
$dataTable->createAdapter(ORMAdapter::class, [
'entity' => Attachment::class,
'query' => function (QueryBuilder $builder) use ($options): void {
$this->getQuery($builder, $options);
$dataTable->createAdapter(TwoStepORMAdapter::class, [
'entity' => ProjectBOMEntry::class,
'hydrate' => AbstractQuery::HYDRATE_OBJECT,
'filter_query' => function (QueryBuilder $builder) use ($options): void {
$this->getFilterQuery($builder, $options);
},
'detail_query' => $this->getDetailQuery(...),
'criteria' => [
function (QueryBuilder $builder) use ($options): void {
$this->buildCriteria($builder, $options);
@ -221,20 +257,71 @@ class ProjectBomEntriesDataTable implements DataTableTypeInterface
]);
}
private function getQuery(QueryBuilder $builder, array $options): void
private function getFilterQuery(QueryBuilder $builder, array $options): void
{
$builder->select('bom_entry')
->addSelect('part')
$builder
->select('bom_entry.id')
->from(ProjectBOMEntry::class, 'bom_entry')
->leftJoin('bom_entry.part', 'part')
->leftJoin('part.category', 'category')
->leftJoin('part.partLots', '_partLots')
->leftJoin('_partLots.storage_location', '_storelocations')
->leftJoin('part.footprint', 'footprint')
->leftJoin('part.manufacturer', 'manufacturer')
->leftJoin('part.partCustomState', 'partCustomState')
->where('bom_entry.project = :project')
->setParameter('project', $options['project'])
->addGroupBy('bom_entry')
->addGroupBy('part')
->addGroupBy('category')
->addGroupBy('footprint')
->addGroupBy('manufacturer')
->addGroupBy('partCustomState')
;
}
private function getDetailQuery(QueryBuilder $builder, array $filter_results): void
{
$ids = array_map(static fn (array $row) => $row['id'], $filter_results);
if ($ids === []) {
$ids = [-1];
}
$builder
->select('bom_entry')
->addSelect('part')
->addSelect('category')
->addSelect('partLots')
->addSelect('storelocations')
->addSelect('footprint')
->addSelect('manufacturer')
->addSelect('partCustomState')
->from(ProjectBOMEntry::class, 'bom_entry')
->leftJoin('bom_entry.part', 'part')
->leftJoin('part.category', 'category')
->leftJoin('part.partLots', 'partLots')
->leftJoin('partLots.storage_location', 'storelocations')
->leftJoin('part.footprint', 'footprint')
->leftJoin('part.manufacturer', 'manufacturer')
->leftJoin('part.partCustomState', 'partCustomState')
->where('bom_entry.id IN (:ids)')
->setParameter('ids', $ids)
->addGroupBy('bom_entry')
->addGroupBy('part')
->addGroupBy('partLots')
->addGroupBy('category')
->addGroupBy('storelocations')
->addGroupBy('footprint')
->addGroupBy('manufacturer')
->addGroupBy('partCustomState')
->setHint(Query::HINT_READ_ONLY, true)
->setHint(Query::HINT_FORCE_PARTIAL_LOAD, false)
;
FieldHelper::addOrderByFieldParam($builder, 'bom_entry.id', 'ids');
}
private function buildCriteria(QueryBuilder $builder, array $options): void
{

View file

@ -0,0 +1,196 @@
<?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\Doctrine\Functions;
use Doctrine\DBAL\Platforms\AbstractMySQLPlatform;
use Doctrine\DBAL\Platforms\PostgreSQLPlatform;
use Doctrine\DBAL\Platforms\SQLitePlatform;
use Doctrine\ORM\Query\AST\Functions\FunctionNode;
use Doctrine\ORM\Query\AST\Node;
use Doctrine\ORM\Query\Parser;
use Doctrine\ORM\Query\SqlWalker;
use Doctrine\ORM\Query\TokenType;
/**
* Custom DQL function that extracts the first numeric value with an optional SI prefix
* from a string and returns the scaled numeric value for sorting.
*
* Usage: SI_VALUE_SORT(part.name)
*
* This enables sorting parts by their physical value. For example, capacitors
* named "100nF", "1uF", "10pF" will be sorted by actual value: 10pF < 100nF < 1uF.
*
* Supported SI prefixes: p (pico, 1e-12), n (nano, 1e-9), u/µ (micro, 1e-6),
* m (milli, 1e-3), k/K (kilo, 1e3), M (mega, 1e6), G (giga, 1e9), T (tera, 1e12).
*
* Only matches numbers at the very beginning of the string (ignoring leading whitespace).
* Names like "Crystal 20MHz" will NOT match since the number is not at the start.
* Names without a recognizable numeric+prefix pattern return NULL and sort last.
*/
class SiValueSort extends FunctionNode
{
private ?Node $field = null;
/**
* SI prefix multipliers. Used by the SQLite PHP callback.
*/
private const SI_MULTIPLIERS = [
'p' => 1e-12,
'n' => 1e-9,
'u' => 1e-6,
'µ' => 1e-6,
'm' => 1e-3,
'k' => 1e3,
'K' => 1e3,
'M' => 1e6,
'G' => 1e9,
'T' => 1e12,
];
public function parse(Parser $parser): void
{
$parser->match(TokenType::T_IDENTIFIER);
$parser->match(TokenType::T_OPEN_PARENTHESIS);
$this->field = $parser->ArithmeticExpression();
$parser->match(TokenType::T_CLOSE_PARENTHESIS);
}
public function getSql(SqlWalker $sqlWalker): string
{
assert($this->field !== null, 'Field is not set');
$platform = $sqlWalker->getConnection()->getDatabasePlatform();
$rawField = $this->field->dispatch($sqlWalker);
// Normalize comma decimal separator to dot for SQL platforms (European locale support)
$fieldSql = "REPLACE({$rawField}, ',', '.')";
if ($platform instanceof PostgreSQLPlatform) {
return $this->getPostgreSQLSql($fieldSql);
}
if ($platform instanceof AbstractMySQLPlatform) {
return $this->getMySQLSql($fieldSql);
}
// SQLite: comma normalization is handled in the PHP callback
$fieldSql = $rawField;
if ($platform instanceof SQLitePlatform) {
return "SI_VALUE({$fieldSql})";
}
// Fallback: return NULL (no SI sorting available)
return 'NULL';
}
/**
* PostgreSQL implementation using substring() with POSIX regex.
*/
private function getPostgreSQLSql(string $field): string
{
// Extract the numeric part using POSIX regex, anchored at start (with optional leading whitespace)
$numericPart = "CAST(substring({$field} FROM '^\\s*(\\d+\\.?\\d*)\\s*[pnuµmkKMGT]?') AS DOUBLE PRECISION)";
// Extract the SI prefix character
$prefixPart = "substring({$field} FROM '^\\s*\\d+\\.?\\d*\\s*([pnuµmkKMGT])')";
return $this->buildCaseExpression($numericPart, $prefixPart);
}
/**
* MySQL/MariaDB implementation using REGEXP_SUBSTR.
*/
private function getMySQLSql(string $field): string
{
// Extract the numeric part, anchored at start (with optional leading whitespace)
$numericPart = "CAST(REGEXP_SUBSTR({$field}, '^[[:space:]]*[0-9]+\\.?[0-9]*') AS DECIMAL(30,15))";
// Extract the prefix: get the full number+prefix match anchored at start, then take the last char
$fullMatch = "REGEXP_SUBSTR({$field}, '^[[:space:]]*[0-9]+\\.?[0-9]*[[:space:]]*[pnuµmkKMGT]')";
$prefixPart = "RIGHT({$fullMatch}, 1)";
return $this->buildCaseExpression($numericPart, $prefixPart);
}
/**
* Build a CASE expression that maps an SI prefix character to a multiplier
* and multiplies it with the numeric value.
*
* @param string $numericExpr SQL expression that evaluates to the numeric part
* @param string $prefixExpr SQL expression that evaluates to the SI prefix character
* @return string SQL CASE expression
*/
private function buildCaseExpression(string $numericExpr, string $prefixExpr): string
{
return "(CASE" .
" WHEN {$numericExpr} IS NULL THEN NULL" .
" WHEN {$prefixExpr} = 'p' THEN {$numericExpr} * 1e-12" .
" WHEN {$prefixExpr} = 'n' THEN {$numericExpr} * 1e-9" .
" WHEN {$prefixExpr} = 'u' THEN {$numericExpr} * 1e-6" .
" WHEN {$prefixExpr} = 'µ' THEN {$numericExpr} * 1e-6" .
" WHEN {$prefixExpr} = 'm' THEN {$numericExpr} * 1e-3" .
" WHEN {$prefixExpr} = 'k' THEN {$numericExpr} * 1e3" .
" WHEN {$prefixExpr} = 'K' THEN {$numericExpr} * 1e3" .
" WHEN {$prefixExpr} = 'M' THEN {$numericExpr} * 1e6" .
" WHEN {$prefixExpr} = 'G' THEN {$numericExpr} * 1e9" .
" WHEN {$prefixExpr} = 'T' THEN {$numericExpr} * 1e12" .
" ELSE {$numericExpr} * 1" .
" END)";
}
/**
* PHP callback for SQLite's SI_VALUE function.
* Extracts the first numeric value with an optional SI prefix and returns the scaled value.
*
* @param string|null $value The input string
* @return float|null The scaled numeric value, or null if no number found
*/
public static function sqliteSiValue(?string $value): ?float
{
if ($value === null) {
return null;
}
// Normalize comma decimal separator to dot (European locale support)
$value = str_replace(',', '.', $value);
// Match a number at the very start (allowing leading whitespace), optionally followed by an SI prefix
if (!preg_match('/^\s*(\d+\.?\d*)\s*([pnuµmkKMGT])?/u', $value, $matches)) {
return null;
}
$number = (float) $matches[1];
$prefix = $matches[2] ?? '';
if ($prefix === '') {
return $number;
}
$multiplier = self::SI_MULTIPLIERS[$prefix] ?? 1.0; //@phpstan-ignore-line - fallback to 1.0 if prefix is not recognized (should not happen due to regex)
return $number * $multiplier;
}
}

View file

@ -23,6 +23,7 @@ declare(strict_types=1);
namespace App\Doctrine\Middleware;
use App\Doctrine\Functions\SiValueSort;
use App\Exceptions\InvalidRegexException;
use Doctrine\DBAL\Driver\Connection;
use Doctrine\DBAL\Driver\Middleware\AbstractDriverMiddleware;
@ -51,6 +52,9 @@ class SQLiteRegexExtensionMiddlewareDriver extends AbstractDriverMiddleware
//Create a new collation for natural sorting
$native_connection->sqliteCreateCollation('NATURAL_CMP', strnatcmp(...));
//Create a function for SI prefix value sorting
$native_connection->sqliteCreateFunction('SI_VALUE', SiValueSort::sqliteSiValue(...), 1, \PDO::SQLITE_DETERMINISTIC);
}
}

View file

@ -24,6 +24,7 @@ declare(strict_types=1);
namespace App\Form\Part\EDA;
use App\Form\Type\StaticFileAutocompleteType;
use App\Settings\MiscSettings\KiCadEDASettings;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\OptionsResolver\Options;
use Symfony\Component\OptionsResolver\OptionsResolver;
@ -39,6 +40,13 @@ class KicadFieldAutocompleteType extends AbstractType
//Do not use a leading slash here! otherwise it will not work under prefixed reverse proxies
public const FOOTPRINT_PATH = 'kicad/footprints.txt';
public const SYMBOL_PATH = 'kicad/symbols.txt';
public const CUSTOM_FOOTPRINT_PATH = 'kicad/footprints_custom.txt';
public const CUSTOM_SYMBOL_PATH = 'kicad/symbols_custom.txt';
public function __construct(
private readonly KiCadEDASettings $kiCadEDASettings,
) {
}
public function configureOptions(OptionsResolver $resolver): void
{
@ -47,8 +55,8 @@ class KicadFieldAutocompleteType extends AbstractType
$resolver->setDefaults([
'file' => fn(Options $options) => match ($options['type']) {
self::TYPE_FOOTPRINT => self::FOOTPRINT_PATH,
self::TYPE_SYMBOL => self::SYMBOL_PATH,
self::TYPE_FOOTPRINT => $this->kiCadEDASettings->useCustomList ? self::CUSTOM_FOOTPRINT_PATH : self::FOOTPRINT_PATH,
self::TYPE_SYMBOL => $this->kiCadEDASettings->useCustomList ? self::CUSTOM_SYMBOL_PATH : self::SYMBOL_PATH,
default => throw new \InvalidArgumentException('Invalid type'),
}
]);
@ -58,4 +66,4 @@ class KicadFieldAutocompleteType extends AbstractType
{
return StaticFileAutocompleteType::class;
}
}
}

View file

@ -0,0 +1,103 @@
<?php
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2024 Jan Böhmer (https://github.com/jbtronics)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
declare(strict_types=1);
namespace App\Form\Settings;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
use Symfony\Component\Form\Extension\Core\Type\TextareaType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
/**
* Form type for editing the custom KiCad footprints and symbols lists.
*/
final class KicadListEditorType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder
->add('useCustomList', CheckboxType::class, [
'label' => 'settings.misc.kicad_eda.use_custom_list',
'help' => 'settings.misc.kicad_eda.use_custom_list.help',
'required' => false,
])
->add('customFootprints', TextareaType::class, [
'label' => 'settings.misc.kicad_eda.editor.custom_footprints',
'help' => 'settings.misc.kicad_eda.editor.footprints.help',
'attr' => [
'rows' => 16,
'spellcheck' => 'false',
'class' => 'font-monospace',
],
])
->add('defaultFootprints', TextareaType::class, [
'label' => 'settings.misc.kicad_eda.editor.default_footprints',
'help' => 'settings.misc.kicad_eda.editor.default_files_help',
'disabled' => true,
'mapped' => false,
'data' => $options['default_footprints'],
'attr' => [
'rows' => 16,
'spellcheck' => 'false',
'class' => 'font-monospace',
'readonly' => 'readonly',
],
])
->add('customSymbols', TextareaType::class, [
'label' => 'settings.misc.kicad_eda.editor.custom_symbols',
'help' => 'settings.misc.kicad_eda.editor.symbols.help',
'attr' => [
'rows' => 16,
'spellcheck' => 'false',
'class' => 'font-monospace',
],
])
->add('defaultSymbols', TextareaType::class, [
'label' => 'settings.misc.kicad_eda.editor.default_symbols',
'help' => 'settings.misc.kicad_eda.editor.default_files_help',
'disabled' => true,
'mapped' => false,
'data' => $options['default_symbols'],
'attr' => [
'rows' => 16,
'spellcheck' => 'false',
'class' => 'font-monospace',
'readonly' => 'readonly',
],
])
->add('save', SubmitType::class, [
'label' => 'save',
]);
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'default_footprints' => '',
'default_symbols' => '',
]);
$resolver->setAllowedTypes('default_footprints', 'string');
$resolver->setAllowedTypes('default_symbols', 'string');
}
}

View file

@ -139,7 +139,7 @@ class TypeSynonymRowType extends AbstractType
*/
private function getPreferredLocales(): array
{
$fromSettings = $this->localizationSettings->languageMenuEntries ?? [];
$fromSettings = $this->localizationSettings->languageMenuEntries;
return !empty($fromSettings) ? array_values($fromSettings) : array_values($this->preferredLanguagesParam);
}

View file

@ -44,6 +44,8 @@ use App\Exceptions\AttachmentDownloadException;
use App\Settings\SystemSettings\AttachmentsSettings;
use Hshn\Base64EncodedFile\HttpFoundation\File\Base64EncodedFile;
use Hshn\Base64EncodedFile\HttpFoundation\File\UploadedBase64EncodedFile;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HttpClient\NoPrivateNetworkHttpClient;
use const DIRECTORY_SEPARATOR;
use InvalidArgumentException;
use RuntimeException;
@ -76,6 +78,8 @@ class AttachmentSubmitHandler
protected FileTypeFilterTools $filterTools,
protected AttachmentsSettings $settings,
protected readonly SVGSanitizer $SVGSanitizer,
#[Autowire(env: "bool:ALLOW_ATTACHMENT_DOWNLOADS_FROM_LOCALNETWORK")]
private readonly bool $allow_local_network_downloads = false,
)
{
//The mapping used to determine which folder will be used for an attachment type
@ -95,6 +99,10 @@ class AttachmentSubmitHandler
UserAttachment::class => 'user',
LabelAttachment::class => 'label_profile',
];
if (!$this->allow_local_network_downloads) {
$this->httpClient = new NoPrivateNetworkHttpClient($this->httpClient);
}
}
/**
@ -373,6 +381,7 @@ class AttachmentSubmitHandler
],
];
$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) {
@ -434,8 +443,8 @@ class AttachmentSubmitHandler
$new_path = $this->pathResolver->realPathToPlaceholder($new_path);
//Save the path to the attachment
$attachment->setInternalPath($new_path);
} catch (TransportExceptionInterface) {
throw new AttachmentDownloadException('Transport error!');
} catch (TransportExceptionInterface $exception) {
throw new AttachmentDownloadException('Transport error: '.$exception->getMessage());
}
return $attachment;

View file

@ -0,0 +1,158 @@
<?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\EDA;
use RuntimeException;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HttpKernel\CacheWarmer\CacheWarmerInterface;
/**
* Manages the KiCad footprints and symbols list files, including reading, writing and ensuring their existence.
*/
final class KicadListFileManager implements CacheWarmerInterface
{
private const FOOTPRINTS_PATH = '/public/kicad/footprints.txt';
private const SYMBOLS_PATH = '/public/kicad/symbols.txt';
private const CUSTOM_FOOTPRINTS_PATH = '/public/kicad/footprints_custom.txt';
private const CUSTOM_SYMBOLS_PATH = '/public/kicad/symbols_custom.txt';
private const CUSTOM_TEMPLATE = <<<'EOT'
# Custom KiCad autocomplete entries. One entry per line.
EOT;
public function __construct(
#[Autowire('%kernel.project_dir%')]
private readonly string $projectDir,
) {
}
public function getFootprintsContent(): string
{
return $this->readFile(self::FOOTPRINTS_PATH);
}
public function getCustomFootprintsContent(): string
{
//Ensure that the custom file exists, so that the UI can always display it without error.
$this->createCustomFileIfNotExists(self::CUSTOM_FOOTPRINTS_PATH);
return $this->readFile(self::CUSTOM_FOOTPRINTS_PATH);
}
public function getSymbolsContent(): string
{
return $this->readFile(self::SYMBOLS_PATH);
}
public function getCustomSymbolsContent(): string
{
//Ensure that the custom file exists, so that the UI can always display it without error.
$this->createCustomFileIfNotExists(self::CUSTOM_SYMBOLS_PATH);
return $this->readFile(self::CUSTOM_SYMBOLS_PATH);
}
public function saveCustom(string $footprints, string $symbols): void
{
$this->writeFile(self::CUSTOM_FOOTPRINTS_PATH, $this->normalizeContent($footprints));
$this->writeFile(self::CUSTOM_SYMBOLS_PATH, $this->normalizeContent($symbols));
}
private function readFile(string $path): string
{
$fullPath = $this->projectDir . $path;
if (!is_file($fullPath)) {
return '';
}
$content = file_get_contents($fullPath);
if ($content === false) {
throw new RuntimeException(sprintf('Failed to read KiCad list file "%s".', $fullPath));
}
return $content;
}
private function writeFile(string $path, string $content): void
{
$fullPath = $this->projectDir . $path;
$tmpPath = $fullPath . '.tmp';
if (file_put_contents($tmpPath, $content, LOCK_EX) === false) {
throw new RuntimeException(sprintf('Failed to write KiCad list file "%s".', $fullPath));
}
if (!rename($tmpPath, $fullPath)) {
@unlink($tmpPath);
throw new RuntimeException(sprintf('Failed to replace KiCad list file "%s".', $fullPath));
}
}
private function normalizeContent(string $content): string
{
$normalized = str_replace(["\r\n", "\r"], "\n", $content);
if ($normalized !== '' && !str_ends_with($normalized, "\n")) {
$normalized .= "\n";
}
return $normalized;
}
private function createCustomFileIfNotExists(string $path): void
{
$fullPath = $this->projectDir . $path;
if (!is_file($fullPath)) {
if (file_put_contents($fullPath, self::CUSTOM_TEMPLATE, LOCK_EX) === false) {
throw new RuntimeException(sprintf('Failed to create custom footprints file "%s".', $fullPath));
}
}
}
/**
* Ensures that the custom footprints and symbols files exist, so that the UI can always display them without error.
* @return void
*/
public function createCustomFilesIfNotExist(): void
{
$this->createCustomFileIfNotExists(self::CUSTOM_FOOTPRINTS_PATH);
$this->createCustomFileIfNotExists(self::CUSTOM_SYMBOLS_PATH);
}
public function isOptional(): bool
{
return false;
}
/**
* Ensure that the custom footprints and symbols files exist and generate them on cache warmup, so that the frontend
* can always display them without error, even if the user has not yet visited the settings page.
*/
public function warmUp(string $cacheDir, ?string $buildDir = null): array
{
$this->createCustomFilesIfNotExist();
return [];
}
}

View file

@ -42,6 +42,7 @@ use Brick\Schema\Interfaces\Thing;
use Brick\Schema\SchemaReader;
use Brick\Schema\SchemaTypeList;
use Symfony\Component\DomCrawler\Crawler;
use Symfony\Component\HttpClient\NoPrivateNetworkHttpClient;
use Symfony\Contracts\HttpClient\HttpClientInterface;
class GenericWebProvider implements InfoProviderInterface
@ -55,7 +56,8 @@ class GenericWebProvider implements InfoProviderInterface
private readonly ProviderRegistry $providerRegistry, private readonly PartInfoRetriever $infoRetriever,
)
{
$this->httpClient = (new RandomizeUseragentHttpClient($httpClient))->withOptions(
//Use NoPrivateNetworkHttpClient to prevent SSRF vulnerabilities, and RandomizeUseragentHttpClient to make it harder for servers to block us
$this->httpClient = (new RandomizeUseragentHttpClient(new NoPrivateNetworkHttpClient($httpClient)))->withOptions(
[
'timeout' => 15,
]

View file

@ -280,9 +280,13 @@ class TMEProvider implements InfoProviderInterface, URLHandlerInfoProviderInterf
{
//If a URL starts with // we assume that it is a relative URL and we add the protocol
if (str_starts_with($url, '//')) {
return 'https:' . $url;
$url = 'https:' . $url;
}
//Encode bare % signs that are not already part of a valid percent-encoded sequence
//Fixes part numbers with % in them e.g. SMD0603-5K1-1%
$url = preg_replace('/%(?![0-9A-Fa-f]{2})/', '%25', $url);
return $url;
}

View file

@ -105,6 +105,10 @@ final class BarcodeScanHelper
return new AmazonBarcodeScanResult($input);
}
if ($type === BarcodeSourceType::TME) {
return TMEBarcodeScanResult::parse($input);
}
//Null means auto and we try the different formats
$result = $this->parseInternalBarcode($input);
@ -144,6 +148,11 @@ final class BarcodeScanHelper
return new AmazonBarcodeScanResult($input);
}
// Try TME barcode
if (TMEBarcodeScanResult::isTMEBarcode($input)) {
return TMEBarcodeScanResult::parse($input);
}
throw new InvalidArgumentException('Unknown barcode');
}
@ -162,6 +171,7 @@ final class BarcodeScanHelper
return LCSCBarcodeScanResult::parse($input);
}
private function parseUserDefinedBarcode(string $input): ?LocalBarcodeScanResult
{
$lot_repo = $this->entityManager->getRepository(PartLot::class);

View file

@ -150,6 +150,10 @@ final readonly class BarcodeScanResultHandler
?? $this->em->getRepository(Part::class)->getPartBySPN($barcodeScan->asin);
}
if ($barcodeScan instanceof TMEBarcodeScanResult) {
return $this->resolvePartFromTME($barcodeScan);
}
return null;
}
@ -236,6 +240,26 @@ final readonly class BarcodeScanResultHandler
}
private function resolvePartFromTME(TMEBarcodeScanResult $barcodeScan): ?Part
{
$pn = $barcodeScan->tmePartNumber;
if ($pn) {
$part = $this->em->getRepository(Part::class)->getPartByProviderInfo($pn);
if ($part !== null) {
return $part;
}
//Try to find the part by SPN/SKU
$part = $this->em->getRepository(Part::class)->getPartBySPN($pn);
if ($part !== null) {
return $part;
}
}
// Fallback: search by MPN
return $this->em->getRepository(Part::class)->getPartByMPN($barcodeScan->mpn, $barcodeScan->manufacturer);
}
/**
* Tries to extract creation information for a part from the given barcode scan result. This can be used to
* automatically fill in the info provider reference of a part, when creating a new part based on the scan result.
@ -247,6 +271,20 @@ final readonly class BarcodeScanResultHandler
*/
public function getCreateInfos(BarcodeScanResultInterface $scanResult): ?array
{
// TME
if ($scanResult instanceof TMEBarcodeScanResult) {
if ($scanResult->tmePartNumber === null) {
return null;
}
return [
'providerKey' => 'tme',
'providerId' => $scanResult->tmePartNumber,
'lotAmount' => $scanResult->quantity,
'lotName' => $scanResult->purchaseOrder,
'lotUserBarcode' => $scanResult->rawInput,
];
}
// LCSC
if ($scanResult instanceof LCSCBarcodeScanResult) {
return [

View file

@ -52,4 +52,7 @@ enum BarcodeSourceType: string
case LCSC = 'lcsc';
case AMAZON = 'amazon';
/** For TME (tme.eu) formatted QR codes */
case TME = 'tme';
}

View file

@ -254,12 +254,16 @@ readonly class EIGP114BarcodeScanResult implements BarcodeScanResultInterface
*/
public static function isFormat06Code(string $input): bool
{
//Code must begin with [)><RS>06<GS>
if(!str_starts_with($input, "[)>\u{1E}06\u{1D}")){
return false;
//Code should begin with [)><RS>06<GS> as per the standard
if(!str_starts_with($input, "[)>\u{1E}06\u{1D}")
// some codes don't contain record separators
&& !str_starts_with($input, "[)>06\u{1D}")
// This is found on old Mouser parts
&& !str_starts_with($input, ">[)>06\u{1D}"))
{
return false;
}
//Digikey does not put a trailer onto the barcode, so we just check for the header
//Digikey and Mouser don't put a trailer onto the barcode, so we just check for the header
return true;
}

View file

@ -0,0 +1,143 @@
<?php
/*
* 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/>.
*/
declare(strict_types=1);
namespace App\Services\LabelSystem\BarcodeScanner;
use InvalidArgumentException;
/**
* This class represents the content of a tme.eu barcode label.
* The format is space-separated KEY:VALUE tokens, e.g.:
* QTY:1000 PN:SMD0603-5K1-1% PO:32723349/7 MFR:ROYALOHM MPN:0603SAF5101T5E CoO:TH RoHS https://www.tme.eu/details/...
*/
readonly class TMEBarcodeScanResult implements BarcodeScanResultInterface
{
/** @var int|null Quantity (QTY) */
public ?int $quantity;
/** @var string|null TME part number (PN) */
public ?string $tmePartNumber;
/** @var string|null Purchase order number (PO) */
public ?string $purchaseOrder;
/** @var string|null Manufacturer name (MFR) */
public ?string $manufacturer;
/** @var string|null Manufacturer part number (MPN) */
public ?string $mpn;
/** @var string|null Country of origin (CoO) */
public ?string $countryOfOrigin;
/** @var bool Whether the part is RoHS compliant */
public bool $rohs;
/** @var string|null The product URL */
public ?string $productUrl;
/**
* @param array<string, string> $fields Parsed key-value fields (keys uppercased)
* @param string $rawInput Original barcode string
*/
public function __construct(
public array $fields,
public string $rawInput,
) {
$this->quantity = isset($this->fields['QTY']) ? (int) $this->fields['QTY'] : null;
$this->tmePartNumber = $this->fields['PN'] ?? null;
$this->purchaseOrder = $this->fields['PO'] ?? null;
$this->manufacturer = $this->fields['MFR'] ?? null;
$this->mpn = $this->fields['MPN'] ?? null;
$this->countryOfOrigin = $this->fields['COO'] ?? null;
$this->rohs = isset($this->fields['ROHS']);
$this->productUrl = $this->fields['URL'] ?? null;
}
public function getSourceType(): BarcodeSourceType
{
return BarcodeSourceType::TME;
}
public function getDecodedForInfoMode(): array
{
return [
'Barcode type' => 'TME',
'TME Part No. (PN)' => $this->tmePartNumber ?? '',
'MPN' => $this->mpn ?? '',
'Manufacturer (MFR)' => $this->manufacturer ?? '',
'Qty' => $this->quantity !== null ? (string) $this->quantity : '',
'Purchase Order (PO)' => $this->purchaseOrder ?? '',
'Country of Origin (CoO)' => $this->countryOfOrigin ?? '',
'RoHS' => $this->rohs ? 'Yes' : 'No',
'URL' => $this->productUrl ?? '',
];
}
/**
* Returns true if the input looks like a TME barcode label (contains tme.eu URL).
*/
public static function isTMEBarcode(string $input): bool
{
return str_contains(strtolower($input), 'tme.eu');
}
/**
* Parse the TME barcode string into a TMEBarcodeScanResult.
*/
public static function parse(string $input): self
{
$raw = trim($input);
if (!self::isTMEBarcode($raw)) {
throw new InvalidArgumentException('Not a TME barcode');
}
$fields = [];
// Split on whitespace; each token is either KEY:VALUE, a bare keyword, or the URL
$tokens = preg_split('/\s+/', $raw);
foreach ($tokens as $token) {
if ($token === '') {
continue;
}
// The TME URL
if (str_starts_with(strtolower($token), 'http')) {
$fields['URL'] = $token;
continue;
}
$colonPos = strpos($token, ':');
if ($colonPos !== false) {
$key = strtoupper(substr($token, 0, $colonPos));
$value = substr($token, $colonPos + 1);
$fields[$key] = $value;
} else {
// Bare keyword like "RoHS"
$fields[strtoupper($token)] = '';
}
}
return new self($fields, $raw);
}
}

View file

@ -25,16 +25,22 @@ namespace App\Services\ProjectSystem;
use App\Entity\Parts\Part;
use App\Entity\ProjectSystem\Project;
use App\Entity\ProjectSystem\ProjectBOMEntry;
use App\Entity\PriceInformations\Currency;
use App\Helpers\Projects\ProjectBuildRequest;
use App\Services\Parts\PartLotWithdrawAddHelper;
use App\Services\Parts\PricedetailHelper;
use Brick\Math\BigDecimal;
use Brick\Math\RoundingMode;
/**
* @see \App\Tests\Services\ProjectSystem\ProjectBuildHelperTest
*/
final readonly class ProjectBuildHelper
{
public function __construct(private PartLotWithdrawAddHelper $withdraw_add_helper)
{
public function __construct(
private PartLotWithdrawAddHelper $withdraw_add_helper,
private PricedetailHelper $pricedetailHelper,
) {
}
/**
@ -168,4 +174,81 @@ final readonly class ProjectBuildHelper
$this->withdraw_add_helper->add($buildRequest->getBuildsPartLot(), $buildRequest->getNumberOfBuilds(), $message);
}
}
/**
* Calculates the total price to build the given project N times, taking bulk pricing into account.
* Returns null if no BOM entry has any pricing information.
*/
public function calculateTotalBuildPrice(Project $project, int $number_of_builds = 1, ?Currency $currency = null): ?BigDecimal
{
$total = BigDecimal::zero();
$has_price = false;
foreach ($project->getBomEntries() as $entry) {
$unit_price = $this->getBomEntryUnitPrice($entry, $number_of_builds, $currency);
if ($unit_price === null) {
continue;
}
$has_price = true;
$total = $total->plus($unit_price->multipliedBy($entry->getQuantity())->multipliedBy($number_of_builds));
}
return $has_price ? $total : null;
}
/**
* Calculates the price to build one unit of the given project when ordering for N builds in total.
* Returns null if no BOM entry has any pricing information.
*/
public function calculateUnitBuildPrice(Project $project, int $number_of_builds = 1, ?Currency $currency = null): ?BigDecimal
{
$total = $this->calculateTotalBuildPrice($project, $number_of_builds, $currency);
if ($total === null) {
return null;
}
return $total->dividedBy($number_of_builds, 10, RoundingMode::HALF_UP);
}
/**
* Returns the total build price rounded up to 2 decimal places, ready for display.
*/
public function roundedTotalBuildPrice(Project $project, int $number_of_builds = 1, ?Currency $currency = null): ?BigDecimal
{
return $this->calculateTotalBuildPrice($project, $number_of_builds, $currency)
?->toScale(2, RoundingMode::UP);
}
/**
* Returns the unit build price rounded up to 2 decimal places, ready for display.
*/
public function roundedUnitBuildPrice(Project $project, int $number_of_builds = 1, ?Currency $currency = null): ?BigDecimal
{
return $this->calculateUnitBuildPrice($project, $number_of_builds, $currency)
?->toScale(2, RoundingMode::UP);
}
/**
* Returns the effective unit price for a single piece of the given BOM entry,
* taking bulk pricing and minimum order amounts into account for N builds.
* Returns BigDecimal::zero() when no pricing data is available.
*/
public function getEntryUnitPrice(ProjectBOMEntry $entry, int $number_of_builds = 1, ?Currency $currency = null): BigDecimal
{
return $this->getBomEntryUnitPrice($entry, $number_of_builds, $currency) ?? BigDecimal::zero();
}
/**
* Returns the effective unit price for a single piece of the given BOM entry,
* taking bulk pricing into account for N builds.
*/
private function getBomEntryUnitPrice(ProjectBOMEntry $entry, int $number_of_builds, ?Currency $currency): ?BigDecimal
{
if ($entry->getPart() instanceof Part) {
$total_qty = $entry->getQuantity() * $number_of_builds;
$min_order = $this->pricedetailHelper->getMinOrderAmount($entry->getPart());
$effective_qty = ($min_order !== null) ? max($total_qty, $min_order) : $total_qty;
return $this->pricedetailHelper->calculateAvgPrice($entry->getPart(), $effective_qty, $currency);
}
return $entry->getPrice();
}
}

View file

@ -52,6 +52,8 @@ enum PartTableColumns : string implements TranslatableInterface
case TAGS = "tags";
case ATTACHMENTS = "attachments";
case SI_VALUE = "si_value";
case EDA_REFERENCE = "eda_reference";
case EDA_VALUE = "eda_value";

View file

@ -62,4 +62,10 @@ class KiCadEDASettings
)]
public bool $defaultOrderdetailsVisibility = false;
#[SettingsParameter(
label: new TM("settings.misc.kicad_eda.use_custom_list"),
description: new TM("settings.misc.kicad_eda.use_custom_list.help"),
)]
public bool $useCustomList = false;
}

View file

@ -55,6 +55,32 @@
</span>
</h6>
</div>
{% set n = number_of_builds ?? 1 %}
{% set total_build_price = buildHelper.roundedTotalBuildPrice(project, n, app.user.currency ?? null) %}
{% set unit_build_price = buildHelper.roundedUnitBuildPrice(project, n, app.user.currency ?? null) %}
{% if total_build_price is not null %}
<div class="mt-1">
<h6>
<span class="badge badge-primary bg-success">
<i class="fa-solid fa-money-bill-wave fa-fw"></i>
{% trans %}project.info.total_build_price{% endtrans %}:
{{ total_build_price | format_money(app.user.currency ?? null, 2) }}
{% if n > 1 and unit_build_price is not null %}
<span class="ms-1">
({% trans %}project.info.per_unit_price{% endtrans %}: {{ unit_build_price | format_money(app.user.currency ?? null, 2) }})
</span>
{% endif %}
</span>
</h6>
</div>
{% endif %}
<form method="get" action="{{ path('project_info', {'id': project.id}) }}" class="mt-2">
<div class="input-group input-group-sm">
<span class="input-group-text">{% trans %}project.builds.number_of_builds{% endtrans %}</span>
<input type="number" min="1" class="form-control" name="n" required value="{{ n }}">
<button class="btn btn-outline-secondary" type="submit">{% trans %}project.build.btn_build{% endtrans %}</button>
</div>
</form>
{% if project.children is not empty %}
<div class="mt-1">
<h6>
@ -69,9 +95,9 @@
</div>
{% if project.comment is not empty %}
<p>
<h5>{% trans %}comment.label{% endtrans %}:</h5>
{{ project.comment|format_markdown }}
</p>
<div class="col-12 mt-2">
<h5>{% trans %}comment.label{% endtrans %}:</h5>
{{ project.comment|format_markdown }}
</div>
{% endif %}
</div>
</div>

View file

@ -0,0 +1,28 @@
{% extends "main_card.html.twig" %}
{% block title %}{% trans %}settings.misc.kicad_eda.editor.title{% endtrans %}{% endblock %}
{% block card_title %}<i class="fa-solid fa-pen-to-square fa-fw"></i> {% trans %}settings.misc.kicad_eda.editor.title{% endtrans %}{% endblock %}
{% block card_content %}
<p class="text-muted">
{% trans %}settings.misc.kicad_eda.editor.description{% endtrans %}
</p>
{{ form_start(form) }}
{{ form_row(form.useCustomList) }}
<div class="row g-3">
<div class="col-12 col-xl-6">
{{ form_row(form.customFootprints) }}
{{ form_row(form.customSymbols) }}
</div>
<div class="col-12 col-xl-6">
{{ form_row(form.defaultFootprints) }}
{{ form_row(form.defaultSymbols) }}
</div>
</div>
{{ form_row(form.save) }}
{{ form_end(form) }}
{% endblock %}

View file

@ -49,6 +49,15 @@
</div>
</div>
{{ form_widget(section_widget) }}
{% if section_widget.vars.name == 'kicadEDA' %}
<div class="row">
<div class="{{ offset_label }} col mt-2 ps-2">
<a href="{{ path('settings_kicad_lists') }}" class="btn btn-outline-secondary btn-sm">
<i class="fa-solid fa-pen-to-square fa-fw"></i> {% trans %}settings.misc.kicad_eda.editor.link{% endtrans %}
</a>
</div>
</div>
{% endif %}
</fieldset>
{% if not loop.last %}
<hr class="mx-0 mb-2 mt-2">

View file

@ -60,6 +60,7 @@ final class ApplicationAvailabilityFunctionalTest extends WebTestCase
//User related things
yield ['/user/settings'];
yield ['/user/info'];
yield ['/settings/misc/kicad-lists'];
//Login/logout
yield ['/login'];

View file

@ -0,0 +1,162 @@
<?php
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2024 Jan Böhmer (https://github.com/jbtronics)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
declare(strict_types=1);
namespace App\Tests\Controller;
use App\Entity\UserSystem\User;
use App\Settings\MiscSettings\KiCadEDASettings;
use Jbtronics\SettingsBundle\Manager\SettingsManagerInterface;
use PHPUnit\Framework\Attributes\Group;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
#[Group('slow')]
#[Group('DB')]
final class KicadListEditorControllerTest extends WebTestCase
{
private string $footprintsPath;
private string $symbolsPath;
private string $customFootprintsPath;
private string $customSymbolsPath;
private string $originalFootprints;
private string $originalSymbols;
private string $originalCustomFootprints;
private string $originalCustomSymbols;
private bool $originalUseCustomList;
protected function setUp(): void
{
parent::setUp();
$projectDir = dirname(__DIR__, 2);
$this->footprintsPath = $projectDir . '/public/kicad/footprints.txt';
$this->symbolsPath = $projectDir . '/public/kicad/symbols.txt';
$this->customFootprintsPath = $projectDir . '/public/kicad/footprints_custom.txt';
$this->customSymbolsPath = $projectDir . '/public/kicad/symbols_custom.txt';
$this->originalFootprints = (string) file_get_contents($this->footprintsPath);
$this->originalSymbols = (string) file_get_contents($this->symbolsPath);
$this->originalCustomFootprints = is_file($this->customFootprintsPath) ? (string) file_get_contents($this->customFootprintsPath) : '';
$this->originalCustomSymbols = is_file($this->customSymbolsPath) ? (string) file_get_contents($this->customSymbolsPath) : '';
static::bootKernel();
/** @var SettingsManagerInterface $settingsManager */
$settingsManager = static::getContainer()->get(SettingsManagerInterface::class);
/** @var KiCadEDASettings $settings */
$settings = $settingsManager->get(KiCadEDASettings::class);
$this->originalUseCustomList = $settings->useCustomList;
static::ensureKernelShutdown();
}
protected function tearDown(): void
{
file_put_contents($this->footprintsPath, $this->originalFootprints);
file_put_contents($this->symbolsPath, $this->originalSymbols);
file_put_contents($this->customFootprintsPath, $this->originalCustomFootprints);
file_put_contents($this->customSymbolsPath, $this->originalCustomSymbols);
static::bootKernel();
/** @var SettingsManagerInterface $settingsManager */
$settingsManager = static::getContainer()->get(SettingsManagerInterface::class);
/** @var KiCadEDASettings $settings */
$settings = $settingsManager->get(KiCadEDASettings::class);
$settings->useCustomList = $this->originalUseCustomList;
$settingsManager->save($settings);
static::ensureKernelShutdown();
parent::tearDown();
}
public function testEditorRequiresAuthentication(): void
{
$client = static::createClient();
$client->request('GET', '/en/settings/misc/kicad-lists');
$this->assertResponseStatusCodeSame(401);
}
public function testEditorAccessibleByAdmin(): void
{
$client = static::createClient();
$this->loginAsUser($client, 'admin');
$client->request('GET', '/en/settings/misc/kicad-lists');
$this->assertResponseIsSuccessful();
$this->assertSelectorExists('form[name="kicad_list_editor"]');
}
public function testEditorShowsDefaultAndCustomFiles(): void
{
$client = static::createClient();
$this->loginAsUser($client, 'admin');
file_put_contents($this->footprintsPath, "DefaultFootprint\n");
file_put_contents($this->symbolsPath, "DefaultSymbol\n");
file_put_contents($this->customFootprintsPath, "CustomFootprint\n");
file_put_contents($this->customSymbolsPath, "CustomSymbol\n");
$crawler = $client->request('GET', '/en/settings/misc/kicad-lists');
$this->assertSame("CustomFootprint\n", $crawler->filter('#kicad_list_editor_customFootprints')->getNode(0)->nodeValue);
$this->assertSame("CustomSymbol\n", $crawler->filter('#kicad_list_editor_customSymbols')->getNode(0)->nodeValue);
$this->assertSame("DefaultFootprint\n", $crawler->filter('#kicad_list_editor_defaultFootprints')->getNode(0)->nodeValue);
$this->assertSame("DefaultSymbol\n", $crawler->filter('#kicad_list_editor_defaultSymbols')->getNode(0)->nodeValue);
}
public function testEditorSavesCustomFilesAndSetting(): void
{
$client = static::createClient();
$this->loginAsUser($client, 'admin');
$crawler = $client->request('GET', '/en/settings/misc/kicad-lists');
$form = $crawler->filter('form[name="kicad_list_editor"]')->form();
$form['kicad_list_editor[customFootprints]'] = "Package_DIP:DIP-8_W7.62mm\n";
$form['kicad_list_editor[customSymbols]'] = "Device:R\n";
$form['kicad_list_editor[useCustomList]']->tick();
$client->submit($form);
$this->assertResponseRedirects('/en/settings/misc/kicad-lists');
$this->assertSame("Package_DIP:DIP-8_W7.62mm\n", (string) file_get_contents($this->customFootprintsPath));
$this->assertSame("Device:R\n", (string) file_get_contents($this->customSymbolsPath));
$this->assertSame($this->originalFootprints, (string) file_get_contents($this->footprintsPath));
$this->assertSame($this->originalSymbols, (string) file_get_contents($this->symbolsPath));
/** @var SettingsManagerInterface $settingsManager */
$settingsManager = $client->getContainer()->get(SettingsManagerInterface::class);
/** @var KiCadEDASettings $settings */
$settings = $settingsManager->reload(KiCadEDASettings::class);
$this->assertTrue($settings->useCustomList);
}
private function loginAsUser($client, string $username): void
{
$entityManager = $client->getContainer()->get('doctrine')->getManager();
$userRepository = $entityManager->getRepository(User::class);
$user = $userRepository->findOneBy(['name' => $username]);
if (!$user) {
$this->markTestSkipped(sprintf('User "%s" not found in fixtures', $username));
}
$client->loginUser($user);
}
}

View file

@ -0,0 +1,68 @@
<?php
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2026 Jan Böhmer (https://github.com/jbtronics)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
declare(strict_types=1);
namespace App\Tests\Doctrine\Functions;
use Doctrine\DBAL\Connection;
use Doctrine\DBAL\Platforms\AbstractPlatform;
use Doctrine\ORM\Query\AST\Node;
use Doctrine\ORM\Query\SqlWalker;
use PHPUnit\Framework\TestCase;
abstract class AbstractDoctrineFunctionTestCase extends TestCase
{
protected function createSqlWalker(AbstractPlatform $platform, string $serverVersion = '11.0.0-MariaDB'): SqlWalker
{
$connection = $this->createMock(Connection::class);
$connection->method('getDatabasePlatform')->willReturn($platform);
$connection->method('getServerVersion')->willReturn($serverVersion);
$sqlWalker = $this->getMockBuilder(SqlWalker::class)
->disableOriginalConstructor()
->onlyMethods(['getConnection'])
->getMock();
$sqlWalker->method('getConnection')->willReturn($connection);
return $sqlWalker;
}
protected function createNode(string $sql): Node
{
$node = $this->createMock(Node::class);
$node->method('dispatch')->willReturn($sql);
return $node;
}
protected function setObjectProperty(object $object, string $property, mixed $value): void
{
$reflection = new \ReflectionProperty($object, $property);
$reflection->setValue($object, $value);
}
protected function setStaticProperty(string $class, string $property, mixed $value): void
{
$reflection = new \ReflectionProperty($class, $property);
$reflection->setValue(null, $value);
}
}

View file

@ -0,0 +1,42 @@
<?php
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2026 Jan Böhmer (https://github.com/jbtronics)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
declare(strict_types=1);
namespace App\Tests\Doctrine\Functions;
use App\Doctrine\Functions\ArrayPosition;
use Doctrine\DBAL\Platforms\PostgreSQLPlatform;
final class ArrayPositionTest extends AbstractDoctrineFunctionTestCase
{
public function testArrayPositionBuildsSql(): void
{
$function = new ArrayPosition('ARRAY_POSITION');
$this->setObjectProperty($function, 'array', $this->createNode(':ids'));
$this->setObjectProperty($function, 'field', $this->createNode('p.id'));
$sql = $function->getSql($this->createSqlWalker(new PostgreSQLPlatform()));
$this->assertSame('ARRAY_POSITION(:ids, p.id)', $sql);
}
}

View file

@ -0,0 +1,45 @@
<?php
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2026 Jan Böhmer (https://github.com/jbtronics)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
declare(strict_types=1);
namespace App\Tests\Doctrine\Functions;
use App\Doctrine\Functions\Field2;
use Doctrine\DBAL\Platforms\MySQLPlatform;
final class Field2Test extends AbstractDoctrineFunctionTestCase
{
public function testField2BuildsSql(): void
{
$function = new Field2('FIELD2');
$this->setObjectProperty($function, 'field', $this->createNode('p.id'));
$this->setObjectProperty($function, 'values', [
$this->createNode('1'),
$this->createNode('2'),
$this->createNode('3'),
]);
$sql = $function->getSql($this->createSqlWalker(new MySQLPlatform()));
$this->assertSame('FIELD2(p.id, 1, 2, 3)', $sql);
}
}

View file

@ -0,0 +1,66 @@
<?php
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2026 Jan Böhmer (https://github.com/jbtronics)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
declare(strict_types=1);
namespace App\Tests\Doctrine\Functions;
use App\Doctrine\Functions\ILike;
use Doctrine\DBAL\Platforms\AbstractPlatform;
use Doctrine\DBAL\Platforms\MySQLPlatform;
use Doctrine\DBAL\Platforms\PostgreSQLPlatform;
use Doctrine\DBAL\Platforms\SQLitePlatform;
use Doctrine\DBAL\Platforms\SQLServerPlatform;
use PHPUnit\Framework\Attributes\DataProvider;
final class ILikeTest extends AbstractDoctrineFunctionTestCase
{
public static function iLikePlatformProvider(): \Generator
{
yield 'mysql' => [new MySQLPlatform(), '(part_name LIKE :pattern)'];
yield 'postgres' => [new PostgreSQLPlatform(), '(part_name ILIKE :pattern)'];
yield 'sqlite' => [new SQLitePlatform(), "(part_name LIKE :pattern ESCAPE '\\')"];
}
#[DataProvider('iLikePlatformProvider')]
public function testILikeUsesExpectedOperator(AbstractPlatform $platform, string $expectedSql): void
{
$function = new ILike('ILIKE');
$function->value = $this->createNode('part_name');
$function->expr = $this->createNode(':pattern');
$sql = $function->getSql($this->createSqlWalker($platform));
$this->assertSame($expectedSql, $sql);
}
public function testILikeThrowsOnUnsupportedPlatform(): void
{
$function = new ILike('ILIKE');
$function->value = $this->createNode('part_name');
$function->expr = $this->createNode(':pattern');
$this->expectException(\RuntimeException::class);
$this->expectExceptionMessage('does not support case insensitive like expressions');
$function->getSql($this->createSqlWalker(new SQLServerPlatform()));
}
}

View file

@ -0,0 +1,95 @@
<?php
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2026 Jan Böhmer (https://github.com/jbtronics)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
declare(strict_types=1);
namespace App\Tests\Doctrine\Functions;
use App\Doctrine\Functions\Natsort;
use Doctrine\DBAL\Platforms\MariaDBPlatform;
use Doctrine\DBAL\Platforms\MySQLPlatform;
use Doctrine\DBAL\Platforms\PostgreSQLPlatform;
use Doctrine\DBAL\Platforms\SQLitePlatform;
final class NatsortTest extends AbstractDoctrineFunctionTestCase
{
protected function setUp(): void
{
parent::setUp();
Natsort::allowSlowNaturalSort(false);
$this->setStaticProperty(Natsort::class, 'supportsNaturalSort', null);
}
public function testNatsortUsesPostgresCollation(): void
{
$function = new Natsort('NATSORT');
$this->setObjectProperty($function, 'field', $this->createNode('part_name'));
$sql = $function->getSql($this->createSqlWalker(new PostgreSQLPlatform()));
$this->assertSame('part_name COLLATE numeric', $sql);
}
public function testNatsortUsesMariaDbNativeFunctionOnSupportedVersion(): void
{
$function = new Natsort('NATSORT');
$this->setObjectProperty($function, 'field', $this->createNode('part_name'));
$sql = $function->getSql($this->createSqlWalker(new MariaDBPlatform(), '10.11.2-MariaDB'));
$this->assertSame('NATURAL_SORT_KEY(part_name)', $sql);
}
public function testNatsortFallsBackWithoutSlowSort(): void
{
$function = new Natsort('NATSORT');
$this->setObjectProperty($function, 'field', $this->createNode('part_name'));
$sql = $function->getSql($this->createSqlWalker(new MariaDBPlatform(), '10.6.10-MariaDB'));
$this->assertSame('part_name', $sql);
}
public function testNatsortUsesSlowSortFunctionOnMySqlWhenEnabled(): void
{
Natsort::allowSlowNaturalSort();
$function = new Natsort('NATSORT');
$this->setObjectProperty($function, 'field', $this->createNode('part_name'));
$sql = $function->getSql($this->createSqlWalker(new MySQLPlatform()));
$this->assertSame('NatSortKey(part_name, 0)', $sql);
}
public function testNatsortUsesSlowSortCollationOnSqliteWhenEnabled(): void
{
Natsort::allowSlowNaturalSort();
$function = new Natsort('NATSORT');
$this->setObjectProperty($function, 'field', $this->createNode('part_name'));
$sql = $function->getSql($this->createSqlWalker(new SQLitePlatform()));
$this->assertSame('part_name COLLATE NATURAL_CMP', $sql);
}
}

View file

@ -0,0 +1,66 @@
<?php
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2026 Jan Böhmer (https://github.com/jbtronics)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
declare(strict_types=1);
namespace App\Tests\Doctrine\Functions;
use App\Doctrine\Functions\Regexp;
use Doctrine\DBAL\Platforms\AbstractPlatform;
use Doctrine\DBAL\Platforms\MySQLPlatform;
use Doctrine\DBAL\Platforms\PostgreSQLPlatform;
use Doctrine\DBAL\Platforms\SQLitePlatform;
use Doctrine\DBAL\Platforms\SQLServerPlatform;
use PHPUnit\Framework\Attributes\DataProvider;
final class RegexpTest extends AbstractDoctrineFunctionTestCase
{
public static function regexpPlatformProvider(): \Generator
{
yield 'mysql' => [new MySQLPlatform(), '(part_name REGEXP :regex)'];
yield 'sqlite' => [new SQLitePlatform(), '(part_name REGEXP :regex)'];
yield 'postgres' => [new PostgreSQLPlatform(), '(part_name ~* :regex)'];
}
#[DataProvider('regexpPlatformProvider')]
public function testRegexpUsesExpectedOperator(AbstractPlatform $platform, string $expectedSql): void
{
$function = new Regexp('REGEXP');
$this->setObjectProperty($function, 'value', $this->createNode('part_name'));
$this->setObjectProperty($function, 'regexp', $this->createNode(':regex'));
$sql = $function->getSql($this->createSqlWalker($platform));
$this->assertSame($expectedSql, $sql);
}
public function testRegexpThrowsOnUnsupportedPlatform(): void
{
$function = new Regexp('REGEXP');
$this->setObjectProperty($function, 'value', $this->createNode('part_name'));
$this->setObjectProperty($function, 'regexp', $this->createNode(':regex'));
$this->expectException(\RuntimeException::class);
$this->expectExceptionMessage('does not support regular expressions');
$function->getSql($this->createSqlWalker(new SQLServerPlatform()));
}
}

View file

@ -0,0 +1,193 @@
<?php
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2026 Jan Böhmer (https://github.com/jbtronics)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
declare(strict_types=1);
namespace App\Tests\Doctrine\Functions;
use App\Doctrine\Functions\SiValueSort;
use Doctrine\DBAL\Platforms\MySQLPlatform;
use Doctrine\DBAL\Platforms\PostgreSQLPlatform;
use Doctrine\DBAL\Platforms\SQLitePlatform;
final class SiValueSortTest extends AbstractDoctrineFunctionTestCase
{
public function testPostgreSQLGeneratesCaseExpression(): void
{
$function = new SiValueSort('SI_VALUE_SORT');
$this->setObjectProperty($function, 'field', $this->createNode('part_name'));
$sql = $function->getSql($this->createSqlWalker(new PostgreSQLPlatform()));
$this->assertStringContainsString('CASE', $sql);
$this->assertStringContainsString("REPLACE(part_name, ',', '.')", $sql);
$this->assertStringContainsString('1e-12', $sql);
$this->assertStringContainsString('1e-9', $sql);
$this->assertStringContainsString('1e-6', $sql);
$this->assertStringContainsString('1e-3', $sql);
$this->assertStringContainsString('1e3', $sql);
$this->assertStringContainsString('1e6', $sql);
$this->assertStringContainsString('1e9', $sql);
$this->assertStringContainsString('1e12', $sql);
}
public function testMySQLGeneratesCaseExpression(): void
{
$function = new SiValueSort('SI_VALUE_SORT');
$this->setObjectProperty($function, 'field', $this->createNode('part_name'));
$sql = $function->getSql($this->createSqlWalker(new MySQLPlatform()));
$this->assertStringContainsString('CASE', $sql);
$this->assertStringContainsString("REPLACE(part_name, ',', '.')", $sql);
$this->assertStringContainsString('1e-12', $sql);
$this->assertStringContainsString('1e6', $sql);
}
public function testSQLiteUsesSiValueFunction(): void
{
$function = new SiValueSort('SI_VALUE_SORT');
$this->setObjectProperty($function, 'field', $this->createNode('part_name'));
$sql = $function->getSql($this->createSqlWalker(new SQLitePlatform()));
$this->assertSame('SI_VALUE(part_name)', $sql);
}
/**
* @dataProvider sqliteSiValueProvider
*/
public function testSqliteSiValue(?string $input, ?float $expected): void
{
$result = SiValueSort::sqliteSiValue($input);
if ($expected === null) {
$this->assertNull($result);
} else {
$this->assertEqualsWithDelta($expected, $result, $expected * 1e-9);
}
}
/**
* @return iterable<string, array{?string, ?float}>
*/
public static function sqliteSiValueProvider(): iterable
{
// Basic SI prefix values
yield 'pico' => ['10pF', 10e-12];
yield 'nano' => ['100nF', 100e-9];
yield 'micro_u' => ['1uF', 1e-6];
yield 'micro_µ' => ['1µF', 1e-6];
yield 'milli' => ['4.7mH', 4.7e-3];
yield 'kilo_lower' => ['4.7k', 4.7e3];
yield 'kilo_upper' => ['4.7K', 4.7e3];
yield 'mega' => ['1M', 1e6];
yield 'giga' => ['2.2G', 2.2e9];
yield 'tera' => ['1T', 1e12];
// No prefix (plain number)
yield 'plain_integer' => ['100', 100.0];
yield 'plain_decimal' => ['4.7', 4.7];
// Decimal values with prefix (dot separator)
yield 'decimal_nano' => ['4.7nF', 4.7e-9];
yield 'decimal_micro' => ['0.1uF', 0.1e-6];
yield 'decimal_kilo' => ['2.2k', 2.2e3];
// Comma decimal separator (European locale)
yield 'comma_kilo' => ['4,7k', 4.7e3];
yield 'comma_micro' => ['2,2uF', 2.2e-6];
yield 'comma_kilo_space' => ['1,2 kΩ', 1.2e3];
// Number NOT at the start — should return NULL
yield 'prefixed_name' => ['CAP-100nF', null];
yield 'name_with_number' => ['R 4.7k 1%', null];
yield 'crystal' => ['Crystal 20MHz', null];
// Number at start with trailing text
yield 'number_with_suffix' => ['10nF 25V', 10e-9];
// Space between number and prefix
yield 'space_before_prefix' => ['100 nF', 100e-9];
// Leading whitespace before number
yield 'leading_whitespace' => [' 10uF', 10e-6];
// No number at all
yield 'no_number' => ['Connector', null];
yield 'text_only' => ['LED red', null];
// Null input
yield 'null' => [null, null];
// Empty string
yield 'empty' => ['', null];
}
/**
* Test that the sort order is correct by comparing sqliteSiValue results.
*/
public function testSortOrder(): void
{
$parts = ['1uF', '100nF', '10pF', '10uF', '0.1mF', '1F', '10kF', '1MF'];
$expected = ['10pF', '100nF', '1uF', '10uF', '0.1mF', '1F', '10kF', '1MF'];
// Sort using sqliteSiValue
usort($parts, static function (string $a, string $b): int {
$va = SiValueSort::sqliteSiValue($a);
$vb = SiValueSort::sqliteSiValue($b);
return $va <=> $vb;
});
$this->assertSame($expected, $parts);
}
/**
* Test that NULL values sort last (after all numeric values).
*/
public function testNullSortsLast(): void
{
$parts = ['Connector', '100nF', 'LED red', '10pF'];
usort($parts, static function (string $a, string $b): int {
$va = SiValueSort::sqliteSiValue($a);
$vb = SiValueSort::sqliteSiValue($b);
// NULL sorts last
if ($va === null && $vb === null) {
return 0;
}
if ($va === null) {
return 1;
}
if ($vb === null) {
return -1;
}
return $va <=> $vb;
});
$this->assertSame('10pF', $parts[0]);
$this->assertSame('100nF', $parts[1]);
// Last two should be the non-numeric names
$this->assertContains('Connector', array_slice($parts, 2));
$this->assertContains('LED red', array_slice($parts, 2));
}
}

View file

@ -0,0 +1,391 @@
<?php
/*
* 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/>.
*/
declare(strict_types=1);
namespace App\Tests\Services\InfoProviderSystem\Providers;
use App\Entity\Parts\ManufacturingStatus;
use App\Services\InfoProviderSystem\DTOs\PartDetailDTO;
use App\Services\InfoProviderSystem\DTOs\PurchaseInfoDTO;
use App\Services\InfoProviderSystem\DTOs\SearchResultDTO;
use App\Services\InfoProviderSystem\Providers\ProviderCapabilities;
use App\Services\InfoProviderSystem\Providers\TMEClient;
use App\Services\InfoProviderSystem\Providers\TMEProvider;
use App\Settings\InfoProviderSystem\TMESettings;
use App\Tests\SettingsTestHelper;
use PHPUnit\Framework\TestCase;
use Symfony\Component\HttpClient\MockHttpClient;
use Symfony\Component\HttpClient\Response\MockResponse;
final class TMEProviderTest extends TestCase
{
private TMESettings $settings;
private TMEProvider $provider;
private MockHttpClient $httpClient;
protected function setUp(): void
{
$this->httpClient = new MockHttpClient();
$this->settings = SettingsTestHelper::createSettingsDummy(TMESettings::class);
// Use a short (anonymous-style) token so grossPrices is read from settings
$this->settings->apiToken = 'test_token_000000000000000000000000000000000000000';
$this->settings->apiSecret = 'test_secret';
$this->settings->currency = 'EUR';
$this->settings->language = 'en';
$this->settings->country = 'DE';
$this->settings->grossPrices = false;
$this->provider = new TMEProvider(new TMEClient($this->httpClient, $this->settings), $this->settings);
}
// --- Mock response helpers ---
// Only fields actually read by TMEProvider are included.
private function mockProductList(array $products): MockResponse
{
return new MockResponse(json_encode([
'Status' => 'OK',
'Data' => ['ProductList' => $products],
]));
}
private function mockFilesList(array $products): MockResponse
{
return new MockResponse(json_encode([
'Status' => 'OK',
'Data' => ['ProductList' => $products],
]));
}
private function mockParametersList(array $products): MockResponse
{
return new MockResponse(json_encode([
'Status' => 'OK',
'Data' => ['ProductList' => $products],
]));
}
private function mockPrices(string $currency, string $priceType, array $products): MockResponse
{
return new MockResponse(json_encode([
'Status' => 'OK',
'Data' => [
'Currency' => $currency,
'PriceType' => $priceType,
'ProductList' => $products,
],
]));
}
// --- Mock data ---
private function smd0603Products(): MockResponse
{
return $this->mockProductList([[
'Symbol' => 'SMD0603-5K1-1%',
'OriginalSymbol' => '0603SAF5101T5E',
'Producer' => 'ROYALOHM',
'Description' => 'Resistor: thick film; SMD; 0603; 5.1kΩ; 0.1W; ±1%; 50V; -55÷155°C',
'Category' => 'SMD resistors',
'Photo' => '//ce8dc832c.cloudimg.io/v7/_cdn_/E9/C2/B0/00/0/732318_1.jpg',
'ProductStatusList' => [],
'ProductInformationPage' => '//www.tme.eu/en/details/smd0603-5k1-1%/smd-resistors/royalohm/0603saf5101t5e/',
'Weight' => 0.021,
'WeightUnit' => 'g',
]]);
}
private function smd0603Files(): MockResponse
{
return $this->mockFilesList([[
'Symbol' => 'SMD0603-5K1-1%',
'Files' => [
'AdditionalPhotoList' => [],
'DocumentList' => [
['DocumentUrl' => '//www.tme.eu/Document/b315665a56acbc42df513c99b390ad98/ROYALOHM-THICKFILM.pdf'],
['DocumentUrl' => '//www.tme.eu/Document/c283990e907c122bb808207d1578ac7f/POWER_RATING-DTE.pdf'],
],
],
]]);
}
private function smd0603Parameters(): MockResponse
{
return $this->mockParametersList([[
'Symbol' => 'SMD0603-5K1-1%',
'ParameterList' => [
['ParameterId' => 34, 'ParameterName' => 'Type of resistor', 'ParameterValue' => 'thick film'],
['ParameterId' => 35, 'ParameterName' => 'Case - mm', 'ParameterValue' => '1608'],
['ParameterId' => 38, 'ParameterName' => 'Resistance', 'ParameterValue' => '5.1kΩ'],
['ParameterId' => 39, 'ParameterName' => 'Tolerance', 'ParameterValue' => '±1%'],
['ParameterId' => 120, 'ParameterName' => 'Operating voltage', 'ParameterValue' => '50V'],
],
]]);
}
private function smd0603Prices(): MockResponse
{
return $this->mockPrices('EUR', 'NET', [[
'Symbol' => 'SMD0603-5K1-1%',
'PriceList' => [
['Amount' => 100, 'PriceValue' => 0.01077],
['Amount' => 1000, 'PriceValue' => 0.00291],
['Amount' => 5000, 'PriceValue' => 0.00150],
],
]]);
}
private function etqp3mProducts(): MockResponse
{
return $this->mockProductList([[
'Symbol' => 'ETQP3M6R8KVP',
'OriginalSymbol' => 'ETQP3M6R8KVP',
'Producer' => 'PANASONIC',
'Description' => 'Inductor: wire; SMD; 6.8uH; 2.9A; R: 65.7mΩ; ±20%; ETQP3M; 5.5x5x3mm',
'Category' => 'Inductors',
'Photo' => '//ce8dc832c.cloudimg.io/v7/_cdn_/9E/27/A0/00/0/684777_1.jpg',
'ProductStatusList' => [],
'ProductInformationPage' => '//www.tme.eu/en/details/etqp3m6r8kvp/inductors/panasonic/',
'Weight' => 0.44,
'WeightUnit' => 'g',
]]);
}
private function etqp3mFiles(): MockResponse
{
return $this->mockFilesList([[
'Symbol' => 'ETQP3M6R8KVP',
'Files' => [
'AdditionalPhotoList' => [],
'DocumentList' => [
['DocumentUrl' => '//www.tme.eu/Document/50a845881f09d8a2248350946e11df38/AGL0000C63.pdf'],
['DocumentUrl' => '//www.tme.eu/Document/8480690a42fa577214e35e33d3fc8d77/ETQP3M100KVN-LNK.txt'],
],
],
]]);
}
private function etqp3mParameters(): MockResponse
{
return $this->mockParametersList([[
'Symbol' => 'ETQP3M6R8KVP',
'ParameterList' => [
['ParameterId' => 566, 'ParameterName' => 'Inductance', 'ParameterValue' => '6.8µH'],
['ParameterId' => 370, 'ParameterName' => 'Operating current', 'ParameterValue' => '2.9A'],
['ParameterId' => 39, 'ParameterName' => 'Tolerance', 'ParameterValue' => '±20%'],
],
]]);
}
private function etqp3mPrices(): MockResponse
{
return $this->mockPrices('EUR', 'NET', [[
'Symbol' => 'ETQP3M6R8KVP',
'PriceList' => [
['Amount' => 1, 'PriceValue' => 0.589],
['Amount' => 5, 'PriceValue' => 0.429],
['Amount' => 10, 'PriceValue' => 0.399],
],
]]);
}
// --- Tests ---
public function testGetProviderInfo(): void
{
$info = $this->provider->getProviderInfo();
$this->assertIsArray($info);
$this->assertArrayHasKey('name', $info);
$this->assertArrayHasKey('description', $info);
$this->assertArrayHasKey('url', $info);
$this->assertEquals('TME', $info['name']);
$this->assertEquals('https://tme.eu/', $info['url']);
}
public function testGetProviderKey(): void
{
$this->assertSame('tme', $this->provider->getProviderKey());
}
public function testIsActiveWithCredentials(): void
{
$this->assertTrue($this->provider->isActive());
}
public function testIsActiveWithoutCredentials(): void
{
$this->settings->apiToken = null;
$provider = new TMEProvider(new TMEClient($this->httpClient, $this->settings), $this->settings);
$this->assertFalse($provider->isActive());
}
public function testGetCapabilities(): void
{
$capabilities = $this->provider->getCapabilities();
$this->assertIsArray($capabilities);
$this->assertContains(ProviderCapabilities::BASIC, $capabilities);
$this->assertContains(ProviderCapabilities::PICTURE, $capabilities);
$this->assertContains(ProviderCapabilities::DATASHEET, $capabilities);
$this->assertContains(ProviderCapabilities::PRICE, $capabilities);
$this->assertContains(ProviderCapabilities::FOOTPRINT, $capabilities);
}
public function testGetHandledDomains(): void
{
$this->assertContains('tme.eu', $this->provider->getHandledDomains());
}
public function testGetIDFromURL(): void
{
$this->assertSame('fi321_se', $this->provider->getIDFromURL('https://www.tme.eu/de/details/fi321_se/kuhler/alutronic/'));
$this->assertSame('smd0603-5k1-1%25', $this->provider->getIDFromURL('https://www.tme.eu/en/details/smd0603-5k1-1%25/smd-resistors/royalohm/0603saf5101t5e/'));
$this->assertNull($this->provider->getIDFromURL('https://www.tme.eu/en/'));
}
public function testSearchByKeyword(): void
{
$this->httpClient->setResponseFactory([$this->smd0603Products()]);
$results = $this->provider->searchByKeyword('SMD0603-5K1-1%');
$this->assertIsArray($results);
$this->assertCount(1, $results);
$this->assertInstanceOf(SearchResultDTO::class, $results[0]);
$this->assertSame('SMD0603-5K1-1%', $results[0]->provider_id);
$this->assertSame('0603SAF5101T5E', $results[0]->name);
$this->assertSame('ROYALOHM', $results[0]->manufacturer);
$this->assertSame('SMD resistors', $results[0]->category);
$this->assertSame(ManufacturingStatus::ACTIVE, $results[0]->manufacturing_status);
$this->assertSame(
'https://www.tme.eu/en/details/smd0603-5k1-1%25/smd-resistors/royalohm/0603saf5101t5e/',
$results[0]->provider_url
);
}
public function testGetDetailsWithPercentInPartNumber(): void
{
$this->httpClient->setResponseFactory([
$this->smd0603Products(),
$this->smd0603Files(),
$this->smd0603Parameters(),
$this->smd0603Prices(),
]);
$result = $this->provider->getDetails('SMD0603-5K1-1%');
$this->assertInstanceOf(PartDetailDTO::class, $result);
$this->assertSame('SMD0603-5K1-1%', $result->provider_id);
$this->assertSame('0603SAF5101T5E', $result->name);
$this->assertSame('Resistor: thick film; SMD; 0603; 5.1kΩ; 0.1W; ±1%; 50V; -55÷155°C', $result->description);
$this->assertSame('ROYALOHM', $result->manufacturer);
$this->assertSame('0603SAF5101T5E', $result->mpn);
$this->assertSame('SMD resistors', $result->category);
$this->assertSame(ManufacturingStatus::ACTIVE, $result->manufacturing_status);
$this->assertSame(0.021, $result->mass);
$this->assertSame('1608', $result->footprint);
$this->assertSame(
'https://www.tme.eu/en/details/smd0603-5k1-1%25/smd-resistors/royalohm/0603saf5101t5e/',
$result->provider_url
);
$this->assertCount(2, $result->datasheets);
$this->assertSame('https://www.tme.eu/Document/b315665a56acbc42df513c99b390ad98/ROYALOHM-THICKFILM.pdf', $result->datasheets[0]->url);
$this->assertCount(0, $result->images);
$this->assertCount(1, $result->vendor_infos);
$vendorInfo = $result->vendor_infos[0];
$this->assertInstanceOf(PurchaseInfoDTO::class, $vendorInfo);
$this->assertSame('TME', $vendorInfo->distributor_name);
$this->assertSame('SMD0603-5K1-1%', $vendorInfo->order_number);
$this->assertSame(
'https://www.tme.eu/en/details/smd0603-5k1-1%25/smd-resistors/royalohm/0603saf5101t5e/',
$vendorInfo->product_url
);
$this->assertCount(3, $vendorInfo->prices);
$this->assertSame(100.0, $vendorInfo->prices[0]->minimum_discount_amount);
$this->assertSame('0.01077', $vendorInfo->prices[0]->price);
$this->assertSame('EUR', $vendorInfo->prices[0]->currency_iso_code);
$this->assertFalse($vendorInfo->prices[0]->includes_tax);
$this->assertCount(5, $result->parameters);
}
public function testGetDetailsForEtqp3m6r8kvp(): void
{
$this->httpClient->setResponseFactory([
$this->etqp3mProducts(),
$this->etqp3mFiles(),
$this->etqp3mParameters(),
$this->etqp3mPrices(),
]);
$result = $this->provider->getDetails('ETQP3M6R8KVP');
$this->assertInstanceOf(PartDetailDTO::class, $result);
$this->assertSame('ETQP3M6R8KVP', $result->provider_id);
$this->assertSame('ETQP3M6R8KVP', $result->name);
$this->assertSame('Inductor: wire; SMD; 6.8uH; 2.9A; R: 65.7mΩ; ±20%; ETQP3M; 5.5x5x3mm', $result->description);
$this->assertSame('PANASONIC', $result->manufacturer);
$this->assertSame('ETQP3M6R8KVP', $result->mpn);
$this->assertSame('Inductors', $result->category);
$this->assertSame(ManufacturingStatus::ACTIVE, $result->manufacturing_status);
$this->assertSame(0.44, $result->mass);
$this->assertNull($result->footprint);
$this->assertSame('https://www.tme.eu/en/details/etqp3m6r8kvp/inductors/panasonic/', $result->provider_url);
$this->assertCount(2, $result->datasheets);
$this->assertSame('https://www.tme.eu/Document/50a845881f09d8a2248350946e11df38/AGL0000C63.pdf', $result->datasheets[0]->url);
$this->assertCount(0, $result->images);
$this->assertCount(1, $result->vendor_infos);
$vendorInfo = $result->vendor_infos[0];
$this->assertSame('TME', $vendorInfo->distributor_name);
$this->assertSame('ETQP3M6R8KVP', $vendorInfo->order_number);
$this->assertSame('https://www.tme.eu/en/details/etqp3m6r8kvp/inductors/panasonic/', $vendorInfo->product_url);
$this->assertCount(3, $vendorInfo->prices);
$this->assertSame(1.0, $vendorInfo->prices[0]->minimum_discount_amount);
$this->assertSame('0.589', $vendorInfo->prices[0]->price);
$this->assertSame('EUR', $vendorInfo->prices[0]->currency_iso_code);
$this->assertFalse($vendorInfo->prices[0]->includes_tax);
$this->assertCount(3, $result->parameters);
}
public function testNormalizeURLEncodesBarePctSign(): void
{
$method = (new \ReflectionClass($this->provider))->getMethod('normalizeURL');
$this->assertSame(
'https://www.tme.eu/en/details/smd0603-5k1-1%25/smd-resistors/royalohm/0603saf5101t5e/',
$method->invoke($this->provider, '//www.tme.eu/en/details/smd0603-5k1-1%/smd-resistors/royalohm/0603saf5101t5e/')
);
$this->assertSame(
'https://www.tme.eu/en/details/smd0603-5k1-1%25/smd-resistors/royalohm/0603saf5101t5e/',
$method->invoke($this->provider, '//www.tme.eu/en/details/smd0603-5k1-1%25/smd-resistors/royalohm/0603saf5101t5e/')
);
$this->assertSame(
'https://www.tme.eu/en/details/etqp3m6r8kvp/inductors/panasonic/',
$method->invoke($this->provider, '//www.tme.eu/en/details/etqp3m6r8kvp/inductors/panasonic/')
);
$this->assertSame('https://example.com/path', $method->invoke($this->provider, 'https://example.com/path'));
}
}

View file

@ -93,6 +93,13 @@ final class EIGP114BarcodeScanResultTest extends TestCase
//Valid code (digikey, without trailer)
$this->assertTrue(EIGP114BarcodeScanResult::isFormat06Code("[)>\x1e06\x1dPQ1045-ND\x1d1P364019-01\x1d30PQ1045-ND\x1dK12432 TRAVIS FOSS P\x1d1K85732873\x1d10K103332956\x1d9D231013\x1d1TQJ13P\x1d11K1\x1d4LTW\x1dQ3\x1d11ZPICK\x1d12Z7360988\x1d13Z999999\x1d20Z0000000000000000000000000000000000000000000000000000000000000000000000000000000000000"));
//Valid code (without record separator)
$this->assertTrue(EIGP114BarcodeScanResult::isFormat06Code("[)>06\x1DP596-777A1-ND\x1D1PXAF4444\x1DQ3\x1D10D1452\x1D1TBF1103\x1D4LUS\x1E\x04"));
//Old mouser format
$this->assertTrue(EIGP114BarcodeScanResult::isFormat06Code(">[)>06\x1DP596-777A1-ND\x1D1PXAF4444\x1DQ3\x1D10D1452\x1D1TBF1103\x1D4LUS\x1E\x04"));
}
public function testParseFormat06CodeInvalid(): void
@ -101,6 +108,32 @@ final class EIGP114BarcodeScanResultTest extends TestCase
EIGP114BarcodeScanResult::parseFormat06Code('');
}
public function testParseWithoutRecordSeparator(): void
{
$barcode = EIGP114BarcodeScanResult::parseFormat06Code("[)>06\x1DP596-777A1-ND\x1D1PXAF4444\x1DQ3\x1D10D1452\x1D1TBF1103\x1D4LUS\x1E\x04");
$this->assertSame([
'P' => '596-777A1-ND',
'1P' => 'XAF4444',
'Q' => '3',
'10D' => '1452',
'1T' => 'BF1103',
'4L' => 'US',
], $barcode->data);
}
public function testParseOldMouserFormat(): void
{
$barcode = EIGP114BarcodeScanResult::parseFormat06Code(">[)>06\x1DP596-777A1-ND\x1D1PXAF4444\x1DQ3\x1D10D1452\x1D1TBF1103\x1D4LUS\x1E\x04");
$this->assertSame([
'P' => '596-777A1-ND',
'1P' => 'XAF4444',
'Q' => '3',
'10D' => '1452',
'1T' => 'BF1103',
'4L' => 'US',
], $barcode->data);
}
public function testParseFormat06Code(): void
{
$barcode = EIGP114BarcodeScanResult::parseFormat06Code("[)>\x1E06\x1DP596-777A1-ND\x1D1PXAF4444\x1DQ3\x1D10D1452\x1D1TBF1103\x1D4LUS\x1E\x04");

View file

@ -0,0 +1,110 @@
<?php
/*
* 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\Tests\Services\LabelSystem\BarcodeScanner;
use App\Services\LabelSystem\BarcodeScanner\BarcodeSourceType;
use App\Services\LabelSystem\BarcodeScanner\TMEBarcodeScanResult;
use InvalidArgumentException;
use PHPUnit\Framework\TestCase;
class TMEBarcodeScanResultTest extends TestCase
{
private const EXAMPLE1 = 'QTY:1000 PN:SMD0603-5K1-1% PO:32723349/7 MFR:ROYALOHM MPN:0603SAF5101T5E CoO:TH RoHS https://www.tme.eu/details/SMD0603-5K1-1%25';
private const EXAMPLE2 = 'QTY:5 PN:ETQP3M6R8KVP PO:31199729/3 MFR:PANASONIC MPN:ETQP3M6R8KVP RoHS https://www.tme.eu/details/ETQP3M6R8KVP';
public function testIsTMEBarcode(): void
{
$this->assertFalse(TMEBarcodeScanResult::isTMEBarcode('invalid'));
$this->assertFalse(TMEBarcodeScanResult::isTMEBarcode('QTY:5 PN:ABC MPN:XYZ'));
$this->assertFalse(TMEBarcodeScanResult::isTMEBarcode(''));
$this->assertTrue(TMEBarcodeScanResult::isTMEBarcode(self::EXAMPLE1));
$this->assertTrue(TMEBarcodeScanResult::isTMEBarcode(self::EXAMPLE2));
}
public function testParseInvalidThrows(): void
{
$this->expectException(InvalidArgumentException::class);
TMEBarcodeScanResult::parse('not-a-tme-barcode');
}
public function testParseExample1(): void
{
$scan = TMEBarcodeScanResult::parse(self::EXAMPLE1);
$this->assertSame(1000, $scan->quantity);
$this->assertSame('SMD0603-5K1-1%', $scan->tmePartNumber);
$this->assertSame('32723349/7', $scan->purchaseOrder);
$this->assertSame('ROYALOHM', $scan->manufacturer);
$this->assertSame('0603SAF5101T5E', $scan->mpn);
$this->assertSame('TH', $scan->countryOfOrigin);
$this->assertTrue($scan->rohs);
$this->assertSame('https://www.tme.eu/details/SMD0603-5K1-1%25', $scan->productUrl);
$this->assertSame(self::EXAMPLE1, $scan->rawInput);
}
public function testParseExample2(): void
{
$scan = TMEBarcodeScanResult::parse(self::EXAMPLE2);
$this->assertSame(5, $scan->quantity);
$this->assertSame('ETQP3M6R8KVP', $scan->tmePartNumber);
$this->assertSame('31199729/3', $scan->purchaseOrder);
$this->assertSame('PANASONIC', $scan->manufacturer);
$this->assertSame('ETQP3M6R8KVP', $scan->mpn);
$this->assertNull($scan->countryOfOrigin);
$this->assertTrue($scan->rohs);
$this->assertSame('https://www.tme.eu/details/ETQP3M6R8KVP', $scan->productUrl);
}
public function testGetSourceType(): void
{
$scan = TMEBarcodeScanResult::parse(self::EXAMPLE2);
$this->assertSame(BarcodeSourceType::TME, $scan->getSourceType());
}
public function testParseUppercaseUrl(): void
{
$input = 'QTY:500 PN:M0.6W-10K MFR:ROYAL.OHM MPN:MF006FF1002A50 PO:7792659/8 HTTPS://WWW.TME.EU/DETAILS/M0.6W-10K';
$this->assertTrue(TMEBarcodeScanResult::isTMEBarcode($input));
$scan = TMEBarcodeScanResult::parse($input);
$this->assertSame(500, $scan->quantity);
$this->assertSame('M0.6W-10K', $scan->tmePartNumber);
$this->assertSame('ROYAL.OHM', $scan->manufacturer);
$this->assertSame('MF006FF1002A50', $scan->mpn);
$this->assertSame('7792659/8', $scan->purchaseOrder);
$this->assertSame('HTTPS://WWW.TME.EU/DETAILS/M0.6W-10K', $scan->productUrl);
}
public function testGetDecodedForInfoMode(): void
{
$scan = TMEBarcodeScanResult::parse(self::EXAMPLE1);
$decoded = $scan->getDecodedForInfoMode();
$this->assertSame('TME', $decoded['Barcode type']);
$this->assertSame('SMD0603-5K1-1%', $decoded['TME Part No. (PN)']);
$this->assertSame('0603SAF5101T5E', $decoded['MPN']);
$this->assertSame('ROYALOHM', $decoded['Manufacturer (MFR)']);
$this->assertSame('1000', $decoded['Qty']);
$this->assertSame('Yes', $decoded['RoHS']);
}
}

View file

@ -26,13 +26,15 @@ use App\Entity\Parts\Part;
use App\Entity\Parts\PartLot;
use App\Entity\ProjectSystem\Project;
use App\Entity\ProjectSystem\ProjectBOMEntry;
use App\Entity\PriceInformations\Orderdetail;
use App\Entity\PriceInformations\Pricedetail;
use App\Services\ProjectSystem\ProjectBuildHelper;
use Brick\Math\BigDecimal;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
final class ProjectBuildHelperTest extends WebTestCase
{
/** @var ProjectBuildHelper */
protected $service;
protected ProjectBuildHelper $service;
protected function setUp(): void
{
@ -130,6 +132,180 @@ final class ProjectBuildHelperTest extends WebTestCase
$project->addBomEntry($bom_entry1);
$this->assertSame('∞', $this->service->getMaximumBuildableCountAsString($project));
}
// --- Build price tests ---
private function makePartWithPrice(float $pricePerPiece, float $minQty = 1.0): Part
{
$part = new Part();
$orderdetail = new Orderdetail();
$pricedetail = (new Pricedetail())
->setMinDiscountQuantity($minQty)
->setPrice(BigDecimal::of((string) $pricePerPiece));
$orderdetail->addPricedetail($pricedetail);
$part->addOrderdetail($orderdetail);
return $part;
}
public function testCalculateTotalBuildPriceEmptyProject(): void
{
$project = new Project();
$this->assertNull($this->service->calculateTotalBuildPrice($project));
}
public function testCalculateTotalBuildPriceNoPricingData(): void
{
$project = new Project();
// Part with no orderdetails — no pricing
$entry = (new ProjectBOMEntry())->setPart(new Part())->setQuantity(2);
$project->addBomEntry($entry);
$this->assertNull($this->service->calculateTotalBuildPrice($project));
}
public function testCalculateTotalBuildPriceNonPartEntry(): void
{
$project = new Project();
$entry = new ProjectBOMEntry();
$entry->setName('Custom wire');
$entry->setQuantity(3);
$entry->setPrice(BigDecimal::of('2.00'));
$project->addBomEntry($entry);
// 3 × 2.00 = 6.00 for 1 build
$result = $this->service->calculateTotalBuildPrice($project, 1);
$this->assertNotNull($result);
$this->assertTrue(BigDecimal::of('6.00')->isEqualTo($result));
}
public function testCalculateTotalBuildPriceNonPartEntryMultipleBuilds(): void
{
$project = new Project();
$entry = new ProjectBOMEntry();
$entry->setName('Custom wire');
$entry->setQuantity(3);
$entry->setPrice(BigDecimal::of('2.00'));
$project->addBomEntry($entry);
// 3 × 2.00 × 5 = 30.00 for 5 builds
$result = $this->service->calculateTotalBuildPrice($project, 5);
$this->assertNotNull($result);
$this->assertTrue(BigDecimal::of('30.00')->isEqualTo($result));
}
public function testCalculateTotalBuildPriceWithPart(): void
{
$project = new Project();
$entry = new ProjectBOMEntry();
$entry->setPart($this->makePartWithPrice(1.50));
$entry->setQuantity(4);
$project->addBomEntry($entry);
// 4 × 1.50 = 6.00 for 1 build
$result = $this->service->calculateTotalBuildPrice($project, 1);
$this->assertNotNull($result);
$this->assertTrue(BigDecimal::of('6.00')->isEqualTo($result));
}
public function testCalculateUnitBuildPriceEqualsTotal(): void
{
$project = new Project();
$entry = new ProjectBOMEntry();
$entry->setName('Screw');
$entry->setQuantity(10);
$entry->setPrice(BigDecimal::of('0.10'));
$project->addBomEntry($entry);
// unit = 10 × 0.10 = 1.00; total for 3 builds = 3.00
$unit = $this->service->calculateUnitBuildPrice($project, 3);
$total = $this->service->calculateTotalBuildPrice($project, 3);
$this->assertNotNull($unit);
$this->assertNotNull($total);
$this->assertTrue($total->isEqualTo($unit->multipliedBy(3)));
}
public function testRoundedTotalBuildPriceRoundsUp(): void
{
$project = new Project();
$entry = new ProjectBOMEntry();
$entry->setName('Tiny part');
$entry->setQuantity(1);
$entry->setPrice(BigDecimal::of('0.001'));
$project->addBomEntry($entry);
// 0.001 rounded up to 2dp = 0.01
$result = $this->service->roundedTotalBuildPrice($project, 1);
$this->assertNotNull($result);
$this->assertTrue(BigDecimal::of('0.01')->isEqualTo($result));
}
public function testCalculateTotalBuildPriceMixedEntries(): void
{
$project = new Project();
// Part entry: 2 × 3.00 = 6.00
$partEntry = new ProjectBOMEntry();
$partEntry->setPart($this->makePartWithPrice(3.00));
$partEntry->setQuantity(2);
$project->addBomEntry($partEntry);
// Non-part entry with price: 5 × 1.00 = 5.00
$nonPartEntry = new ProjectBOMEntry();
$nonPartEntry->setName('Solder');
$nonPartEntry->setQuantity(5);
$nonPartEntry->setPrice(BigDecimal::of('1.00'));
$project->addBomEntry($nonPartEntry);
// Total = 11.00
$result = $this->service->calculateTotalBuildPrice($project, 1);
$this->assertNotNull($result);
$this->assertTrue(BigDecimal::of('11.00')->isEqualTo($result));
}
public function testGetEntryUnitPriceReturnsZeroForNoPricingData(): void
{
$entry = new ProjectBOMEntry();
$entry->setPart(new Part()); // part with no orderdetails
$entry->setQuantity(5);
$result = $this->service->getEntryUnitPrice($entry);
$this->assertTrue(BigDecimal::zero()->isEqualTo($result));
}
public function testGetEntryUnitPriceNonPartEntry(): void
{
$entry = new ProjectBOMEntry();
$entry->setName('Wire');
$entry->setQuantity(2);
$entry->setPrice(BigDecimal::of('1.25'));
$result = $this->service->getEntryUnitPrice($entry);
$this->assertTrue(BigDecimal::of('1.25')->isEqualTo($result));
}
public function testGetEntryUnitPriceWithPart(): void
{
$entry = new ProjectBOMEntry();
$entry->setPart($this->makePartWithPrice(2.00));
$entry->setQuantity(3);
$result = $this->service->getEntryUnitPrice($entry);
$this->assertTrue(BigDecimal::of('2.00')->isEqualTo($result));
}
public function testCalculateTotalBuildPriceRespectsMinOrderAmount(): void
{
$project = new Project();
// Part has a minimum order quantity of 10 at 0.50/piece
$entry = new ProjectBOMEntry();
$entry->setPart($this->makePartWithPrice(0.50, 10.0));
$entry->setQuantity(1); // BOM only needs 1, but MOQ is 10
$project->addBomEntry($entry);
// Price lookup uses qty=10 (MOQ), returns 0.50. Cost = 1 × 0.50 = 0.50
$result = $this->service->calculateTotalBuildPrice($project, 1);
$this->assertNotNull($result);
$this->assertTrue(BigDecimal::of('0.50')->isEqualTo($result));
}
}

View file

@ -28,8 +28,7 @@ use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
final class ProjectBuildPartHelperTest extends WebTestCase
{
/** @var ProjectBuildPartHelper */
protected $service;
protected ProjectBuildPartHelper $service;
protected function setUp(): void
{

View file

@ -7241,6 +7241,12 @@ Element 3</target>
<target>Cena</target>
</segment>
</unit>
<unit id="bomExPrc" name="project.bom.ext_price">
<segment state="initial">
<source>project.bom.ext_price</source>
<target>Extended Price</target>
</segment>
</unit>
<unit id="8tP3lQI" name="part.info.withdraw_modal.title.withdraw">
<segment state="translated">
<source>part.info.withdraw_modal.title.withdraw</source>

View file

@ -7232,6 +7232,12 @@ Element 3</target>
<target>Pris</target>
</segment>
</unit>
<unit id="bomExPrc" name="project.bom.ext_price">
<segment state="initial">
<source>project.bom.ext_price</source>
<target>Extended Price</target>
</segment>
</unit>
<unit id="8tP3lQI" name="part.info.withdraw_modal.title.withdraw">
<segment state="translated">
<source>part.info.withdraw_modal.title.withdraw</source>

View file

@ -1,6 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<xliff xmlns="urn:oasis:names:tc:xliff:document:2.0" version="2.0" srcLang="en" trgLang="de">
<file id="messages.en">
<file id="messages.de">
<unit id="x_wTSQS" name="attachment_type.caption">
<segment state="translated">
<source>attachment_type.caption</source>

View file

@ -7259,6 +7259,12 @@ Elemento 3</target>
<target>Precio</target>
</segment>
</unit>
<unit id="bomExPrc" name="project.bom.ext_price">
<segment state="initial">
<source>project.bom.ext_price</source>
<target>Extended Price</target>
</segment>
</unit>
<unit id="8tP3lQI" name="part.info.withdraw_modal.title.withdraw">
<segment state="translated">
<source>part.info.withdraw_modal.title.withdraw</source>

View file

@ -7198,6 +7198,12 @@
<target>Ár</target>
</segment>
</unit>
<unit id="bomExPrc" name="project.bom.ext_price">
<segment state="initial">
<source>project.bom.ext_price</source>
<target>Extended Price</target>
</segment>
</unit>
<unit id="8tP3lQI" name="part.info.withdraw_modal.title.withdraw">
<segment state="translated">
<source>part.info.withdraw_modal.title.withdraw</source>

View file

@ -7186,6 +7186,12 @@ Element 3</target>
<target>Prezzo</target>
</segment>
</unit>
<unit id="bomExPrc" name="project.bom.ext_price">
<segment state="initial">
<source>project.bom.ext_price</source>
<target>Extended Price</target>
</segment>
</unit>
<unit id="8tP3lQI" name="part.info.withdraw_modal.title.withdraw">
<segment state="translated">
<source>part.info.withdraw_modal.title.withdraw</source>

View file

@ -7256,6 +7256,12 @@ Element 3</target>
<target>Cena</target>
</segment>
</unit>
<unit id="bomExPrc" name="project.bom.ext_price">
<segment state="initial">
<source>project.bom.ext_price</source>
<target>Extended Price</target>
</segment>
</unit>
<unit id="8tP3lQI" name="part.info.withdraw_modal.title.withdraw">
<segment state="translated">
<source>part.info.withdraw_modal.title.withdraw</source>

View file

@ -7260,6 +7260,12 @@
<target>Цена</target>
</segment>
</unit>
<unit id="bomExPrc" name="project.bom.ext_price">
<segment state="initial">
<source>project.bom.ext_price</source>
<target>Extended Price</target>
</segment>
</unit>
<unit id="8tP3lQI" name="part.info.withdraw_modal.title.withdraw">
<segment state="translated">
<source>part.info.withdraw_modal.title.withdraw</source>

View file

@ -7259,6 +7259,12 @@ Element 3</target>
<target>价格</target>
</segment>
</unit>
<unit id="bomExPrc" name="project.bom.ext_price">
<segment state="initial">
<source>project.bom.ext_price</source>
<target>Extended Price</target>
</segment>
</unit>
<unit id="hO.xnng" name="part.info.withdraw_modal.title.withdraw">
<segment state="translated">
<source>part.info.withdraw_modal.title.withdraw</source>

5599
yarn.lock

File diff suppressed because it is too large Load diff