mirror of
https://github.com/Part-DB/Part-DB-server.git
synced 2026-05-18 17:31:35 +00:00
Merge branch 'master' into l10n_master
This commit is contained in:
commit
fe8dfe61b4
71 changed files with 5746 additions and 4072 deletions
4
.env
4
.env
|
|
@ -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.
|
# In demo mode things it is not possible for a user to change his password and his settings.
|
||||||
DEMO_MODE=0
|
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
|
# 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
|
# In that case all URL contains the index.php front controller in URL
|
||||||
NO_URL_REWRITE_AVAILABLE=0
|
NO_URL_REWRITE_AVAILABLE=0
|
||||||
|
|
|
||||||
2
.github/workflows/assets_artifact_build.yml
vendored
2
.github/workflows/assets_artifact_build.yml
vendored
|
|
@ -67,7 +67,7 @@ jobs:
|
||||||
- name: Setup node
|
- name: Setup node
|
||||||
uses: actions/setup-node@v6
|
uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
node-version: '20'
|
node-version: '22'
|
||||||
|
|
||||||
- name: Install yarn dependencies
|
- name: Install yarn dependencies
|
||||||
run: yarn install
|
run: yarn install
|
||||||
|
|
|
||||||
4
.github/workflows/tests.yml
vendored
4
.github/workflows/tests.yml
vendored
|
|
@ -106,7 +106,7 @@ jobs:
|
||||||
- name: Setup node
|
- name: Setup node
|
||||||
uses: actions/setup-node@v6
|
uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
node-version: '20'
|
node-version: '22'
|
||||||
|
|
||||||
- name: Install yarn dependencies
|
- name: Install yarn dependencies
|
||||||
run: yarn install
|
run: yarn install
|
||||||
|
|
@ -129,7 +129,7 @@ jobs:
|
||||||
run: ./bin/phpunit --coverage-clover=coverage.xml
|
run: ./bin/phpunit --coverage-clover=coverage.xml
|
||||||
|
|
||||||
- name: Upload coverage
|
- name: Upload coverage
|
||||||
uses: codecov/codecov-action@v5
|
uses: codecov/codecov-action@v6
|
||||||
with:
|
with:
|
||||||
env_vars: PHP_VERSION,DB_TYPE
|
env_vars: PHP_VERSION,DB_TYPE
|
||||||
token: ${{ secrets.CODECOV_TOKEN }}
|
token: ${{ secrets.CODECOV_TOKEN }}
|
||||||
|
|
|
||||||
11
.gitignore
vendored
11
.gitignore
vendored
|
|
@ -25,6 +25,10 @@
|
||||||
uploads/*
|
uploads/*
|
||||||
!uploads/.keep
|
!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
|
# Do not keep cache files
|
||||||
.php_cs.cache
|
.php_cs.cache
|
||||||
.phpcs-cache
|
.phpcs-cache
|
||||||
|
|
@ -51,3 +55,10 @@ phpstan.neon
|
||||||
|
|
||||||
.claude/
|
.claude/
|
||||||
CLAUDE.md
|
CLAUDE.md
|
||||||
|
|
||||||
|
.codex
|
||||||
|
migrations/.codex
|
||||||
|
docker-data/
|
||||||
|
scripts/
|
||||||
|
db/
|
||||||
|
docker-compose.yaml
|
||||||
|
|
|
||||||
|
|
@ -62,7 +62,7 @@ RUN yarn build
|
||||||
RUN yarn cache clean && rm -rf node_modules/
|
RUN yarn cache clean && rm -rf node_modules/
|
||||||
|
|
||||||
# FrankenPHP base stage
|
# FrankenPHP base stage
|
||||||
FROM dunglas/frankenphp:1-php8.4 AS frankenphp_upstream
|
FROM dunglas/frankenphp:1-php8.4-bookworm AS frankenphp_upstream
|
||||||
ARG TARGETARCH
|
ARG TARGETARCH
|
||||||
RUN --mount=type=cache,id=apt-cache-$TARGETARCH,target=/var/cache/apt \
|
RUN --mount=type=cache,id=apt-cache-$TARGETARCH,target=/var/cache/apt \
|
||||||
--mount=type=cache,id=apt-lists-$TARGETARCH,target=/var/lib/apt/lists \
|
--mount=type=cache,id=apt-lists-$TARGETARCH,target=/var/lib/apt/lists \
|
||||||
|
|
|
||||||
|
|
@ -74,11 +74,11 @@ Part-DB is also used by small companies and universities for managing their inve
|
||||||
## Requirements
|
## Requirements
|
||||||
|
|
||||||
* A **web server** (like Apache2 or nginx) that is capable of
|
* 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**
|
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.
|
* 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!
|
* 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
|
## Installation
|
||||||
|
|
||||||
|
|
|
||||||
2
VERSION
2
VERSION
|
|
@ -1 +1 @@
|
||||||
2.9.1
|
2.10.0
|
||||||
|
|
|
||||||
1466
composer.lock
generated
1466
composer.lock
generated
File diff suppressed because it is too large
Load diff
|
|
@ -56,6 +56,7 @@ doctrine:
|
||||||
natsort: App\Doctrine\Functions\Natsort
|
natsort: App\Doctrine\Functions\Natsort
|
||||||
array_position: App\Doctrine\Functions\ArrayPosition
|
array_position: App\Doctrine\Functions\ArrayPosition
|
||||||
ilike: App\Doctrine\Functions\ILike
|
ilike: App\Doctrine\Functions\ILike
|
||||||
|
si_value_sort: App\Doctrine\Functions\SiValueSort
|
||||||
|
|
||||||
when@test:
|
when@test:
|
||||||
doctrine:
|
doctrine:
|
||||||
|
|
|
||||||
|
|
@ -105,6 +105,8 @@ parameters:
|
||||||
|
|
||||||
env(DATABASE_EMULATE_NATURAL_SORT): 0
|
env(DATABASE_EMULATE_NATURAL_SORT): 0
|
||||||
|
|
||||||
|
env(ALLOW_ATTACHMENT_DOWNLOADS_FROM_LOCALNETWORK): 0
|
||||||
|
|
||||||
######################################################################################################################
|
######################################################################################################################
|
||||||
# Bulk Info Provider Import Configuration
|
# Bulk Info Provider Import Configuration
|
||||||
######################################################################################################################
|
######################################################################################################################
|
||||||
|
|
|
||||||
|
|
@ -1550,7 +1550,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
|
||||||
* template_parameters?: array{ // Default parameters to be passed to the template
|
* 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"
|
* 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
|
* 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"
|
* 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
|
* use_underscore?: bool|Param, // Default: true
|
||||||
* unordered_list_markers?: list<scalar|Param|null>,
|
* unordered_list_markers?: list<scalar|Param|null>,
|
||||||
* },
|
* },
|
||||||
* ...<mixed>
|
* ...<string, mixed>
|
||||||
* },
|
* },
|
||||||
* }
|
* }
|
||||||
* @psalm-type GregwarCaptchaConfig = array{
|
* @psalm-type GregwarCaptchaConfig = array{
|
||||||
* length?: scalar|Param|null, // Default: 5
|
* length?: scalar|Param|null, // Default: 5
|
||||||
* width?: scalar|Param|null, // Default: 130
|
* width?: scalar|Param|null, // Default: 130
|
||||||
* height?: scalar|Param|null, // Default: 50
|
* 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
|
* keep_value?: scalar|Param|null, // Default: false
|
||||||
* charset?: scalar|Param|null, // Default: "abcdefhjkmnprstuvwxyz23456789"
|
* charset?: scalar|Param|null, // Default: "abcdefhjkmnprstuvwxyz23456789"
|
||||||
* as_file?: scalar|Param|null, // Default: false
|
* 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
|
* 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: []
|
* 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"
|
* 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: " "
|
* glue?: scalar|Param|null, // xkey glue between keys // Default: " "
|
||||||
* },
|
* },
|
||||||
* },
|
* },
|
||||||
|
|
@ -2649,7 +2649,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
|
||||||
* cast_fn?: mixed,
|
* cast_fn?: mixed,
|
||||||
* default?: mixed,
|
* default?: mixed,
|
||||||
* filter_class?: mixed,
|
* filter_class?: mixed,
|
||||||
* ...<mixed>
|
* ...<string, mixed>
|
||||||
* }>,
|
* }>,
|
||||||
* strict_query_parameter_validation?: mixed,
|
* strict_query_parameter_validation?: mixed,
|
||||||
* hide_hydra_operation?: mixed,
|
* hide_hydra_operation?: mixed,
|
||||||
|
|
@ -2669,7 +2669,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
|
||||||
* name?: mixed,
|
* name?: mixed,
|
||||||
* allow_create?: mixed,
|
* allow_create?: mixed,
|
||||||
* item_uri_template?: mixed,
|
* item_uri_template?: mixed,
|
||||||
* ...<mixed>
|
* ...<string, mixed>
|
||||||
* },
|
* },
|
||||||
* }
|
* }
|
||||||
* @psalm-type ConfigType = array{
|
* @psalm-type ConfigType = array{
|
||||||
|
|
|
||||||
|
|
@ -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
|
* `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
|
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.
|
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.
|
* `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,
|
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.
|
if you trust the users who can upload files. When set to 0, HTML files are rendered as plain text.
|
||||||
|
|
|
||||||
|
|
@ -95,6 +95,11 @@ services:
|
||||||
docker-compose up -d
|
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
|
6. Create the initial database with
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|
|
||||||
|
|
@ -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
|
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.
|
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
|
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
|
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.
|
If you want to change them, you must migrate them to the settings interface as described below.
|
||||||
|
|
||||||
### Docker installation
|
### 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.
|
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`
|
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).
|
3. Ensure that your docker compose file uses the new latest images (either `latest` or `2` tag).
|
||||||
|
|
|
||||||
|
|
@ -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.
|
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 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
|
### Parts and category visibility
|
||||||
|
|
||||||
|
|
|
||||||
18
package.json
18
package.json
|
|
@ -9,16 +9,16 @@
|
||||||
"@symfony/stimulus-bridge": "^4.0.0",
|
"@symfony/stimulus-bridge": "^4.0.0",
|
||||||
"@symfony/ux-translator": "file:vendor/symfony/ux-translator/assets",
|
"@symfony/ux-translator": "file:vendor/symfony/ux-translator/assets",
|
||||||
"@symfony/ux-turbo": "file:vendor/symfony/ux-turbo/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",
|
"bootstrap": "^5.1.3",
|
||||||
"core-js": "^3.38.0",
|
"core-js": "^3.38.0",
|
||||||
"intl-messageformat": "^10.2.5",
|
"intl-messageformat": "^10.5.11",
|
||||||
"jquery": "^3.5.1",
|
"jquery": "^3.5.1",
|
||||||
"popper.js": "^1.14.7",
|
"popper.js": "^1.14.7",
|
||||||
"regenerator-runtime": "^0.13.9",
|
"regenerator-runtime": "^0.14.1",
|
||||||
"webpack": "^5.74.0",
|
"webpack": "^5.74.0",
|
||||||
"webpack-bundle-analyzer": "^5.1.1",
|
"webpack-bundle-analyzer": "^5.1.1",
|
||||||
"webpack-cli": "^5.1.0",
|
"webpack-cli": "^6.0.0",
|
||||||
"webpack-notifier": "^1.15.0"
|
"webpack-notifier": "^1.15.0"
|
||||||
},
|
},
|
||||||
"license": "AGPL-3.0-or-later",
|
"license": "AGPL-3.0-or-later",
|
||||||
|
|
@ -30,14 +30,14 @@
|
||||||
"build": "encore production --progress"
|
"build": "encore production --progress"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=20.0.0"
|
"node": ">=22.0.0"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@algolia/autocomplete-js": "^1.17.0",
|
"@algolia/autocomplete-js": "^1.17.0",
|
||||||
"@algolia/autocomplete-plugin-recent-searches": "^1.17.0",
|
"@algolia/autocomplete-plugin-recent-searches": "^1.17.0",
|
||||||
"@algolia/autocomplete-theme-classic": "^1.17.0",
|
"@algolia/autocomplete-theme-classic": "^1.17.0",
|
||||||
"@ckeditor/ckeditor5-dev-translations": "^43.0.1",
|
"@ckeditor/ckeditor5-dev-translations": "^53",
|
||||||
"@ckeditor/ckeditor5-dev-utils": "^43.0.1",
|
"@ckeditor/ckeditor5-dev-utils": "^53",
|
||||||
"@jbtronics/bs-treeview": "^1.0.1",
|
"@jbtronics/bs-treeview": "^1.0.1",
|
||||||
"@part-db/html5-qrcode": "^4.0.0",
|
"@part-db/html5-qrcode": "^4.0.0",
|
||||||
"@zxcvbn-ts/core": "^3.0.2",
|
"@zxcvbn-ts/core": "^3.0.2",
|
||||||
|
|
@ -69,11 +69,11 @@
|
||||||
"marked": "^17.0.1",
|
"marked": "^17.0.1",
|
||||||
"marked-gfm-heading-id": "^4.1.1",
|
"marked-gfm-heading-id": "^4.1.1",
|
||||||
"marked-mangle": "^1.0.1",
|
"marked-mangle": "^1.0.1",
|
||||||
"pdfmake": "^0.2.2",
|
"pdfmake": "^0.3.7",
|
||||||
"stimulus-use": "^0.52.0",
|
"stimulus-use": "^0.52.0",
|
||||||
"tom-select": "^2.1.0",
|
"tom-select": "^2.1.0",
|
||||||
"ts-loader": "^9.2.6",
|
"ts-loader": "^9.2.6",
|
||||||
"typescript": "^5.7.2"
|
"typescript": "^6.0.2"
|
||||||
},
|
},
|
||||||
"resolutions": {
|
"resolutions": {
|
||||||
"jquery": "^3.5.1"
|
"jquery": "^3.5.1"
|
||||||
|
|
|
||||||
|
|
@ -59,6 +59,9 @@ parameters:
|
||||||
- '#expects .*PartParameter, .*AbstractParameter given.#'
|
- '#expects .*PartParameter, .*AbstractParameter given.#'
|
||||||
- '#Part::getParameters\(\) should return .*AbstractParameter#'
|
- '#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
|
# Ignore doctrine type mapping mismatch
|
||||||
- '#Property .* type mapping mismatch: property can contain .* but database expects .*#'
|
- '#Property .* type mapping mismatch: property can contain .* but database expects .*#'
|
||||||
|
|
||||||
|
|
@ -70,3 +73,6 @@ parameters:
|
||||||
|
|
||||||
- message: '#Access to an undefined property Brick\\Schema\\Interfaces\\#'
|
- message: '#Access to an undefined property Brick\\Schema\\Interfaces\\#'
|
||||||
path: src/Services/InfoProviderSystem/Providers/GenericWebProvider.php
|
path: src/Services/InfoProviderSystem/Providers/GenericWebProvider.php
|
||||||
|
|
||||||
|
-
|
||||||
|
identifier: nullCoalesce.property
|
||||||
|
|
|
||||||
3
public/kicad/.gitignore
vendored
Normal file
3
public/kicad/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
# They are user generated and should not be tracked by git
|
||||||
|
footprints_custom.txt
|
||||||
|
symbols_custom.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
|
# This file contains all footprints available in the offical KiCAD library
|
||||||
Audio_Module:Reverb_BTDR-1H
|
Audio_Module:Reverb_BTDR-1H
|
||||||
Audio_Module:Reverb_BTDR-1V
|
Audio_Module:Reverb_BTDR-1V
|
||||||
|
|
|
||||||
|
|
@ -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
|
# This file contains all symbols available in the offical KiCAD library
|
||||||
4xxx:14528
|
4xxx:14528
|
||||||
4xxx:14529
|
4xxx:14529
|
||||||
|
|
@ -899,6 +899,7 @@ Amplifier_Buffer:BUF634AxD
|
||||||
Amplifier_Buffer:BUF634AxDDA
|
Amplifier_Buffer:BUF634AxDDA
|
||||||
Amplifier_Buffer:BUF634AxDRB
|
Amplifier_Buffer:BUF634AxDRB
|
||||||
Amplifier_Buffer:BUF634U
|
Amplifier_Buffer:BUF634U
|
||||||
|
Amplifier_Buffer:BUF802
|
||||||
Amplifier_Buffer:EL2001CN
|
Amplifier_Buffer:EL2001CN
|
||||||
Amplifier_Buffer:LH0002H
|
Amplifier_Buffer:LH0002H
|
||||||
Amplifier_Buffer:LM6321H
|
Amplifier_Buffer:LM6321H
|
||||||
|
|
@ -1667,7 +1668,6 @@ Analog_ADC:CA3300
|
||||||
Analog_ADC:HX711
|
Analog_ADC:HX711
|
||||||
Analog_ADC:ICL7106CPL
|
Analog_ADC:ICL7106CPL
|
||||||
Analog_ADC:ICL7107CPL
|
Analog_ADC:ICL7107CPL
|
||||||
Analog_ADC:INA234AxYBJ
|
|
||||||
Analog_ADC:LTC1406CGN
|
Analog_ADC:LTC1406CGN
|
||||||
Analog_ADC:LTC1406IGN
|
Analog_ADC:LTC1406IGN
|
||||||
Analog_ADC:LTC1594CS
|
Analog_ADC:LTC1594CS
|
||||||
|
|
@ -2198,6 +2198,7 @@ Audio:WM8731SEDS
|
||||||
Audio:YM2149
|
Audio:YM2149
|
||||||
Audio:YM2612
|
Audio:YM2612
|
||||||
Audio:YM3438
|
Audio:YM3438
|
||||||
|
Auxiliary_Items:Generic_Outline
|
||||||
Auxiliary_Items:Jumper_Shunt
|
Auxiliary_Items:Jumper_Shunt
|
||||||
Auxiliary_Items:MountingScrew
|
Auxiliary_Items:MountingScrew
|
||||||
Battery_Management:ADP5063
|
Battery_Management:ADP5063
|
||||||
|
|
@ -2254,6 +2255,11 @@ Battery_Management:BQ76200PW
|
||||||
Battery_Management:BQ76920PW
|
Battery_Management:BQ76920PW
|
||||||
Battery_Management:BQ76930DBT
|
Battery_Management:BQ76930DBT
|
||||||
Battery_Management:BQ76940DBT
|
Battery_Management:BQ76940DBT
|
||||||
|
Battery_Management:BQ7695201PFBR
|
||||||
|
Battery_Management:BQ7695202PFBR
|
||||||
|
Battery_Management:BQ7695203PFBR
|
||||||
|
Battery_Management:BQ7695204PFBR
|
||||||
|
Battery_Management:BQ76952PFBR
|
||||||
Battery_Management:BQ78350DBT
|
Battery_Management:BQ78350DBT
|
||||||
Battery_Management:BQ78350DBT-R1
|
Battery_Management:BQ78350DBT-R1
|
||||||
Battery_Management:CN3063
|
Battery_Management:CN3063
|
||||||
|
|
@ -2763,6 +2769,8 @@ Connector:DIN41612_02x32_AC
|
||||||
Connector:DIN41612_02x32_AE
|
Connector:DIN41612_02x32_AE
|
||||||
Connector:DIN41612_02x32_ZB
|
Connector:DIN41612_02x32_ZB
|
||||||
Connector:DIN41612_03x32_C_Split
|
Connector:DIN41612_03x32_C_Split
|
||||||
|
Connector:DP_Sink
|
||||||
|
Connector:DP_Source
|
||||||
Connector:DVI-D_Dual_Link
|
Connector:DVI-D_Dual_Link
|
||||||
Connector:DVI-I_Dual_Link
|
Connector:DVI-I_Dual_Link
|
||||||
Connector:ExpressCard
|
Connector:ExpressCard
|
||||||
|
|
@ -2901,6 +2909,7 @@ Connector:TestPoint_Alt
|
||||||
Connector:TestPoint_Flag
|
Connector:TestPoint_Flag
|
||||||
Connector:TestPoint_Probe
|
Connector:TestPoint_Probe
|
||||||
Connector:TestPoint_Small
|
Connector:TestPoint_Small
|
||||||
|
Connector:TestPoint_Square
|
||||||
Connector:UEXT_Host
|
Connector:UEXT_Host
|
||||||
Connector:UEXT_Slave
|
Connector:UEXT_Slave
|
||||||
Connector:USB3_A
|
Connector:USB3_A
|
||||||
|
|
@ -7772,6 +7781,7 @@ FPGA_Lattice:ICE40HX1K-TQ144
|
||||||
FPGA_Lattice:ICE40HX4K-BG121
|
FPGA_Lattice:ICE40HX4K-BG121
|
||||||
FPGA_Lattice:ICE40HX4K-TQ144
|
FPGA_Lattice:ICE40HX4K-TQ144
|
||||||
FPGA_Lattice:ICE40HX8K-BG121
|
FPGA_Lattice:ICE40HX8K-BG121
|
||||||
|
FPGA_Lattice:ICE40LP384-SG32
|
||||||
FPGA_Lattice:ICE40UL1K-SWG16
|
FPGA_Lattice:ICE40UL1K-SWG16
|
||||||
FPGA_Lattice:ICE40UP5K-SG48ITR
|
FPGA_Lattice:ICE40UP5K-SG48ITR
|
||||||
FPGA_Lattice:ICE5LP1K-SG48
|
FPGA_Lattice:ICE5LP1K-SG48
|
||||||
|
|
@ -15731,6 +15741,7 @@ Power_Management:RT9742AGJ5F
|
||||||
Power_Management:RT9742ANGJ5F
|
Power_Management:RT9742ANGJ5F
|
||||||
Power_Management:RT9742BGJ5F
|
Power_Management:RT9742BGJ5F
|
||||||
Power_Management:RT9742BNGJ5F
|
Power_Management:RT9742BNGJ5F
|
||||||
|
Power_Management:RT9742SNGV
|
||||||
Power_Management:SN6505ADBV
|
Power_Management:SN6505ADBV
|
||||||
Power_Management:SN6505BDBV
|
Power_Management:SN6505BDBV
|
||||||
Power_Management:SN6507DGQ
|
Power_Management:SN6507DGQ
|
||||||
|
|
@ -18692,6 +18703,7 @@ Regulator_Linear:TPS7A0530PDBZ
|
||||||
Regulator_Linear:TPS7A0531PDBV
|
Regulator_Linear:TPS7A0531PDBV
|
||||||
Regulator_Linear:TPS7A0533PDBV
|
Regulator_Linear:TPS7A0533PDBV
|
||||||
Regulator_Linear:TPS7A0533PDBZ
|
Regulator_Linear:TPS7A0533PDBZ
|
||||||
|
Regulator_Linear:TPS7A20xxxDBV
|
||||||
Regulator_Linear:TPS7A20xxxDQN
|
Regulator_Linear:TPS7A20xxxDQN
|
||||||
Regulator_Linear:TPS7A3301RGW
|
Regulator_Linear:TPS7A3301RGW
|
||||||
Regulator_Linear:TPS7A39
|
Regulator_Linear:TPS7A39
|
||||||
|
|
@ -20301,7 +20313,6 @@ Sensor:BME280
|
||||||
Sensor:BME680
|
Sensor:BME680
|
||||||
Sensor:CHT11
|
Sensor:CHT11
|
||||||
Sensor:DHT11
|
Sensor:DHT11
|
||||||
Sensor:INA260
|
|
||||||
Sensor:LTC2990
|
Sensor:LTC2990
|
||||||
Sensor:MAX30102
|
Sensor:MAX30102
|
||||||
Sensor:Nuclear-Radiation_Detector
|
Sensor:Nuclear-Radiation_Detector
|
||||||
|
|
@ -20588,9 +20599,12 @@ Sensor_Energy:INA219BxD
|
||||||
Sensor_Energy:INA219BxDCN
|
Sensor_Energy:INA219BxDCN
|
||||||
Sensor_Energy:INA226
|
Sensor_Energy:INA226
|
||||||
Sensor_Energy:INA228
|
Sensor_Energy:INA228
|
||||||
|
Sensor_Energy:INA229
|
||||||
Sensor_Energy:INA233
|
Sensor_Energy:INA233
|
||||||
|
Sensor_Energy:INA234AxYBJ
|
||||||
Sensor_Energy:INA237
|
Sensor_Energy:INA237
|
||||||
Sensor_Energy:INA238
|
Sensor_Energy:INA238
|
||||||
|
Sensor_Energy:INA260
|
||||||
Sensor_Energy:LTC4151xMS
|
Sensor_Energy:LTC4151xMS
|
||||||
Sensor_Energy:MCP39F521
|
Sensor_Energy:MCP39F521
|
||||||
Sensor_Energy:PAC1931x-xJ6CX
|
Sensor_Energy:PAC1931x-xJ6CX
|
||||||
|
|
@ -20872,6 +20886,7 @@ Sensor_Proximity:BPR-105
|
||||||
Sensor_Proximity:BPR-105F
|
Sensor_Proximity:BPR-105F
|
||||||
Sensor_Proximity:BPR-205
|
Sensor_Proximity:BPR-205
|
||||||
Sensor_Proximity:CNY70
|
Sensor_Proximity:CNY70
|
||||||
|
Sensor_Proximity:FDC1004DGS
|
||||||
Sensor_Proximity:GP2S700HCP
|
Sensor_Proximity:GP2S700HCP
|
||||||
Sensor_Proximity:ITR1201SR10AR
|
Sensor_Proximity:ITR1201SR10AR
|
||||||
Sensor_Proximity:ITR8307
|
Sensor_Proximity:ITR8307
|
||||||
|
|
@ -21791,6 +21806,7 @@ Transistor_BJT:Q_NPN_Darlington_ECBC
|
||||||
Transistor_BJT:Q_NPN_EBC
|
Transistor_BJT:Q_NPN_EBC
|
||||||
Transistor_BJT:Q_NPN_ECB
|
Transistor_BJT:Q_NPN_ECB
|
||||||
Transistor_BJT:Q_NPN_ECBC
|
Transistor_BJT:Q_NPN_ECBC
|
||||||
|
Transistor_BJT:Q_PNP_ACAB
|
||||||
Transistor_BJT:Q_PNP_BCE
|
Transistor_BJT:Q_PNP_BCE
|
||||||
Transistor_BJT:Q_PNP_BCEC
|
Transistor_BJT:Q_PNP_BCEC
|
||||||
Transistor_BJT:Q_PNP_BEC
|
Transistor_BJT:Q_PNP_BEC
|
||||||
|
|
|
||||||
|
|
@ -201,6 +201,10 @@ class BackupCommand extends Command
|
||||||
$config_dir = $this->project_dir.'/config';
|
$config_dir = $this->project_dir.'/config';
|
||||||
$zip->addFile($config_dir.'/parameters.yaml', 'config/parameters.yaml');
|
$zip->addFile($config_dir.'/parameters.yaml', 'config/parameters.yaml');
|
||||||
$zip->addFile($config_dir.'/banner.md', 'config/banner.md');
|
$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
|
protected function backupAttachments(ZipFile $zip, SymfonyStyle $io): void
|
||||||
|
|
|
||||||
|
|
@ -56,13 +56,16 @@ class LoadFixturesCommand extends Command
|
||||||
}
|
}
|
||||||
|
|
||||||
$factory = new ResetAutoIncrementPurgerFactory();
|
$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();
|
$purger->purge();
|
||||||
|
|
||||||
//Afterwards run the load fixtures command as normal, but with the --append option
|
//Afterwards run the load fixtures command as normal, but with the --append option
|
||||||
$new_input = new ArrayInput([
|
$new_input = new ArrayInput([
|
||||||
'command' => 'doctrine:fixtures:load',
|
'command' => 'doctrine:fixtures:load',
|
||||||
|
'--purge-with-truncate' => true,
|
||||||
'--append' => true,
|
'--append' => true,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|
|
||||||
88
src/Controller/KicadListEditorController.php
Normal file
88
src/Controller/KicadListEditorController.php
Normal 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,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -69,10 +69,13 @@ class ProjectController extends AbstractController
|
||||||
return $table->getResponse();
|
return $table->getResponse();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$number_of_builds = max(1, $request->query->getInt('n', 1));
|
||||||
|
|
||||||
return $this->render('projects/info/info.html.twig', [
|
return $this->render('projects/info/info.html.twig', [
|
||||||
'buildHelper' => $buildHelper,
|
'buildHelper' => $buildHelper,
|
||||||
'datatable' => $table,
|
'datatable' => $table,
|
||||||
'project' => $project,
|
'project' => $project,
|
||||||
|
'number_of_builds' => $number_of_builds,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -44,6 +44,7 @@ class SettingsController extends AbstractController
|
||||||
public function systemSettings(Request $request, TagAwareCacheInterface $cache): Response
|
public function systemSettings(Request $request, TagAwareCacheInterface $cache): Response
|
||||||
{
|
{
|
||||||
$this->denyAccessUnlessGranted('@config.change_system_settings');
|
$this->denyAccessUnlessGranted('@config.change_system_settings');
|
||||||
|
$this->denyAccessUnlessGranted('IS_AUTHENTICATED_FULLY');
|
||||||
|
|
||||||
//Create a clone of the settings object
|
//Create a clone of the settings object
|
||||||
$settings = $this->settingsManager->createTemporaryCopy(AppSettings::class);
|
$settings = $this->settingsManager->createTemporaryCopy(AppSettings::class);
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,7 @@ use App\DataTables\Filters\PartFilter;
|
||||||
use App\DataTables\Filters\PartSearchFilter;
|
use App\DataTables\Filters\PartSearchFilter;
|
||||||
use App\DataTables\Helpers\ColumnSortHelper;
|
use App\DataTables\Helpers\ColumnSortHelper;
|
||||||
use App\DataTables\Helpers\PartDataTableHelper;
|
use App\DataTables\Helpers\PartDataTableHelper;
|
||||||
|
use App\Doctrine\Functions\SiValueSort;
|
||||||
use App\Doctrine\Helpers\FieldHelper;
|
use App\Doctrine\Helpers\FieldHelper;
|
||||||
use App\Entity\Parts\ManufacturingStatus;
|
use App\Entity\Parts\ManufacturingStatus;
|
||||||
use App\Entity\Parts\Part;
|
use App\Entity\Parts\Part;
|
||||||
|
|
@ -118,6 +119,18 @@ final class PartsDataTable implements DataTableTypeInterface
|
||||||
'render' => fn($value, Part $context) => $this->partDataTableHelper->renderName($context),
|
'render' => fn($value, Part $context) => $this->partDataTableHelper->renderName($context),
|
||||||
'orderField' => 'NATSORT(part.name)'
|
'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, [
|
->add('id', TextColumn::class, [
|
||||||
'label' => $this->translator->trans('part.table.id'),
|
'label' => $this->translator->trans('part.table.id'),
|
||||||
])
|
])
|
||||||
|
|
@ -484,6 +497,19 @@ final class PartsDataTable implements DataTableTypeInterface
|
||||||
//$builder->addGroupBy('_bulkImportJob');
|
//$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;
|
return $builder;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,5 @@
|
||||||
<?php
|
<?php
|
||||||
|
/**
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
/*
|
|
||||||
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
|
* 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)
|
* 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
|
* 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/>.
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace App\DataTables;
|
namespace App\DataTables;
|
||||||
|
|
||||||
|
use App\DataTables\Adapters\TwoStepORMAdapter;
|
||||||
use App\DataTables\Column\EntityColumn;
|
use App\DataTables\Column\EntityColumn;
|
||||||
use App\DataTables\Column\EnumColumn;
|
use App\DataTables\Column\EnumColumn;
|
||||||
use App\DataTables\Column\LocaleDateTimeColumn;
|
use App\DataTables\Column\LocaleDateTimeColumn;
|
||||||
use App\DataTables\Column\MarkdownColumn;
|
use App\DataTables\Column\MarkdownColumn;
|
||||||
use App\DataTables\Helpers\PartDataTableHelper;
|
use App\DataTables\Helpers\PartDataTableHelper;
|
||||||
use App\Entity\Attachments\Attachment;
|
use App\Doctrine\Helpers\FieldHelper;
|
||||||
use App\Entity\Parts\Part;
|
|
||||||
use App\Entity\Parts\ManufacturingStatus;
|
use App\Entity\Parts\ManufacturingStatus;
|
||||||
|
use App\Entity\Parts\Part;
|
||||||
use App\Entity\ProjectSystem\ProjectBOMEntry;
|
use App\Entity\ProjectSystem\ProjectBOMEntry;
|
||||||
use App\Services\ElementTypeNameGenerator;
|
use App\Services\ElementTypeNameGenerator;
|
||||||
use App\Services\EntityURLGenerator;
|
use App\Services\EntityURLGenerator;
|
||||||
use App\Services\Formatters\AmountFormatter;
|
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 Doctrine\ORM\QueryBuilder;
|
||||||
use Omines\DataTablesBundle\Adapter\Doctrine\ORM\SearchCriteriaProvider;
|
use Omines\DataTablesBundle\Adapter\Doctrine\ORM\SearchCriteriaProvider;
|
||||||
use Omines\DataTablesBundle\Adapter\Doctrine\ORMAdapter;
|
|
||||||
use Omines\DataTablesBundle\Column\TextColumn;
|
use Omines\DataTablesBundle\Column\TextColumn;
|
||||||
use Omines\DataTablesBundle\DataTable;
|
use Omines\DataTablesBundle\DataTable;
|
||||||
use Omines\DataTablesBundle\DataTableTypeInterface;
|
use Omines\DataTablesBundle\DataTableTypeInterface;
|
||||||
|
|
@ -44,9 +49,14 @@ use Symfony\Contracts\Translation\TranslatorInterface;
|
||||||
|
|
||||||
class ProjectBomEntriesDataTable implements DataTableTypeInterface
|
class ProjectBomEntriesDataTable implements DataTableTypeInterface
|
||||||
{
|
{
|
||||||
public function __construct(protected TranslatorInterface $translator, protected PartDataTableHelper $partDataTableHelper,
|
public function __construct(
|
||||||
protected EntityURLGenerator $entityURLGenerator, protected AmountFormatter $amountFormatter)
|
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 '';
|
||||||
}
|
}
|
||||||
return $this->partDataTableHelper->renderPicture($context->getPart());
|
return $this->partDataTableHelper->renderPicture($context->getPart());
|
||||||
},
|
}
|
||||||
])
|
])
|
||||||
|
|
||||||
->add('id', TextColumn::class, [
|
->add('id', TextColumn::class, [
|
||||||
|
|
@ -133,23 +143,24 @@ class ProjectBomEntriesDataTable implements DataTableTypeInterface
|
||||||
->add('category', EntityColumn::class, [
|
->add('category', EntityColumn::class, [
|
||||||
'label' => $this->translator->trans('part.table.category'),
|
'label' => $this->translator->trans('part.table.category'),
|
||||||
'property' => 'part.category',
|
'property' => 'part.category',
|
||||||
'orderField' => 'NATSORT(category.name)',
|
'orderField' => 'NATSORT(category.name)'
|
||||||
])
|
])
|
||||||
->add('footprint', EntityColumn::class, [
|
->add('footprint', EntityColumn::class, [
|
||||||
'property' => 'part.footprint',
|
'property' => 'part.footprint',
|
||||||
'label' => $this->translator->trans('part.table.footprint'),
|
'label' => $this->translator->trans('part.table.footprint'),
|
||||||
'orderField' => 'NATSORT(footprint.name)',
|
'orderField' => 'NATSORT(footprint.name)'
|
||||||
])
|
])
|
||||||
|
|
||||||
->add('manufacturer', EntityColumn::class, [
|
->add('manufacturer', EntityColumn::class, [
|
||||||
'property' => 'part.manufacturer',
|
'property' => 'part.manufacturer',
|
||||||
'label' => $this->translator->trans('part.table.manufacturer'),
|
'label' => $this->translator->trans('part.table.manufacturer'),
|
||||||
'orderField' => 'NATSORT(manufacturer.name)',
|
'orderField' => 'NATSORT(manufacturer.name)'
|
||||||
])
|
])
|
||||||
|
|
||||||
->add('manufacturing_status', EnumColumn::class, [
|
->add('manufacturing_status', EnumColumn::class, [
|
||||||
'label' => $this->translator->trans('part.table.manufacturingStatus'),
|
'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,
|
'class' => ManufacturingStatus::class,
|
||||||
'render' => function (?ManufacturingStatus $status, ProjectBOMEntry $context): string {
|
'render' => function (?ManufacturingStatus $status, ProjectBOMEntry $context): string {
|
||||||
if ($status === null) {
|
if ($status === null) {
|
||||||
|
|
@ -183,8 +194,10 @@ class ProjectBomEntriesDataTable implements DataTableTypeInterface
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
])
|
])
|
||||||
->add('storageLocations', TextColumn::class, [
|
->add('storelocation', TextColumn::class, [
|
||||||
'label' => 'part.table.storeLocations',
|
'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,
|
'visible' => false,
|
||||||
'render' => function ($value, ProjectBOMEntry $context) {
|
'render' => function ($value, ProjectBOMEntry $context) {
|
||||||
if ($context->getPart() !== null) {
|
if ($context->getPart() !== null) {
|
||||||
|
|
@ -194,6 +207,27 @@ class ProjectBomEntriesDataTable implements DataTableTypeInterface
|
||||||
return '';
|
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, [
|
->add('addedDate', LocaleDateTimeColumn::class, [
|
||||||
'label' => $this->translator->trans('part.table.addedDate'),
|
'label' => $this->translator->trans('part.table.addedDate'),
|
||||||
|
|
@ -207,11 +241,13 @@ class ProjectBomEntriesDataTable implements DataTableTypeInterface
|
||||||
|
|
||||||
$dataTable->addOrderBy('name', DataTable::SORT_ASCENDING);
|
$dataTable->addOrderBy('name', DataTable::SORT_ASCENDING);
|
||||||
|
|
||||||
$dataTable->createAdapter(ORMAdapter::class, [
|
$dataTable->createAdapter(TwoStepORMAdapter::class, [
|
||||||
'entity' => Attachment::class,
|
'entity' => ProjectBOMEntry::class,
|
||||||
'query' => function (QueryBuilder $builder) use ($options): void {
|
'hydrate' => AbstractQuery::HYDRATE_OBJECT,
|
||||||
$this->getQuery($builder, $options);
|
'filter_query' => function (QueryBuilder $builder) use ($options): void {
|
||||||
|
$this->getFilterQuery($builder, $options);
|
||||||
},
|
},
|
||||||
|
'detail_query' => $this->getDetailQuery(...),
|
||||||
'criteria' => [
|
'criteria' => [
|
||||||
function (QueryBuilder $builder) use ($options): void {
|
function (QueryBuilder $builder) use ($options): void {
|
||||||
$this->buildCriteria($builder, $options);
|
$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')
|
$builder
|
||||||
->addSelect('part')
|
->select('bom_entry.id')
|
||||||
->from(ProjectBOMEntry::class, 'bom_entry')
|
->from(ProjectBOMEntry::class, 'bom_entry')
|
||||||
->leftJoin('bom_entry.part', 'part')
|
->leftJoin('bom_entry.part', 'part')
|
||||||
->leftJoin('part.category', 'category')
|
->leftJoin('part.category', 'category')
|
||||||
|
->leftJoin('part.partLots', '_partLots')
|
||||||
|
->leftJoin('_partLots.storage_location', '_storelocations')
|
||||||
->leftJoin('part.footprint', 'footprint')
|
->leftJoin('part.footprint', 'footprint')
|
||||||
->leftJoin('part.manufacturer', 'manufacturer')
|
->leftJoin('part.manufacturer', 'manufacturer')
|
||||||
|
->leftJoin('part.partCustomState', 'partCustomState')
|
||||||
->where('bom_entry.project = :project')
|
->where('bom_entry.project = :project')
|
||||||
->setParameter('project', $options['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
|
private function buildCriteria(QueryBuilder $builder, array $options): void
|
||||||
{
|
{
|
||||||
|
|
||||||
|
|
|
||||||
196
src/Doctrine/Functions/SiValueSort.php
Normal file
196
src/Doctrine/Functions/SiValueSort.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -23,6 +23,7 @@ declare(strict_types=1);
|
||||||
|
|
||||||
namespace App\Doctrine\Middleware;
|
namespace App\Doctrine\Middleware;
|
||||||
|
|
||||||
|
use App\Doctrine\Functions\SiValueSort;
|
||||||
use App\Exceptions\InvalidRegexException;
|
use App\Exceptions\InvalidRegexException;
|
||||||
use Doctrine\DBAL\Driver\Connection;
|
use Doctrine\DBAL\Driver\Connection;
|
||||||
use Doctrine\DBAL\Driver\Middleware\AbstractDriverMiddleware;
|
use Doctrine\DBAL\Driver\Middleware\AbstractDriverMiddleware;
|
||||||
|
|
@ -51,6 +52,9 @@ class SQLiteRegexExtensionMiddlewareDriver extends AbstractDriverMiddleware
|
||||||
|
|
||||||
//Create a new collation for natural sorting
|
//Create a new collation for natural sorting
|
||||||
$native_connection->sqliteCreateCollation('NATURAL_CMP', strnatcmp(...));
|
$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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,7 @@ declare(strict_types=1);
|
||||||
namespace App\Form\Part\EDA;
|
namespace App\Form\Part\EDA;
|
||||||
|
|
||||||
use App\Form\Type\StaticFileAutocompleteType;
|
use App\Form\Type\StaticFileAutocompleteType;
|
||||||
|
use App\Settings\MiscSettings\KiCadEDASettings;
|
||||||
use Symfony\Component\Form\AbstractType;
|
use Symfony\Component\Form\AbstractType;
|
||||||
use Symfony\Component\OptionsResolver\Options;
|
use Symfony\Component\OptionsResolver\Options;
|
||||||
use Symfony\Component\OptionsResolver\OptionsResolver;
|
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
|
//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 FOOTPRINT_PATH = 'kicad/footprints.txt';
|
||||||
public const SYMBOL_PATH = 'kicad/symbols.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
|
public function configureOptions(OptionsResolver $resolver): void
|
||||||
{
|
{
|
||||||
|
|
@ -47,8 +55,8 @@ class KicadFieldAutocompleteType extends AbstractType
|
||||||
|
|
||||||
$resolver->setDefaults([
|
$resolver->setDefaults([
|
||||||
'file' => fn(Options $options) => match ($options['type']) {
|
'file' => fn(Options $options) => match ($options['type']) {
|
||||||
self::TYPE_FOOTPRINT => self::FOOTPRINT_PATH,
|
self::TYPE_FOOTPRINT => $this->kiCadEDASettings->useCustomList ? self::CUSTOM_FOOTPRINT_PATH : self::FOOTPRINT_PATH,
|
||||||
self::TYPE_SYMBOL => self::SYMBOL_PATH,
|
self::TYPE_SYMBOL => $this->kiCadEDASettings->useCustomList ? self::CUSTOM_SYMBOL_PATH : self::SYMBOL_PATH,
|
||||||
default => throw new \InvalidArgumentException('Invalid type'),
|
default => throw new \InvalidArgumentException('Invalid type'),
|
||||||
}
|
}
|
||||||
]);
|
]);
|
||||||
|
|
|
||||||
103
src/Form/Settings/KicadListEditorType.php
Normal file
103
src/Form/Settings/KicadListEditorType.php
Normal 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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -139,7 +139,7 @@ class TypeSynonymRowType extends AbstractType
|
||||||
*/
|
*/
|
||||||
private function getPreferredLocales(): array
|
private function getPreferredLocales(): array
|
||||||
{
|
{
|
||||||
$fromSettings = $this->localizationSettings->languageMenuEntries ?? [];
|
$fromSettings = $this->localizationSettings->languageMenuEntries;
|
||||||
return !empty($fromSettings) ? array_values($fromSettings) : array_values($this->preferredLanguagesParam);
|
return !empty($fromSettings) ? array_values($fromSettings) : array_values($this->preferredLanguagesParam);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -44,6 +44,8 @@ use App\Exceptions\AttachmentDownloadException;
|
||||||
use App\Settings\SystemSettings\AttachmentsSettings;
|
use App\Settings\SystemSettings\AttachmentsSettings;
|
||||||
use Hshn\Base64EncodedFile\HttpFoundation\File\Base64EncodedFile;
|
use Hshn\Base64EncodedFile\HttpFoundation\File\Base64EncodedFile;
|
||||||
use Hshn\Base64EncodedFile\HttpFoundation\File\UploadedBase64EncodedFile;
|
use Hshn\Base64EncodedFile\HttpFoundation\File\UploadedBase64EncodedFile;
|
||||||
|
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||||
|
use Symfony\Component\HttpClient\NoPrivateNetworkHttpClient;
|
||||||
use const DIRECTORY_SEPARATOR;
|
use const DIRECTORY_SEPARATOR;
|
||||||
use InvalidArgumentException;
|
use InvalidArgumentException;
|
||||||
use RuntimeException;
|
use RuntimeException;
|
||||||
|
|
@ -76,6 +78,8 @@ class AttachmentSubmitHandler
|
||||||
protected FileTypeFilterTools $filterTools,
|
protected FileTypeFilterTools $filterTools,
|
||||||
protected AttachmentsSettings $settings,
|
protected AttachmentsSettings $settings,
|
||||||
protected readonly SVGSanitizer $SVGSanitizer,
|
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
|
//The mapping used to determine which folder will be used for an attachment type
|
||||||
|
|
@ -95,6 +99,10 @@ class AttachmentSubmitHandler
|
||||||
UserAttachment::class => 'user',
|
UserAttachment::class => 'user',
|
||||||
LabelAttachment::class => 'label_profile',
|
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);
|
$response = $this->httpClient->request('GET', $url, $opts);
|
||||||
//Digikey wants TLSv1.3, so try again with that if we get a 403
|
//Digikey wants TLSv1.3, so try again with that if we get a 403
|
||||||
if ($response->getStatusCode() === 403) {
|
if ($response->getStatusCode() === 403) {
|
||||||
|
|
@ -434,8 +443,8 @@ class AttachmentSubmitHandler
|
||||||
$new_path = $this->pathResolver->realPathToPlaceholder($new_path);
|
$new_path = $this->pathResolver->realPathToPlaceholder($new_path);
|
||||||
//Save the path to the attachment
|
//Save the path to the attachment
|
||||||
$attachment->setInternalPath($new_path);
|
$attachment->setInternalPath($new_path);
|
||||||
} catch (TransportExceptionInterface) {
|
} catch (TransportExceptionInterface $exception) {
|
||||||
throw new AttachmentDownloadException('Transport error!');
|
throw new AttachmentDownloadException('Transport error: '.$exception->getMessage());
|
||||||
}
|
}
|
||||||
|
|
||||||
return $attachment;
|
return $attachment;
|
||||||
|
|
|
||||||
158
src/Services/EDA/KicadListFileManager.php
Normal file
158
src/Services/EDA/KicadListFileManager.php
Normal 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 [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -42,6 +42,7 @@ use Brick\Schema\Interfaces\Thing;
|
||||||
use Brick\Schema\SchemaReader;
|
use Brick\Schema\SchemaReader;
|
||||||
use Brick\Schema\SchemaTypeList;
|
use Brick\Schema\SchemaTypeList;
|
||||||
use Symfony\Component\DomCrawler\Crawler;
|
use Symfony\Component\DomCrawler\Crawler;
|
||||||
|
use Symfony\Component\HttpClient\NoPrivateNetworkHttpClient;
|
||||||
use Symfony\Contracts\HttpClient\HttpClientInterface;
|
use Symfony\Contracts\HttpClient\HttpClientInterface;
|
||||||
|
|
||||||
class GenericWebProvider implements InfoProviderInterface
|
class GenericWebProvider implements InfoProviderInterface
|
||||||
|
|
@ -55,7 +56,8 @@ class GenericWebProvider implements InfoProviderInterface
|
||||||
private readonly ProviderRegistry $providerRegistry, private readonly PartInfoRetriever $infoRetriever,
|
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,
|
'timeout' => 15,
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -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 a URL starts with // we assume that it is a relative URL and we add the protocol
|
||||||
if (str_starts_with($url, '//')) {
|
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;
|
return $url;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -105,6 +105,10 @@ final class BarcodeScanHelper
|
||||||
return new AmazonBarcodeScanResult($input);
|
return new AmazonBarcodeScanResult($input);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ($type === BarcodeSourceType::TME) {
|
||||||
|
return TMEBarcodeScanResult::parse($input);
|
||||||
|
}
|
||||||
|
|
||||||
//Null means auto and we try the different formats
|
//Null means auto and we try the different formats
|
||||||
$result = $this->parseInternalBarcode($input);
|
$result = $this->parseInternalBarcode($input);
|
||||||
|
|
||||||
|
|
@ -144,6 +148,11 @@ final class BarcodeScanHelper
|
||||||
return new AmazonBarcodeScanResult($input);
|
return new AmazonBarcodeScanResult($input);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Try TME barcode
|
||||||
|
if (TMEBarcodeScanResult::isTMEBarcode($input)) {
|
||||||
|
return TMEBarcodeScanResult::parse($input);
|
||||||
|
}
|
||||||
|
|
||||||
throw new InvalidArgumentException('Unknown barcode');
|
throw new InvalidArgumentException('Unknown barcode');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -162,6 +171,7 @@ final class BarcodeScanHelper
|
||||||
return LCSCBarcodeScanResult::parse($input);
|
return LCSCBarcodeScanResult::parse($input);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
private function parseUserDefinedBarcode(string $input): ?LocalBarcodeScanResult
|
private function parseUserDefinedBarcode(string $input): ?LocalBarcodeScanResult
|
||||||
{
|
{
|
||||||
$lot_repo = $this->entityManager->getRepository(PartLot::class);
|
$lot_repo = $this->entityManager->getRepository(PartLot::class);
|
||||||
|
|
|
||||||
|
|
@ -150,6 +150,10 @@ final readonly class BarcodeScanResultHandler
|
||||||
?? $this->em->getRepository(Part::class)->getPartBySPN($barcodeScan->asin);
|
?? $this->em->getRepository(Part::class)->getPartBySPN($barcodeScan->asin);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ($barcodeScan instanceof TMEBarcodeScanResult) {
|
||||||
|
return $this->resolvePartFromTME($barcodeScan);
|
||||||
|
}
|
||||||
|
|
||||||
return null;
|
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
|
* 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.
|
* 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
|
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
|
// LCSC
|
||||||
if ($scanResult instanceof LCSCBarcodeScanResult) {
|
if ($scanResult instanceof LCSCBarcodeScanResult) {
|
||||||
return [
|
return [
|
||||||
|
|
|
||||||
|
|
@ -52,4 +52,7 @@ enum BarcodeSourceType: string
|
||||||
case LCSC = 'lcsc';
|
case LCSC = 'lcsc';
|
||||||
|
|
||||||
case AMAZON = 'amazon';
|
case AMAZON = 'amazon';
|
||||||
|
|
||||||
|
/** For TME (tme.eu) formatted QR codes */
|
||||||
|
case TME = 'tme';
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -254,12 +254,16 @@ readonly class EIGP114BarcodeScanResult implements BarcodeScanResultInterface
|
||||||
*/
|
*/
|
||||||
public static function isFormat06Code(string $input): bool
|
public static function isFormat06Code(string $input): bool
|
||||||
{
|
{
|
||||||
//Code must begin with [)><RS>06<GS>
|
//Code should begin with [)><RS>06<GS> as per the standard
|
||||||
if(!str_starts_with($input, "[)>\u{1E}06\u{1D}")){
|
if(!str_starts_with($input, "[)>\u{1E}06\u{1D}")
|
||||||
return false;
|
// 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 and Mouser don't put a trailer onto the barcode, so we just check for the header
|
||||||
//Digikey does not put a trailer onto the barcode, so we just check for the header
|
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
143
src/Services/LabelSystem/BarcodeScanner/TMEBarcodeScanResult.php
Normal file
143
src/Services/LabelSystem/BarcodeScanner/TMEBarcodeScanResult.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -25,16 +25,22 @@ namespace App\Services\ProjectSystem;
|
||||||
use App\Entity\Parts\Part;
|
use App\Entity\Parts\Part;
|
||||||
use App\Entity\ProjectSystem\Project;
|
use App\Entity\ProjectSystem\Project;
|
||||||
use App\Entity\ProjectSystem\ProjectBOMEntry;
|
use App\Entity\ProjectSystem\ProjectBOMEntry;
|
||||||
|
use App\Entity\PriceInformations\Currency;
|
||||||
use App\Helpers\Projects\ProjectBuildRequest;
|
use App\Helpers\Projects\ProjectBuildRequest;
|
||||||
use App\Services\Parts\PartLotWithdrawAddHelper;
|
use App\Services\Parts\PartLotWithdrawAddHelper;
|
||||||
|
use App\Services\Parts\PricedetailHelper;
|
||||||
|
use Brick\Math\BigDecimal;
|
||||||
|
use Brick\Math\RoundingMode;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @see \App\Tests\Services\ProjectSystem\ProjectBuildHelperTest
|
* @see \App\Tests\Services\ProjectSystem\ProjectBuildHelperTest
|
||||||
*/
|
*/
|
||||||
final readonly class ProjectBuildHelper
|
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);
|
$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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -52,6 +52,8 @@ enum PartTableColumns : string implements TranslatableInterface
|
||||||
case TAGS = "tags";
|
case TAGS = "tags";
|
||||||
case ATTACHMENTS = "attachments";
|
case ATTACHMENTS = "attachments";
|
||||||
|
|
||||||
|
case SI_VALUE = "si_value";
|
||||||
|
|
||||||
case EDA_REFERENCE = "eda_reference";
|
case EDA_REFERENCE = "eda_reference";
|
||||||
|
|
||||||
case EDA_VALUE = "eda_value";
|
case EDA_VALUE = "eda_value";
|
||||||
|
|
|
||||||
|
|
@ -62,4 +62,10 @@ class KiCadEDASettings
|
||||||
|
|
||||||
)]
|
)]
|
||||||
public bool $defaultOrderdetailsVisibility = false;
|
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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -55,6 +55,32 @@
|
||||||
</span>
|
</span>
|
||||||
</h6>
|
</h6>
|
||||||
</div>
|
</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 %}
|
{% if project.children is not empty %}
|
||||||
<div class="mt-1">
|
<div class="mt-1">
|
||||||
<h6>
|
<h6>
|
||||||
|
|
@ -69,9 +95,9 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% if project.comment is not empty %}
|
{% if project.comment is not empty %}
|
||||||
<p>
|
<div class="col-12 mt-2">
|
||||||
<h5>{% trans %}comment.label{% endtrans %}:</h5>
|
<h5>{% trans %}comment.label{% endtrans %}:</h5>
|
||||||
{{ project.comment|format_markdown }}
|
{{ project.comment|format_markdown }}
|
||||||
</p>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
28
templates/settings/kicad_list_editor.html.twig
Normal file
28
templates/settings/kicad_list_editor.html.twig
Normal 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 %}
|
||||||
|
|
@ -49,6 +49,15 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{{ form_widget(section_widget) }}
|
{{ 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>
|
</fieldset>
|
||||||
{% if not loop.last %}
|
{% if not loop.last %}
|
||||||
<hr class="mx-0 mb-2 mt-2">
|
<hr class="mx-0 mb-2 mt-2">
|
||||||
|
|
|
||||||
|
|
@ -60,6 +60,7 @@ final class ApplicationAvailabilityFunctionalTest extends WebTestCase
|
||||||
//User related things
|
//User related things
|
||||||
yield ['/user/settings'];
|
yield ['/user/settings'];
|
||||||
yield ['/user/info'];
|
yield ['/user/info'];
|
||||||
|
yield ['/settings/misc/kicad-lists'];
|
||||||
|
|
||||||
//Login/logout
|
//Login/logout
|
||||||
yield ['/login'];
|
yield ['/login'];
|
||||||
|
|
|
||||||
162
tests/Controller/KicadListEditorControllerTest.php
Normal file
162
tests/Controller/KicadListEditorControllerTest.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
42
tests/Doctrine/Functions/ArrayPositionTest.php
Normal file
42
tests/Doctrine/Functions/ArrayPositionTest.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
45
tests/Doctrine/Functions/Field2Test.php
Normal file
45
tests/Doctrine/Functions/Field2Test.php
Normal file
|
|
@ -0,0 +1,45 @@
|
||||||
|
<?php
|
||||||
|
/*
|
||||||
|
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
|
||||||
|
*
|
||||||
|
* Copyright (C) 2019 - 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
66
tests/Doctrine/Functions/ILikeTest.php
Normal file
66
tests/Doctrine/Functions/ILikeTest.php
Normal 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()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
95
tests/Doctrine/Functions/NatsortTest.php
Normal file
95
tests/Doctrine/Functions/NatsortTest.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
66
tests/Doctrine/Functions/RegexpTest.php
Normal file
66
tests/Doctrine/Functions/RegexpTest.php
Normal 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()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
193
tests/Doctrine/Functions/SiValueSortTest.php
Normal file
193
tests/Doctrine/Functions/SiValueSortTest.php
Normal 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
391
tests/Services/InfoProviderSystem/Providers/TMEProviderTest.php
Normal file
391
tests/Services/InfoProviderSystem/Providers/TMEProviderTest.php
Normal 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'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -93,6 +93,13 @@ final class EIGP114BarcodeScanResultTest extends TestCase
|
||||||
|
|
||||||
//Valid code (digikey, without trailer)
|
//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"));
|
$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
|
public function testParseFormat06CodeInvalid(): void
|
||||||
|
|
@ -101,6 +108,32 @@ final class EIGP114BarcodeScanResultTest extends TestCase
|
||||||
EIGP114BarcodeScanResult::parseFormat06Code('');
|
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
|
public function testParseFormat06Code(): void
|
||||||
{
|
{
|
||||||
$barcode = EIGP114BarcodeScanResult::parseFormat06Code("[)>\x1E06\x1DP596-777A1-ND\x1D1PXAF4444\x1DQ3\x1D10D1452\x1D1TBF1103\x1D4LUS\x1E\x04");
|
$barcode = EIGP114BarcodeScanResult::parseFormat06Code("[)>\x1E06\x1DP596-777A1-ND\x1D1PXAF4444\x1DQ3\x1D10D1452\x1D1TBF1103\x1D4LUS\x1E\x04");
|
||||||
|
|
|
||||||
|
|
@ -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']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -26,13 +26,15 @@ use App\Entity\Parts\Part;
|
||||||
use App\Entity\Parts\PartLot;
|
use App\Entity\Parts\PartLot;
|
||||||
use App\Entity\ProjectSystem\Project;
|
use App\Entity\ProjectSystem\Project;
|
||||||
use App\Entity\ProjectSystem\ProjectBOMEntry;
|
use App\Entity\ProjectSystem\ProjectBOMEntry;
|
||||||
|
use App\Entity\PriceInformations\Orderdetail;
|
||||||
|
use App\Entity\PriceInformations\Pricedetail;
|
||||||
use App\Services\ProjectSystem\ProjectBuildHelper;
|
use App\Services\ProjectSystem\ProjectBuildHelper;
|
||||||
|
use Brick\Math\BigDecimal;
|
||||||
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
|
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
|
||||||
|
|
||||||
final class ProjectBuildHelperTest extends WebTestCase
|
final class ProjectBuildHelperTest extends WebTestCase
|
||||||
{
|
{
|
||||||
/** @var ProjectBuildHelper */
|
protected ProjectBuildHelper $service;
|
||||||
protected $service;
|
|
||||||
|
|
||||||
protected function setUp(): void
|
protected function setUp(): void
|
||||||
{
|
{
|
||||||
|
|
@ -130,6 +132,180 @@ final class ProjectBuildHelperTest extends WebTestCase
|
||||||
$project->addBomEntry($bom_entry1);
|
$project->addBomEntry($bom_entry1);
|
||||||
|
|
||||||
$this->assertSame('∞', $this->service->getMaximumBuildableCountAsString($project));
|
$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));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -28,8 +28,7 @@ use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
|
||||||
|
|
||||||
final class ProjectBuildPartHelperTest extends WebTestCase
|
final class ProjectBuildPartHelperTest extends WebTestCase
|
||||||
{
|
{
|
||||||
/** @var ProjectBuildPartHelper */
|
protected ProjectBuildPartHelper $service;
|
||||||
protected $service;
|
|
||||||
|
|
||||||
protected function setUp(): void
|
protected function setUp(): void
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -7241,6 +7241,12 @@ Element 3</target>
|
||||||
<target>Cena</target>
|
<target>Cena</target>
|
||||||
</segment>
|
</segment>
|
||||||
</unit>
|
</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">
|
<unit id="8tP3lQI" name="part.info.withdraw_modal.title.withdraw">
|
||||||
<segment state="translated">
|
<segment state="translated">
|
||||||
<source>part.info.withdraw_modal.title.withdraw</source>
|
<source>part.info.withdraw_modal.title.withdraw</source>
|
||||||
|
|
|
||||||
|
|
@ -7232,6 +7232,12 @@ Element 3</target>
|
||||||
<target>Pris</target>
|
<target>Pris</target>
|
||||||
</segment>
|
</segment>
|
||||||
</unit>
|
</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">
|
<unit id="8tP3lQI" name="part.info.withdraw_modal.title.withdraw">
|
||||||
<segment state="translated">
|
<segment state="translated">
|
||||||
<source>part.info.withdraw_modal.title.withdraw</source>
|
<source>part.info.withdraw_modal.title.withdraw</source>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<xliff xmlns="urn:oasis:names:tc:xliff:document:2.0" version="2.0" srcLang="en" trgLang="de">
|
<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">
|
<unit id="x_wTSQS" name="attachment_type.caption">
|
||||||
<segment state="translated">
|
<segment state="translated">
|
||||||
<source>attachment_type.caption</source>
|
<source>attachment_type.caption</source>
|
||||||
|
|
|
||||||
|
|
@ -7259,6 +7259,12 @@ Elemento 3</target>
|
||||||
<target>Precio</target>
|
<target>Precio</target>
|
||||||
</segment>
|
</segment>
|
||||||
</unit>
|
</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">
|
<unit id="8tP3lQI" name="part.info.withdraw_modal.title.withdraw">
|
||||||
<segment state="translated">
|
<segment state="translated">
|
||||||
<source>part.info.withdraw_modal.title.withdraw</source>
|
<source>part.info.withdraw_modal.title.withdraw</source>
|
||||||
|
|
|
||||||
|
|
@ -7198,6 +7198,12 @@
|
||||||
<target>Ár</target>
|
<target>Ár</target>
|
||||||
</segment>
|
</segment>
|
||||||
</unit>
|
</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">
|
<unit id="8tP3lQI" name="part.info.withdraw_modal.title.withdraw">
|
||||||
<segment state="translated">
|
<segment state="translated">
|
||||||
<source>part.info.withdraw_modal.title.withdraw</source>
|
<source>part.info.withdraw_modal.title.withdraw</source>
|
||||||
|
|
|
||||||
|
|
@ -7186,6 +7186,12 @@ Element 3</target>
|
||||||
<target>Prezzo</target>
|
<target>Prezzo</target>
|
||||||
</segment>
|
</segment>
|
||||||
</unit>
|
</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">
|
<unit id="8tP3lQI" name="part.info.withdraw_modal.title.withdraw">
|
||||||
<segment state="translated">
|
<segment state="translated">
|
||||||
<source>part.info.withdraw_modal.title.withdraw</source>
|
<source>part.info.withdraw_modal.title.withdraw</source>
|
||||||
|
|
|
||||||
|
|
@ -7256,6 +7256,12 @@ Element 3</target>
|
||||||
<target>Cena</target>
|
<target>Cena</target>
|
||||||
</segment>
|
</segment>
|
||||||
</unit>
|
</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">
|
<unit id="8tP3lQI" name="part.info.withdraw_modal.title.withdraw">
|
||||||
<segment state="translated">
|
<segment state="translated">
|
||||||
<source>part.info.withdraw_modal.title.withdraw</source>
|
<source>part.info.withdraw_modal.title.withdraw</source>
|
||||||
|
|
|
||||||
|
|
@ -7260,6 +7260,12 @@
|
||||||
<target>Цена</target>
|
<target>Цена</target>
|
||||||
</segment>
|
</segment>
|
||||||
</unit>
|
</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">
|
<unit id="8tP3lQI" name="part.info.withdraw_modal.title.withdraw">
|
||||||
<segment state="translated">
|
<segment state="translated">
|
||||||
<source>part.info.withdraw_modal.title.withdraw</source>
|
<source>part.info.withdraw_modal.title.withdraw</source>
|
||||||
|
|
|
||||||
|
|
@ -7259,6 +7259,12 @@ Element 3</target>
|
||||||
<target>价格</target>
|
<target>价格</target>
|
||||||
</segment>
|
</segment>
|
||||||
</unit>
|
</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">
|
<unit id="hO.xnng" name="part.info.withdraw_modal.title.withdraw">
|
||||||
<segment state="translated">
|
<segment state="translated">
|
||||||
<source>part.info.withdraw_modal.title.withdraw</source>
|
<source>part.info.withdraw_modal.title.withdraw</source>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue