diff --git a/VERSION b/VERSION
index dedcc7d4..10c2c0c3 100644
--- a/VERSION
+++ b/VERSION
@@ -1 +1 @@
-2.9.1
+2.10.0
diff --git a/composer.lock b/composer.lock
index 8e509fbf..217a3e49 100644
--- a/composer.lock
+++ b/composer.lock
@@ -13083,7 +13083,7 @@
},
{
"name": "symfony/polyfill-ctype",
- "version": "v1.34.0",
+ "version": "v1.36.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/polyfill-ctype.git",
@@ -13142,7 +13142,7 @@
"portable"
],
"support": {
- "source": "https://github.com/symfony/polyfill-ctype/tree/v1.34.0"
+ "source": "https://github.com/symfony/polyfill-ctype/tree/v1.36.0"
},
"funding": [
{
@@ -13166,7 +13166,7 @@
},
{
"name": "symfony/polyfill-intl-grapheme",
- "version": "v1.34.0",
+ "version": "v1.36.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/polyfill-intl-grapheme.git",
@@ -13224,7 +13224,7 @@
"shim"
],
"support": {
- "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.34.0"
+ "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.36.0"
},
"funding": [
{
@@ -13248,7 +13248,7 @@
},
{
"name": "symfony/polyfill-intl-icu",
- "version": "v1.34.0",
+ "version": "v1.36.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/polyfill-intl-icu.git",
@@ -13312,7 +13312,7 @@
"shim"
],
"support": {
- "source": "https://github.com/symfony/polyfill-intl-icu/tree/v1.34.0"
+ "source": "https://github.com/symfony/polyfill-intl-icu/tree/v1.36.0"
},
"funding": [
{
@@ -13336,7 +13336,7 @@
},
{
"name": "symfony/polyfill-intl-idn",
- "version": "v1.34.0",
+ "version": "v1.36.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/polyfill-intl-idn.git",
@@ -13399,7 +13399,7 @@
"shim"
],
"support": {
- "source": "https://github.com/symfony/polyfill-intl-idn/tree/v1.34.0"
+ "source": "https://github.com/symfony/polyfill-intl-idn/tree/v1.36.0"
},
"funding": [
{
@@ -13423,7 +13423,7 @@
},
{
"name": "symfony/polyfill-intl-normalizer",
- "version": "v1.34.0",
+ "version": "v1.36.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/polyfill-intl-normalizer.git",
@@ -13484,7 +13484,7 @@
"shim"
],
"support": {
- "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.34.0"
+ "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.36.0"
},
"funding": [
{
@@ -13508,7 +13508,7 @@
},
{
"name": "symfony/polyfill-php83",
- "version": "v1.34.0",
+ "version": "v1.36.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/polyfill-php83.git",
@@ -13564,7 +13564,7 @@
"shim"
],
"support": {
- "source": "https://github.com/symfony/polyfill-php83/tree/v1.34.0"
+ "source": "https://github.com/symfony/polyfill-php83/tree/v1.36.0"
},
"funding": [
{
@@ -13588,7 +13588,7 @@
},
{
"name": "symfony/polyfill-php84",
- "version": "v1.34.0",
+ "version": "v1.36.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/polyfill-php84.git",
@@ -13644,7 +13644,7 @@
"shim"
],
"support": {
- "source": "https://github.com/symfony/polyfill-php84/tree/v1.34.0"
+ "source": "https://github.com/symfony/polyfill-php84/tree/v1.36.0"
},
"funding": [
{
@@ -13668,7 +13668,7 @@
},
{
"name": "symfony/polyfill-php85",
- "version": "v1.34.0",
+ "version": "v1.36.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/polyfill-php85.git",
@@ -13724,7 +13724,7 @@
"shim"
],
"support": {
- "source": "https://github.com/symfony/polyfill-php85/tree/v1.34.0"
+ "source": "https://github.com/symfony/polyfill-php85/tree/v1.36.0"
},
"funding": [
{
@@ -13748,7 +13748,7 @@
},
{
"name": "symfony/polyfill-uuid",
- "version": "v1.34.0",
+ "version": "v1.36.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/polyfill-uuid.git",
@@ -13807,7 +13807,7 @@
"uuid"
],
"support": {
- "source": "https://github.com/symfony/polyfill-uuid/tree/v1.34.0"
+ "source": "https://github.com/symfony/polyfill-uuid/tree/v1.36.0"
},
"funding": [
{
@@ -18469,11 +18469,11 @@
},
{
"name": "phpstan/phpstan",
- "version": "2.1.47",
+ "version": "2.1.48",
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/phpstan/phpstan/zipball/79015445d8bd79e62b29140f12e5bfced1dcca65",
- "reference": "79015445d8bd79e62b29140f12e5bfced1dcca65",
+ "url": "https://api.github.com/repos/phpstan/phpstan/zipball/231397213efb7c0a066ee024b5c3c87f2d3adfa0",
+ "reference": "231397213efb7c0a066ee024b5c3c87f2d3adfa0",
"shasum": ""
},
"require": {
@@ -18518,7 +18518,7 @@
"type": "github"
}
],
- "time": "2026-04-13T15:49:08+00:00"
+ "time": "2026-04-15T20:24:19+00:00"
},
{
"name": "phpstan/phpstan-doctrine",
@@ -19244,12 +19244,12 @@
"source": {
"type": "git",
"url": "https://github.com/Roave/SecurityAdvisories.git",
- "reference": "b0b156ed9d5d2eb313c33f92af3dbc886ba4688a"
+ "reference": "bb550b5adb0d4d74c4f6857c6b3b3638c022e90b"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/Roave/SecurityAdvisories/zipball/b0b156ed9d5d2eb313c33f92af3dbc886ba4688a",
- "reference": "b0b156ed9d5d2eb313c33f92af3dbc886ba4688a",
+ "url": "https://api.github.com/repos/Roave/SecurityAdvisories/zipball/bb550b5adb0d4d74c4f6857c6b3b3638c022e90b",
+ "reference": "bb550b5adb0d4d74c4f6857c6b3b3638c022e90b",
"shasum": ""
},
"conflict": {
@@ -19379,7 +19379,7 @@
"codingms/modules": "<4.3.11|>=5,<5.7.4|>=6,<6.4.2|>=7,<7.5.5",
"commerceteam/commerce": ">=0.9.6,<0.9.9",
"components/jquery": ">=1.0.3,<3.5",
- "composer/composer": "<1.10.27|>=2,<2.2.26|>=2.3,<2.9.3",
+ "composer/composer": "<2.2.27|>=2.3,<2.9.6",
"concrete5/concrete5": "<9.4.8",
"concrete5/core": "<8.5.8|>=9,<9.1",
"contao-components/mediaelement": ">=2.14.2,<2.21.1",
@@ -19396,7 +19396,7 @@
"cpsit/typo3-mailqueue": "<0.4.5|>=0.5,<0.5.2",
"craftcms/aws-s3": ">=2.0.2,<=2.2.4",
"craftcms/azure-blob": ">=2.0.0.0-beta1,<=2.1",
- "craftcms/cms": "<=4.17.7|>=5,<=5.9.13",
+ "craftcms/cms": "<=4.17.8|>=5,<5.9.15",
"craftcms/commerce": ">=4,<4.11|>=5,<5.6",
"craftcms/composer": ">=4.0.0.0-RC1-dev,<=4.10|>=5.0.0.0-RC1-dev,<=5.5.1",
"craftcms/craft": ">=3.5,<=4.16.17|>=5.0.0.0-RC1-dev,<=5.8.21",
@@ -19664,7 +19664,7 @@
"kelvinmo/simplexrd": "<3.1.1",
"kevinpapst/kimai2": "<1.16.7",
"khodakhah/nodcms": "<=3",
- "kimai/kimai": "<=2.50",
+ "kimai/kimai": "<2.53",
"kitodo/presentation": "<3.2.3|>=3.3,<3.3.4",
"klaviyo/magento2-extension": ">=1,<3",
"knplabs/knp-snappy": "<=1.4.2",
@@ -19805,8 +19805,8 @@
"october/backend": "<1.1.2",
"october/cms": "<1.0.469|==1.0.469|==1.0.471|==1.1.1",
"october/october": "<3.7.5",
- "october/rain": "<1.0.472|>=1.1,<1.1.2",
- "october/system": "<=3.7.12|>=4,<=4.0.11",
+ "october/rain": "<=3.7.13|>=4,<=4.1.9",
+ "october/system": "<=3.7.13|>=4,<=4.1.9",
"oliverklee/phpunit": "<3.5.15",
"omeka/omeka-s": "<4.0.3",
"onelogin/php-saml": "<2.21.1|>=3,<3.8.1|>=4,<4.3.1",
@@ -19885,7 +19885,7 @@
"pixelfed/pixelfed": "<0.12.5",
"plotly/plotly.js": "<2.25.2",
"pocketmine/bedrock-protocol": "<8.0.2",
- "pocketmine/pocketmine-mp": "<5.41.1",
+ "pocketmine/pocketmine-mp": "<5.42.1",
"pocketmine/raklib": ">=0.14,<0.14.6|>=0.15,<0.15.1",
"pressbooks/pressbooks": "<5.18",
"prestashop/autoupgrade": ">=4,<4.10.1",
@@ -19939,6 +19939,7 @@
"rudloff/rtmpdump-bin": "<=2.3.1",
"s-cart/core": "<=9.0.5",
"s-cart/s-cart": "<6.9",
+ "s9y/serendipity": "<2.6",
"sabberworm/php-css-parser": ">=1,<1.0.1|>=2,<2.0.1|>=3,<3.0.1|>=4,<4.0.1|>=5,<5.0.9|>=5.1,<5.1.3|>=5.2,<5.2.1|>=6,<6.0.2|>=7,<7.0.4|>=8,<8.0.1|>=8.1,<8.1.1|>=8.2,<8.2.1|>=8.3,<8.3.1",
"sabre/dav": ">=1.6,<1.7.11|>=1.8,<1.8.9",
"saloonphp/saloon": "<4",
@@ -20168,6 +20169,7 @@
"webcoast/deferred-image-processing": "<1.0.2",
"webklex/laravel-imap": "<5.3",
"webklex/php-imap": "<5.3",
+ "webonyx/graphql-php": "<=15.31.4",
"webpa/webpa": "<3.1.2",
"webreinvent/vaahcms": "<=2.3.1",
"wikibase/wikibase": "<=1.39.3",
@@ -20187,7 +20189,7 @@
"wpcloud/wp-stateless": "<3.2",
"wpglobus/wpglobus": "<=1.9.6",
"wpmetabox/meta-box": "<5.11.2",
- "wwbn/avideo": "<=26",
+ "wwbn/avideo": "<=29",
"xataface/xataface": "<3",
"xpressengine/xpressengine": "<3.0.15",
"yab/quarx": "<2.4.5",
@@ -20287,7 +20289,7 @@
"type": "tidelift"
}
],
- "time": "2026-04-13T18:30:45+00:00"
+ "time": "2026-04-15T20:21:07+00:00"
},
{
"name": "sebastian/cli-parser",
diff --git a/config/packages/doctrine.yaml b/config/packages/doctrine.yaml
index 5261c295..164ac717 100644
--- a/config/packages/doctrine.yaml
+++ b/config/packages/doctrine.yaml
@@ -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:
diff --git a/phpstan.dist.neon b/phpstan.dist.neon
index fe51518d..c7da636f 100644
--- a/phpstan.dist.neon
+++ b/phpstan.dist.neon
@@ -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 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
diff --git a/public/kicad/footprints.txt b/public/kicad/footprints.txt
index a34b9406..551d7d9c 100644
--- a/public/kicad/footprints.txt
+++ b/public/kicad/footprints.txt
@@ -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
diff --git a/public/kicad/symbols.txt b/public/kicad/symbols.txt
index 54fd79d2..34e246a5 100644
--- a/public/kicad/symbols.txt
+++ b/public/kicad/symbols.txt
@@ -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
diff --git a/src/Controller/ProjectController.php b/src/Controller/ProjectController.php
index d2c35efd..531deb3f 100644
--- a/src/Controller/ProjectController.php
+++ b/src/Controller/ProjectController.php
@@ -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,
]);
}
diff --git a/src/DataTables/PartsDataTable.php b/src/DataTables/PartsDataTable.php
index 8bb5f6aa..b34eef9d 100644
--- a/src/DataTables/PartsDataTable.php
+++ b/src/DataTables/PartsDataTable.php
@@ -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;
}
diff --git a/src/DataTables/ProjectBomEntriesDataTable.php b/src/DataTables/ProjectBomEntriesDataTable.php
index 04d8206b..2d5c4ebc 100644
--- a/src/DataTables/ProjectBomEntriesDataTable.php
+++ b/src/DataTables/ProjectBomEntriesDataTable.php
@@ -29,12 +29,15 @@ use App\DataTables\Column\LocaleDateTimeColumn;
use App\DataTables\Column\MarkdownColumn;
use App\DataTables\Helpers\PartDataTableHelper;
use App\Doctrine\Helpers\FieldHelper;
-use App\Entity\Parts\Part;
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;
@@ -50,7 +53,9 @@ class ProjectBomEntriesDataTable implements DataTableTypeInterface
protected EntityURLGenerator $entityURLGenerator,
protected TranslatorInterface $translator,
protected AmountFormatter $amountFormatter,
- protected PartDataTableHelper $partDataTableHelper
+ protected PartDataTableHelper $partDataTableHelper,
+ protected ProjectBuildHelper $projectBuildHelper,
+ protected MoneyFormatter $moneyFormatter,
) {
}
@@ -202,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'),
diff --git a/src/Doctrine/Functions/SiValueSort.php b/src/Doctrine/Functions/SiValueSort.php
new file mode 100644
index 00000000..c4d16444
--- /dev/null
+++ b/src/Doctrine/Functions/SiValueSort.php
@@ -0,0 +1,196 @@
+.
+ */
+
+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;
+ }
+}
diff --git a/src/Doctrine/Middleware/SQLiteRegexExtensionMiddlewareDriver.php b/src/Doctrine/Middleware/SQLiteRegexExtensionMiddlewareDriver.php
index ad572d4c..aa6108c9 100644
--- a/src/Doctrine/Middleware/SQLiteRegexExtensionMiddlewareDriver.php
+++ b/src/Doctrine/Middleware/SQLiteRegexExtensionMiddlewareDriver.php
@@ -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);
}
}
diff --git a/src/Services/ProjectSystem/ProjectBuildHelper.php b/src/Services/ProjectSystem/ProjectBuildHelper.php
index a541c29d..ee5b8c68 100644
--- a/src/Services/ProjectSystem/ProjectBuildHelper.php
+++ b/src/Services/ProjectSystem/ProjectBuildHelper.php
@@ -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();
+ }
}
diff --git a/src/Settings/BehaviorSettings/PartTableColumns.php b/src/Settings/BehaviorSettings/PartTableColumns.php
index 3b30e0a4..32f6100b 100644
--- a/src/Settings/BehaviorSettings/PartTableColumns.php
+++ b/src/Settings/BehaviorSettings/PartTableColumns.php
@@ -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";
diff --git a/templates/projects/info/_info.html.twig b/templates/projects/info/_info.html.twig
index b95be253..c3a8e86d 100644
--- a/templates/projects/info/_info.html.twig
+++ b/templates/projects/info/_info.html.twig
@@ -55,6 +55,32 @@
+ {% 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 %}
+
+
+
+
+ {% 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 %}
+
+ ({% trans %}project.info.per_unit_price{% endtrans %}: {{ unit_build_price | format_money(app.user.currency ?? null, 2) }})
+
+ {% endif %}
+
+
+
+ {% endif %}
+
{% if project.children is not empty %}
@@ -69,9 +95,9 @@
{% if project.comment is not empty %}
-
-
{% trans %}comment.label{% endtrans %}:
- {{ project.comment|format_markdown }}
-
+
+
{% trans %}comment.label{% endtrans %}:
+ {{ project.comment|format_markdown }}
+
{% endif %}
-
\ No newline at end of file
+
diff --git a/tests/Doctrine/Functions/SiValueSortTest.php b/tests/Doctrine/Functions/SiValueSortTest.php
new file mode 100644
index 00000000..dbdd9d28
--- /dev/null
+++ b/tests/Doctrine/Functions/SiValueSortTest.php
@@ -0,0 +1,193 @@
+.
+ */
+
+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
+ */
+ 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));
+ }
+}
diff --git a/tests/Services/ProjectSystem/ProjectBuildHelperTest.php b/tests/Services/ProjectSystem/ProjectBuildHelperTest.php
index fb31b51e..b80adb2f 100644
--- a/tests/Services/ProjectSystem/ProjectBuildHelperTest.php
+++ b/tests/Services/ProjectSystem/ProjectBuildHelperTest.php
@@ -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));
}
}
diff --git a/tests/Services/ProjectSystem/ProjectBuildPartHelperTest.php b/tests/Services/ProjectSystem/ProjectBuildPartHelperTest.php
index 894f6315..8126c83d 100644
--- a/tests/Services/ProjectSystem/ProjectBuildPartHelperTest.php
+++ b/tests/Services/ProjectSystem/ProjectBuildPartHelperTest.php
@@ -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
{
diff --git a/translations/messages.cs.xlf b/translations/messages.cs.xlf
index 74ca2a26..0f4cf2c8 100644
--- a/translations/messages.cs.xlf
+++ b/translations/messages.cs.xlf
@@ -7241,6 +7241,12 @@ Element 3
Cena
+
+
+ project.bom.ext_price
+ Extended Price
+
+ part.info.withdraw_modal.title.withdraw
diff --git a/translations/messages.da.xlf b/translations/messages.da.xlf
index 9878a09e..a435396c 100644
--- a/translations/messages.da.xlf
+++ b/translations/messages.da.xlf
@@ -642,6 +642,12 @@ Underelementer vil blive flyttet opad.
Gruppe
+
+
+ specifications.eda_visibility.help
+ Eksporter denne parameter som et EDA felt
+
+ specification.create
@@ -2923,6 +2929,42 @@ Bemærk også, at uden to-faktor-godkendelse er din konto ikke længere så godt
Bilag
+
+
+ part.table.eda_status
+ EDA
+
+
+
+
+ eda.status.symbol_set
+ KiCad symbolsæt
+
+
+
+
+ eda.status.footprint_set
+ KiCad footprintsæt
+
+
+
+
+ eda.status.reference_set
+ eda. status.reference_set
+
+
+
+
+ eda.status.complete
+ EDA felter udfyldt (symbol, footprint, reference)
+
+
+
+
+ eda.status.partial
+ EDA felter delvist udfyldt
+
+ flash.login_successful
@@ -3265,6 +3307,12 @@ Bemærk også, at uden to-faktor-godkendelse er din konto ikke længere så godt
Ikke længere tilgængelig
+
+
+ orderdetails.edit.eda_visibility
+ Synlige i EDA
+
+ orderdetails.edit.supplierpartnr.placeholder
@@ -7184,6 +7232,12 @@ Element 3
Pris
+
+
+ project.bom.ext_price
+ Extended Price
+
+ part.info.withdraw_modal.title.withdraw
@@ -9502,6 +9556,12 @@ Bemærk venligst, at du ikke kan kopiere fra deaktiveret bruger. Hvis du prøver
EIGP 114 stregkode (f.eks. Datamatrix-kode fra Digikey og Mouser dele)
+
+
+ scan_dialog.mode.lcsc
+ LCSC.com barcode
+
+ scan_dialog.info_mode
@@ -9514,6 +9574,24 @@ Bemærk venligst, at du ikke kan kopiere fra deaktiveret bruger. Hvis du prøver
Afkodet information
+
+
+ label_scanner.target_found
+ Genstand fundet i database
+
+
+
+
+ label_scanner.scan_result.title
+ Scan-resultat
+
+
+
+
+ label_scanner.no_locations
+ Part er ikke gemt på nogen lokation.
+
+ label_generator.edit_profiles
@@ -9948,6 +10026,18 @@ Bemærk venligst, at du ikke kan kopiere fra deaktiveret bruger. Hvis du prøver
Denne værdi bestemmer dybden af kategoritræet, der er synligt i KiCad. 0 betyder, at kun kategorierne på øverste niveau er synlige. Indstil værdien til > 0 for at vise yderligere niveauer. Indstil værdien til -1 for at vise alle dele af deldatabasen inden for en enkelt kategori i KiCad.
+
+
+ settings.misc.kicad_eda.datasheet_link
+ Databladsfelt linker til PDF
+
+
+
+
+ settings.misc.kicad_eda.datasheet_link.help
+ Når det er aktiveret, vil dataarkfeltet i KiCad linke til den faktiske PDF-fil (hvis den findes). Når det er deaktiveret, vil det i stedet linke til Part-DB-siden. Linket til Part-DB-siden er altid tilgængeligt som et separat felt "Part-DB URL".
+
+ settings.behavior.sidebar
@@ -10290,6 +10380,24 @@ Bemærk venligst, at du ikke kan kopiere fra deaktiveret bruger. Hvis du prøver
Vis billedoverlejringen med detaljer om vedhæftet fil, når du holder musen over billedgalleriet med dele.
+
+
+ settings.behavior.keybindings
+ Tastaturgenveje
+
+
+
+
+ settings.behavior.keybindings.enable_special_characters
+ Aktivér tastaturgenveje for specialtegn
+
+
+
+
+ settings.behavior.keybindings.enable_special_characters.help
+ Aktivér genvejstasten Alt+ for at indsætte specialtegn (græske bogstaver, matematiske symboler osv.) i tekstfelter. Deaktiver dette, hvis genvejene er i konflikt med dit tastaturlayout eller systemgenveje.
+
+ perm.config.change_system_settings
@@ -10914,6 +11022,84 @@ Bemærk venligst, at du ikke kan kopiere fra deaktiveret bruger. Hvis du prøver
Masseimport af datakilder
+
+
+ part_list.action.group.eda
+ EDA / KiCad
+
+
+
+
+ part_list.action.batch_edit_eda
+ Batchredigering af EDA-felter
+
+
+
+
+ batch_eda.title
+ Batchredigering af EDA-felter
+
+
+
+
+ batch_eda.description
+ Rediger EDA/KiCad-felter for %count% valgte dele. Markér feltet "Anvend" ud for hvert felt, du vil ændre.
+
+
+
+
+ batch_eda.show_parts
+ Vis valgte dele
+
+
+
+
+ batch_eda.apply_hint
+ Kun felter, hvor afkrydsningsfeltet "Anvend" er markeret, ændres. Felter, der ikke er markeret, ændres ikke.
+
+
+
+
+ batch_eda.apply
+ Anvend
+
+
+
+
+ batch_eda.field
+ Felt
+
+
+
+
+ batch_eda.value
+ Værdi
+
+
+
+
+ batch_eda.submit
+ Anvend på udvalgte dele
+
+
+
+
+ batch_eda.cancel
+ Annullér
+
+
+
+
+ batch_eda.success
+ EDA felter er nu opdateret
+
+
+
+
+ batch_eda.no_parts_selected
+ Ingen dele blev valgt til batchredigering.
+
+ info_providers.bulk_import.step1.spn_recommendation
@@ -12227,7 +12413,7 @@ Buerklin API-godkendelsesserver: 10 anmodninger/minut pr. IP-adresse
update_manager.progress.downgrade_title
- Downgrade fremskridt
+ Downgrade fremskridtPart-DB er blevet nedgraderet! Du skal muligvis opdatere siden for at se den nye version.
@@ -12314,6 +12500,102 @@ Buerklin API-godkendelsesserver: 10 anmodninger/minut pr. IP-adresse
Gendannelse af sikkerhedskopi er deaktiveret af serverkonfigurationen.
+
+
+ update_manager.backup.create
+ Opret sikkerhedskopi
+
+
+
+
+ update_manager.backup.create.confirm
+ Vil du lave en fuld sikkerhedskopi nu? Det kan tage et stykke tid.
+
+
+
+
+ update_manager.backup.created
+ Sikkerhedskopi er oprettet.
+
+
+
+
+ update_manager.backup.delete.confirm
+ Er du sikker på at du vil slette denne backup?
+
+
+
+
+ update_manager.backup.deleted
+ Sikkerhedskopi er slettet.
+
+
+
+
+ update_manager.backup.delete_error
+ Sikkerhedskopi kunne ikke udføres.
+
+
+
+
+ update_manager.log.delete.confirm
+ Er du sikker på at du vil slette denne log?
+
+
+
+
+ update_manager.log.deleted
+ Log slettet.
+
+
+
+
+ update_manager.log.delete_error
+ Kunne ikke slette loggen.
+
+
+
+
+ update_manager.view_log
+ Vis log.
+
+
+
+
+ update_manager.delete
+ Slet
+
+
+
+
+ update_manager.backup.download
+ Download sikkerhedskopi
+
+
+
+
+ update_manager.backup.download.password_label
+ Bekræft password for at downloade
+
+
+
+
+ update_manager.backup.download.security_warning
+ Sikkerhedskopier indeholder følsomme data, herunder password-hashes og hemmeligheder. Bekræft venligst dit password for at fortsætte med download.
+
+
+
+
+ update_manager.backup.download.invalid_password
+ Ugyldigt password. Download af sikkerhedskopi er afvist.
+
+
+
+
+ update_manager.backup.docker_warning
+ Docker-installation registreret. Sikkerhedskopier gemmes i var/backups/, som ikke er en persistent enhed. Brug downloadknappen til at gemme sikkerhedskopier eksternt, eller montér var/backups/ som en enhed i din docker-compose.yml.
+
+ settings.ips.conrad
@@ -12404,5 +12686,281 @@ Buerklin API-godkendelsesserver: 10 anmodninger/minut pr. IP-adresse
Opdatér til
+
+
+ part.gtin
+ GTIN / EAN
+
+
+
+
+ info_providers.capabilities.gtin
+ GTIN / EAN
+
+
+
+
+ part.table.gtin
+ GTIN
+
+
+
+
+ scan_dialog.mode.gtin
+ GTIN / EAN barcode
+
+
+
+
+ attachment_type.edit.allowed_targets
+ Anvend kun til
+
+
+
+
+ attachment_type.edit.allowed_targets.help
+ Gør kun denne bilagstype tilgængelig for bestemte elementklasser. Lad feltet stå tomt for at vise denne bilagstype for alle elementklasser.
+
+
+
+
+ orderdetails.edit.prices_includes_vat
+ Pris inklusiv moms.
+
+
+
+
+ prices.incl_vat
+ Inkl. moms
+
+
+
+
+ prices.excl_vat
+ Ekskl. moms
+
+
+
+
+ settings.system.localization.prices_include_tax_by_default
+ Priserne er som standard inklusive moms
+
+
+
+
+ settings.system.localization.prices_include_tax_by_default.description
+ Standardværdien for nyoprettede købsoplysninger, uanset om priserne inkluderer moms eller ej.
+
+
+
+
+ part_lot.edit.last_stocktake_at
+ Seneste optælling
+
+
+
+
+ perm.parts_stock.stocktake
+ Lageropgørelse
+
+
+
+
+ part.info.stocktake_modal.title
+ Lagerbeholdning
+
+
+
+
+ part.info.stocktake_modal.expected_amount
+ Forventet mængde
+
+
+
+
+ part.info.stocktake_modal.actual_amount
+ Aktuel mængde
+
+
+
+
+ log.part_stock_changed.stock_take
+ Lagerbeholdning
+
+
+
+
+ log.element_edited.changed_fields.last_stocktake_at
+ Sidste lagerbeholdning
+
+
+
+
+ part.table.eda_reference
+ EDA reference
+
+
+
+
+ part.table.eda_value
+ EDA-værdi
+
+
+
+
+ settings.misc.kicad_eda.default_parameter_visibility
+ Standard EDA-synlighed for parametre
+
+
+
+
+ settings.misc.kicad_eda.default_parameter_visibility.help
+ EDA-synlighed for alle [Part]-parametre, som ikke har en eksplicit synlighedsindstilling. Når den er aktiveret, vil alle parametre som standard være synlige i EDA-softwaren.
+
+
+
+
+ settings.misc.kicad_eda.default_orderdetails_visibility
+ Standard EDA-synlighed for købsoplysninger
+
+
+
+
+ settings.misc.kicad_eda.default_orderdetails_visibility.help
+ EDA-synlighed for alle købsoplysninger, som ikke har en eksplicit synlighedsindstilling. Når den er aktiveret, vil alle købsoplysninger som standard være synlige i EDA-softwaren.
+
+
+
+
+ label_scanner.open
+ Vis detaljer
+
+
+
+
+ label_scanner.db_part_found
+ Database [part] fundet for barcode
+
+
+
+
+ label_scanner.part_can_be_created
+ [Part] kan oprettes
+
+
+
+
+ label_scanner.part_can_be_created.help
+ Der blev ikke fundet nogen matchende [part] i databasen, men du kan oprette en ny [part] baseret på denne stregkode.
+
+
+
+
+ label_scanner.part_create_btn
+ Opret [part] fra barcode
+
+
+
+
+ parts.create_from_scan.title
+ Opret [part] ud fra labelscanning
+
+
+
+
+ scan_dialog.mode.amazon
+ Amazon barcode
+
+
+
+
+ settings.ips.canopy
+ Canopy
+
+
+
+
+ settings.ips.canopy.alwaysGetDetails
+ Hent altid detaljer
+
+
+
+
+ settings.ips.canopy.alwaysGetDetails.help
+ Når dette er valgt, hentes flere detaljer fra canopy, når en del oprettes. Dette forårsager en yderligere API-anmodning, men giver produktpunkter og kategorioplysninger.
+
+
+
+
+ attachment.sandbox.warning
+ ADVARSEL: Du ser en brugeruploadet vedhæftet fil. Dette er indhold, der ikke er tillid til. Vær forsigtig.
+
+
+
+
+ attachment.sandbox.back_to_partdb
+ Tilbage til Part-DB
+
+
+
+
+ settings.system.attachments.showHTMLAttachments
+ Vis uploadede HTML-filvedhæftninger (sandboxed)
+
+
+
+
+ settings.system.attachments.showHTMLAttachments.help
+ ⚠️ Når det er aktiveret, kan brugeruploadede HTML-vedhæftninger ses direkte i browseren. Mange potentielt skadelige funktioner er begrænsede, men dette er stadig en potentiel sikkerhedsrisiko og bør kun aktiveres, hvis du har tillid til de brugere, der kan uploade filer.
+
+
+
+
+ attachment.sandbox.title
+ HTML [Vedhæftning]
+
+
+
+
+ attachment.sandbox.as_plain_text
+ Vis som alm. tekst
+
+
+
+
+ modal.cancel
+ Annuller
+
+
+
+
+ update_manager.web_updates_allowed
+ Web-opdateringer tilladt
+
+
+
+
+ update_manager.backup_restore_allowed
+ Indlæsning af sikkerhedskopi (backup) tilladt
+
+
+
+
+ update_manager.backup_download_allowed
+ Download af sikkerhedskopi tilladt
+
+
+
+
+ part.create_from_info_provider.lot_filled_from_barcode
+ [Part_lot] oprettet fra stregkode: Kontroller venligst, om dataene er korrekte og ønskede.
+
+
+
+
+ project.bom_import.field_mapping.error.check_delimiter
+ Felttilknytningsfejl: Kontroller, om du har valgt den rigtige tegn-afgrænser!
+
+
-
\ No newline at end of file
+
diff --git a/translations/messages.de.xlf b/translations/messages.de.xlf
index db595136..7e070ff2 100644
--- a/translations/messages.de.xlf
+++ b/translations/messages.de.xlf
@@ -2779,6 +2779,12 @@ Wenn Sie dies fehlerhafterweise gemacht haben oder ein Computer nicht mehr vertr
Name
+
+
+ part.table.si_value
+ SI-Wert
+
+ part.table.id
@@ -7211,6 +7217,18 @@ Element 1 -> Element 1.2
Unterprojekte
+
+
+ project.info.total_build_price
+ Gesamterstellpreis
+
+
+
+
+ project.info.per_unit_price
+ pro Einheit
+
+ project.info.bom_add_parts
@@ -7235,6 +7253,12 @@ Element 1 -> Element 1.2
Preis
+
+
+ project.bom.ext_price
+ Gesamtpreis
+
+ part.info.withdraw_modal.title.withdraw
@@ -10028,6 +10052,90 @@ Bitte beachten Sie, dass Sie sich nicht als deaktivierter Benutzer ausgeben kön
Wenn aktiviert, verlinkt das Datenblatt-Feld in KiCad auf die tatsächliche PDF-Datei (sofern gefunden). Wenn deaktiviert, führt es stattdessen zur Part-DB-Seite. Der Link zur Part-DB-Seite ist immer als separates "Part-DB URL"-Feld verfügbar.
+
+
+ settings.misc.kicad_eda.editor.title
+ KiCad Autovervollständigungslisten
+
+
+
+
+ settings.misc.kicad_eda.editor.link
+ Autovervollständigungseinstellungen
+
+
+
+
+ settings.misc.kicad_eda.editor.description
+ Konfigurieren Sie, ob KiCad Autovervollständigung die automatisch generierten Standardlisten oder Ihre benutzerdefinierten Überschreibungsdateien verwendet. Die benutzerdefinierten Dateien sind hier bearbeitbar, während die Standarddateien nur lesbar zur Referenz angezeigt werden.
+
+
+
+
+ settings.misc.kicad_eda.editor.footprints
+ Footprint-Liste
+
+
+
+
+ settings.misc.kicad_eda.editor.footprints.help
+ Ein Eintrag pro Zeile. Wird als Autovervollständigungsvorschlag für KiCad-Footprintfelder verwendet.
+
+
+
+
+ settings.misc.kicad_eda.editor.symbols
+ Symbolliste
+
+
+
+
+ settings.misc.kicad_eda.editor.symbols.help
+ Ein Eintrag pro Zeile. Wird als Autovervollständigungsvorschlag für KiCad-Symbolfelder verwendet.
+
+
+
+
+ settings.misc.kicad_eda.use_custom_list
+ Benutzerdefinierte Autovervollständigungslisten verwenden
+
+
+
+
+ settings.misc.kicad_eda.use_custom_list.help
+ Wenn aktiviert, verwendet die KiCad Autovervollständigung public/kicad/footprints_custom.txt und public/kicad/symbols_custom.txt anstelle der automatisch generierten Standarddateien.
+
+
+
+
+ settings.misc.kicad_eda.editor.custom_footprints
+ Benutzerdefinierte Footprint-Liste
+
+
+
+
+ settings.misc.kicad_eda.editor.custom_symbols
+ Benutzerdefinierte Symbolliste
+
+
+
+
+ settings.misc.kicad_eda.editor.default_footprints
+ Standard Footprint-Liste
+
+
+
+
+ settings.misc.kicad_eda.editor.default_symbols
+ Standardsymboliste
+
+
+
+
+ settings.misc.kicad_eda.editor.default_files_help
+ Automatisch generierte Datei wird nur zur Referenz angezeigt. Änderungen müssen in der benutzerdefinierten Liste vorgenommen werden.
+
+ settings.behavior.sidebar
diff --git a/translations/messages.en.xlf b/translations/messages.en.xlf
index 176c6650..4da88512 100644
--- a/translations/messages.en.xlf
+++ b/translations/messages.en.xlf
@@ -2780,6 +2780,12 @@ If you have done this incorrectly or if a computer is no longer trusted, you can
Name
+
+
+ part.table.si_value
+ SI Value
+
+ part.table.id
@@ -7212,6 +7218,18 @@ Element 1 -> Element 1.2
Subprojects
+
+
+ project.info.total_build_price
+ Total build price
+
+
+
+
+ project.info.per_unit_price
+ per unit
+
+ project.info.bom_add_parts
@@ -7236,6 +7254,12 @@ Element 1 -> Element 1.2
Price
+
+
+ project.bom.ext_price
+ Extended Price
+
+ part.info.withdraw_modal.title.withdraw
diff --git a/translations/messages.es.xlf b/translations/messages.es.xlf
index 17b2156b..c580a491 100644
--- a/translations/messages.es.xlf
+++ b/translations/messages.es.xlf
@@ -7259,6 +7259,12 @@ Elemento 3
Precio
+
+
+ project.bom.ext_price
+ Extended Price
+
+ part.info.withdraw_modal.title.withdraw
diff --git a/translations/messages.fr.xlf b/translations/messages.fr.xlf
index 37e0d27e..49b7ca03 100644
--- a/translations/messages.fr.xlf
+++ b/translations/messages.fr.xlf
@@ -1,13 +1,13 @@
-
+
-
+ attachment_type.caption
- Types pour fichiers joints
+ Type de fichiers pour la pièce jointe
-
+ new
@@ -16,7 +16,7 @@
Modifier le type de pièce jointe
-
+ new
@@ -25,25 +25,25 @@
Nouveau type de pièce jointe
-
+ category.labelpCatégories
-
+ admin.optionsOptions
-
+ admin.advancedAvancé
-
+ new
@@ -52,7 +52,7 @@
Éditer la catégorie
-
+ new
@@ -61,34 +61,28 @@
Nouvelle catégorie
-
-
- currency.caption
- Devise
-
-
-
+ currency.iso_code.captionCode ISO
-
+ currency.symbol.captionSymbole de la devise
-
+ newcurrency.edit
- Editer la devise
+ Éditer la devise
-
+ new
@@ -97,43 +91,61 @@
Nouvelle devise
-
+
+
+ new
+
+
+ project.edit
+ Éditer le projet
+
+
+
+
+ new
+
+
+ project.new
+ Nouveau projet
+
+
+ search.placeholderRecherche
-
+ expandAllAgrandir tout
-
+ reduceAllRéduire tout
-
+ part.info.timetravel_hint
- C'est ainsi que le composant apparaissait avant le %timestamp%. <i>Veuillez noter que cette fonctionnalité est expérimentale, donc les infos ne sont peut-être pas correctes. </i>
+ C'est ainsi que le composant apparaissait avant le %timestamp%. <i>Veuillez noter que cette fonctionnalité est expérimentale, les informations ne sont peut-être pas correctes. </i>
-
+ standard.labelPropriétés
-
+ infos.labelInformations
-
+ new
@@ -142,82 +154,82 @@
Historique
-
+ export.labelExporter
-
+ import_export.label
- Importer exporter
+ Importer / exporter
-
+ mass_creation.label
- Création multiple
+ Création en masse
-
+ admin.commonCommun
-
+ admin.attachmentsFichiers joints
-
+ admin.parametersParamètres
-
+ export_all.labelExporter tous les éléments
-
+ mass_creation.help
- Chaque ligne sera interprétée comme le nom d'un élément qui sera créé.
+ Chaque ligne sera interprétée comme le nom d'un élément qui sera créé. Vous pouvez créer des structures imbriquées par indentations.
-
+ edit.captionÉditer l'élément "%name"
-
+ new.captionNouvel élément
-
+ footprint.labelpEmpreintes
-
+ newfootprint.edit
- Editer l'empreinte
+ Éditer l'empreinte
-
+ new
@@ -226,28 +238,22 @@
Nouvelle empreinte
-
-
- group.edit.caption
- Groupes
-
-
-
+ user.edit.permissionsPermissions
-
+ newgroup.edit
- Editer le groupe
+ Éditer le groupe
-
+ new
@@ -256,34 +262,28 @@
Nouveau groupe
-
-
- label_profile.caption
- Profil des étiquettes
-
-
-
+ label_profile.advancedAvancé
-
+ label_profile.commentCommentaire
-
+ newlabel_profile.edit
- Editer profil d'étiquette
+ Éditer profil d'étiquette
-
+ new
@@ -292,13 +292,7 @@
Nouveau profil d'étiquette
-
-
- manufacturer.caption
- Fabricants
-
-
-
+ new
@@ -307,7 +301,7 @@
Modifiez le fabricant
-
+ new
@@ -316,25 +310,13 @@
Nouveau fabricant
-
-
- measurement_unit.caption
- Unité de mesure
-
-
-
-
- part_custom_state.caption
- État personnalisé du composant
-
-
-
+ storelocation.labelpEmplacement de stockage
-
+ new
@@ -343,7 +325,7 @@
Modifier l'emplacement de stockage
-
+ new
@@ -352,7 +334,7 @@
Nouvel emplacement de stockage
-
+ new
@@ -361,7 +343,7 @@
Modifier le fournisseur
-
+ new
@@ -370,67 +352,61 @@
Nouveau fournisseur
-
-
- user.edit.caption
- Utilisateurs
-
-
-
+ user.edit.configurationConfiguration
-
+ user.edit.passwordMot de passe
-
+ user.edit.tfa.captionAuthentification à deux facteurs
-
+ user.edit.tfa.google_activeApplication d'authentification active
-
+ tfa_backup.remaining_tokensNombre de codes de secours restant
-
+ tfa_backup.generation_dateDate de génération des codes de secours
-
+ user.edit.tfa.disabledMéthode désactivée
-
+ user.edit.tfa.u2f_keys_countClés de sécurité actives
-
+ user.edit.tfa.disable_tfa_title
- Voulez vous vraiment poursuivre ?
+ Voulez-vous vraiment poursuivre ?
-
+ user.edit.tfa.disable_tfa_messageCela désactivera <b> toutes les méthodes d'authentification à deux facteurs de l'utilisateur</b> et supprimera <b>les codes de secours</b>!
@@ -439,13 +415,13 @@ L'utilisateur devra configurer à nouveau toutes les méthodes d'authentificatio
<b>Ne faites ceci qu'en étant sûr de l'identité de l'utilisateur (ayant besoin d'aide),autrement le compte pourrai être compromis!</b>
-
+ user.edit.tfa.disable_tfa.btnDésactiver toutes les méthodes d'authentification à deux facteurs
-
+ new
@@ -454,7 +430,7 @@ L'utilisateur devra configurer à nouveau toutes les méthodes d'authentificatio
Modifier l'utilisateur
-
+ new
@@ -463,75 +439,75 @@ L'utilisateur devra configurer à nouveau toutes les méthodes d'authentificatio
Nouvel utilisateur
-
+ attachment.deleteSupprimer
-
+
- attachment.external
- Externe
+ attachment.external_only
+ Pièce jointe externe uniquement
-
+ attachment.preview.altMiniature du fichier joint
-
+
- attachment.view
- Afficher
+ attachment.view_local
+ Vue locale de la pièce jointe
-
+ attachment.file_not_foundFichier introuvable
-
+ attachment.secureFichier joint privé
-
+ attachment.createAjouter un fichier joint
-
+ part_lot.edit.delete.confirm
- Voulez vous vraiment supprimer ce stock ? Cette action ne pourra pas être annulée!
+ Voulez-vous vraiment supprimer ce stock ? Cette action ne pourra pas être annulée !
-
+ entity.delete.confirm_title
- Voulez vous vraiment supprimer %name%?
+ Voulez-vous vraiment supprimer %name% ?
-
+ entity.delete.message
- Cette action ne pourra pas être annulée!
+ Cette action ne pourra pas être annulée !
<br>
Les sous éléments seront déplacés vers le haut.
-
+ entity.deleteSupprimer l'élément
-
+ new
@@ -540,308 +516,313 @@ Les sous éléments seront déplacés vers le haut.
Éditer le commentaire
-
+ entity.delete.recursiveSuppression récursive (tous les sous éléments)
-
+ entity.duplicateDupliquer l’élément
-
+ export.formatFormat de fichier
-
+ export.levelNiveau de verbosité
-
+ export.level.simpleSimple
-
+ export.level.extendedÉtendu
-
+ export.level.fullComplet
-
+ export.include_childrenExporter également les sous éléments
-
+ export.btnExporter
-
+ id.labelID
-
+ createdAtCréé le
-
+ lastModifiedDernière modification
-
+ entity.info.parts_countNombre de composants avec cet élément
-
+ specifications.propertyParamètre
-
+ specifications.symbolSymbole
-
+ specifications.value_minMin.
-
+ specifications.value_typ
- Typ.
+ Type.
-
+ specifications.value_maxMax.
-
+ specifications.unitUnité
-
+ specifications.textTexte
-
+ specifications.groupGroupe
-
+
+
+ specifications.eda_visibility.help
+ Exporter en tant que paramètre EDA
+
+
+ specification.createNouveau paramètre
-
+ parameter.delete.confirmSouhaitez-vous vraiment supprimer ce paramètre ?
-
+ attachment.list.titleListe des fichiers joints
-
+ part_list.loading.captionChargement
-
+ part_list.loading.messageCela peut prendre un moment.Si ce message ne disparaît pas, essayez de recharger la page.
-
+ vendor.base.javascript_hint
- Activez Javascipt pour profiter de toutes les fonctionnalités!
+ Activez JavaScript pour profiter de toutes les fonctionnalités !
-
+ sidebar.big.toggle
- Afficher/Cacher le panneau latéral
-Show/Hide sidebar
+ Afficher / Cacher le panneau latéral
-
+ loading.caption
- Chargement:
+ Chargement :
-
+ loading.messageCela peut prendre un moment.Si ce message ne disparaît pas, essayez de recharger la page.
-
+ loading.barChargement...
-
+ back_to_topRetour en haut de page
-
+ permission.edit.permissionPermissions
-
+ permission.edit.valueValeur
-
+ permission.legend.title
- Explication des états:
+ Explication des états :
-
+ permission.legend.disallowInterdire
-
+ permission.legend.allowAutoriser
-
+ permission.legend.inheritHériter du groupe (parent)
-
+ bool.trueVrai
-
+ bool.falseFaux
-
+ YesOui
-
+ NoNon
-
+ specifications.valueValeur
-
+ version.captionVersion
-
+ homepage.license
- Information de license
+ Information de licence
-
+ homepage.github.captionPage du projet
-
+ homepage.github.text
- Retrouvez les téléchargements, report de bugs, to-do-list etc. sur <a href="%href%" class="link-external" target="_blank">la page du projet GitHub</a>
+ Retrouvez les téléchargements, report de bugs, to-do-list, etc. sur <a href="%href%" class="link-external" target="_blank">la page du projet GitHub</a>
-
+ homepage.help.captionAide
-
+ homepage.help.textDe l'aide et des conseils sont disponibles sur le Wiki de la <a href="%href%" class="link-external" target="_blank">page GitHub</a>
-
+ homepage.forum.captionForum
-
+ new
@@ -850,97 +831,97 @@ Show/Hide sidebar
Activité récente
-
+ label_generator.titleGénérateur d'étiquettes
-
+ label_generator.commonCommun
-
+ label_generator.advancedAvancé
-
+ label_generator.profilesProfils
-
+ label_generator.selected_profileProfil actuellement sélectionné
-
+ label_generator.edit_profileModifier le profil
-
+ label_generator.load_profileCharger le profil
-
+ label_generator.downloadTélécharger
-
+ label_generator.label_btnGénérer une étiquette
-
+ label_generator.label_emptyNouvelle étiquette vide
-
+ label_scanner.titleLecteur d'étiquettes
-
+ label_scanner.no_cam_found.titleAucune webcam trouvée
-
+ label_scanner.no_cam_found.textVous devez disposer d'une webcam et donner l'autorisation d'utiliser la fonction de scanner. Vous pouvez entrer le code à barres manuellement ci-dessous.
-
+ label_scanner.source_selectSélectionnez une source
-
+ log.list.titleJournal système
-
+ new
@@ -949,7 +930,7 @@ Show/Hide sidebar
Annuler le changement / revenir à une date antérieure ?
-
+ new
@@ -958,337 +939,337 @@ Show/Hide sidebar
Voulez-vous annuler la modification donnée / réinitialiser l'élément à une date donnée ?
-
+ mail.footer.email_sent_byCet email a été envoyé automatiquement par
-
+ mail.footer.dont_replyNe répondez pas à cet email.
-
+ email.hi %name%Bonjour %name%
-
+ email.pw_reset.messageQuelqu’un (surement vous) a demandé une réinitialisation de votre mot de passe.Si ce n'est pas le cas, ignorez simplement cet email.
-
+ email.pw_reset.buttonCliquez ici pour réinitialiser votre mot de passe
-
+ email.pw_reset.fallbackSi cela ne fonctionne pas pour vous, allez à <a href="%url%">%url%</a> et entrez les informations suivantes
-
+ email.pw_reset.usernameNom d'utilisateur
-
+ email.pw_reset.tokenJeton
-
+ email.pw_reset.valid_unit %date%Le jeton de réinitialisation sera valable jusqu'au <i>%date%</i>.
-
+ orderdetail.deleteSupprimer
-
+ pricedetails.edit.min_qtyQuantité minimale de commande
-
+ pricedetails.edit.pricePrix
-
+ pricedetails.edit.price_qtyPour la quantité
-
+ pricedetail.createAjouter prix
-
+ part.edit.titleÉditer le composant
-
+ part.edit.card_titleÉditer le composant
-
+ part.edit.tab.commonGénéral
-
+ part.edit.tab.manufacturerFabricant
-
+ part.edit.tab.advancedAvancé
-
+ part.edit.tab.advanced.ipn.commonSectionHeader
- Suggestions sans incrément de partie
+ Suggestions sans incrémentation de composant
-
+ part.edit.tab.advanced.ipn.partIncrementHeader
- Propositions avec incréments numériques de parties
+ Suggestions avec incrément numérique du composant
-
+ part.edit.tab.advanced.ipn.prefix.description.current-increment
- Spécification IPN actuelle pour la pièce
+ Caractéristiques IPN actuelles du composant
-
+ part.edit.tab.advanced.ipn.prefix.description.increment
- Prochaine spécification IPN possible basée sur une description identique de la pièce
+ Prochaine caractéristique IPN possible basé sur la description d'un composant identique
-
+ part.edit.tab.advanced.ipn.prefix_empty.direct_category
- Le préfixe IPN de la catégorie directe est vide, veuillez le spécifier dans la catégorie "%name%"
+ Préfixe IPN ou catégorie vide, en spécifier une dans la catégorie "%name%"
-
+ part.edit.tab.advanced.ipn.prefix.direct_category
- Préfixe IPN de la catégorie directe
+ Préfixe IPN ou catégorie directe
-
+ part.edit.tab.advanced.ipn.prefix.direct_category.increment
- Préfixe IPN de la catégorie directe et d'un incrément spécifique à la partie
+ Préfixe IPN de catégorie directe et incrément spécifique au composant
-
+ part.edit.tab.advanced.ipn.prefix.hierarchical.no_increment
- Préfixes IPN avec un ordre hiérarchique des catégories des préfixes parents
+ Préfixes IPN avec ordre hiérarchique de catégorie du préfixe parent
-
+ part.edit.tab.advanced.ipn.prefix.hierarchical.increment
- Préfixes IPN avec un ordre hiérarchique des catégories des préfixes parents et un incrément spécifique à la pièce
+ Préfixes IPN avec ordre hiérarchique de catégorie du préfixe parent et incrément spécifique à la pièce
-
+ part.edit.tab.advanced.ipn.prefix.not_saved
- Créez d'abord une pièce et assignez-la à une catégorie : avec les catégories existantes et leurs propres préfixes IPN, l'identifiant IPN pour la pièce peut être proposé automatiquement
+ Créer d'abord une pièce et l'assigner à une catégorie : pour les catégories existantes et leur propre préfixe IPN, l'IPN de la pièce peut-être suggérer automatiquement
-
+ part.edit.tab.part_lotsStocks
-
+ part.edit.tab.attachmentsFichiers joints
-
+ part.edit.tab.orderdetailsInformations pour la commande
-
+ part.edit.tab.specificationsCaractéristiques
-
+ part.edit.tab.commentCommentaire
-
+ part.new.card_titleCréer un nouveau composant
-
+ part_lot.deleteSupprimer
-
+ part_lot.createCréer un inventaire
-
+ orderdetail.createAjouter un fournisseur
-
+ pricedetails.edit.delete.confirmVoulez-vous vraiment supprimer ce prix ? Cela ne peut pas être défait !
-
+ orderdetails.edit.delete.confirmVoulez-vous vraiment supprimer ce fournisseur ? Cela ne peut pas être défait !
-
+ part.info.titleInformations détaillées pour
-
+ part.part_lots.labelStocks
-
+ comment.labelCommentaire
-
+ part.info.specificationsCaractéristiques
-
+ attachment.labelpFichiers joints
-
+ vendor.partinfo.shopping_infosInformations de commande
-
+ vendor.partinfo.historyHistorique
-
+ tools.labelOutils
-
+ extended_info.labelInformations complémentaires
-
+ attachment.nameNom
-
+ attachment.attachment_typeType de fichier joint
-
+ attachment.file_nameNom du fichier
-
+ attachment.file_sizeTaille du fichier
-
+ attachment.previewAperçu de l'image
-
+
- attachment.download
- Téléchargement
+ attachment.download_local
+ Télécharger la pièce jointe locale
-
+ new
@@ -1297,13 +1278,13 @@ Show/Hide sidebar
Utilisateur qui a créé ce composant
-
+ UnknownInconnu
-
+ new
@@ -1312,7 +1293,7 @@ Show/Hide sidebar
Accès refusé
-
+ new
@@ -1321,31 +1302,31 @@ Show/Hide sidebar
Utilisateur qui a édité ce composant en dernier
-
+ part.isFavoriteFavoris
-
+ part.minOrderAmountQuantité minimale de commande
-
+ manufacturer.labelFabricant
-
+ name.labelNom
-
+ new
@@ -1354,439 +1335,439 @@ Show/Hide sidebar
Retour à la version actuelle
-
+ description.labelDescription
-
+ category.labelCatégorie
-
+ instock.labelEn stock
-
+ mininstock.labelStock minimum
-
+ footprint.labelEmpreinte
-
+ part.avg_price.labelPrix moyen
-
+ part.supplier.nameNom
-
+ part.supplier.partnr
- Lien/Code cmd.
+ Lien/Code Fournisseur
-
+ part.order.minamountNombre minimum
-
+ part.order.pricePrix
-
+ part.order.single_pricePrix unitaire
-
+ part_lots.descriptionDescription
-
+ part_lots.storage_locationEmplacement de stockage
-
+ part_lots.amountQuantité
-
+ part_lots.location_unknownEmplacement de stockage inconnu
-
+ part_lots.instock_unknownQuantité inconnue
-
+ part_lots.expiration_dateDate d'expiration
-
+ part_lots.is_expiredExpiré
-
+ part_lots.need_refillDoit être rempli à nouveau
-
+ part.info.prev_pictureImage précédente
-
+ part.info.next_pictureImage suivante
-
+ part.mass.tooltipPoids
-
+ part.needs_review.badgeRévision nécessaire
-
+ part.favorite.badgeFavoris
-
+ part.obsolete.badgeN'est plus disponible
-
+ parameters.extracted_from_descriptionAutomatiquement extrait de la description
-
+ parameters.auto_extracted_from_commentAutomatiquement extrait du commentaire
-
+ part.edit.btnÉditer
-
+ part.clone.btnDuplication
-
+ part.create.btnCréer un nouveau composant
-
+ part.delete.confirm_titleVoulez-vous vraiment supprimer ce composant ?
-
+ part.delete.messageLe composant et toutes les informations associées (stocks, fichiers joints, etc.) sont supprimés. Cela ne pourra pas être annulé.
-
+ part.deleteSupprimer le composant
-
+ parts_list.all.titleTous les composants
-
+ parts_list.category.titleComposants avec catégorie
-
+ parts_list.footprint.titleComposants avec empreinte
-
+ parts_list.manufacturer.titleComposants avec fabricant
-
+ parts_list.search.titleRecherche de composants
-
+ parts_list.storelocation.titleComposants avec lieu de stockage
-
+ parts_list.supplier.titleComposants avec fournisseur
-
+ parts_list.tags.titleComposants avec tag
-
+ entity.info.common.tabGénéral
-
+ entity.info.statistics.tabStatistiques
-
+ entity.info.attachments.tabPièces jointes
-
+ entity.info.parameters.tabCaractéristiques
-
+ entity.info.nameNom
-
+ entity.info.parentParent
-
+ entity.edit.btnÉditer
-
+ entity.info.children_countNombre de sous-éléments
-
+ tfa.check.titleAuthentification à deux facteurs requise
-
+