mirror of
https://github.com/Part-DB/Part-DB-server.git
synced 2026-02-26 19:52:37 +01:00
Compare commits
110 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
76295b73c8 | ||
|
|
8c00769757 | ||
|
|
773d0e9d20 | ||
|
|
d14f596479 | ||
|
|
700ed42ce5 | ||
|
|
dc2369c71e | ||
|
|
5fc760f6ad | ||
|
|
ffb5d3e790 | ||
|
|
512947e0d0 | ||
|
|
9e69a09a19 | ||
|
|
b447a69dae | ||
|
|
d52e6b5881 | ||
|
|
6cff19358a | ||
|
|
a6d476f953 | ||
|
|
aba73174ab | ||
|
|
83d43d931c | ||
|
|
64cebaba77 | ||
|
|
07535c26a6 | ||
|
|
aab1dcf8e6 | ||
|
|
4b88de9316 | ||
|
|
84c111ac7c | ||
|
|
2feeb1c868 | ||
|
|
17000da97e | ||
|
|
5b09cbf1ac | ||
|
|
07088c94e7 | ||
|
|
1da5e7ccd7 | ||
|
|
b9956e38b8 | ||
|
|
36879dd7da | ||
|
|
099ea63740 | ||
|
|
615defa84a | ||
|
|
3eeeb01ad1 | ||
|
|
73f6d79925 | ||
|
|
b0f5d9b55f | ||
|
|
50069c7611 | ||
|
|
c86694ab8f | ||
|
|
478d5e2a3a | ||
|
|
e7b766906d | ||
|
|
c5435df6f9 | ||
|
|
e8f4cd9fec | ||
|
|
378d695a24 | ||
|
|
a4b16f7f09 | ||
|
|
1fe3a614c9 | ||
|
|
773e393f55 | ||
|
|
87626589a3 | ||
|
|
01784a9d1f | ||
|
|
f99323f9b3 | ||
|
|
83ad99215f | ||
|
|
958d59a0ff | ||
|
|
de8a68c70d | ||
|
|
5f87d5b1ac | ||
|
|
c2ea880dad | ||
|
|
7eba4254e6 | ||
|
|
76bb3eae9d | ||
|
|
3da656c08b | ||
|
|
b6dc3eb1a2 | ||
|
|
fefa65941b | ||
|
|
74d75c6e1f | ||
|
|
01ed3eeecd | ||
|
|
9a3b9b84bc | ||
|
|
90a1ffa2ac | ||
|
|
5442aa5e07 | ||
|
|
0ab604d468 | ||
|
|
0b178b46f2 | ||
|
|
d12bde2b1e | ||
|
|
96a771e7ac | ||
|
|
3e6b80d1cf | ||
|
|
4d7d196a3c | ||
|
|
4e1f6277c6 | ||
|
|
626c4dd5d6 | ||
|
|
c8bd800b9f | ||
|
|
0fa03d8bb0 | ||
|
|
22606f01d2 | ||
|
|
3c2e535117 | ||
|
|
7f612bc371 | ||
|
|
cc2332a83a | ||
|
|
c7892cb9e2 | ||
|
|
5bd2d9b344 | ||
|
|
81f8b365e9 | ||
|
|
8ab9cf1417 | ||
|
|
b7cfdebad5 | ||
|
|
0447a7e6b3 | ||
|
|
6d67ee8106 | ||
|
|
2d7058329c | ||
|
|
9e58baa574 | ||
|
|
6d8cb9cc08 | ||
|
|
5cfccab671 | ||
|
|
3953e36921 | ||
|
|
7163df6d46 | ||
|
|
5f86253b94 | ||
|
|
93d0f97cfd | ||
|
|
9732b71f85 | ||
|
|
cf11320789 | ||
|
|
5e326bca12 | ||
|
|
3c52e57a44 | ||
|
|
2002b9d5d3 | ||
|
|
323c70393d | ||
|
|
eabd03dc53 | ||
|
|
3ac82cf76a | ||
|
|
1409d19922 | ||
|
|
bdcd51d533 | ||
|
|
563edb1731 | ||
|
|
cd7013f776 | ||
|
|
783a00ca2f | ||
|
|
e233940f1f | ||
|
|
717a9fb0a3 | ||
|
|
5144b75ed7 | ||
|
|
aeed7c0802 | ||
|
|
2b470e6cdd | ||
|
|
e6870c61ee | ||
|
|
f8ccd5bc22 |
@@ -34,7 +34,7 @@
|
||||
PassEnv ERROR_PAGE_ADMIN_EMAIL ERROR_PAGE_SHOW_HELP
|
||||
PassEnv DEMO_MODE NO_URL_REWRITE_AVAILABLE FIXER_API_KEY BANNER
|
||||
# In old version the SAML sp private key env, was wrongly named SAMLP_SP_PRIVATE_KEY, keep it for backward compatibility
|
||||
PassEnv SAML_ENABLED SAML_ROLE_MAPPING SAML_UPDATE_GROUP_ON_LOGIN SAML_IDP_ENTITY_ID SAML_IDP_SINGLE_SIGN_ON_SERVICE SAML_IDP_SINGLE_LOGOUT_SERVICE SAML_IDP_X509_CERT SAML_SP_ENTITY_ID SAML_SP_X509_CERT SAML_SP_PRIVATE_KEY SAMLP_SP_PRIVATE_KEY
|
||||
PassEnv SAML_ENABLED SAML_BEHIND_PROXY SAML_ROLE_MAPPING SAML_UPDATE_GROUP_ON_LOGIN SAML_IDP_ENTITY_ID SAML_IDP_SINGLE_SIGN_ON_SERVICE SAML_IDP_SINGLE_LOGOUT_SERVICE SAML_IDP_X509_CERT SAML_SP_ENTITY_ID SAML_SP_X509_CERT SAML_SP_PRIVATE_KEY SAMLP_SP_PRIVATE_KEY
|
||||
PassEnv TABLE_DEFAULT_PAGE_SIZE TABLE_PARTS_DEFAULT_COLUMNS
|
||||
|
||||
PassEnv PROVIDER_DIGIKEY_CLIENT_ID PROVIDER_DIGIKEY_SECRET PROVIDER_DIGIKEY_CURRENCY PROVIDER_DIGIKEY_LANGUAGE PROVIDER_DIGIKEY_COUNTRY
|
||||
|
||||
5
.env
5
.env
@@ -163,6 +163,9 @@ PROVIDER_MOUSER_SEARCH_WITH_SIGNUP_LANGUAGE='true'
|
||||
# Set this to 1 to enable SAML single sign on
|
||||
SAML_ENABLED=0
|
||||
|
||||
# Set to 1, if your Part-DB installation is behind a reverse proxy and you want to use SAML
|
||||
SAML_BEHIND_PROXY=0
|
||||
|
||||
# A JSON encoded array of role mappings in the form { "saml_role": PARTDB_GROUP_ID, "*": PARTDB_GROUP_ID }
|
||||
# The first match is used, so the order is important! Put the group mapping with the most privileges first.
|
||||
# Please not to only use single quotes to enclose the JSON string
|
||||
@@ -214,7 +217,7 @@ APP_SECRET=a03498528f5a5fc089273ec9ae5b2849
|
||||
|
||||
|
||||
# Set the trusted IPs here, when using an reverse proxy
|
||||
#TRUSTED_PROXIES=127.0.0.0/8,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16
|
||||
#TRUSTED_PROXIES=127.0.0.0/8,::1,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16
|
||||
#TRUSTED_HOSTS='^(localhost|example\.com)$'
|
||||
|
||||
|
||||
|
||||
2
.github/workflows/assets_artifact_build.yml
vendored
2
.github/workflows/assets_artifact_build.yml
vendored
@@ -57,7 +57,7 @@ jobs:
|
||||
${{ runner.os }}-yarn-
|
||||
|
||||
- name: Setup node
|
||||
uses: actions/setup-node@v3
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '18'
|
||||
|
||||
|
||||
5
.github/workflows/tests.yml
vendored
5
.github/workflows/tests.yml
vendored
@@ -13,12 +13,11 @@ on:
|
||||
jobs:
|
||||
phpunit:
|
||||
name: PHPUnit and coverage Test (PHP ${{ matrix.php-versions }}, ${{ matrix.db-type }})
|
||||
# Ubuntu 20.04 ships MySQL 8.0 which causes problems with login, so we just use ubuntu 18.04 for now...
|
||||
runs-on: ubuntu-22.04
|
||||
|
||||
strategy:
|
||||
matrix:
|
||||
php-versions: [ '8.1', '8.2' ]
|
||||
php-versions: [ '8.1', '8.2', '8.3' ]
|
||||
db-type: [ 'mysql', 'sqlite' ]
|
||||
|
||||
env:
|
||||
@@ -87,7 +86,7 @@ jobs:
|
||||
run: composer install --prefer-dist --no-progress
|
||||
|
||||
- name: Setup node
|
||||
uses: actions/setup-node@v3
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '18'
|
||||
|
||||
|
||||
26
README.md
26
README.md
@@ -9,7 +9,7 @@
|
||||

|
||||
[](https://part-db.crowdin.com/part-db)
|
||||
|
||||
**[Documentation](https://docs.part-db.de/)** | **[Demo](https://part-db.herokuapp.com)** | **[Docker Image](https://hub.docker.com/r/jbtronics/part-db1)**
|
||||
**[Documentation](https://docs.part-db.de/)** | **[Demo](https://demo.part-db.de/)** | **[Docker Image](https://hub.docker.com/r/jbtronics/part-db1)**
|
||||
|
||||
# Part-DB
|
||||
|
||||
@@ -24,8 +24,8 @@ for everybody.
|
||||
|
||||
## Demo
|
||||
|
||||
If you want to test Part-DB without installing it, you can use [this](https://part-db.herokuapp.com) Heroku instance.
|
||||
(Or this link for the [German Version](https://part-db.herokuapp.com/de/)).
|
||||
If you want to test Part-DB without installing it, you can use [this](https://demo.part-db.de/) Heroku instance.
|
||||
(Or this link for the [German Version](https://demo.part-db.de/de/)).
|
||||
|
||||
You can log in with username: *user* and password: *user*.
|
||||
|
||||
@@ -101,24 +101,20 @@ for a detailed guide how to install Part-DB.**
|
||||
In bigger instances with concurrent accesses, MySQL is more performant. This can not be changed easily later, so
|
||||
choose wisely.
|
||||
4. Install composer dependencies and generate autoload files: `composer install -o --no-dev`
|
||||
5. If you have put Part-DB into a subdirectory on your server (like `part-db/`), you have to edit the file
|
||||
`webpack.config.js` and uncomment the lines (remove the `//` before the lines) `.setPublicPath('/part-db/build')` (
|
||||
line 43) and
|
||||
`.setManifestKeyPrefix('build/')` (line 44). You have to replace `/part-db` with your own path on line 44.
|
||||
6. Install client side dependencies and build it: `yarn install` and `yarn build`
|
||||
7. _Optional_ (speeds up first load): Warmup cache: `php bin/console cache:warmup`
|
||||
8. Upgrade database to new scheme (or create it, when it was empty): `php bin/console doctrine:migrations:migrate` and
|
||||
5. Install client side dependencies and build it: `yarn install` and `yarn build`
|
||||
6. _Optional_ (speeds up first load): Warmup cache: `php bin/console cache:warmup`
|
||||
7. Upgrade database to new scheme (or create it, when it was empty): `php bin/console doctrine:migrations:migrate` and
|
||||
follow the instructions given. During the process the password for the admin is user is shown. Copy it. **Caution**:
|
||||
This steps tamper with your database and could potentially destroy it. So make sure to make a backup of your
|
||||
database.
|
||||
9. You can configure Part-DB via `config/parameters.yaml`. You should check if settings match your expectations, after
|
||||
8. You can configure Part-DB via `config/parameters.yaml`. You should check if settings match your expectations, after
|
||||
you installed/upgraded Part-DB. Check if `partdb.default_currency` matches your mainly used currency (this can not be
|
||||
changed after creating price information).
|
||||
Run `php bin/console cache:clear` when you changed something.
|
||||
10. Access Part-DB in your browser (under the URL you put it) and login with user *admin*. Password is the one outputted
|
||||
during DB setup.
|
||||
If you can not remember the password, set a new one with `php bin/console app:set-password admin`. You can create
|
||||
new users with the admin user and start using Part-DB.
|
||||
9. Access Part-DB in your browser (under the URL you put it) and login with user *admin*. Password is the one outputted
|
||||
during DB setup.
|
||||
If you can not remember the password, set a new one with `php bin/console app:set-password admin`. You can create
|
||||
new users with the admin user and start using Part-DB.
|
||||
|
||||
When you want to upgrade to a newer version, then just copy the new files into the folder
|
||||
and repeat the steps 4. to 7.
|
||||
|
||||
@@ -85,6 +85,9 @@ const PLACEHOLDERS = [
|
||||
['[[COMMENT_T]]', 'Comment (plain text)'],
|
||||
['[[LAST_MODIFIED]]', 'Last modified datetime'],
|
||||
['[[CREATION_DATE]]', 'Creation datetime'],
|
||||
['[[IPN_BARCODE_QR]]', 'IPN as QR code'],
|
||||
['[[IPN_BARCODE_C128]]', 'IPN as Code 128 barcode'],
|
||||
['[[IPN_BARCODE_C39]]', 'IPN as Code 39 barcode'],
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
@@ -48,6 +48,9 @@ Object.assign( window.CKEDITOR_TRANSLATIONS[ 'de' ].dictionary, {
|
||||
'Comment (plain text)': 'Kommentar (Nur-Text)',
|
||||
'Last modified datetime': 'Zuletzt geändert',
|
||||
'Creation datetime': 'Erstellt',
|
||||
'IPN as QR code': 'IPN als QR Code',
|
||||
'IPN as Code 128 barcode': 'IPN als Code 128 Barcode',
|
||||
'IPN as Code 39 barcode': 'IPN als Code 39 Barcode',
|
||||
|
||||
'Lot ID': 'Lot ID',
|
||||
'Lot name': 'Lot Name',
|
||||
|
||||
@@ -43,7 +43,8 @@ export default class extends Controller
|
||||
const message = this.element.dataset.deleteMessage;
|
||||
const title = this.element.dataset.deleteTitle;
|
||||
|
||||
const form = this.element;
|
||||
//Use event target, to find the form, where the submit button was clicked
|
||||
const form = event.target;
|
||||
const submitter = event.submitter;
|
||||
const that = this;
|
||||
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
/*
|
||||
* 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/>.
|
||||
*/
|
||||
|
||||
import {Controller} from "@hotwired/stimulus";
|
||||
|
||||
export default class extends Controller
|
||||
{
|
||||
static values = {
|
||||
id: String
|
||||
}
|
||||
|
||||
connect() {
|
||||
this.loadState()
|
||||
this.element.addEventListener('change', () => {
|
||||
this.saveState()
|
||||
});
|
||||
}
|
||||
|
||||
loadState() {
|
||||
let storageKey = this.getStorageKey();
|
||||
let value = localStorage.getItem(storageKey);
|
||||
if (value === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (value === 'true') {
|
||||
this.element.checked = true
|
||||
}
|
||||
if (value === 'false') {
|
||||
this.element.checked = false
|
||||
}
|
||||
}
|
||||
|
||||
saveState() {
|
||||
let storageKey = this.getStorageKey();
|
||||
|
||||
if (this.element.checked) {
|
||||
localStorage.setItem(storageKey, 'true');
|
||||
} else {
|
||||
localStorage.setItem(storageKey, 'false');
|
||||
}
|
||||
}
|
||||
|
||||
getStorageKey() {
|
||||
if (this.hasIdValue) {
|
||||
return 'persistent_checkbox_' + this.idValue
|
||||
}
|
||||
|
||||
return 'persistent_checkbox_' + this.element.id;
|
||||
}
|
||||
}
|
||||
@@ -27,7 +27,7 @@ export default class extends Controller {
|
||||
}
|
||||
|
||||
let tmp = '<div class="row m-0">' +
|
||||
"<div class='col-2 p-0 d-flex align-items-center'>" +
|
||||
"<div class='col-2 p-0 d-flex align-items-center' style='max-width: 80px;'>" +
|
||||
(data.image ? "<img class='typeahead-image' src='" + data.image + "'/>" : "") +
|
||||
"</div>" +
|
||||
"<div class='col-10'>" +
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
/*
|
||||
* 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/>.
|
||||
*/
|
||||
|
||||
import {Controller} from "@hotwired/stimulus";
|
||||
|
||||
export default class extends Controller {
|
||||
|
||||
static targets = [ "display", "select" ]
|
||||
|
||||
connect()
|
||||
{
|
||||
this.update();
|
||||
this.selectTarget.addEventListener('change', this.update.bind(this));
|
||||
}
|
||||
|
||||
update()
|
||||
{
|
||||
//If the select value is 0, then we show the input field
|
||||
if( this.selectTarget.value === '0')
|
||||
{
|
||||
this.displayTarget.classList.remove('d-none');
|
||||
}
|
||||
else
|
||||
{
|
||||
this.displayTarget.classList.add('d-none');
|
||||
}
|
||||
}
|
||||
}
|
||||
68
assets/controllers/pages/part_merge_modal_controller.js
Normal file
68
assets/controllers/pages/part_merge_modal_controller.js
Normal file
@@ -0,0 +1,68 @@
|
||||
/*
|
||||
* 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/>.
|
||||
*/
|
||||
|
||||
import {Controller} from "@hotwired/stimulus";
|
||||
|
||||
export default class extends Controller
|
||||
{
|
||||
static targets = ['link', 'mode', 'otherSelect'];
|
||||
static values = {
|
||||
targetId: Number,
|
||||
};
|
||||
|
||||
connect() {
|
||||
}
|
||||
|
||||
update() {
|
||||
const link = this.linkTarget;
|
||||
const other_select = this.otherSelectTarget;
|
||||
|
||||
//Extract the mode using the mode radio buttons (we filter the array to get the checked one)
|
||||
const mode = (this.modeTargets.filter((e)=>e.checked))[0].value;
|
||||
|
||||
if (other_select.value === '') {
|
||||
link.classList.add('disabled');
|
||||
return;
|
||||
}
|
||||
|
||||
//Extract href template from data attribute on link target
|
||||
let href = link.getAttribute('data-href-template');
|
||||
|
||||
let target, other;
|
||||
if (mode === '1') {
|
||||
target = this.targetIdValue;
|
||||
other = other_select.value;
|
||||
} else if (mode === '2') {
|
||||
target = other_select.value;
|
||||
other = this.targetIdValue;
|
||||
} else {
|
||||
throw 'Invalid mode';
|
||||
}
|
||||
|
||||
//Replace placeholder with actual target id
|
||||
href = href.replace('__target__', target);
|
||||
//Replace placeholder with selected value of the select (the event sender)
|
||||
href = href.replace('__other__', other);
|
||||
|
||||
//Assign new href to link
|
||||
link.setAttribute('href', href);
|
||||
//Make link clickable
|
||||
link.classList.remove('disabled');
|
||||
}
|
||||
}
|
||||
@@ -19,7 +19,7 @@
|
||||
|
||||
"use strict";
|
||||
|
||||
import {Tab, Dropdown} from "bootstrap";
|
||||
import {Tab, Dropdown, Collapse} from "bootstrap";
|
||||
import tab from "bootstrap/js/src/tab";
|
||||
|
||||
/**
|
||||
@@ -54,6 +54,7 @@ class TabRememberHelper {
|
||||
const first_element = merged[0] ?? null;
|
||||
if(first_element) {
|
||||
this.revealElementOnTab(first_element);
|
||||
this.revealElementInCollapse(first_element);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,10 +63,20 @@ class TabRememberHelper {
|
||||
* @param event
|
||||
*/
|
||||
onInvalid(event) {
|
||||
this.revealElementInCollapse(event.target);
|
||||
this.revealElementOnTab(event.target);
|
||||
this.revealElementInDropdown(event.target);
|
||||
}
|
||||
|
||||
revealElementInCollapse(element) {
|
||||
let collapse = element.closest('.collapse');
|
||||
|
||||
if(collapse) {
|
||||
let bs_collapse = Collapse.getOrCreateInstance(collapse);
|
||||
bs_collapse.show();
|
||||
}
|
||||
}
|
||||
|
||||
revealElementInDropdown(element) {
|
||||
let dropdown = element.closest('.dropdown-menu');
|
||||
|
||||
|
||||
@@ -70,6 +70,7 @@
|
||||
"symfony/runtime": "6.3.*",
|
||||
"symfony/security-bundle": "6.3.*",
|
||||
"symfony/serializer": "6.3.*",
|
||||
"symfony/string": "6.3.*",
|
||||
"symfony/translation": "6.3.*",
|
||||
"symfony/twig-bundle": "6.3.*",
|
||||
"symfony/ux-translator": "^2.10",
|
||||
|
||||
1453
composer.lock
generated
1453
composer.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -8,6 +8,8 @@ api_platform:
|
||||
# eager_loading:
|
||||
# max_joins: 100
|
||||
|
||||
keep_legacy_inflector: false
|
||||
|
||||
swagger:
|
||||
api_keys:
|
||||
# overridden in OpenApiFactoryDecorator
|
||||
|
||||
@@ -5,6 +5,7 @@ parameters:
|
||||
saml.sp.privateKey: '%env(string:SAML_SP_PRIVATE_KEY)%'
|
||||
|
||||
nbgrp_onelogin_saml:
|
||||
use_proxy_vars: '%env(bool:SAML_BEHIND_PROXY)%'
|
||||
onelogin_settings:
|
||||
default:
|
||||
# Basic settings
|
||||
|
||||
@@ -20,6 +20,7 @@ twig:
|
||||
avatar_helper: '@App\Services\UserSystem\UserAvatarHelper'
|
||||
available_themes: '%partdb.available_themes%'
|
||||
saml_enabled: '%partdb.saml.enabled%'
|
||||
part_preview_generator: '@App\Services\Attachments\PartPreviewGenerator'
|
||||
|
||||
when@test:
|
||||
twig:
|
||||
|
||||
@@ -137,6 +137,8 @@ want to edit it on docker, you have to map the file to a volume.
|
||||
|
||||
* `SAML_ENABLED`: When this is set to 1, SAML SSO is enabled and the SSO Login button is shown in the login form. You
|
||||
have to configure the SAML settings below, before you can use this feature.
|
||||
* `SAML_BEHIND_PROXY`: Set this to 1, if Part-DB is behind a reverse proxy. See [here]({% link installation/reverse-proxy.md %})
|
||||
for more information. Otherwise, leave it to 0 (default.)
|
||||
* `SAML_ROLE_MAPPING`: A [JSON](https://en.wikipedia.org/wiki/JSON) encoded map which specifies how Part-DB should
|
||||
convert the user roles given by SAML attribute `group` should be converted to a Part-DB group (specified by ID). You
|
||||
can use a wildcard `*` to map all otherwise unmapped roles to a certain group.
|
||||
|
||||
@@ -12,8 +12,8 @@ It is installed on a web server and so can be accessed with any browser without
|
||||
{: .important-title }
|
||||
> Demo
|
||||
>
|
||||
> If you want to test Part-DB without installing it, you can use [this](https://part-db.herokuapp.com) Heroku instance.
|
||||
> (Or this link for the [German Version](https://part-db.herokuapp.com/de/)).
|
||||
> If you want to test Part-DB without installing it, you can use [this](https://demo.part-db.de/) Heroku instance.
|
||||
> (Or this link for the [German Version](https://demo.part-db.de/de/)).
|
||||
>
|
||||
> You can log in with username: **user** and password: **user**, to change/create data.
|
||||
>
|
||||
|
||||
@@ -150,6 +150,7 @@ services:
|
||||
database:
|
||||
container_name: partdb_database
|
||||
image: mysql:8.0
|
||||
restart: unless-stopped
|
||||
command: --default-authentication-plugin=mysql_native_password
|
||||
environment:
|
||||
# Change this Password
|
||||
|
||||
@@ -20,4 +20,21 @@ TRUSTED_PROXIES=192.168.2.10
|
||||
```
|
||||
|
||||
Set the `DEFAULT_URI` environment variable to the URL of your Part-DB installation, available from the outside (so via
|
||||
the reverse proxy).
|
||||
the reverse proxy).
|
||||
|
||||
## Part-DB in a subpath via reverse proxy
|
||||
|
||||
If you put Part-DB into a subpath via the reverse proxy, you have to configure your webserver to include `X-Forwarded-Prefix` in the request headers.
|
||||
For example if you put Part-DB behind a reverse proxy with the URL `https://example.com/partdb`, you have to set the `X-Forwarded-Prefix` header to `/partdb`.
|
||||
|
||||
In apache, you can do this by adding the following line to your virtual host configuration:
|
||||
|
||||
```
|
||||
RequestHeader set X-Forwarded-Prefix "/partdb"
|
||||
```
|
||||
|
||||
and in nginx, you can do this by adding the following line to your server configuration:
|
||||
|
||||
```
|
||||
proxy_set_header X-Forwarded-Prefix "/partdb";
|
||||
```
|
||||
@@ -230,3 +230,8 @@ Normally you don't have to change anything here.
|
||||
Please note that this file is not saved by the Part-DB backup tool, so you have to save it manually if you want to keep
|
||||
your changes. On docker containers you have to configure a volume mapping for it.
|
||||
|
||||
## SAML behind a reverse proxy
|
||||
|
||||
If you are running Part-DB behind a reverse proxy, configure the `TRUSTED_PROXIES` environment and other reverse proxy
|
||||
settings as described in the [reverse proxy guide]({% link installation/reverse-proxy.md %}).
|
||||
If you want to use SAML you also need to set `SAML_BEHIND_PROXY` to `true` to enable the SAML proxy mode.
|
||||
|
||||
@@ -160,7 +160,7 @@ EOD;
|
||||
21840,21840,21840,21840,21840,21520,21520,21520,20480,21520,20480,
|
||||
20480,20480,20480,20480,21504,20480),
|
||||
(
|
||||
2,'admin', '${admin_pw}','','',
|
||||
2,'admin', '$admin_pw','','',
|
||||
'','',1,1,21845,21845,21845,21,85,21,349525,21845,21845,21845,21845
|
||||
,21845,21845,21845,21845,21845,21845,21845,21845,21845,21845,21845,
|
||||
21845,21845,21845,21845,21845,21845);
|
||||
|
||||
@@ -234,8 +234,8 @@ final class Version20190902140506 extends AbstractMultiPlatformMigration
|
||||
'orderdetails', 'pricedetails', 'storelocations', 'suppliers', ];
|
||||
|
||||
foreach ($tables as $table) {
|
||||
$this->addSql("UPDATE ${table} SET datetime_added = NOW() WHERE datetime_added = '0000-00-00 00:00:00'");
|
||||
$this->addSql("UPDATE ${table} SET last_modified = datetime_added WHERE last_modified = '0000-00-00 00:00:00'");
|
||||
$this->addSql("UPDATE $table SET datetime_added = NOW() WHERE datetime_added = '0000-00-00 00:00:00'");
|
||||
$this->addSql("UPDATE $table SET last_modified = datetime_added WHERE last_modified = '0000-00-00 00:00:00'");
|
||||
}
|
||||
|
||||
//Set the dbVersion to a high value, to prevent the old Part-DB versions to upgrade DB!
|
||||
|
||||
@@ -83,9 +83,12 @@ final class Version20221114193325 extends AbstractMultiPlatformMigration impleme
|
||||
//Reset the permissions of the admin user, to allow admin permissions (like the admins group)
|
||||
$this->addSql("UPDATE `users` SET permissions_data = '$admin' WHERE id = 2;");
|
||||
|
||||
//This warning should not be needed, anymore, as almost everybody should have updated to the new version by now, and this warning would just irritate new users of the software
|
||||
/*
|
||||
$this->logger->warning('<bg=cyan;fg=black>!!! All permissions were reset! Please change them to the desired state, immediately !!!</>');
|
||||
$this->logger->warning('<bg=cyan;fg=black>!!! For security reasons all users (except the admin user) were disabled. Login with admin user and reenable other users after checking their permissions !!!</>');
|
||||
$this->logger->warning('<bg=cyan;fg=black>!!! For more infos see: https://github.com/Part-DB/Part-DB-symfony/discussions/193 !!!</>');
|
||||
*/
|
||||
}
|
||||
|
||||
public function mySQLUp(Schema $schema): void
|
||||
|
||||
71
migrations/Version20231114223101.php
Normal file
71
migrations/Version20231114223101.php
Normal file
@@ -0,0 +1,71 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use App\Migration\AbstractMultiPlatformMigration;
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
/**
|
||||
* Auto-generated Migration: Please modify to your needs!
|
||||
*/
|
||||
final class Version20231114223101 extends AbstractMultiPlatformMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Add schema for part associations and vendor barcodes';
|
||||
}
|
||||
|
||||
public function mySQLUp(Schema $schema): void
|
||||
{
|
||||
$this->addSql('CREATE TABLE part_association (id INT AUTO_INCREMENT NOT NULL, owner_id INT NOT NULL, other_id INT NOT NULL, type SMALLINT NOT NULL, other_type VARCHAR(255) DEFAULT NULL, comment LONGTEXT DEFAULT NULL, last_modified DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, datetime_added DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, INDEX IDX_61B952E07E3C61F9 (owner_id), INDEX IDX_61B952E0998D9879 (other_id), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB');
|
||||
$this->addSql('ALTER TABLE part_association ADD CONSTRAINT FK_61B952E07E3C61F9 FOREIGN KEY (owner_id) REFERENCES `parts` (id) ON DELETE CASCADE');
|
||||
$this->addSql('ALTER TABLE part_association ADD CONSTRAINT FK_61B952E0998D9879 FOREIGN KEY (other_id) REFERENCES `parts` (id) ON DELETE CASCADE');
|
||||
$this->addSql('ALTER TABLE part_lots ADD vendor_barcode VARCHAR(255) DEFAULT NULL');
|
||||
$this->addSql('CREATE INDEX part_lots_idx_barcode ON part_lots (vendor_barcode)');
|
||||
}
|
||||
|
||||
public function mySQLDown(Schema $schema): void
|
||||
{
|
||||
$this->addSql('ALTER TABLE part_association DROP FOREIGN KEY FK_61B952E07E3C61F9');
|
||||
$this->addSql('ALTER TABLE part_association DROP FOREIGN KEY FK_61B952E0998D9879');
|
||||
$this->addSql('DROP TABLE part_association');
|
||||
$this->addSql('DROP INDEX part_lots_idx_barcode ON part_lots');
|
||||
$this->addSql('ALTER TABLE part_lots DROP vendor_barcode');
|
||||
}
|
||||
|
||||
public function sqLiteUp(Schema $schema): void
|
||||
{
|
||||
$this->addSql('CREATE TABLE part_association (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, owner_id INTEGER NOT NULL, other_id INTEGER NOT NULL, type SMALLINT NOT NULL, other_type VARCHAR(255) DEFAULT NULL, comment CLOB DEFAULT NULL, last_modified DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, datetime_added DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, CONSTRAINT FK_61B952E07E3C61F9 FOREIGN KEY (owner_id) REFERENCES "parts" (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE, CONSTRAINT FK_61B952E0998D9879 FOREIGN KEY (other_id) REFERENCES "parts" (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE)');
|
||||
$this->addSql('CREATE INDEX IDX_61B952E07E3C61F9 ON part_association (owner_id)');
|
||||
$this->addSql('CREATE INDEX IDX_61B952E0998D9879 ON part_association (other_id)');
|
||||
$this->addSql('CREATE TEMPORARY TABLE __temp__part_lots AS SELECT id, id_store_location, id_part, id_owner, description, comment, expiration_date, instock_unknown, amount, needs_refill, last_modified, datetime_added FROM part_lots');
|
||||
$this->addSql('DROP TABLE part_lots');
|
||||
$this->addSql('CREATE TABLE part_lots (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, id_store_location INTEGER DEFAULT NULL, id_part INTEGER NOT NULL, id_owner INTEGER DEFAULT NULL, description CLOB NOT NULL, comment CLOB NOT NULL, expiration_date DATETIME DEFAULT NULL, instock_unknown BOOLEAN NOT NULL, amount DOUBLE PRECISION NOT NULL, needs_refill BOOLEAN NOT NULL, last_modified DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, datetime_added DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, vendor_barcode VARCHAR(255) DEFAULT NULL, CONSTRAINT FK_EBC8F9435D8F4B37 FOREIGN KEY (id_store_location) REFERENCES storelocations (id) ON UPDATE NO ACTION ON DELETE NO ACTION NOT DEFERRABLE INITIALLY IMMEDIATE, CONSTRAINT FK_EBC8F943C22F6CC4 FOREIGN KEY (id_part) REFERENCES parts (id) ON UPDATE NO ACTION ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE, CONSTRAINT FK_EBC8F94321E5A74C FOREIGN KEY (id_owner) REFERENCES users (id) ON UPDATE NO ACTION ON DELETE SET NULL NOT DEFERRABLE INITIALLY IMMEDIATE)');
|
||||
$this->addSql('INSERT INTO part_lots (id, id_store_location, id_part, id_owner, description, comment, expiration_date, instock_unknown, amount, needs_refill, last_modified, datetime_added) SELECT id, id_store_location, id_part, id_owner, description, comment, expiration_date, instock_unknown, amount, needs_refill, last_modified, datetime_added FROM __temp__part_lots');
|
||||
$this->addSql('DROP TABLE __temp__part_lots');
|
||||
$this->addSql('CREATE INDEX IDX_EBC8F94321E5A74C ON part_lots (id_owner)');
|
||||
$this->addSql('CREATE INDEX IDX_EBC8F943C22F6CC4 ON part_lots (id_part)');
|
||||
$this->addSql('CREATE INDEX IDX_EBC8F9435D8F4B37 ON part_lots (id_store_location)');
|
||||
$this->addSql('CREATE INDEX part_lots_idx_instock_un_expiration_id_part ON part_lots (instock_unknown, expiration_date, id_part)');
|
||||
$this->addSql('CREATE INDEX part_lots_idx_needs_refill ON part_lots (needs_refill)');
|
||||
$this->addSql('CREATE INDEX part_lots_idx_barcode ON part_lots (vendor_barcode)');
|
||||
}
|
||||
|
||||
public function sqLiteDown(Schema $schema): void
|
||||
{
|
||||
$this->addSql('DROP TABLE part_association');
|
||||
$this->addSql('CREATE TEMPORARY TABLE __temp__part_lots AS SELECT id, id_store_location, id_part, id_owner, description, comment, expiration_date, instock_unknown, amount, needs_refill, last_modified, datetime_added FROM part_lots');
|
||||
$this->addSql('DROP TABLE part_lots');
|
||||
$this->addSql('CREATE TABLE part_lots (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, id_store_location INTEGER DEFAULT NULL, id_part INTEGER NOT NULL, id_owner INTEGER DEFAULT NULL, description CLOB NOT NULL, comment CLOB NOT NULL, expiration_date DATETIME DEFAULT NULL, instock_unknown BOOLEAN NOT NULL, amount DOUBLE PRECISION NOT NULL, needs_refill BOOLEAN NOT NULL, last_modified DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, datetime_added DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, CONSTRAINT FK_EBC8F9435D8F4B37 FOREIGN KEY (id_store_location) REFERENCES "storelocations" (id) NOT DEFERRABLE INITIALLY IMMEDIATE, CONSTRAINT FK_EBC8F943C22F6CC4 FOREIGN KEY (id_part) REFERENCES "parts" (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE, CONSTRAINT FK_EBC8F94321E5A74C FOREIGN KEY (id_owner) REFERENCES "users" (id) ON DELETE SET NULL NOT DEFERRABLE INITIALLY IMMEDIATE)');
|
||||
$this->addSql('INSERT INTO part_lots (id, id_store_location, id_part, id_owner, description, comment, expiration_date, instock_unknown, amount, needs_refill, last_modified, datetime_added) SELECT id, id_store_location, id_part, id_owner, description, comment, expiration_date, instock_unknown, amount, needs_refill, last_modified, datetime_added FROM __temp__part_lots');
|
||||
$this->addSql('DROP TABLE __temp__part_lots');
|
||||
$this->addSql('CREATE INDEX IDX_EBC8F9435D8F4B37 ON part_lots (id_store_location)');
|
||||
$this->addSql('CREATE INDEX IDX_EBC8F943C22F6CC4 ON part_lots (id_part)');
|
||||
$this->addSql('CREATE INDEX IDX_EBC8F94321E5A74C ON part_lots (id_owner)');
|
||||
$this->addSql('CREATE INDEX part_lots_idx_instock_un_expiration_id_part ON part_lots (instock_unknown, expiration_date, id_part)');
|
||||
$this->addSql('CREATE INDEX part_lots_idx_needs_refill ON part_lots (needs_refill)');
|
||||
}
|
||||
}
|
||||
@@ -26,7 +26,7 @@ parameters:
|
||||
|
||||
checkUninitializedProperties: true
|
||||
|
||||
checkFunctionNameCase: true
|
||||
checkFunctionNameCase: false
|
||||
|
||||
checkAlwaysTrueInstanceof: false
|
||||
checkAlwaysTrueCheckTypeFunctionCall: false
|
||||
|
||||
@@ -26,6 +26,7 @@ namespace App\ApiResource;
|
||||
use ApiPlatform\Metadata\ApiFilter;
|
||||
use ApiPlatform\Metadata\ApiResource;
|
||||
use ApiPlatform\Metadata\Get;
|
||||
use ApiPlatform\OpenApi\Model\Operation;
|
||||
use ApiPlatform\Serializer\Filter\PropertyFilter;
|
||||
use App\State\PartDBInfoProvider;
|
||||
|
||||
@@ -35,7 +36,7 @@ use App\State\PartDBInfoProvider;
|
||||
#[ApiResource(
|
||||
uriTemplate: '/info.{_format}',
|
||||
description: 'Basic information about Part-DB like version, title, etc.',
|
||||
operations: [new Get(openapiContext: ['summary' => 'Get basic information about the installed Part-DB instance.'])],
|
||||
operations: [new Get(openapi: new Operation(summary: 'Get basic information about the installed Part-DB instance.'))],
|
||||
provider: PartDBInfoProvider::class
|
||||
)]
|
||||
#[ApiFilter(PropertyFilter::class)]
|
||||
|
||||
@@ -23,6 +23,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Controller;
|
||||
|
||||
use App\Entity\Parts\Part;
|
||||
use App\Exceptions\AttachmentDownloadException;
|
||||
use App\Form\InfoProviderSystem\PartSearchType;
|
||||
use App\Form\Part\PartBaseType;
|
||||
@@ -32,6 +33,7 @@ use App\Services\InfoProviderSystem\ProviderRegistry;
|
||||
use App\Services\LogSystem\EventCommentHelper;
|
||||
use App\Services\Parts\PartFormHelper;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Bridge\Doctrine\Attribute\MapEntity;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\Form\FormInterface;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
@@ -61,7 +63,8 @@ class InfoProviderController extends AbstractController
|
||||
}
|
||||
|
||||
#[Route('/search', name: 'info_providers_search')]
|
||||
public function search(Request $request): Response
|
||||
#[Route('/update/{target}', name: 'info_providers_update_part_search')]
|
||||
public function search(Request $request, #[MapEntity(id: 'target')] ?Part $update_target): Response
|
||||
{
|
||||
$this->denyAccessUnlessGranted('@info_providers.create_parts');
|
||||
|
||||
@@ -70,6 +73,12 @@ class InfoProviderController extends AbstractController
|
||||
|
||||
$results = null;
|
||||
|
||||
//When we are updating a part, use its name as keyword, to make searching easier
|
||||
//However we can only do this, if the form was not submitted yet
|
||||
if ($update_target !== null && !$form->isSubmitted()) {
|
||||
$form->get('keyword')->setData($update_target->getName());
|
||||
}
|
||||
|
||||
if ($form->isSubmitted() && $form->isValid()) {
|
||||
$keyword = $form->get('keyword')->getData();
|
||||
$providers = $form->get('providers')->getData();
|
||||
@@ -80,6 +89,7 @@ class InfoProviderController extends AbstractController
|
||||
return $this->render('info_providers/search/part_search.html.twig', [
|
||||
'form' => $form,
|
||||
'results' => $results,
|
||||
'update_target' => $update_target
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -36,6 +36,7 @@ use App\Exceptions\AttachmentDownloadException;
|
||||
use App\Form\Part\PartBaseType;
|
||||
use App\Services\Attachments\AttachmentSubmitHandler;
|
||||
use App\Services\Attachments\PartPreviewGenerator;
|
||||
use App\Services\EntityMergers\Mergers\PartMerger;
|
||||
use App\Services\InfoProviderSystem\PartInfoRetriever;
|
||||
use App\Services\LogSystem\EventCommentHelper;
|
||||
use App\Services\LogSystem\HistoryHelper;
|
||||
@@ -233,6 +234,48 @@ class PartController extends AbstractController
|
||||
]);
|
||||
}
|
||||
|
||||
#[Route('/{target}/merge/{other}', name: 'part_merge')]
|
||||
public function merge(Request $request, Part $target, Part $other, PartMerger $partMerger): Response
|
||||
{
|
||||
$this->denyAccessUnlessGranted('edit', $target);
|
||||
$this->denyAccessUnlessGranted('delete', $other);
|
||||
|
||||
//Save the old name of the target part for the template
|
||||
$target_name = $target->getName();
|
||||
|
||||
$this->addFlash('notice', t('part.merge.flash.please_review'));
|
||||
|
||||
$merged = $partMerger->merge($target, $other);
|
||||
return $this->renderPartForm('merge', $request, $merged, [], [
|
||||
'tname_before' => $target_name,
|
||||
'other_part' => $other,
|
||||
]);
|
||||
}
|
||||
|
||||
#[Route(path: '/{id}/from_info_provider/{providerKey}/{providerId}/update', name: 'info_providers_update_part', requirements: ['providerId' => '.+'])]
|
||||
public function updateFromInfoProvider(Part $part, Request $request, string $providerKey, string $providerId,
|
||||
PartInfoRetriever $infoRetriever, PartMerger $partMerger): Response
|
||||
{
|
||||
$this->denyAccessUnlessGranted('edit', $part);
|
||||
$this->denyAccessUnlessGranted('@info_providers.create_parts');
|
||||
|
||||
//Save the old name of the target part for the template
|
||||
$old_name = $part->getName();
|
||||
|
||||
$dto = $infoRetriever->getDetails($providerKey, $providerId);
|
||||
$provider_part = $infoRetriever->dtoToPart($dto);
|
||||
|
||||
$part = $partMerger->merge($part, $provider_part);
|
||||
|
||||
$this->addFlash('notice', t('part.merge.flash.please_review'));
|
||||
|
||||
return $this->renderPartForm('update_from_ip', $request, $part, [
|
||||
'info_provider_dto' => $dto,
|
||||
], [
|
||||
'tname_before' => $old_name
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* This function provides a common implementation for methods, which use the part form.
|
||||
* @param Request $request
|
||||
@@ -240,10 +283,10 @@ class PartController extends AbstractController
|
||||
* @param array $form_options
|
||||
* @return Response
|
||||
*/
|
||||
private function renderPartForm(string $mode, Request $request, Part $data, array $form_options = []): Response
|
||||
private function renderPartForm(string $mode, Request $request, Part $data, array $form_options = [], array $merge_infos = []): Response
|
||||
{
|
||||
//Ensure that mode is either 'new' or 'edit
|
||||
if (!in_array($mode, ['new', 'edit'], true)) {
|
||||
if (!in_array($mode, ['new', 'edit', 'merge', 'update_from_ip'], true)) {
|
||||
throw new \InvalidArgumentException('Invalid mode given');
|
||||
}
|
||||
|
||||
@@ -276,6 +319,12 @@ class PartController extends AbstractController
|
||||
$this->commentHelper->setMessage($form['log_comment']->getData());
|
||||
|
||||
$this->em->persist($new_part);
|
||||
|
||||
//When we are in merge mode, we have to remove the other part
|
||||
if ($mode === 'merge') {
|
||||
$this->em->remove($merge_infos['other_part']);
|
||||
}
|
||||
|
||||
$this->em->flush();
|
||||
if ($mode === 'new') {
|
||||
$this->addFlash('success', 'part.created_flash');
|
||||
@@ -310,12 +359,18 @@ class PartController extends AbstractController
|
||||
$template = 'parts/edit/new_part.html.twig';
|
||||
} else if ($mode === 'edit') {
|
||||
$template = 'parts/edit/edit_part_info.html.twig';
|
||||
} else if ($mode === 'merge') {
|
||||
$template = 'parts/edit/merge_parts.html.twig';
|
||||
} else if ($mode === 'update_from_ip') {
|
||||
$template = 'parts/edit/update_from_ip.html.twig';
|
||||
}
|
||||
|
||||
return $this->render($template,
|
||||
[
|
||||
'part' => $new_part,
|
||||
'form' => $form,
|
||||
'merge_old_name' => $merge_infos['tname_before'] ?? null,
|
||||
'merge_other' => $merge_infos['other_part'] ?? null
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -345,23 +400,35 @@ class PartController extends AbstractController
|
||||
$amount = (float) $request->request->get('amount');
|
||||
$comment = $request->request->get('comment');
|
||||
$action = $request->request->get('action');
|
||||
$delete_lot_if_empty = $request->request->getBoolean('delete_lot_if_empty', false);
|
||||
|
||||
$timestamp = null;
|
||||
$timestamp_str = $request->request->getString('timestamp', '');
|
||||
//Try to parse the timestamp
|
||||
if($timestamp_str !== '') {
|
||||
$timestamp = new DateTime($timestamp_str);
|
||||
}
|
||||
|
||||
//Ensure that the timestamp is not in the future
|
||||
if($timestamp !== null && $timestamp > new DateTime("+20min")) {
|
||||
throw new \LogicException("The timestamp must not be in the future!");
|
||||
}
|
||||
|
||||
try {
|
||||
switch ($action) {
|
||||
case "withdraw":
|
||||
case "remove":
|
||||
$this->denyAccessUnlessGranted('withdraw', $partLot);
|
||||
$withdrawAddHelper->withdraw($partLot, $amount, $comment);
|
||||
$withdrawAddHelper->withdraw($partLot, $amount, $comment, $timestamp, $delete_lot_if_empty);
|
||||
break;
|
||||
case "add":
|
||||
$this->denyAccessUnlessGranted('add', $partLot);
|
||||
$withdrawAddHelper->add($partLot, $amount, $comment);
|
||||
$withdrawAddHelper->add($partLot, $amount, $comment, $timestamp);
|
||||
break;
|
||||
case "move":
|
||||
$this->denyAccessUnlessGranted('move', $partLot);
|
||||
$this->denyAccessUnlessGranted('move', $targetLot);
|
||||
$withdrawAddHelper->move($partLot, $targetLot, $amount, $comment);
|
||||
$withdrawAddHelper->move($partLot, $targetLot, $amount, $comment, $timestamp, $delete_lot_if_empty);
|
||||
break;
|
||||
default:
|
||||
throw new \RuntimeException("Unknown action!");
|
||||
|
||||
@@ -42,8 +42,10 @@ declare(strict_types=1);
|
||||
namespace App\Controller;
|
||||
|
||||
use App\Form\LabelSystem\ScanDialogType;
|
||||
use App\Services\LabelSystem\Barcodes\BarcodeNormalizer;
|
||||
use App\Services\LabelSystem\Barcodes\BarcodeScanHelper;
|
||||
use App\Services\LabelSystem\Barcodes\BarcodeRedirector;
|
||||
use App\Services\LabelSystem\Barcodes\BarcodeScanResult;
|
||||
use App\Services\LabelSystem\Barcodes\BarcodeSourceType;
|
||||
use Doctrine\ORM\EntityNotFoundException;
|
||||
use InvalidArgumentException;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
@@ -55,7 +57,7 @@ use Symfony\Component\Routing\Annotation\Route;
|
||||
#[Route(path: '/scan')]
|
||||
class ScanController extends AbstractController
|
||||
{
|
||||
public function __construct(protected BarcodeRedirector $barcodeParser, protected BarcodeNormalizer $barcodeNormalizer)
|
||||
public function __construct(protected BarcodeRedirector $barcodeParser, protected BarcodeScanHelper $barcodeNormalizer)
|
||||
{
|
||||
}
|
||||
|
||||
@@ -69,14 +71,14 @@ class ScanController extends AbstractController
|
||||
|
||||
if ($input === null && $form->isSubmitted() && $form->isValid()) {
|
||||
$input = $form['input']->getData();
|
||||
$mode = $form['mode']->getData();
|
||||
}
|
||||
|
||||
if ($input !== null) {
|
||||
try {
|
||||
[$type, $id] = $this->barcodeNormalizer->normalizeBarcodeContent($input);
|
||||
|
||||
$scan_result = $this->barcodeNormalizer->scanBarcodeContent($input, $mode ?? null);
|
||||
try {
|
||||
return $this->redirect($this->barcodeParser->getRedirectURL($type, $id));
|
||||
return $this->redirect($this->barcodeParser->getRedirectURL($scan_result));
|
||||
} catch (EntityNotFoundException) {
|
||||
$this->addFlash('success', 'scan.qr_not_found');
|
||||
}
|
||||
@@ -95,10 +97,23 @@ class ScanController extends AbstractController
|
||||
*/
|
||||
public function scanQRCode(string $type, int $id): Response
|
||||
{
|
||||
$type = strtolower($type);
|
||||
|
||||
try {
|
||||
$this->addFlash('success', 'scan.qr_success');
|
||||
|
||||
return $this->redirect($this->barcodeParser->getRedirectURL($type, $id));
|
||||
if (!isset(BarcodeScanHelper::QR_TYPE_MAP[$type])) {
|
||||
throw new InvalidArgumentException('Unknown type: '.$type);
|
||||
}
|
||||
//Construct the scan result manually, as we don't have a barcode here
|
||||
$scan_result = new BarcodeScanResult(
|
||||
target_type: BarcodeScanHelper::QR_TYPE_MAP[$type],
|
||||
target_id: $id,
|
||||
//The routes are only used on the internal generated QR codes
|
||||
source_type: BarcodeSourceType::INTERNAL
|
||||
);
|
||||
|
||||
return $this->redirect($this->barcodeParser->getRedirectURL($scan_result));
|
||||
} catch (EntityNotFoundException) {
|
||||
$this->addFlash('success', 'scan.qr_not_found');
|
||||
|
||||
|
||||
@@ -83,6 +83,7 @@ class PartFixtures extends Fixture implements DependentFixtureInterface
|
||||
$part->setManufacturer($manager->find(Manufacturer::class, 1));
|
||||
$part->setTags('test, Test, Part2');
|
||||
$part->setMass(100.2);
|
||||
$part->setIpn('IPN123');
|
||||
$part->setNeedsReview(true);
|
||||
$part->setManufacturingStatus(ManufacturingStatus::ACTIVE);
|
||||
$manager->persist($part);
|
||||
@@ -102,6 +103,7 @@ class PartFixtures extends Fixture implements DependentFixtureInterface
|
||||
$partLot2->setComment('Test');
|
||||
$partLot2->setNeedsRefill(true);
|
||||
$partLot2->setStorageLocation($manager->find(StorageLocation::class, 3));
|
||||
$partLot2->setVendorBarcode('lot2_vendor_barcode');
|
||||
$part->addPartLot($partLot2);
|
||||
|
||||
$orderdetail = new Orderdetail();
|
||||
|
||||
@@ -20,14 +20,17 @@ declare(strict_types=1);
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
namespace App\DataTables\Helpers;
|
||||
|
||||
use App\Entity\Parts\StorageLocation;
|
||||
use App\Entity\ProjectSystem\Project;
|
||||
use App\Entity\Attachments\Attachment;
|
||||
use App\Entity\Parts\Part;
|
||||
use App\Services\Attachments\AttachmentURLGenerator;
|
||||
use App\Services\Attachments\PartPreviewGenerator;
|
||||
use App\Services\EntityURLGenerator;
|
||||
use App\Services\Formatters\AmountFormatter;
|
||||
use Symfony\Contracts\Translation\TranslatorInterface;
|
||||
|
||||
/**
|
||||
@@ -35,8 +38,13 @@ use Symfony\Contracts\Translation\TranslatorInterface;
|
||||
*/
|
||||
class PartDataTableHelper
|
||||
{
|
||||
public function __construct(private readonly PartPreviewGenerator $previewGenerator, private readonly AttachmentURLGenerator $attachmentURLGenerator, private readonly EntityURLGenerator $entityURLGenerator, private readonly TranslatorInterface $translator)
|
||||
{
|
||||
public function __construct(
|
||||
private readonly PartPreviewGenerator $previewGenerator,
|
||||
private readonly AttachmentURLGenerator $attachmentURLGenerator,
|
||||
private readonly EntityURLGenerator $entityURLGenerator,
|
||||
private readonly TranslatorInterface $translator,
|
||||
private readonly AmountFormatter $amountFormatter,
|
||||
) {
|
||||
}
|
||||
|
||||
public function renderName(Part $context): string
|
||||
@@ -45,14 +53,16 @@ class PartDataTableHelper
|
||||
|
||||
//Depending on the part status we show a different icon (the later conditions have higher priority)
|
||||
if ($context->isFavorite()) {
|
||||
$icon = sprintf('<i class="fa-solid fa-star fa-fw me-1" title="%s"></i>', $this->translator->trans('part.favorite.badge'));
|
||||
$icon = sprintf('<i class="fa-solid fa-star fa-fw me-1" title="%s"></i>',
|
||||
$this->translator->trans('part.favorite.badge'));
|
||||
}
|
||||
if ($context->isNeedsReview()) {
|
||||
$icon = sprintf('<i class="fa-solid fa-ambulance fa-fw me-1" title="%s"></i>', $this->translator->trans('part.needs_review.badge'));
|
||||
$icon = sprintf('<i class="fa-solid fa-ambulance fa-fw me-1" title="%s"></i>',
|
||||
$this->translator->trans('part.needs_review.badge'));
|
||||
}
|
||||
if ($context->getBuiltProject() instanceof Project) {
|
||||
$icon = sprintf('<i class="fa-solid fa-box-archive fa-fw me-1" title="%s"></i>',
|
||||
$this->translator->trans('part.info.projectBuildPart.hint') . ': ' . $context->getBuiltProject()->getName());
|
||||
$this->translator->trans('part.info.projectBuildPart.hint').': '.$context->getBuiltProject()->getName());
|
||||
}
|
||||
|
||||
|
||||
@@ -85,4 +95,62 @@ class PartDataTableHelper
|
||||
$title
|
||||
);
|
||||
}
|
||||
|
||||
public function renderStorageLocations(Part $context): string
|
||||
{
|
||||
$tmp = [];
|
||||
foreach ($context->getPartLots() as $lot) {
|
||||
//Ignore lots without storelocation
|
||||
if (!$lot->getStorageLocation() instanceof StorageLocation) {
|
||||
continue;
|
||||
}
|
||||
$tmp[] = sprintf(
|
||||
'<a href="%s" title="%s">%s</a>',
|
||||
$this->entityURLGenerator->listPartsURL($lot->getStorageLocation()),
|
||||
htmlspecialchars($lot->getStorageLocation()->getFullPath()),
|
||||
htmlspecialchars($lot->getStorageLocation()->getName())
|
||||
);
|
||||
}
|
||||
|
||||
return implode('<br>', $tmp);
|
||||
}
|
||||
|
||||
public function renderAmount(Part $context): string
|
||||
{
|
||||
$amount = $context->getAmountSum();
|
||||
$expiredAmount = $context->getExpiredAmountSum();
|
||||
|
||||
$ret = '';
|
||||
|
||||
if ($context->isAmountUnknown()) {
|
||||
//When all amounts are unknown, we show a question mark
|
||||
if ($amount === 0.0) {
|
||||
$ret .= sprintf('<b class="text-primary" title="%s">?</b>',
|
||||
$this->translator->trans('part_lots.instock_unknown'));
|
||||
} else { //Otherwise mark it with greater equal and the (known) amount
|
||||
$ret .= sprintf('<b class="text-primary" title="%s">≥</b>',
|
||||
$this->translator->trans('part_lots.instock_unknown')
|
||||
);
|
||||
$ret .= htmlspecialchars($this->amountFormatter->format($amount, $context->getPartUnit()));
|
||||
}
|
||||
} else {
|
||||
$ret .= htmlspecialchars($this->amountFormatter->format($amount, $context->getPartUnit()));
|
||||
}
|
||||
|
||||
//If we have expired lots, we show them in parentheses behind
|
||||
if ($expiredAmount > 0) {
|
||||
$ret .= sprintf(' <span title="%s" class="text-muted">(+%s)</span>',
|
||||
$this->translator->trans('part_lots.is_expired'),
|
||||
htmlspecialchars($this->amountFormatter->format($expiredAmount, $context->getPartUnit())));
|
||||
}
|
||||
|
||||
//When the amount is below the minimum amount, we highlight the number red
|
||||
if ($context->isNotEnoughInstock()) {
|
||||
$ret = sprintf('<b class="text-danger" title="%s">%s</b>',
|
||||
$this->translator->trans('part.info.amount.less_than_desired'),
|
||||
$ret);
|
||||
}
|
||||
|
||||
return $ret;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -139,7 +139,7 @@ class LogDataTable implements DataTableTypeInterface
|
||||
if ($context instanceof PartStockChangedLogEntry) {
|
||||
$text .= sprintf(
|
||||
' (<i>%s</i>)',
|
||||
$this->translator->trans('log.part_stock_changed.' . $context->getInstockChangeType()->toExtraShortType())
|
||||
$this->translator->trans($context->getInstockChangeType()->toTranslationKey())
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -139,63 +139,11 @@ final class PartsDataTable implements DataTableTypeInterface
|
||||
->add('storelocation', TextColumn::class, [
|
||||
'label' => $this->translator->trans('part.table.storeLocations'),
|
||||
'orderField' => 'storelocations.name',
|
||||
'render' => function ($value, Part $context): string {
|
||||
$tmp = [];
|
||||
foreach ($context->getPartLots() as $lot) {
|
||||
//Ignore lots without storelocation
|
||||
if (!$lot->getStorageLocation() instanceof StorageLocation) {
|
||||
continue;
|
||||
}
|
||||
$tmp[] = sprintf(
|
||||
'<a href="%s" title="%s">%s</a>',
|
||||
$this->urlGenerator->listPartsURL($lot->getStorageLocation()),
|
||||
htmlspecialchars($lot->getStorageLocation()->getFullPath()),
|
||||
htmlspecialchars($lot->getStorageLocation()->getName())
|
||||
);
|
||||
}
|
||||
|
||||
return implode('<br>', $tmp);
|
||||
},
|
||||
'render' => fn ($value, Part $context) => $this->partDataTableHelper->renderStorageLocations($context),
|
||||
], alias: 'storage_location')
|
||||
->add('amount', TextColumn::class, [
|
||||
'label' => $this->translator->trans('part.table.amount'),
|
||||
'render' => function ($value, Part $context) {
|
||||
$amount = $context->getAmountSum();
|
||||
$expiredAmount = $context->getExpiredAmountSum();
|
||||
|
||||
$ret = '';
|
||||
|
||||
if ($context->isAmountUnknown()) {
|
||||
//When all amounts are unknown, we show a question mark
|
||||
if ($amount === 0.0) {
|
||||
$ret .= sprintf('<b class="text-primary" title="%s">?</b>',
|
||||
$this->translator->trans('part_lots.instock_unknown'));
|
||||
} else { //Otherwise mark it with greater equal and the (known) amount
|
||||
$ret .= sprintf('<b class="text-primary" title="%s">≥</b>',
|
||||
$this->translator->trans('part_lots.instock_unknown')
|
||||
);
|
||||
$ret .= htmlspecialchars($this->amountFormatter->format($amount, $context->getPartUnit()));
|
||||
}
|
||||
} else {
|
||||
$ret .= htmlspecialchars($this->amountFormatter->format($amount, $context->getPartUnit()));
|
||||
}
|
||||
|
||||
//If we have expired lots, we show them in parentheses behind
|
||||
if ($expiredAmount > 0) {
|
||||
$ret .= sprintf(' <span title="%s" class="text-muted">(+%s)</span>',
|
||||
$this->translator->trans('part_lots.is_expired'),
|
||||
htmlspecialchars($this->amountFormatter->format($expiredAmount, $context->getPartUnit())));
|
||||
}
|
||||
|
||||
//When the amount is below the minimum amount, we highlight the number red
|
||||
if ($context->isNotEnoughInstock()) {
|
||||
$ret = sprintf('<b class="text-danger" title="%s">%s</b>',
|
||||
$this->translator->trans('part.info.amount.less_than_desired'),
|
||||
$ret);
|
||||
}
|
||||
|
||||
return $ret;
|
||||
},
|
||||
'render' => fn ($value, Part $context) => $this->partDataTableHelper->renderAmount($context),
|
||||
'orderField' => 'amountSum'
|
||||
])
|
||||
->add('minamount', TextColumn::class, [
|
||||
|
||||
@@ -100,7 +100,16 @@ class ProjectBomEntriesDataTable implements DataTableTypeInterface
|
||||
throw new \Exception('This should never happen!');
|
||||
},
|
||||
])
|
||||
|
||||
->add('ipn', TextColumn::class, [
|
||||
'label' => $this->translator->trans('part.table.ipn'),
|
||||
'orderField' => 'part.ipn',
|
||||
'visible' => false,
|
||||
'render' => function ($value, ProjectBOMEntry $context) {
|
||||
if($context->getPart() instanceof Part) {
|
||||
return $context->getPart()->getIpn();
|
||||
}
|
||||
}
|
||||
])
|
||||
->add('description', MarkdownColumn::class, [
|
||||
'label' => $this->translator->trans('part.table.description'),
|
||||
'data' => function (ProjectBOMEntry $context) {
|
||||
@@ -142,6 +151,28 @@ class ProjectBomEntriesDataTable implements DataTableTypeInterface
|
||||
},
|
||||
])
|
||||
|
||||
->add('instockAmount', TextColumn::class, [
|
||||
'label' => 'project.bom.instockAmount',
|
||||
'visible' => false,
|
||||
'render' => function ($value, ProjectBOMEntry $context) {
|
||||
if ($context->getPart()) {
|
||||
return $this->partDataTableHelper->renderAmount($context->getPart());
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
])
|
||||
->add('storageLocations', TextColumn::class, [
|
||||
'label' => 'part.table.storeLocations',
|
||||
'visible' => false,
|
||||
'render' => function ($value, ProjectBOMEntry $context) {
|
||||
if ($context->getPart()) {
|
||||
return $this->partDataTableHelper->renderStorageLocations($context->getPart());
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
])
|
||||
|
||||
->add('addedDate', LocaleDateTimeColumn::class, [
|
||||
'label' => $this->translator->trans('part.table.addedDate'),
|
||||
|
||||
@@ -41,6 +41,20 @@ class TinyIntType extends Type
|
||||
return 'tinyint';
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*
|
||||
* @param T $value
|
||||
*
|
||||
* @return (T is null ? null : int)
|
||||
*
|
||||
* @template T
|
||||
*/
|
||||
public function convertToPHPValue($value, AbstractPlatform $platform)
|
||||
{
|
||||
return $value === null ? null : (int) $value;
|
||||
}
|
||||
|
||||
public function requiresSQLCommentHint(AbstractPlatform $platform): bool
|
||||
{
|
||||
//We use the comment, so that doctrine migrations can properly detect, that nothing has changed and no migration is needed.
|
||||
|
||||
@@ -33,6 +33,7 @@ use ApiPlatform\Metadata\GetCollection;
|
||||
use ApiPlatform\Metadata\Link;
|
||||
use ApiPlatform\Metadata\Patch;
|
||||
use ApiPlatform\Metadata\Post;
|
||||
use ApiPlatform\OpenApi\Model\Operation;
|
||||
use ApiPlatform\Serializer\Filter\PropertyFilter;
|
||||
use App\ApiPlatform\Filter\LikeFilter;
|
||||
use App\Entity\Parts\Footprint;
|
||||
@@ -70,7 +71,7 @@ use Symfony\Component\Validator\Constraints as Assert;
|
||||
#[ApiResource(
|
||||
uriTemplate: '/attachment_types/{id}/children.{_format}',
|
||||
operations: [
|
||||
new GetCollection(openapiContext: ['summary' => 'Retrieves the children elements of an attachment type.'],
|
||||
new GetCollection(openapi: new Operation(summary: 'Retrieves the children elements of an attachment type.'),
|
||||
security: 'is_granted("@attachment_types.read")')
|
||||
],
|
||||
uriVariables: [
|
||||
|
||||
@@ -29,6 +29,7 @@ use App\Entity\Parts\Footprint;
|
||||
use App\Entity\Parts\Manufacturer;
|
||||
use App\Entity\Parts\MeasurementUnit;
|
||||
use App\Entity\Parts\Part;
|
||||
use App\Entity\Parts\PartAssociation;
|
||||
use App\Entity\Parts\PartLot;
|
||||
use App\Entity\Parts\StorageLocation;
|
||||
use App\Entity\Parts\Supplier;
|
||||
@@ -63,6 +64,8 @@ enum LogTargetType: int
|
||||
case PARAMETER = 18;
|
||||
case LABEL_PROFILE = 19;
|
||||
|
||||
case PART_ASSOCIATION = 20;
|
||||
|
||||
/**
|
||||
* Returns the class name of the target type or null if the target type is NONE.
|
||||
* @return string|null
|
||||
@@ -90,6 +93,7 @@ enum LogTargetType: int
|
||||
self::MEASUREMENT_UNIT => MeasurementUnit::class,
|
||||
self::PARAMETER => AbstractParameter::class,
|
||||
self::LABEL_PROFILE => LabelProfile::class,
|
||||
self::PART_ASSOCIATION => PartAssociation::class,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -39,6 +39,11 @@ enum PartStockChangeType: string
|
||||
};
|
||||
}
|
||||
|
||||
public function toTranslationKey(): string
|
||||
{
|
||||
return 'log.part_stock_changed.' . $this->value;
|
||||
}
|
||||
|
||||
public static function fromExtraShortType(string $value): self
|
||||
{
|
||||
return match ($value) {
|
||||
|
||||
@@ -41,8 +41,10 @@ class PartStockChangedLogEntry extends AbstractLogEntry
|
||||
* @param float $new_total_part_instock The new total instock of the part.
|
||||
* @param string $comment The comment associated with the change.
|
||||
* @param PartLot|null $move_to_target The target lot if the type is TYPE_MOVE.
|
||||
* @param \DateTimeInterface|null $action_timestamp The optional timestamp, where the action happened. Useful if the action happened in the past, and the log entry is created afterwards.
|
||||
*/
|
||||
protected function __construct(PartStockChangeType $type, PartLot $lot, float $old_stock, float $new_stock, float $new_total_part_instock, string $comment, ?PartLot $move_to_target = null)
|
||||
protected function __construct(PartStockChangeType $type, PartLot $lot, float $old_stock, float $new_stock, float $new_total_part_instock, string $comment, ?PartLot $move_to_target = null,
|
||||
?\DateTimeInterface $action_timestamp = null)
|
||||
{
|
||||
parent::__construct();
|
||||
|
||||
@@ -62,6 +64,11 @@ class PartStockChangedLogEntry extends AbstractLogEntry
|
||||
$this->extra['c'] = mb_strimwidth($comment, 0, self::COMMENT_MAX_LENGTH, '...');
|
||||
}
|
||||
|
||||
if ($action_timestamp instanceof \DateTimeInterface) {
|
||||
//The action timestamp is saved as an ISO 8601 string
|
||||
$this->extra['a'] = $action_timestamp->format(\DateTimeInterface::ATOM);
|
||||
}
|
||||
|
||||
if ($move_to_target instanceof PartLot) {
|
||||
if ($type !== PartStockChangeType::MOVE) {
|
||||
throw new \InvalidArgumentException('The move_to_target parameter can only be set if the type is "move"!');
|
||||
@@ -78,11 +85,12 @@ class PartStockChangedLogEntry extends AbstractLogEntry
|
||||
* @param float $new_stock The new stock of the lot.
|
||||
* @param float $new_total_part_instock The new total instock of the part.
|
||||
* @param string $comment The comment associated with the change.
|
||||
* @param \DateTimeInterface|null $action_timestamp The optional timestamp, where the action happened. Useful if the action happened in the past, and the log entry is created afterwards.
|
||||
* @return self
|
||||
*/
|
||||
public static function add(PartLot $lot, float $old_stock, float $new_stock, float $new_total_part_instock, string $comment): self
|
||||
public static function add(PartLot $lot, float $old_stock, float $new_stock, float $new_total_part_instock, string $comment, ?\DateTimeInterface $action_timestamp = null): self
|
||||
{
|
||||
return new self(PartStockChangeType::ADD, $lot, $old_stock, $new_stock, $new_total_part_instock, $comment);
|
||||
return new self(PartStockChangeType::ADD, $lot, $old_stock, $new_stock, $new_total_part_instock, $comment, action_timestamp: $action_timestamp);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -92,11 +100,12 @@ class PartStockChangedLogEntry extends AbstractLogEntry
|
||||
* @param float $new_stock The new stock of the lot.
|
||||
* @param float $new_total_part_instock The new total instock of the part.
|
||||
* @param string $comment The comment associated with the change.
|
||||
* @param \DateTimeInterface|null $action_timestamp The optional timestamp, where the action happened. Useful if the action happened in the past, and the log entry is created afterwards.
|
||||
* @return self
|
||||
*/
|
||||
public static function withdraw(PartLot $lot, float $old_stock, float $new_stock, float $new_total_part_instock, string $comment): self
|
||||
public static function withdraw(PartLot $lot, float $old_stock, float $new_stock, float $new_total_part_instock, string $comment, ?\DateTimeInterface $action_timestamp = null): self
|
||||
{
|
||||
return new self(PartStockChangeType::WITHDRAW, $lot, $old_stock, $new_stock, $new_total_part_instock, $comment);
|
||||
return new self(PartStockChangeType::WITHDRAW, $lot, $old_stock, $new_stock, $new_total_part_instock, $comment, action_timestamp: $action_timestamp);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -107,10 +116,12 @@ class PartStockChangedLogEntry extends AbstractLogEntry
|
||||
* @param float $new_total_part_instock The new total instock of the part.
|
||||
* @param string $comment The comment associated with the change.
|
||||
* @param PartLot $move_to_target The target lot.
|
||||
* @param \DateTimeInterface|null $action_timestamp The optional timestamp, where the action happened. Useful if the action happened in the past, and the log entry is created afterwards.
|
||||
* @return self
|
||||
*/
|
||||
public static function move(PartLot $lot, float $old_stock, float $new_stock, float $new_total_part_instock, string $comment, PartLot $move_to_target): self
|
||||
public static function move(PartLot $lot, float $old_stock, float $new_stock, float $new_total_part_instock, string $comment, PartLot $move_to_target, ?\DateTimeInterface $action_timestamp = null): self
|
||||
{
|
||||
return new self(PartStockChangeType::MOVE, $lot, $old_stock, $new_stock, $new_total_part_instock, $comment, $move_to_target);
|
||||
return new self(PartStockChangeType::MOVE, $lot, $old_stock, $new_stock, $new_total_part_instock, $comment, $move_to_target, action_timestamp: $action_timestamp);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -169,4 +180,18 @@ class PartStockChangedLogEntry extends AbstractLogEntry
|
||||
{
|
||||
return $this->extra['m'] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the timestamp when this action was performed and not when the log entry was created.
|
||||
* This is useful if the action happened in the past, and the log entry is created afterwards.
|
||||
* If the timestamp is not set, null is returned.
|
||||
* @return \DateTimeInterface|null
|
||||
*/
|
||||
public function getActionTimestamp(): ?\DateTimeInterface
|
||||
{
|
||||
if (!empty($this->extra['a'])) {
|
||||
return \DateTimeImmutable::createFromFormat(\DateTimeInterface::ATOM, $this->extra['a']);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
46
src/Entity/Parts/AssociationType.php
Normal file
46
src/Entity/Parts/AssociationType.php
Normal file
@@ -0,0 +1,46 @@
|
||||
<?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\Entity\Parts;
|
||||
|
||||
/**
|
||||
* The values of this enums are used to describe how two parts are associated with each other.
|
||||
*/
|
||||
enum AssociationType: int
|
||||
{
|
||||
/** A user definable association type, which can be described in the comment field */
|
||||
case OTHER = 0;
|
||||
/** The owning part is compatible with the other part */
|
||||
case COMPATIBLE = 1;
|
||||
/** The owning part supersedes the other part (owner is newer version) */
|
||||
case SUPERSEDES = 2;
|
||||
|
||||
/**
|
||||
* Returns the translation key for this association type.
|
||||
* @return string
|
||||
*/
|
||||
public function getTranslationKey(): string
|
||||
{
|
||||
return 'part_association.type.' . strtolower($this->name);
|
||||
}
|
||||
}
|
||||
@@ -34,6 +34,7 @@ use ApiPlatform\Metadata\GetCollection;
|
||||
use ApiPlatform\Metadata\Link;
|
||||
use ApiPlatform\Metadata\Patch;
|
||||
use ApiPlatform\Metadata\Post;
|
||||
use ApiPlatform\OpenApi\Model\Operation;
|
||||
use ApiPlatform\Serializer\Filter\PropertyFilter;
|
||||
use App\ApiPlatform\Filter\LikeFilter;
|
||||
use App\Entity\Attachments\Attachment;
|
||||
@@ -72,8 +73,10 @@ use Symfony\Component\Validator\Constraints as Assert;
|
||||
#[ApiResource(
|
||||
uriTemplate: '/categories/{id}/children.{_format}',
|
||||
operations: [
|
||||
new GetCollection(openapiContext: ['summary' => 'Retrieves the children elements of a category.'],
|
||||
security: 'is_granted("@categories.read")')
|
||||
new GetCollection(
|
||||
openapi: new Operation(summary: 'Retrieves the children elements of a category.'),
|
||||
security: 'is_granted("@categories.read")'
|
||||
)
|
||||
],
|
||||
uriVariables: [
|
||||
'id' => new Link(fromProperty: 'children', fromClass: Category::class)
|
||||
|
||||
@@ -34,6 +34,7 @@ use ApiPlatform\Metadata\GetCollection;
|
||||
use ApiPlatform\Metadata\Link;
|
||||
use ApiPlatform\Metadata\Patch;
|
||||
use ApiPlatform\Metadata\Post;
|
||||
use ApiPlatform\OpenApi\Model\Operation;
|
||||
use ApiPlatform\Serializer\Filter\PropertyFilter;
|
||||
use App\ApiPlatform\Filter\LikeFilter;
|
||||
use App\Entity\Attachments\Attachment;
|
||||
@@ -72,8 +73,10 @@ use Symfony\Component\Validator\Constraints as Assert;
|
||||
#[ApiResource(
|
||||
uriTemplate: '/footprints/{id}/children.{_format}',
|
||||
operations: [
|
||||
new GetCollection(openapiContext: ['summary' => 'Retrieves the children elements of a footprint.'],
|
||||
security: 'is_granted("@footprints.read")')
|
||||
new GetCollection(
|
||||
openapi: new Operation(summary: 'Retrieves the children elements of a footprint.'),
|
||||
security: 'is_granted("@footprints.read")'
|
||||
)
|
||||
],
|
||||
uriVariables: [
|
||||
'id' => new Link(fromProperty: 'children', fromClass: Footprint::class)
|
||||
|
||||
@@ -33,6 +33,7 @@ use ApiPlatform\Metadata\GetCollection;
|
||||
use ApiPlatform\Metadata\Link;
|
||||
use ApiPlatform\Metadata\Patch;
|
||||
use ApiPlatform\Metadata\Post;
|
||||
use ApiPlatform\OpenApi\Model\Operation;
|
||||
use ApiPlatform\Serializer\Filter\PropertyFilter;
|
||||
use App\ApiPlatform\Filter\LikeFilter;
|
||||
use App\Entity\Attachments\Attachment;
|
||||
@@ -71,8 +72,10 @@ use Symfony\Component\Validator\Constraints as Assert;
|
||||
#[ApiResource(
|
||||
uriTemplate: '/manufacturers/{id}/children.{_format}',
|
||||
operations: [
|
||||
new GetCollection(openapiContext: ['summary' => 'Retrieves the children elements of a manufacturer.'],
|
||||
security: 'is_granted("@manufacturers.read")')
|
||||
new GetCollection(
|
||||
openapi: new Operation(summary: 'Retrieves the children elements of a manufacturer.'),
|
||||
security: 'is_granted("@manufacturers.read")'
|
||||
)
|
||||
],
|
||||
uriVariables: [
|
||||
'id' => new Link(fromProperty: 'children', fromClass: Manufacturer::class)
|
||||
|
||||
@@ -33,6 +33,7 @@ use ApiPlatform\Metadata\GetCollection;
|
||||
use ApiPlatform\Metadata\Link;
|
||||
use ApiPlatform\Metadata\Patch;
|
||||
use ApiPlatform\Metadata\Post;
|
||||
use ApiPlatform\OpenApi\Model\Operation;
|
||||
use ApiPlatform\Serializer\Filter\PropertyFilter;
|
||||
use App\ApiPlatform\Filter\LikeFilter;
|
||||
use App\Entity\Attachments\Attachment;
|
||||
@@ -75,8 +76,10 @@ use Symfony\Component\Validator\Constraints as Assert;
|
||||
#[ApiResource(
|
||||
uriTemplate: '/footprints/{id}/children.{_format}',
|
||||
operations: [
|
||||
new GetCollection(openapiContext: ['summary' => 'Retrieves the children elements of a MeasurementUnit.'],
|
||||
security: 'is_granted("@measurement_units.read")')
|
||||
new GetCollection(
|
||||
openapi: new Operation(summary: 'Retrieves the children elements of a MeasurementUnit.'),
|
||||
security: 'is_granted("@measurement_units.read")'
|
||||
)
|
||||
],
|
||||
uriVariables: [
|
||||
'id' => new Link(fromProperty: 'children', fromClass: MeasurementUnit::class)
|
||||
|
||||
@@ -41,6 +41,7 @@ use App\ApiPlatform\Filter\EntityFilter;
|
||||
use App\ApiPlatform\Filter\LikeFilter;
|
||||
use App\ApiPlatform\Filter\PartStoragelocationFilter;
|
||||
use App\Entity\Attachments\AttachmentTypeAttachment;
|
||||
use App\Entity\Parts\PartTraits\AssociationTrait;
|
||||
use App\Repository\PartRepository;
|
||||
use Doctrine\DBAL\Types\Types;
|
||||
use App\Entity\Attachments\Attachment;
|
||||
@@ -58,6 +59,7 @@ use DateTime;
|
||||
use Doctrine\Common\Collections\ArrayCollection;
|
||||
use Doctrine\Common\Collections\Collection;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Jfcherng\Diff\Utility\Arr;
|
||||
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
|
||||
use Symfony\Component\Serializer\Annotation\Groups;
|
||||
use Symfony\Component\Validator\Constraints as Assert;
|
||||
@@ -112,6 +114,7 @@ class Part extends AttachmentContainingDBElement
|
||||
use OrderTrait;
|
||||
use ParametersTrait;
|
||||
use ProjectTrait;
|
||||
use AssociationTrait;
|
||||
|
||||
/** @var Collection<int, PartParameter>
|
||||
*/
|
||||
@@ -165,6 +168,9 @@ class Part extends AttachmentContainingDBElement
|
||||
$this->parameters = new ArrayCollection();
|
||||
$this->project_bom_entries = new ArrayCollection();
|
||||
|
||||
$this->associated_parts_as_owner = new ArrayCollection();
|
||||
$this->associated_parts_as_other = new ArrayCollection();
|
||||
|
||||
//By default, the part has no provider
|
||||
$this->providerReference = InfoProviderReference::noProvider();
|
||||
}
|
||||
@@ -193,6 +199,13 @@ class Part extends AttachmentContainingDBElement
|
||||
$this->addParameter(clone $parameter);
|
||||
}
|
||||
|
||||
//Deep clone the owned part associations (the owned ones make not much sense without the owner)
|
||||
$ownedAssociations = $this->associated_parts_as_owner;
|
||||
$this->associated_parts_as_owner = new ArrayCollection();
|
||||
foreach ($ownedAssociations as $association) {
|
||||
$this->addAssociatedPartsAsOwner(clone $association);
|
||||
}
|
||||
|
||||
//Deep clone info provider
|
||||
$this->providerReference = clone $this->providerReference;
|
||||
}
|
||||
|
||||
233
src/Entity/Parts/PartAssociation.php
Normal file
233
src/Entity/Parts/PartAssociation.php
Normal file
@@ -0,0 +1,233 @@
|
||||
<?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\Entity\Parts;
|
||||
|
||||
use ApiPlatform\Doctrine\Orm\Filter\BooleanFilter;
|
||||
use ApiPlatform\Doctrine\Orm\Filter\DateFilter;
|
||||
use ApiPlatform\Doctrine\Orm\Filter\OrderFilter;
|
||||
use ApiPlatform\Doctrine\Orm\Filter\RangeFilter;
|
||||
use ApiPlatform\Metadata\ApiFilter;
|
||||
use ApiPlatform\Metadata\ApiProperty;
|
||||
use ApiPlatform\Metadata\ApiResource;
|
||||
use ApiPlatform\Metadata\Delete;
|
||||
use ApiPlatform\Metadata\Get;
|
||||
use ApiPlatform\Metadata\GetCollection;
|
||||
use ApiPlatform\Metadata\Patch;
|
||||
use ApiPlatform\Metadata\Post;
|
||||
use ApiPlatform\Serializer\Filter\PropertyFilter;
|
||||
use App\ApiPlatform\Filter\LikeFilter;
|
||||
use App\Repository\DBElementRepository;
|
||||
use Doctrine\DBAL\Types\Types;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use App\Entity\Base\AbstractDBElement;
|
||||
use App\Entity\Base\TimestampTrait;
|
||||
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
|
||||
use Symfony\Component\Serializer\Annotation\Groups;
|
||||
use Symfony\Component\Validator\Constraints as Assert;
|
||||
|
||||
/**
|
||||
* This entity describes a part association, which is a semantic connection between two parts.
|
||||
* For example, a part association can be used to describe that a part is a replacement for another part.
|
||||
*/
|
||||
#[ORM\Entity(repositoryClass: DBElementRepository::class)]
|
||||
#[ORM\HasLifecycleCallbacks]
|
||||
#[UniqueEntity(fields: ['other', 'owner', 'type'], message: 'validator.part_association.already_exists')]
|
||||
#[ApiResource(
|
||||
operations: [
|
||||
new Get(security: 'is_granted("read", object)'),
|
||||
new GetCollection(security: 'is_granted("@parts.read")'),
|
||||
new Post(securityPostDenormalize: 'is_granted("create", object)'),
|
||||
new Patch(security: 'is_granted("edit", object)'),
|
||||
new Delete(security: 'is_granted("delete", object)'),
|
||||
],
|
||||
normalizationContext: ['groups' => ['part_assoc:read', 'part_assoc:read:standalone', 'api:basic:read'], 'openapi_definition_name' => 'Read'],
|
||||
denormalizationContext: ['groups' => ['part_assoc:write', 'api:basic:write'], 'openapi_definition_name' => 'Write'],
|
||||
)]
|
||||
#[ApiFilter(PropertyFilter::class)]
|
||||
#[ApiFilter(LikeFilter::class, properties: ["other_type", "comment"])]
|
||||
#[ApiFilter(DateFilter::class, strategy: DateFilter::EXCLUDE_NULL)]
|
||||
#[ApiFilter(OrderFilter::class, properties: ['comment', 'addedDate', 'lastModified'])]
|
||||
class PartAssociation extends AbstractDBElement
|
||||
{
|
||||
use TimestampTrait;
|
||||
|
||||
/**
|
||||
* @var AssociationType The type of this association (how the two parts are related)
|
||||
*/
|
||||
#[ORM\Column(type: Types::SMALLINT, enumType: AssociationType::class)]
|
||||
#[Groups(['part_assoc:read', 'part_assoc:write'])]
|
||||
protected AssociationType $type = AssociationType::OTHER;
|
||||
|
||||
/**
|
||||
* @var string|null A user definable association type, which can be described in the comment field, which
|
||||
* is used if the type is OTHER
|
||||
*/
|
||||
#[ORM\Column(type: Types::STRING, length: 255, nullable: true)]
|
||||
#[Assert\Expression("this.getType().value !== 0 or this.getOtherType() !== null",
|
||||
message: 'validator.part_association.must_set_an_value_if_type_is_other')]
|
||||
#[Groups(['part_assoc:read', 'part_assoc:write'])]
|
||||
protected ?string $other_type = null;
|
||||
|
||||
/**
|
||||
* @var string|null A comment describing this association further.
|
||||
*/
|
||||
#[ORM\Column(type: Types::TEXT, nullable: true)]
|
||||
#[Groups(['part_assoc:read', 'part_assoc:write'])]
|
||||
protected ?string $comment = null;
|
||||
|
||||
/**
|
||||
* @var Part|null The part which "owns" this association, e.g. the part which is a replacement for another part
|
||||
*/
|
||||
#[ORM\ManyToOne(targetEntity: Part::class, inversedBy: 'associated_parts_as_owner')]
|
||||
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
|
||||
#[Assert\NotNull]
|
||||
#[Groups(['part_assoc:read:standalone', 'part_assoc:write'])]
|
||||
protected ?Part $owner = null;
|
||||
|
||||
/**
|
||||
* @var Part|null The part which is "owned" by this association, e.g. the part which is replaced by another part
|
||||
*/
|
||||
#[ORM\ManyToOne(targetEntity: Part::class, inversedBy: 'associated_parts_as_other')]
|
||||
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
|
||||
#[Assert\NotNull]
|
||||
#[Assert\Expression("this.getOwner() !== this.getOther()",
|
||||
message: 'validator.part_association.part_cannot_be_associated_with_itself')]
|
||||
#[Groups(['part_assoc:read', 'part_assoc:write'])]
|
||||
protected ?Part $other = null;
|
||||
|
||||
/**
|
||||
* Returns the (semantic) relation type of this association as an AssociationType enum value.
|
||||
* If the type is set to OTHER, then the other_type field value is used for the user defined type.
|
||||
* @return AssociationType
|
||||
*/
|
||||
public function getType(): AssociationType
|
||||
{
|
||||
return $this->type;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the (semantic) relation type of this association as an AssociationType enum value.
|
||||
* @param AssociationType $type
|
||||
* @return $this
|
||||
*/
|
||||
public function setType(AssociationType $type): PartAssociation
|
||||
{
|
||||
$this->type = $type;
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a comment, which describes this association further.
|
||||
* @return string|null
|
||||
*/
|
||||
public function getComment(): ?string
|
||||
{
|
||||
return $this->comment;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets a comment, which describes this association further.
|
||||
* @param string|null $comment
|
||||
* @return $this
|
||||
*/
|
||||
public function setComment(?string $comment): PartAssociation
|
||||
{
|
||||
$this->comment = $comment;
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the part which "owns" this association, e.g. the part which is a replacement for another part.
|
||||
* @return Part|null
|
||||
*/
|
||||
public function getOwner(): ?Part
|
||||
{
|
||||
return $this->owner;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the part which "owns" this association, e.g. the part which is a replacement for another part.
|
||||
* @param Part|null $owner
|
||||
* @return $this
|
||||
*/
|
||||
public function setOwner(?Part $owner): PartAssociation
|
||||
{
|
||||
$this->owner = $owner;
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the part which is "owned" by this association, e.g. the part which is replaced by another part.
|
||||
* @return Part|null
|
||||
*/
|
||||
public function getOther(): ?Part
|
||||
{
|
||||
return $this->other;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the part which is "owned" by this association, e.g. the part which is replaced by another part.
|
||||
* @param Part|null $other
|
||||
* @return $this
|
||||
*/
|
||||
public function setOther(?Part $other): PartAssociation
|
||||
{
|
||||
$this->other = $other;
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the user defined association type, which is used if the type is set to OTHER.
|
||||
* @return string|null
|
||||
*/
|
||||
public function getOtherType(): ?string
|
||||
{
|
||||
return $this->other_type;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the user defined association type, which is used if the type is set to OTHER.
|
||||
* @param string|null $other_type
|
||||
* @return $this
|
||||
*/
|
||||
public function setOtherType(?string $other_type): PartAssociation
|
||||
{
|
||||
$this->other_type = $other_type;
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the translation key for the type of this association.
|
||||
* If the type is set to OTHER, then the other_type field value is used.
|
||||
* @return string
|
||||
*/
|
||||
public function getTypeTranslationKey(): string
|
||||
{
|
||||
if ($this->type === AssociationType::OTHER) {
|
||||
return $this->other_type ?? 'Unknown';
|
||||
}
|
||||
return $this->type->getTranslationKey();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -27,6 +27,7 @@ use ApiPlatform\Doctrine\Orm\Filter\DateFilter;
|
||||
use ApiPlatform\Doctrine\Orm\Filter\OrderFilter;
|
||||
use ApiPlatform\Doctrine\Orm\Filter\RangeFilter;
|
||||
use ApiPlatform\Metadata\ApiFilter;
|
||||
use ApiPlatform\Metadata\ApiProperty;
|
||||
use ApiPlatform\Metadata\ApiResource;
|
||||
use ApiPlatform\Metadata\Delete;
|
||||
use ApiPlatform\Metadata\Get;
|
||||
@@ -47,6 +48,7 @@ use App\Validator\Constraints\ValidPartLot;
|
||||
use DateTime;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Exception;
|
||||
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
|
||||
use Symfony\Component\Serializer\Annotation\Groups;
|
||||
use Symfony\Component\Validator\Constraints as Assert;
|
||||
use Symfony\Component\Validator\Context\ExecutionContextInterface;
|
||||
@@ -60,9 +62,11 @@ use Symfony\Component\Validator\Context\ExecutionContextInterface;
|
||||
#[ORM\Entity]
|
||||
#[ORM\HasLifecycleCallbacks]
|
||||
#[ORM\Table(name: 'part_lots')]
|
||||
#[ORM\Index(name: 'part_lots_idx_instock_un_expiration_id_part', columns: ['instock_unknown', 'expiration_date', 'id_part'])]
|
||||
#[ORM\Index(name: 'part_lots_idx_needs_refill', columns: ['needs_refill'])]
|
||||
#[ORM\Index(columns: ['instock_unknown', 'expiration_date', 'id_part'], name: 'part_lots_idx_instock_un_expiration_id_part')]
|
||||
#[ORM\Index(columns: ['needs_refill'], name: 'part_lots_idx_needs_refill')]
|
||||
#[ORM\Index(columns: ['vendor_barcode'], name: 'part_lots_idx_barcode')]
|
||||
#[ValidPartLot]
|
||||
#[UniqueEntity(['vendor_barcode'], message: 'validator.part_lot.vendor_barcode_must_be_unique')]
|
||||
#[ApiResource(
|
||||
operations: [
|
||||
new Get(security: 'is_granted("read", object)'),
|
||||
@@ -144,6 +148,7 @@ class PartLot extends AbstractDBElement implements TimeStampableInterface, Named
|
||||
#[ORM\ManyToOne(targetEntity: Part::class, inversedBy: 'partLots')]
|
||||
#[ORM\JoinColumn(name: 'id_part', nullable: false, onDelete: 'CASCADE')]
|
||||
#[Groups(['part_lot:read:standalone', 'part_lot:write'])]
|
||||
#[ApiProperty(writableLink: false)]
|
||||
protected ?Part $part = null;
|
||||
|
||||
/**
|
||||
@@ -152,8 +157,16 @@ class PartLot extends AbstractDBElement implements TimeStampableInterface, Named
|
||||
#[ORM\ManyToOne(targetEntity: User::class)]
|
||||
#[ORM\JoinColumn(name: 'id_owner', onDelete: 'SET NULL')]
|
||||
#[Groups(['part_lot:read', 'part_lot:write'])]
|
||||
#[ApiProperty(writableLink: false)]
|
||||
protected ?User $owner = null;
|
||||
|
||||
/**
|
||||
* @var string|null The content of the barcode of this part lot (e.g. a barcode on the package put by the vendor)
|
||||
*/
|
||||
#[ORM\Column(type: Types::STRING, nullable: true)]
|
||||
#[Groups(['part_lot:read', 'part_lot:write'])]
|
||||
protected ?string $vendor_barcode = null;
|
||||
|
||||
public function __clone()
|
||||
{
|
||||
if ($this->id) {
|
||||
@@ -354,6 +367,29 @@ class PartLot extends AbstractDBElement implements TimeStampableInterface, Named
|
||||
return $this->description;
|
||||
}
|
||||
|
||||
/**
|
||||
* The content of the barcode of this part lot (e.g. a barcode on the package put by the vendor), or
|
||||
* null if no barcode is set.
|
||||
* @return string|null
|
||||
*/
|
||||
public function getVendorBarcode(): ?string
|
||||
{
|
||||
return $this->vendor_barcode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the content of the barcode of this part lot (e.g. a barcode on the package put by the vendor).
|
||||
* @param string|null $vendor_barcode
|
||||
* @return $this
|
||||
*/
|
||||
public function setVendorBarcode(?string $vendor_barcode): PartLot
|
||||
{
|
||||
$this->vendor_barcode = $vendor_barcode;
|
||||
return $this;
|
||||
}
|
||||
|
||||
|
||||
|
||||
#[Assert\Callback]
|
||||
public function validate(ExecutionContextInterface $context, $payload): void
|
||||
{
|
||||
|
||||
111
src/Entity/Parts/PartTraits/AssociationTrait.php
Normal file
111
src/Entity/Parts/PartTraits/AssociationTrait.php
Normal file
@@ -0,0 +1,111 @@
|
||||
<?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\Entity\Parts\PartTraits;
|
||||
|
||||
use App\Entity\Parts\Part;
|
||||
use App\Entity\Parts\PartAssociation;
|
||||
use Doctrine\Common\Collections\ArrayCollection;
|
||||
use Doctrine\Common\Collections\Collection;
|
||||
use Symfony\Component\Serializer\Annotation\Groups;
|
||||
use Symfony\Component\Validator\Constraints\Valid;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
|
||||
trait AssociationTrait
|
||||
{
|
||||
/**
|
||||
* @var Collection<PartAssociation> All associations where this part is the owner
|
||||
*/
|
||||
#[Valid]
|
||||
#[ORM\OneToMany(mappedBy: 'owner', targetEntity: PartAssociation::class,
|
||||
cascade: ['persist', 'remove'], orphanRemoval: true)]
|
||||
#[Groups(['part:read', 'part:write'])]
|
||||
protected Collection $associated_parts_as_owner;
|
||||
|
||||
/**
|
||||
* @var Collection<PartAssociation> All associations where this part is the owned/other part
|
||||
*/
|
||||
#[Valid]
|
||||
#[ORM\OneToMany(mappedBy: 'other', targetEntity: PartAssociation::class,
|
||||
cascade: ['persist', 'remove'], orphanRemoval: true)]
|
||||
#[Groups(['part:read'])]
|
||||
protected Collection $associated_parts_as_other;
|
||||
|
||||
/**
|
||||
* Returns all associations where this part is the owner.
|
||||
* @return Collection<PartAssociation>
|
||||
*/
|
||||
public function getAssociatedPartsAsOwner(): Collection
|
||||
{
|
||||
return $this->associated_parts_as_owner;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a new association where this part is the owner.
|
||||
* @param PartAssociation $association
|
||||
* @return $this
|
||||
*/
|
||||
public function addAssociatedPartsAsOwner(PartAssociation $association): self
|
||||
{
|
||||
//Ensure that the association is really owned by this part
|
||||
$association->setOwner($this);
|
||||
|
||||
$this->associated_parts_as_owner->add($association);
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove an association where this part is the owner.
|
||||
* @param PartAssociation $association
|
||||
* @return $this
|
||||
*/
|
||||
public function removeAssociatedPartsAsOwner(PartAssociation $association): self
|
||||
{
|
||||
$this->associated_parts_as_owner->removeElement($association);
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns all associations where this part is the owned/other part.
|
||||
* If you want to modify the association, do it on the owning part
|
||||
* @return Collection<PartAssociation>
|
||||
*/
|
||||
public function getAssociatedPartsAsOther(): Collection
|
||||
{
|
||||
return $this->associated_parts_as_other;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns all associations where this part is the owned or other part.
|
||||
* @return Collection<PartAssociation>
|
||||
*/
|
||||
public function getAssociatedPartsAll(): Collection
|
||||
{
|
||||
return new ArrayCollection(
|
||||
array_merge(
|
||||
$this->associated_parts_as_owner->toArray(),
|
||||
$this->associated_parts_as_other->toArray()
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -33,6 +33,7 @@ use ApiPlatform\Metadata\GetCollection;
|
||||
use ApiPlatform\Metadata\Link;
|
||||
use ApiPlatform\Metadata\Patch;
|
||||
use ApiPlatform\Metadata\Post;
|
||||
use ApiPlatform\OpenApi\Model\Operation;
|
||||
use ApiPlatform\Serializer\Filter\PropertyFilter;
|
||||
use App\ApiPlatform\Filter\LikeFilter;
|
||||
use App\Entity\Attachments\Attachment;
|
||||
@@ -71,8 +72,10 @@ use Symfony\Component\Validator\Constraints as Assert;
|
||||
#[ApiResource(
|
||||
uriTemplate: '/storage_locations/{id}/children.{_format}',
|
||||
operations: [
|
||||
new GetCollection(openapiContext: ['summary' => 'Retrieves the children elements of a storage location.'],
|
||||
security: 'is_granted("@storelocations.read")')
|
||||
new GetCollection(
|
||||
openapi: new Operation(summary: 'Retrieves the children elements of a storage location.'),
|
||||
security: 'is_granted("@storelocations.read")'
|
||||
)
|
||||
],
|
||||
uriVariables: [
|
||||
'id' => new Link(fromProperty: 'children', fromClass: Manufacturer::class)
|
||||
|
||||
@@ -33,6 +33,7 @@ use ApiPlatform\Metadata\GetCollection;
|
||||
use ApiPlatform\Metadata\Link;
|
||||
use ApiPlatform\Metadata\Patch;
|
||||
use ApiPlatform\Metadata\Post;
|
||||
use ApiPlatform\OpenApi\Model\Operation;
|
||||
use ApiPlatform\Serializer\Filter\PropertyFilter;
|
||||
use App\ApiPlatform\Filter\LikeFilter;
|
||||
use App\Entity\Attachments\Attachment;
|
||||
@@ -75,8 +76,10 @@ use Symfony\Component\Validator\Constraints as Assert;
|
||||
)]
|
||||
#[ApiResource(
|
||||
uriTemplate: '/suppliers/{id}/children.{_format}',
|
||||
operations: [new GetCollection(openapiContext: ['summary' => 'Retrieves the children elements of a supplier'],
|
||||
security: 'is_granted("@manufacturers.read")')],
|
||||
operations: [new GetCollection(
|
||||
openapi: new Operation(summary: 'Retrieves the children elements of a supplier.'),
|
||||
security: 'is_granted("@manufacturers.read")'
|
||||
)],
|
||||
uriVariables: [
|
||||
'id' => new Link(fromClass: Supplier::class, fromProperty: 'children')
|
||||
],
|
||||
|
||||
@@ -33,6 +33,7 @@ use ApiPlatform\Metadata\GetCollection;
|
||||
use ApiPlatform\Metadata\Link;
|
||||
use ApiPlatform\Metadata\Patch;
|
||||
use ApiPlatform\Metadata\Post;
|
||||
use ApiPlatform\OpenApi\Model\Operation;
|
||||
use ApiPlatform\Serializer\Filter\PropertyFilter;
|
||||
use App\ApiPlatform\Filter\LikeFilter;
|
||||
use App\Entity\Attachments\Attachment;
|
||||
@@ -75,8 +76,10 @@ use Symfony\Component\Validator\Constraints as Assert;
|
||||
#[ApiResource(
|
||||
uriTemplate: '/currencies/{id}/children.{_format}',
|
||||
operations: [
|
||||
new GetCollection(openapiContext: ['summary' => 'Retrieves the children elements of a currency.'],
|
||||
security: 'is_granted("@currencies.read")')
|
||||
new GetCollection(
|
||||
openapi: new Operation(summary: 'Retrieves the children elements of a currency.'),
|
||||
security: 'is_granted("@currencies.read")'
|
||||
)
|
||||
],
|
||||
uriVariables: [
|
||||
'id' => new Link(fromProperty: 'children', fromClass: Currency::class)
|
||||
|
||||
@@ -34,6 +34,7 @@ use ApiPlatform\Metadata\GetCollection;
|
||||
use ApiPlatform\Metadata\Link;
|
||||
use ApiPlatform\Metadata\Patch;
|
||||
use ApiPlatform\Metadata\Post;
|
||||
use ApiPlatform\OpenApi\Model\Operation;
|
||||
use ApiPlatform\Serializer\Filter\PropertyFilter;
|
||||
use App\ApiPlatform\Filter\LikeFilter;
|
||||
use Doctrine\DBAL\Types\Types;
|
||||
@@ -73,8 +74,10 @@ use Symfony\Component\Validator\Constraints as Assert;
|
||||
#[ApiResource(
|
||||
uriTemplate: '/parts/{id}/orderdetails.{_format}',
|
||||
operations: [
|
||||
new GetCollection(openapiContext: ['summary' => 'Retrieves the orderdetails of a part.'],
|
||||
security: 'is_granted("@parts.read")')
|
||||
new GetCollection(
|
||||
openapi: new Operation(summary: 'Retrieves the orderdetails of a part.'),
|
||||
security: 'is_granted("@parts.read")'
|
||||
)
|
||||
],
|
||||
uriVariables: [
|
||||
'id' => new Link(toProperty: 'part', fromClass: Part::class)
|
||||
|
||||
@@ -33,6 +33,7 @@ use ApiPlatform\Metadata\GetCollection;
|
||||
use ApiPlatform\Metadata\Link;
|
||||
use ApiPlatform\Metadata\Patch;
|
||||
use ApiPlatform\Metadata\Post;
|
||||
use ApiPlatform\OpenApi\Model\Operation;
|
||||
use ApiPlatform\Serializer\Filter\PropertyFilter;
|
||||
use App\ApiPlatform\Filter\LikeFilter;
|
||||
use App\Entity\Attachments\Attachment;
|
||||
@@ -74,8 +75,10 @@ use Symfony\Component\Validator\Context\ExecutionContextInterface;
|
||||
#[ApiResource(
|
||||
uriTemplate: '/projects/{id}/children.{_format}',
|
||||
operations: [
|
||||
new GetCollection(openapiContext: ['summary' => 'Retrieves the children elements of a project.'],
|
||||
security: 'is_granted("@projects.read")')
|
||||
new GetCollection(
|
||||
openapi: new Operation(summary: 'Retrieves the children elements of a project.'),
|
||||
security: 'is_granted("@projects.read")'
|
||||
)
|
||||
],
|
||||
uriVariables: [
|
||||
'id' => new Link(fromProperty: 'children', fromClass: Project::class)
|
||||
@@ -183,7 +186,7 @@ class Project extends AbstractStructuralDBElement
|
||||
//Set master attachment is needed
|
||||
foreach ($bom_entries as $bom_entry) {
|
||||
$clone = clone $bom_entry;
|
||||
$this->bom_entries->add($clone);
|
||||
$this->addBomEntry($clone);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -32,6 +32,7 @@ use ApiPlatform\Metadata\GetCollection;
|
||||
use ApiPlatform\Metadata\Link;
|
||||
use ApiPlatform\Metadata\Patch;
|
||||
use ApiPlatform\Metadata\Post;
|
||||
use ApiPlatform\OpenApi\Model\Operation;
|
||||
use ApiPlatform\Serializer\Filter\PropertyFilter;
|
||||
use App\ApiPlatform\Filter\LikeFilter;
|
||||
use App\Validator\UniqueValidatableInterface;
|
||||
@@ -69,8 +70,10 @@ use Symfony\Component\Validator\Context\ExecutionContextInterface;
|
||||
#[ApiResource(
|
||||
uriTemplate: '/projects/{id}/bom.{_format}',
|
||||
operations: [
|
||||
new GetCollection(openapiContext: ['summary' => 'Retrieves the BOM entries of the given project.'],
|
||||
security: 'is_granted("@projects.read")')
|
||||
new GetCollection(
|
||||
openapi: new Operation(summary: 'Retrieves the BOM entries of the given project.'),
|
||||
security: 'is_granted("@projects.read")'
|
||||
)
|
||||
],
|
||||
uriVariables: [
|
||||
'id' => new Link(fromProperty: 'bom_entries', fromClass: Project::class)
|
||||
|
||||
@@ -26,6 +26,7 @@ namespace App\Entity\UserSystem;
|
||||
use ApiPlatform\Metadata\ApiFilter;
|
||||
use ApiPlatform\Metadata\ApiResource;
|
||||
use ApiPlatform\Metadata\Get;
|
||||
use ApiPlatform\OpenApi\Model\Operation;
|
||||
use ApiPlatform\Serializer\Filter\PropertyFilter;
|
||||
use App\Entity\Base\AbstractNamedDBElement;
|
||||
use App\Entity\Base\TimestampTrait;
|
||||
@@ -46,7 +47,9 @@ use Symfony\Component\Validator\Constraints\NotBlank;
|
||||
#[ApiResource(
|
||||
uriTemplate: '/tokens/current.{_format}',
|
||||
description: 'A token used to authenticate API requests.',
|
||||
operations: [new Get(openapiContext: ['summary' => 'Get information about the API token that is currently used.'])],
|
||||
operations: [new Get(
|
||||
openapi: new Operation(summary: 'Get information about the API token that is currently used.'),
|
||||
)],
|
||||
normalizationContext: ['groups' => ['token:read', 'api:basic:read'], 'openapi_definition_name' => 'Read'],
|
||||
provider: CurrentApiTokenProvider::class,
|
||||
)]
|
||||
|
||||
@@ -31,6 +31,7 @@ use ApiPlatform\Metadata\ApiResource;
|
||||
use ApiPlatform\Metadata\Get;
|
||||
use ApiPlatform\Metadata\GetCollection;
|
||||
use ApiPlatform\Metadata\Post;
|
||||
use ApiPlatform\OpenApi\Model\Operation;
|
||||
use ApiPlatform\Serializer\Filter\PropertyFilter;
|
||||
use App\ApiPlatform\Filter\LikeFilter;
|
||||
use App\Entity\Attachments\Attachment;
|
||||
@@ -86,10 +87,14 @@ use Jbtronics\TFAWebauthn\Model\TwoFactorInterface as WebauthnTwoFactorInterface
|
||||
#[ApiResource(
|
||||
shortName: 'User',
|
||||
operations: [
|
||||
new Get(openapiContext: ['summary' => 'Get a specific user.'],
|
||||
security: 'is_granted("read", object)'),
|
||||
new GetCollection(openapiContext: ['summary' => 'Get all users defined in the system.'],
|
||||
security: 'is_granted("@users.read")'),
|
||||
new Get(
|
||||
openapi: new Operation(summary: 'Get information about the current user.'),
|
||||
security: 'is_granted("read", object)'
|
||||
),
|
||||
new GetCollection(
|
||||
openapi: new Operation(summary: 'Get all users defined in the system.'),
|
||||
security: 'is_granted("@users.read")'
|
||||
),
|
||||
],
|
||||
normalizationContext: ['groups' => ['user:read'], 'openapi_definition_name' => 'Read'],
|
||||
)]
|
||||
|
||||
57
src/EventSubscriber/WebpackAutoPathSubscriber.php
Normal file
57
src/EventSubscriber/WebpackAutoPathSubscriber.php
Normal file
@@ -0,0 +1,57 @@
|
||||
<?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\EventSubscriber;
|
||||
|
||||
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
|
||||
use Symfony\WebpackEncoreBundle\Event\RenderAssetTagEvent;
|
||||
|
||||
/**
|
||||
* This class fixes the wrong pathes generated by webpack using the auto publicPath mode.
|
||||
* Basically it replaces the wrong /auto/ part of the path with the correct /build/ in all encore entrypoints.
|
||||
*/
|
||||
class WebpackAutoPathSubscriber implements EventSubscriberInterface
|
||||
{
|
||||
public static function getSubscribedEvents(): array
|
||||
{
|
||||
return [
|
||||
RenderAssetTagEvent::class => 'onRenderAssetTag'
|
||||
];
|
||||
}
|
||||
|
||||
public function onRenderAssetTag(RenderAssetTagEvent $event): void
|
||||
{
|
||||
if ($event->isScriptTag()) {
|
||||
$event->setAttribute('src', $this->resolveAuto($event->getUrl()));
|
||||
}
|
||||
if ($event->isLinkTag()) {
|
||||
$event->setAttribute('href', $this->resolveAuto($event->getUrl()));
|
||||
}
|
||||
}
|
||||
|
||||
private function resolveAuto(string $path): string
|
||||
{
|
||||
//Replace the first occurence of /auto/ with /build/ to get the correct path
|
||||
return preg_replace('/\/auto\//', '/build/', $path, 1);
|
||||
}
|
||||
}
|
||||
@@ -148,6 +148,7 @@ class LogFilterType extends AbstractType
|
||||
LogTargetType::MEASUREMENT_UNIT => 'measurement_unit.label',
|
||||
LogTargetType::PARAMETER => 'parameter.label',
|
||||
LogTargetType::LABEL_PROFILE => 'label_profile.label',
|
||||
LogTargetType::PART_ASSOCIATION => 'part_association.label',
|
||||
},
|
||||
]);
|
||||
|
||||
|
||||
@@ -41,7 +41,9 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Form\LabelSystem;
|
||||
|
||||
use App\Services\LabelSystem\Barcodes\BarcodeSourceType;
|
||||
use Symfony\Component\Form\AbstractType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\EnumType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\TextType;
|
||||
use Symfony\Component\Form\FormBuilderInterface;
|
||||
@@ -59,6 +61,21 @@ class ScanDialogType extends AbstractType
|
||||
],
|
||||
]);
|
||||
|
||||
$builder->add('mode', EnumType::class, [
|
||||
'label' => 'scan_dialog.mode',
|
||||
'expanded' => true,
|
||||
'class' => BarcodeSourceType::class,
|
||||
'required' => false,
|
||||
'placeholder' => 'scan_dialog.mode.auto',
|
||||
'choice_label' => fn (?BarcodeSourceType $enum) => match($enum) {
|
||||
null => 'scan_dialog.mode.auto',
|
||||
BarcodeSourceType::INTERNAL => 'scan_dialog.mode.internal',
|
||||
BarcodeSourceType::IPN => 'scan_dialog.mode.ipn',
|
||||
BarcodeSourceType::VENDOR => 'scan_dialog.mode.vendor',
|
||||
},
|
||||
|
||||
]);
|
||||
|
||||
$builder->add('submit', SubmitType::class, [
|
||||
'label' => 'scan_dialog.submit',
|
||||
]);
|
||||
|
||||
73
src/Form/Part/PartAssociationType.php
Normal file
73
src/Form/Part/PartAssociationType.php
Normal file
@@ -0,0 +1,73 @@
|
||||
<?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\Form\Part;
|
||||
|
||||
use App\Entity\Parts\AssociationType;
|
||||
use App\Entity\Parts\PartAssociation;
|
||||
use App\Form\Type\PartSelectType;
|
||||
use Symfony\Component\Form\AbstractType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\EnumType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\TextType;
|
||||
use Symfony\Component\Form\FormBuilderInterface;
|
||||
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||
|
||||
class PartAssociationType extends AbstractType
|
||||
{
|
||||
public function buildForm(FormBuilderInterface $builder, array $options): void
|
||||
{
|
||||
$builder
|
||||
->add('other', PartSelectType::class, [
|
||||
'label' => 'part_association.edit.other_part',
|
||||
])
|
||||
->add('type', EnumType::class, [
|
||||
'class' => AssociationType::class,
|
||||
'label' => 'part_association.edit.type',
|
||||
'choice_label' => fn(AssociationType $type) => $type->getTranslationKey(),
|
||||
'help' => 'part_association.edit.type.help',
|
||||
'attr' => [
|
||||
'data-pages--association-edit-type-select-target' => 'select'
|
||||
]
|
||||
])
|
||||
->add('other_type', TextType::class, [
|
||||
'required' => false,
|
||||
'label' => 'part_association.edit.other_type',
|
||||
'row_attr' => [
|
||||
'data-pages--association-edit-type-select-target' => 'display'
|
||||
]
|
||||
])
|
||||
->add('comment', TextType::class, [
|
||||
'required' => false,
|
||||
'label' => 'part_association.edit.comment'
|
||||
])
|
||||
;
|
||||
|
||||
}
|
||||
|
||||
public function configureOptions(OptionsResolver $resolver): void
|
||||
{
|
||||
$resolver->setDefaults([
|
||||
'data_class' => PartAssociation::class,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -245,6 +245,16 @@ class PartBaseType extends AbstractType
|
||||
],
|
||||
]);
|
||||
|
||||
//Part associations
|
||||
$builder->add('associated_parts_as_owner', CollectionType::class, [
|
||||
'entry_type' => PartAssociationType::class,
|
||||
'allow_add' => true,
|
||||
'allow_delete' => true,
|
||||
'reindex_enable' => true,
|
||||
'label' => false,
|
||||
'by_reference' => false,
|
||||
]);
|
||||
|
||||
$builder->add('log_comment', TextType::class, [
|
||||
'label' => 'edit.log_comment',
|
||||
'mapped' => false,
|
||||
|
||||
@@ -80,7 +80,7 @@ class PartLotType extends AbstractType
|
||||
'required' => false,
|
||||
]);
|
||||
|
||||
$builder->add('expirationDate', DateType::class, [
|
||||
$builder->add('expiration_date', DateType::class, [
|
||||
'label' => 'part_lot.edit.expiration_date',
|
||||
'attr' => [],
|
||||
'widget' => 'single_text',
|
||||
@@ -102,6 +102,12 @@ class PartLotType extends AbstractType
|
||||
'required' => false,
|
||||
'help' => 'part_lot.owner.help',
|
||||
]);
|
||||
|
||||
$builder->add('vendor_barcode', TextType::class, [
|
||||
'label' => 'part_lot.edit.vendor_barcode',
|
||||
'help' => 'part_lot.edit.vendor_barcode.help',
|
||||
'required' => false,
|
||||
]);
|
||||
}
|
||||
|
||||
public function configureOptions(OptionsResolver $resolver): void
|
||||
|
||||
@@ -45,6 +45,9 @@ class PermissionsType extends AbstractType
|
||||
$resolver->setDefaults([
|
||||
'show_legend' => true,
|
||||
'show_presets' => false,
|
||||
'show_dependency_notice' => static function (Options $options) {
|
||||
return !$options['disabled'];
|
||||
},
|
||||
'constraints' => static function (Options $options) {
|
||||
if (!$options['disabled']) {
|
||||
return [new NoLockout()];
|
||||
@@ -60,6 +63,7 @@ class PermissionsType extends AbstractType
|
||||
{
|
||||
$view->vars['show_legend'] = $options['show_legend'];
|
||||
$view->vars['show_presets'] = $options['show_presets'];
|
||||
$view->vars['show_dependency_notice'] = $options['show_dependency_notice'];
|
||||
}
|
||||
|
||||
public function buildForm(FormBuilderInterface $builder, array $options): void
|
||||
|
||||
104
src/Security/Voter/PartAssociationVoter.php
Normal file
104
src/Security/Voter/PartAssociationVoter.php
Normal file
@@ -0,0 +1,104 @@
|
||||
<?php
|
||||
/*
|
||||
* 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)
|
||||
*
|
||||
* 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);
|
||||
|
||||
/**
|
||||
* 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)
|
||||
*
|
||||
* 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\Security\Voter;
|
||||
|
||||
use App\Entity\Parts\PartAssociation;
|
||||
use App\Services\UserSystem\VoterHelper;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
use App\Entity\Parts\Part;
|
||||
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
|
||||
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
|
||||
|
||||
/**
|
||||
* This voter handles permissions for part associations.
|
||||
* The permissions are inherited from the part.
|
||||
*/
|
||||
final class PartAssociationVoter extends Voter
|
||||
{
|
||||
public function __construct(private readonly Security $security, private readonly VoterHelper $helper)
|
||||
{
|
||||
}
|
||||
|
||||
protected const ALLOWED_PERMS = ['read', 'edit', 'create', 'delete', 'show_history', 'revert_element'];
|
||||
|
||||
protected function voteOnAttribute(string $attribute, $subject, TokenInterface $token): bool
|
||||
{
|
||||
if (!is_string($subject) && !$subject instanceof PartAssociation) {
|
||||
throw new \RuntimeException('Invalid subject type!');
|
||||
}
|
||||
|
||||
$operation = match ($attribute) {
|
||||
'read' => 'read',
|
||||
'edit', 'create', 'delete' => 'edit',
|
||||
'show_history' => 'show_history',
|
||||
'revert_element' => 'revert_element',
|
||||
default => throw new \RuntimeException('Encountered unknown operation "'.$attribute.'"!'),
|
||||
};
|
||||
|
||||
//If we have no part associated use the generic part permission
|
||||
if (is_string($subject) || !$subject->getOwner() instanceof Part) {
|
||||
return $this->helper->isGranted($token, 'parts', $operation);
|
||||
}
|
||||
|
||||
//Otherwise vote on the part
|
||||
return $this->security->isGranted($attribute, $subject->getOwner());
|
||||
}
|
||||
|
||||
protected function supports($attribute, $subject): bool
|
||||
{
|
||||
if (is_a($subject, PartAssociation::class, true)) {
|
||||
return in_array($attribute, self::ALLOWED_PERMS, true);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public function supportsType(string $subjectType): bool
|
||||
{
|
||||
return $subjectType === 'string' || is_a($subjectType, PartAssociation::class, true);
|
||||
}
|
||||
|
||||
public function supportsAttribute(string $attribute): bool
|
||||
{
|
||||
return in_array($attribute, self::ALLOWED_PERMS, true);
|
||||
}
|
||||
}
|
||||
@@ -28,6 +28,7 @@ use App\Entity\Attachments\AttachmentType;
|
||||
use App\Entity\Base\AbstractDBElement;
|
||||
use App\Entity\Base\AbstractNamedDBElement;
|
||||
use App\Entity\Contracts\NamedElementInterface;
|
||||
use App\Entity\Parts\PartAssociation;
|
||||
use App\Entity\ProjectSystem\Project;
|
||||
use App\Entity\LabelSystem\LabelProfile;
|
||||
use App\Entity\Parameters\AbstractParameter;
|
||||
@@ -80,6 +81,7 @@ class ElementTypeNameGenerator
|
||||
User::class => $this->translator->trans('user.label'),
|
||||
AbstractParameter::class => $this->translator->trans('parameter.label'),
|
||||
LabelProfile::class => $this->translator->trans('label_profile.label'),
|
||||
PartAssociation::class => $this->translator->trans('part_association.label'),
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
76
src/Services/EntityMergers/EntityMerger.php
Normal file
76
src/Services/EntityMergers/EntityMerger.php
Normal file
@@ -0,0 +1,76 @@
|
||||
<?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\EntityMergers;
|
||||
|
||||
use App\Services\EntityMergers\Mergers\EntityMergerInterface;
|
||||
use Symfony\Component\DependencyInjection\Attribute\TaggedIterator;
|
||||
|
||||
/**
|
||||
* This service is used to merge two entities together.
|
||||
* It automatically finds the correct merger (implementing EntityMergerInterface) for the two entities if one exists.
|
||||
*/
|
||||
class EntityMerger
|
||||
{
|
||||
public function __construct(#[TaggedIterator('app.entity_merger')] protected iterable $mergers)
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* This function finds the first merger that supports merging the other entity into the target entity.
|
||||
* @param object $target
|
||||
* @param object $other
|
||||
* @param array $context
|
||||
* @return EntityMergerInterface|null
|
||||
*/
|
||||
public function findMergerForObject(object $target, object $other, array $context = []): ?EntityMergerInterface
|
||||
{
|
||||
foreach ($this->mergers as $merger) {
|
||||
if ($merger->supports($target, $other, $context)) {
|
||||
return $merger;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* This function merges the other entity into the target entity. If no merger is found an exception is thrown.
|
||||
* The target entity will be modified and returned.
|
||||
* @param object $target
|
||||
* @param object $other
|
||||
* @param array $context
|
||||
* @template T of object
|
||||
* @phpstan-param T $target
|
||||
* @phpstan-param T $other
|
||||
* @phpstan-return T
|
||||
* @return object
|
||||
*/
|
||||
public function merge(object $target, object $other, array $context = []): object
|
||||
{
|
||||
$merger = $this->findMergerForObject($target, $other, $context);
|
||||
if ($merger === null) {
|
||||
throw new \RuntimeException('No merger found for merging '.get_class($other).' into '.get_class($target));
|
||||
}
|
||||
return $merger->merge($target, $other, $context);
|
||||
}
|
||||
}
|
||||
374
src/Services/EntityMergers/Mergers/EntityMergerHelperTrait.php
Normal file
374
src/Services/EntityMergers/Mergers/EntityMergerHelperTrait.php
Normal file
@@ -0,0 +1,374 @@
|
||||
<?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\EntityMergers\Mergers;
|
||||
|
||||
use App\Entity\Attachments\Attachment;
|
||||
use App\Entity\Attachments\AttachmentContainingDBElement;
|
||||
use App\Entity\Base\AbstractNamedDBElement;
|
||||
use App\Entity\Base\AbstractStructuralDBElement;
|
||||
use App\Entity\Parameters\AbstractParameter;
|
||||
use App\Entity\Parts\Part;
|
||||
use Doctrine\Common\Collections\ArrayCollection;
|
||||
use Doctrine\Common\Collections\Collection;
|
||||
use Symfony\Component\PropertyAccess\PropertyAccessorInterface;
|
||||
use Symfony\Contracts\Service\Attribute\Required;
|
||||
|
||||
use function Symfony\Component\String\u;
|
||||
|
||||
/**
|
||||
* This trait provides helper methods for entity mergers.
|
||||
* By default, it uses the value from the target entity, unless it not fullfills a condition.
|
||||
*/
|
||||
trait EntityMergerHelperTrait
|
||||
{
|
||||
protected PropertyAccessorInterface $property_accessor;
|
||||
|
||||
#[Required]
|
||||
public function setPropertyAccessor(PropertyAccessorInterface $property_accessor): void
|
||||
{
|
||||
$this->property_accessor = $property_accessor;
|
||||
}
|
||||
|
||||
/**
|
||||
* Choice the value to use from the target or the other entity by using a callback function.
|
||||
*
|
||||
* @param callable $callback The callback to use. The signature is: function($target_value, $other_value, $target, $other, $field). The callback should return the value to use.
|
||||
* @param object $target The target entity
|
||||
* @param object $other The other entity
|
||||
* @param string $field The field to use
|
||||
* @return object The target entity with the value set
|
||||
*/
|
||||
protected function useCallback(callable $callback, object $target, object $other, string $field): object
|
||||
{
|
||||
//Get the values from the entities
|
||||
$target_value = $this->property_accessor->getValue($target, $field);
|
||||
$other_value = $this->property_accessor->getValue($other, $field);
|
||||
|
||||
//Call the callback, with the signature: function($target_value, $other_value, $target, $other, $field)
|
||||
//The callback should return the value to use
|
||||
$value = $callback($target_value, $other_value, $target, $other, $field);
|
||||
|
||||
//Set the value
|
||||
$this->property_accessor->setValue($target, $field, $value);
|
||||
|
||||
return $target;
|
||||
}
|
||||
|
||||
/**
|
||||
* Use the value from the other entity, if the value from the target entity is null.
|
||||
*
|
||||
* @param object $target The target entity
|
||||
* @param object $other The other entity
|
||||
* @param string $field The field to use
|
||||
* @return object The target entity with the value set
|
||||
*/
|
||||
protected function useOtherValueIfNotNull(object $target, object $other, string $field): object
|
||||
{
|
||||
return $this->useCallback(
|
||||
function ($target_value, $other_value) {
|
||||
return $target_value ?? $other_value;
|
||||
},
|
||||
$target,
|
||||
$other,
|
||||
$field
|
||||
);
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Use the value from the other entity, if the value from the target entity is empty.
|
||||
*
|
||||
* @param object $target The target entity
|
||||
* @param object $other The other entity
|
||||
* @param string $field The field to use
|
||||
* @return object The target entity with the value set
|
||||
*/
|
||||
protected function useOtherValueIfNotEmtpy(object $target, object $other, string $field): object
|
||||
{
|
||||
return $this->useCallback(
|
||||
function ($target_value, $other_value) {
|
||||
return empty($target_value) ? $other_value : $target_value;
|
||||
},
|
||||
$target,
|
||||
$other,
|
||||
$field
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Use the larger value from the target and the other entity for the given field.
|
||||
*
|
||||
* @param object $target
|
||||
* @param object $other
|
||||
* @param string $field
|
||||
* @return object
|
||||
*/
|
||||
protected function useLargerValue(object $target, object $other, string $field): object
|
||||
{
|
||||
return $this->useCallback(
|
||||
function ($target_value, $other_value) {
|
||||
return max($target_value, $other_value);
|
||||
},
|
||||
$target,
|
||||
$other,
|
||||
$field
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Use the smaller value from the target and the other entity for the given field.
|
||||
*
|
||||
* @param object $target
|
||||
* @param object $other
|
||||
* @param string $field
|
||||
* @return object
|
||||
*/
|
||||
protected function useSmallerValue(object $target, object $other, string $field): object
|
||||
{
|
||||
return $this->useCallback(
|
||||
function ($target_value, $other_value) {
|
||||
return min($target_value, $other_value);
|
||||
},
|
||||
$target,
|
||||
$other,
|
||||
$field
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform an OR operation on the boolean values from the target and the other entity for the given field.
|
||||
* This effectively means that the value is true, if it is true in at least one of the entities.
|
||||
* @param object $target
|
||||
* @param object $other
|
||||
* @param string $field
|
||||
* @return object
|
||||
*/
|
||||
protected function useTrueValue(object $target, object $other, string $field): object
|
||||
{
|
||||
return $this->useCallback(
|
||||
function (bool $target_value, bool $other_value): bool {
|
||||
return $target_value || $other_value;
|
||||
},
|
||||
$target,
|
||||
$other,
|
||||
$field
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform a merge of comma separated lists from the target and the other entity for the given field.
|
||||
* The values are merged and duplicates are removed.
|
||||
* @param object $target
|
||||
* @param object $other
|
||||
* @param string $field
|
||||
* @return object
|
||||
*/
|
||||
protected function mergeTags(object $target, object $other, string $field, string $separator = ','): object
|
||||
{
|
||||
return $this->useCallback(
|
||||
function (string|null $t, string|null $o) use ($separator): string {
|
||||
//Explode the strings into arrays
|
||||
$t_array = explode($separator, $t ?? '');
|
||||
$o_array = explode($separator, $o ?? '');
|
||||
|
||||
//Merge the arrays and remove duplicates
|
||||
$tmp = array_unique(array_merge($t_array, $o_array));
|
||||
|
||||
//Implode the array back to a string
|
||||
return implode($separator, $tmp);
|
||||
},
|
||||
$target,
|
||||
$other,
|
||||
$field
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge the collections from the target and the other entity for the given field and put all items into the target collection.
|
||||
* @param object $target
|
||||
* @param object $other
|
||||
* @param string $field
|
||||
* @param callable|null $equal_fn A function, which checks if two items are equal. The signature is: function(object $target, object other): bool.
|
||||
* Return true if the items are equal, false otherwise. If two items are equal, the item from the other collection is not added to the target collection.
|
||||
* If null, the items are compared by (instance) identity.
|
||||
* @return object
|
||||
*/
|
||||
protected function mergeCollections(object $target, object $other, string $field, ?callable $equal_fn = null): object
|
||||
{
|
||||
$target_collection = $this->property_accessor->getValue($target, $field);
|
||||
$other_collection = $this->property_accessor->getValue($other, $field);
|
||||
|
||||
if (!$target_collection instanceof Collection) {
|
||||
throw new \InvalidArgumentException("The target field $field is not a collection");
|
||||
}
|
||||
|
||||
//Clone the items from the other collection
|
||||
$clones = [];
|
||||
foreach ($other_collection as $item) {
|
||||
//Check if the item is already in the target collection
|
||||
if ($equal_fn !== null) {
|
||||
foreach ($target_collection as $target_item) {
|
||||
if ($equal_fn($target_item, $item)) {
|
||||
continue 2;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if ($target_collection->contains($item)) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
$clones[] = clone $item;
|
||||
}
|
||||
|
||||
$tmp = array_merge($target_collection->toArray(), $clones);
|
||||
|
||||
//Create a new collection with the clones and merge it into the target collection
|
||||
$this->property_accessor->setValue($target, $field, $tmp);
|
||||
|
||||
return $target;
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge the attachments from the target and the other entity.
|
||||
* @param AttachmentContainingDBElement $target
|
||||
* @param AttachmentContainingDBElement $other
|
||||
* @return object
|
||||
*/
|
||||
protected function mergeAttachments(AttachmentContainingDBElement $target, AttachmentContainingDBElement $other): object
|
||||
{
|
||||
return $this->mergeCollections($target, $other, 'attachments', function (Attachment $t, Attachment $o): bool {
|
||||
return $t->getName() === $o->getName()
|
||||
&& $t->getAttachmentType() === $o->getAttachmentType()
|
||||
&& $t->getPath() === $o->getPath();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge the parameters from the target and the other entity.
|
||||
* @param AbstractStructuralDBElement|Part $target
|
||||
* @param AbstractStructuralDBElement|Part $other
|
||||
* @return object
|
||||
*/
|
||||
protected function mergeParameters(AbstractStructuralDBElement|Part $target, AbstractStructuralDBElement|Part $other): object
|
||||
{
|
||||
return $this->mergeCollections($target, $other, 'parameters', function (AbstractParameter $t, AbstractParameter $o): bool {
|
||||
return $t->getName() === $o->getName()
|
||||
&& $t->getSymbol() === $o->getSymbol()
|
||||
&& $t->getUnit() === $o->getUnit()
|
||||
&& $t->getValueMax() === $o->getValueMax()
|
||||
&& $t->getValueMin() === $o->getValueMin()
|
||||
&& $t->getValueTypical() === $o->getValueTypical()
|
||||
&& $t->getValueText() === $o->getValueText()
|
||||
&& $t->getGroup() === $o->getGroup();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the two strings have equal content.
|
||||
* This method is case-insensitive and ignores whitespace.
|
||||
* @param string|\Stringable $t
|
||||
* @param string|\Stringable $o
|
||||
* @return bool
|
||||
*/
|
||||
protected function areStringsEqual(string|\Stringable $t, string|\Stringable $o): bool
|
||||
{
|
||||
$t_str = u($t)->trim()->folded();
|
||||
$o_str = u($o)->trim()->folded();
|
||||
|
||||
return $t_str->equalsTo($o_str);
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge the text from the target and the other entity for the given field by attaching the other text to the target text via the given separator.
|
||||
* For example, if the target text is "Hello" and the other text is "World", the result is "Hello / World".
|
||||
* If the text is the same in both entities, the target text is returned.
|
||||
* @param object $target
|
||||
* @param object $other
|
||||
* @param string $field
|
||||
* @param string $separator
|
||||
* @return object
|
||||
*/
|
||||
protected function mergeTextWithSeparator(object $target, object $other, string $field, string $separator = ' / '): object
|
||||
{
|
||||
return $this->useCallback(
|
||||
function (string $t, string $o) use ($separator): string {
|
||||
//Check if the strings are equal
|
||||
if ($this->areStringsEqual($t, $o)) {
|
||||
return $t;
|
||||
}
|
||||
|
||||
//Skip empty strings
|
||||
if (trim($t) === '') {
|
||||
return trim($o);
|
||||
}
|
||||
if (trim($o) === '') {
|
||||
return trim($t);
|
||||
}
|
||||
|
||||
return trim($t) . $separator . trim($o);
|
||||
},
|
||||
$target,
|
||||
$other,
|
||||
$field
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge two comments from the target and the other entity for the given field.
|
||||
* The comments of the both entities get concated, while the second part get a headline with the name of the old part.
|
||||
* @param AbstractNamedDBElement $target
|
||||
* @param AbstractNamedDBElement $other
|
||||
* @param string $field
|
||||
* @return object
|
||||
*/
|
||||
protected function mergeComment(AbstractNamedDBElement $target, AbstractNamedDBElement $other, string $field = 'comment'): object
|
||||
{
|
||||
return $this->useCallback(
|
||||
function (string $t, string $o) use ($other): string {
|
||||
//Check if the strings are equal
|
||||
if ($this->areStringsEqual($t, $o)) {
|
||||
return $t;
|
||||
}
|
||||
|
||||
//Skip empty strings
|
||||
if (trim($t) === '') {
|
||||
return trim($o);
|
||||
}
|
||||
if (trim($o) === '') {
|
||||
return trim($t);
|
||||
}
|
||||
|
||||
return sprintf("%s\n\n<b>%s:</b>\n%s",
|
||||
trim($t),
|
||||
$other->getName(),
|
||||
trim($o)
|
||||
);
|
||||
},
|
||||
$target,
|
||||
$other,
|
||||
$field
|
||||
);
|
||||
}
|
||||
}
|
||||
58
src/Services/EntityMergers/Mergers/EntityMergerInterface.php
Normal file
58
src/Services/EntityMergers/Mergers/EntityMergerInterface.php
Normal file
@@ -0,0 +1,58 @@
|
||||
<?php
|
||||
/*
|
||||
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
|
||||
*
|
||||
* Copyright (C) 2019 - 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\EntityMergers\Mergers;
|
||||
|
||||
|
||||
use Symfony\Component\DependencyInjection\Attribute\AutoconfigureTag;
|
||||
|
||||
/**
|
||||
* @template T of object
|
||||
*/
|
||||
#[AutoconfigureTag('app.entity_merger')]
|
||||
interface EntityMergerInterface
|
||||
{
|
||||
/**
|
||||
* Determines if this merger supports merging the other entity into the target entity.
|
||||
* @param object $target
|
||||
* @phpstan-param T $target
|
||||
* @param object $other
|
||||
* @phpstan-param T $other
|
||||
* @param array $context
|
||||
* @return bool True if this merger supports merging the other entity into the target entity, false otherwise
|
||||
*/
|
||||
public function supports(object $target, object $other, array $context = []): bool;
|
||||
|
||||
/**
|
||||
* Merge the other entity into the target entity.
|
||||
* The target entity will be modified and returned.
|
||||
* @param object $target
|
||||
* @phpstan-param T $target
|
||||
* @param object $other
|
||||
* @phpstan-param T $other
|
||||
* @param array $context
|
||||
* @phpstan-return T
|
||||
* @return object
|
||||
*/
|
||||
public function merge(object $target, object $other, array $context = []): object;
|
||||
}
|
||||
187
src/Services/EntityMergers/Mergers/PartMerger.php
Normal file
187
src/Services/EntityMergers/Mergers/PartMerger.php
Normal file
@@ -0,0 +1,187 @@
|
||||
<?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\EntityMergers\Mergers;
|
||||
|
||||
use App\Entity\Parts\InfoProviderReference;
|
||||
use App\Entity\Parts\ManufacturingStatus;
|
||||
use App\Entity\Parts\Part;
|
||||
use App\Entity\Parts\PartAssociation;
|
||||
use App\Entity\Parts\PartLot;
|
||||
use App\Entity\PriceInformations\Orderdetail;
|
||||
use Symfony\Component\DependencyInjection\Attribute\Autoconfigure;
|
||||
|
||||
/**
|
||||
* This class merges two parts together.
|
||||
*
|
||||
* @implements EntityMergerInterface<Part>
|
||||
*/
|
||||
class PartMerger implements EntityMergerInterface
|
||||
{
|
||||
|
||||
use EntityMergerHelperTrait;
|
||||
|
||||
public function supports(object $target, object $other, array $context = []): bool
|
||||
{
|
||||
return $target instanceof Part && $other instanceof Part;
|
||||
}
|
||||
|
||||
public function merge(object $target, object $other, array $context = []): Part
|
||||
{
|
||||
if (!$target instanceof Part || !$other instanceof Part) {
|
||||
throw new \InvalidArgumentException('The target and the other entity must be instances of Part');
|
||||
}
|
||||
|
||||
//Merge basic fields
|
||||
$this->mergeTextWithSeparator($target, $other, 'name');
|
||||
$this->mergeTextWithSeparator($target, $other, 'description');
|
||||
$this->mergeComment($target, $other);
|
||||
$this->useOtherValueIfNotEmtpy($target, $other, 'manufacturer_product_url');
|
||||
$this->useOtherValueIfNotEmtpy($target, $other, 'manufacturer_product_number');
|
||||
$this->useOtherValueIfNotEmtpy($target, $other, 'mass');
|
||||
$this->useOtherValueIfNotEmtpy($target, $other, 'ipn');
|
||||
|
||||
//Merge relations to other entities
|
||||
$this->useOtherValueIfNotNull($target, $other, 'manufacturer');
|
||||
$this->useOtherValueIfNotNull($target, $other, 'footprint');
|
||||
$this->useOtherValueIfNotNull($target, $other, 'category');
|
||||
$this->useOtherValueIfNotNull($target, $other, 'partUnit');
|
||||
|
||||
//We assume that the higher value is the correct one for minimum instock
|
||||
$this->useLargerValue($target, $other, 'minamount');
|
||||
|
||||
//We assume that a part needs review and is a favorite if one of the parts is
|
||||
$this->useTrueValue($target, $other, 'needs_review');
|
||||
$this->useTrueValue($target, $other, 'favorite');
|
||||
|
||||
//Merge the tags using the tag merger
|
||||
$this->mergeTags($target, $other, 'tags');
|
||||
|
||||
//Merge manufacturing status
|
||||
$this->useCallback(function (?ManufacturingStatus $t, ?ManufacturingStatus $o): ManufacturingStatus {
|
||||
//Use the other value, if the target value is not set
|
||||
if ($t === ManufacturingStatus::NOT_SET || $t === null) {
|
||||
return $o ?? ManufacturingStatus::NOT_SET;
|
||||
}
|
||||
|
||||
return $t;
|
||||
}, $target, $other, 'manufacturing_status');
|
||||
|
||||
//Merge provider reference
|
||||
$this->useCallback(function (InfoProviderReference $t, InfoProviderReference $o): InfoProviderReference {
|
||||
if (!$t->isProviderCreated() && $o->isProviderCreated()) {
|
||||
return $o;
|
||||
}
|
||||
return $t;
|
||||
}, $target, $other, 'providerReference');
|
||||
|
||||
//Merge the collections
|
||||
$this->mergeCollectionFields($target, $other, $context);
|
||||
|
||||
return $target;
|
||||
}
|
||||
|
||||
private static function comparePartAssociations(PartAssociation $t, PartAssociation $o): bool {
|
||||
//We compare the translation keys, as it contains info about the type and other type info
|
||||
return $t->getOther() === $o->getOther()
|
||||
&& $t->getTypeTranslationKey() === $o->getTypeTranslationKey();
|
||||
}
|
||||
|
||||
private function mergeCollectionFields(Part $target, Part $other, array $context): void
|
||||
{
|
||||
/********************************************************************************
|
||||
* Merge collections
|
||||
********************************************************************************/
|
||||
|
||||
//Lots from different parts are never considered equal, so we just merge them together
|
||||
$this->mergeCollections($target, $other, 'partLots');
|
||||
$this->mergeAttachments($target, $other);
|
||||
$this->mergeParameters($target, $other);
|
||||
|
||||
//Merge the associations
|
||||
$this->mergeCollections($target, $other, 'associated_parts_as_owner', self::comparePartAssociations(...));
|
||||
|
||||
//We have to recreate the associations towards the other part, as they are not created by the merger
|
||||
foreach ($other->getAssociatedPartsAsOther() as $association) {
|
||||
//Clone the association
|
||||
$clone = clone $association;
|
||||
//Set the target part as the other part
|
||||
$clone->setOther($target);
|
||||
$owner = $clone->getOwner();
|
||||
if (!$owner) {
|
||||
continue;
|
||||
}
|
||||
//Ensure that the association is not already present
|
||||
foreach ($owner->getAssociatedPartsAsOwner() as $existing_association) {
|
||||
if (self::comparePartAssociations($existing_association, $clone)) {
|
||||
continue 2;
|
||||
}
|
||||
}
|
||||
|
||||
//Add the association to the owner
|
||||
$owner->addAssociatedPartsAsOwner($clone);
|
||||
}
|
||||
|
||||
$this->mergeCollections($target, $other, 'orderdetails', function (Orderdetail $t, Orderdetail $o) {
|
||||
//First check that the orderdetails infos are equal
|
||||
$tmp = $t->getSupplier() === $o->getSupplier()
|
||||
&& $t->getSupplierPartNr() === $o->getSupplierPartNr()
|
||||
&& $t->getSupplierProductUrl(false) === $o->getSupplierProductUrl(false);
|
||||
|
||||
if (!$tmp) {
|
||||
return false;
|
||||
}
|
||||
|
||||
//Check if the pricedetails are equal
|
||||
$t_pricedetails = $t->getPricedetails();
|
||||
$o_pricedetails = $o->getPricedetails();
|
||||
//Ensure that both pricedetails have the same length
|
||||
if (count($t_pricedetails) !== count($o_pricedetails)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
//Check if all pricedetails are equal
|
||||
for ($n=0, $nMax = count($t_pricedetails); $n< $nMax; $n++) {
|
||||
$t_price = $t_pricedetails->get($n);
|
||||
$o_price = $o_pricedetails->get($n);
|
||||
|
||||
if (!$t_price->getPrice()->isEqualTo($o_price->getPrice())
|
||||
|| $t_price->getCurrency() !== $o_price->getCurrency()
|
||||
|| $t_price->getPriceRelatedQuantity() !== $o_price->getPriceRelatedQuantity()
|
||||
|| $t_price->getMinDiscountQuantity() !== $o_price->getMinDiscountQuantity()
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
//If all pricedetails are equal, the orderdetails are equal
|
||||
return true;
|
||||
});
|
||||
//The pricedetails are not correctly assigned to the new orderdetails, so fix that
|
||||
foreach ($target->getOrderdetails() as $orderdetail) {
|
||||
foreach ($orderdetail->getPricedetails() as $pricedetail) {
|
||||
$pricedetail->setOrderdetail($orderdetail);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -41,6 +41,8 @@ class PriceDTO
|
||||
public readonly ?string $currency_iso_code,
|
||||
/** @var bool If the price includes tax */
|
||||
public readonly ?bool $includes_tax = true,
|
||||
/** @var float the price related quantity */
|
||||
public readonly ?float $price_related_quantity = 1.0,
|
||||
)
|
||||
{
|
||||
$this->price_as_big_decimal = BigDecimal::of($this->price);
|
||||
@@ -54,4 +56,4 @@ class PriceDTO
|
||||
{
|
||||
return $this->price_as_big_decimal;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -87,6 +87,7 @@ final class DTOtoEntityConverter
|
||||
{
|
||||
$entity->setMinDiscountQuantity($dto->minimum_discount_amount);
|
||||
$entity->setPrice($dto->getPriceAsBigDecimal());
|
||||
$entity->setPriceRelatedQuantity($dto->price_related_quantity);
|
||||
|
||||
//Currency TODO
|
||||
if ($dto->currency_iso_code !== null) {
|
||||
@@ -95,7 +96,6 @@ final class DTOtoEntityConverter
|
||||
$entity->setCurrency(null);
|
||||
}
|
||||
|
||||
|
||||
return $entity;
|
||||
}
|
||||
|
||||
@@ -289,7 +289,7 @@ final class DTOtoEntityConverter
|
||||
//If the entity was newly created, set the file filter
|
||||
if ($tmp->getID() === null) {
|
||||
$tmp->setFiletypeFilter('image/*');
|
||||
$tmp->setAlternativeNames(self::TYPE_DATASHEETS_NAME);
|
||||
$tmp->setAlternativeNames(self::TYPE_IMAGE_NAME);
|
||||
}
|
||||
|
||||
return $tmp;
|
||||
|
||||
@@ -76,11 +76,11 @@ final class BarcodeContentGenerator
|
||||
{
|
||||
$type = $this->classToString(self::URL_MAP, $target);
|
||||
|
||||
return $this->urlGenerator->generate('scan_qr', [
|
||||
'type' => $type,
|
||||
return $this->urlGenerator->generate('scan_qr', [
|
||||
'type' => $type,
|
||||
'id' => $target->getID() ?? 0,
|
||||
'_locale' => null,
|
||||
], UrlGeneratorInterface::ABSOLUTE_URL);
|
||||
], UrlGeneratorInterface::ABSOLUTE_URL);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
96
src/Services/LabelSystem/Barcodes/BarcodeHelper.php
Normal file
96
src/Services/LabelSystem/Barcodes/BarcodeHelper.php
Normal file
@@ -0,0 +1,96 @@
|
||||
<?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\Barcodes;
|
||||
|
||||
use App\Entity\LabelSystem\BarcodeType;
|
||||
use Com\Tecnick\Barcode\Barcode;
|
||||
|
||||
/**
|
||||
* This function is used to generate barcodes of various types using arbitrary (text) content.
|
||||
*/
|
||||
class BarcodeHelper
|
||||
{
|
||||
|
||||
/**
|
||||
* Generates a barcode with the given content and type and returns it as SVG string.
|
||||
* @param string $content
|
||||
* @param BarcodeType $type
|
||||
* @return string
|
||||
*/
|
||||
public function barcodeAsSVG(string $content, BarcodeType $type): string
|
||||
{
|
||||
$barcode = new Barcode();
|
||||
|
||||
$type_str = match ($type) {
|
||||
BarcodeType::NONE => throw new \InvalidArgumentException('Barcode type must not be NONE! This would make no sense...'),
|
||||
BarcodeType::QR => 'QRCODE',
|
||||
BarcodeType::DATAMATRIX => 'DATAMATRIX',
|
||||
BarcodeType::CODE39 => 'C39',
|
||||
BarcodeType::CODE93 => 'C93',
|
||||
BarcodeType::CODE128 => 'C128A',
|
||||
};
|
||||
|
||||
return $barcode->getBarcodeObj($type_str, $content)->getSvgCode();
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a barcode with the given content and type and returns it as HTML image tag.
|
||||
* @param string $content
|
||||
* @param BarcodeType $type
|
||||
* @param string $width Width of the image tag
|
||||
* @param string|null $alt_text The alt text of the image tag. If null, the content is used.
|
||||
* @return string
|
||||
*/
|
||||
public function barcodeAsHTML(string $content, BarcodeType $type, string $width = '100%', ?string $alt_text = null): string
|
||||
{
|
||||
$svg = $this->barcodeAsSVG($content, $type);
|
||||
$base64 = $this->dataUri($svg, 'image/svg+xml');
|
||||
$alt_text = $alt_text ?? $content;
|
||||
|
||||
return '<img src="'.$base64.'" width="'.$width.'" style="min-height: 25px;" alt="'.$alt_text.'"/>';
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a data URI (RFC 2397).
|
||||
* Based on the Twig implementation from HTMLExtension
|
||||
*
|
||||
* Length validation is not performed on purpose, validation should
|
||||
* be done before calling this filter.
|
||||
*
|
||||
* @return string The generated data URI
|
||||
*/
|
||||
private function dataUri(string $data, string $mime): string
|
||||
{
|
||||
$repr = 'data:';
|
||||
|
||||
$repr .= $mime;
|
||||
if (str_starts_with($mime, 'text/')) {
|
||||
$repr .= ','.rawurlencode($data);
|
||||
} else {
|
||||
$repr .= ';base64,'.base64_encode($data);
|
||||
}
|
||||
|
||||
return $repr;
|
||||
}
|
||||
}
|
||||
@@ -1,110 +0,0 @@
|
||||
<?php
|
||||
/*
|
||||
* 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)
|
||||
*
|
||||
* 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);
|
||||
|
||||
/**
|
||||
* 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)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
namespace App\Services\LabelSystem\Barcodes;
|
||||
|
||||
use InvalidArgumentException;
|
||||
|
||||
/**
|
||||
* @see \App\Tests\Services\LabelSystem\Barcodes\BarcodeNormalizerTest
|
||||
*/
|
||||
final class BarcodeNormalizer
|
||||
{
|
||||
private const PREFIX_TYPE_MAP = [
|
||||
'L' => 'lot',
|
||||
'P' => 'part',
|
||||
'S' => 'location',
|
||||
];
|
||||
|
||||
/**
|
||||
* Parses barcode content and normalizes it.
|
||||
* Returns an array in the format ['part', 1]: First entry contains element type, second the ID of the element.
|
||||
*/
|
||||
public function normalizeBarcodeContent(string $input): array
|
||||
{
|
||||
$input = trim($input);
|
||||
$matches = [];
|
||||
|
||||
//Some scanner output '-' as ß, so replace it (ß is never used, so we can replace it safely)
|
||||
$input = str_replace('ß', '-', $input);
|
||||
|
||||
//Extract parts from QR code's URL
|
||||
if (preg_match('#^https?://.*/scan/(\w+)/(\d+)/?$#', $input, $matches)) {
|
||||
return [$matches[1], (int) $matches[2]];
|
||||
}
|
||||
|
||||
//New Code39 barcode use L0001 format
|
||||
if (preg_match('#^([A-Z])(\d{4,})$#', $input, $matches)) {
|
||||
$prefix = $matches[1];
|
||||
$id = (int) $matches[2];
|
||||
|
||||
if (!isset(self::PREFIX_TYPE_MAP[$prefix])) {
|
||||
throw new InvalidArgumentException('Unknown prefix '.$prefix);
|
||||
}
|
||||
|
||||
return [self::PREFIX_TYPE_MAP[$prefix], $id];
|
||||
}
|
||||
|
||||
//During development the L-000001 format was used
|
||||
if (preg_match('#^(\w)-(\d{6,})$#', $input, $matches)) {
|
||||
$prefix = $matches[1];
|
||||
$id = (int) $matches[2];
|
||||
|
||||
if (!isset(self::PREFIX_TYPE_MAP[$prefix])) {
|
||||
throw new InvalidArgumentException('Unknown prefix '.$prefix);
|
||||
}
|
||||
|
||||
return [self::PREFIX_TYPE_MAP[$prefix], $id];
|
||||
}
|
||||
|
||||
//Legacy Part-DB location labels used $L00336 format
|
||||
if (preg_match('#^\$L(\d{5,})$#', $input, $matches)) {
|
||||
return ['location', (int) $matches[1]];
|
||||
}
|
||||
|
||||
//Legacy Part-DB used EAN8 barcodes for part labels. Format 0000001(2) (note the optional 8th digit => checksum)
|
||||
if (preg_match('#^(\d{7})\d?$#', $input, $matches)) {
|
||||
return ['part', (int) $matches[1]];
|
||||
}
|
||||
|
||||
throw new InvalidArgumentException('Unknown barcode format!');
|
||||
}
|
||||
}
|
||||
@@ -41,6 +41,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Services\LabelSystem\Barcodes;
|
||||
|
||||
use App\Entity\LabelSystem\LabelSupportedElement;
|
||||
use App\Entity\Parts\PartLot;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Doctrine\ORM\EntityNotFoundException;
|
||||
@@ -59,32 +60,30 @@ final class BarcodeRedirector
|
||||
/**
|
||||
* Determines the URL to which the user should be redirected, when scanning a QR code.
|
||||
*
|
||||
* @param string $type The type of the element that was scanned (e.g. 'part', 'lot', etc.)
|
||||
* @param int $id The ID of the element that was scanned
|
||||
*
|
||||
* @param BarcodeScanResult $barcodeScan The result of the barcode scan
|
||||
* @return string the URL to which should be redirected
|
||||
*
|
||||
* @throws EntityNotFoundException
|
||||
*/
|
||||
public function getRedirectURL(string $type, int $id): string
|
||||
public function getRedirectURL(BarcodeScanResult $barcodeScan): string
|
||||
{
|
||||
switch ($type) {
|
||||
case 'part':
|
||||
return $this->urlGenerator->generate('app_part_show', ['id' => $id]);
|
||||
case 'lot':
|
||||
switch ($barcodeScan->target_type) {
|
||||
case LabelSupportedElement::PART:
|
||||
return $this->urlGenerator->generate('app_part_show', ['id' => $barcodeScan->target_id]);
|
||||
case LabelSupportedElement::PART_LOT:
|
||||
//Try to determine the part to the given lot
|
||||
$lot = $this->em->find(PartLot::class, $id);
|
||||
$lot = $this->em->find(PartLot::class, $barcodeScan->target_id);
|
||||
if (!$lot instanceof PartLot) {
|
||||
throw new EntityNotFoundException();
|
||||
}
|
||||
|
||||
return $this->urlGenerator->generate('app_part_show', ['id' => $lot->getPart()->getID()]);
|
||||
|
||||
case 'location':
|
||||
return $this->urlGenerator->generate('part_list_store_location', ['id' => $id]);
|
||||
case LabelSupportedElement::STORELOCATION:
|
||||
return $this->urlGenerator->generate('part_list_store_location', ['id' => $barcodeScan->target_id]);
|
||||
|
||||
default:
|
||||
throw new InvalidArgumentException('Unknown $type: '.$type);
|
||||
throw new InvalidArgumentException('Unknown $type: '.$barcodeScan->target_type->name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
230
src/Services/LabelSystem/Barcodes/BarcodeScanHelper.php
Normal file
230
src/Services/LabelSystem/Barcodes/BarcodeScanHelper.php
Normal file
@@ -0,0 +1,230 @@
|
||||
<?php
|
||||
/*
|
||||
* 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)
|
||||
*
|
||||
* 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);
|
||||
|
||||
/**
|
||||
* 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)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
namespace App\Services\LabelSystem\Barcodes;
|
||||
|
||||
use App\Entity\LabelSystem\LabelSupportedElement;
|
||||
use App\Entity\Parts\Part;
|
||||
use App\Entity\Parts\PartLot;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use InvalidArgumentException;
|
||||
|
||||
/**
|
||||
* @see \App\Tests\Services\LabelSystem\Barcodes\BarcodeNormalizerTest
|
||||
*/
|
||||
final class BarcodeScanHelper
|
||||
{
|
||||
private const PREFIX_TYPE_MAP = [
|
||||
'L' => LabelSupportedElement::PART_LOT,
|
||||
'P' => LabelSupportedElement::PART,
|
||||
'S' => LabelSupportedElement::STORELOCATION,
|
||||
];
|
||||
|
||||
public const QR_TYPE_MAP = [
|
||||
'lot' => LabelSupportedElement::PART_LOT,
|
||||
'part' => LabelSupportedElement::PART,
|
||||
'location' => LabelSupportedElement::STORELOCATION,
|
||||
];
|
||||
|
||||
public function __construct(private readonly EntityManagerInterface $entityManager)
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the given barcode content and return the target type and ID.
|
||||
* If the barcode could not be parsed, an exception is thrown.
|
||||
* Using the $type parameter, you can specify how the barcode should be parsed. If set to null, the function
|
||||
* will try to guess the type.
|
||||
* @param string $input
|
||||
* @param BarcodeSourceType|null $type
|
||||
* @return BarcodeScanResult
|
||||
*/
|
||||
public function scanBarcodeContent(string $input, ?BarcodeSourceType $type = null): BarcodeScanResult
|
||||
{
|
||||
//Do specific parsing
|
||||
if ($type === BarcodeSourceType::INTERNAL) {
|
||||
return $this->parseInternalBarcode($input) ?? throw new InvalidArgumentException('Could not parse barcode');
|
||||
}
|
||||
if ($type === BarcodeSourceType::VENDOR) {
|
||||
return $this->parseVendorBarcode($input) ?? throw new InvalidArgumentException('Could not parse barcode');
|
||||
}
|
||||
if ($type === BarcodeSourceType::IPN) {
|
||||
return $this->parseIPNBarcode($input) ?? throw new InvalidArgumentException('Could not parse barcode');
|
||||
}
|
||||
|
||||
//Null means auto and we try the different formats
|
||||
$result = $this->parseInternalBarcode($input);
|
||||
|
||||
if ($result !== null) {
|
||||
return $result;
|
||||
}
|
||||
|
||||
//Try to parse as vendor barcode
|
||||
$result = $this->parseVendorBarcode($input);
|
||||
if ($result !== null) {
|
||||
return $result;
|
||||
}
|
||||
|
||||
//Try to parse as IPN barcode
|
||||
$result = $this->parseIPNBarcode($input);
|
||||
if ($result !== null) {
|
||||
return $result;
|
||||
}
|
||||
|
||||
throw new InvalidArgumentException('Unknown barcode');
|
||||
}
|
||||
|
||||
private function parseVendorBarcode(string $input): ?BarcodeScanResult
|
||||
{
|
||||
$lot_repo = $this->entityManager->getRepository(PartLot::class);
|
||||
//Find only the first result
|
||||
$results = $lot_repo->findBy(['vendor_barcode' => $input], limit: 1);
|
||||
|
||||
if (count($results) === 0) {
|
||||
return null;
|
||||
}
|
||||
//We found a part, so use it to create the result
|
||||
$lot = $results[0];
|
||||
|
||||
return new BarcodeScanResult(
|
||||
target_type: LabelSupportedElement::PART_LOT,
|
||||
target_id: $lot->getID(),
|
||||
source_type: BarcodeSourceType::VENDOR
|
||||
);
|
||||
}
|
||||
|
||||
private function parseIPNBarcode(string $input): ?BarcodeScanResult
|
||||
{
|
||||
$part_repo = $this->entityManager->getRepository(Part::class);
|
||||
//Find only the first result
|
||||
$results = $part_repo->findBy(['ipn' => $input], limit: 1);
|
||||
|
||||
if (count($results) === 0) {
|
||||
return null;
|
||||
}
|
||||
//We found a part, so use it to create the result
|
||||
$part = $results[0];
|
||||
|
||||
return new BarcodeScanResult(
|
||||
target_type: LabelSupportedElement::PART,
|
||||
target_id: $part->getID(),
|
||||
source_type: BarcodeSourceType::IPN
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* This function tries to interpret the given barcode content as an internal barcode.
|
||||
* If the barcode could not be parsed at all, null is returned. If the barcode is a valid format, but could
|
||||
* not be found in the database, an exception is thrown.
|
||||
* @param string $input
|
||||
* @return BarcodeScanResult|null
|
||||
*/
|
||||
private function parseInternalBarcode(string $input): ?BarcodeScanResult
|
||||
{
|
||||
$input = trim($input);
|
||||
$matches = [];
|
||||
|
||||
//Some scanner output '-' as ß, so replace it (ß is never used, so we can replace it safely)
|
||||
$input = str_replace('ß', '-', $input);
|
||||
|
||||
//Extract parts from QR code's URL
|
||||
if (preg_match('#^https?://.*/scan/(\w+)/(\d+)/?$#', $input, $matches)) {
|
||||
return new BarcodeScanResult(
|
||||
target_type: self::QR_TYPE_MAP[strtolower($matches[1])],
|
||||
target_id: (int) $matches[2],
|
||||
source_type: BarcodeSourceType::INTERNAL
|
||||
);
|
||||
}
|
||||
|
||||
//New Code39 barcode use L0001 format
|
||||
if (preg_match('#^([A-Z])(\d{4,})$#', $input, $matches)) {
|
||||
$prefix = $matches[1];
|
||||
$id = (int) $matches[2];
|
||||
|
||||
if (!isset(self::PREFIX_TYPE_MAP[$prefix])) {
|
||||
throw new InvalidArgumentException('Unknown prefix '.$prefix);
|
||||
}
|
||||
|
||||
return new BarcodeScanResult(
|
||||
target_type: self::PREFIX_TYPE_MAP[$prefix],
|
||||
target_id: $id,
|
||||
source_type: BarcodeSourceType::INTERNAL
|
||||
);
|
||||
}
|
||||
|
||||
//During development the L-000001 format was used
|
||||
if (preg_match('#^(\w)-(\d{6,})$#', $input, $matches)) {
|
||||
$prefix = $matches[1];
|
||||
$id = (int) $matches[2];
|
||||
|
||||
if (!isset(self::PREFIX_TYPE_MAP[$prefix])) {
|
||||
throw new InvalidArgumentException('Unknown prefix '.$prefix);
|
||||
}
|
||||
|
||||
return new BarcodeScanResult(
|
||||
target_type: self::PREFIX_TYPE_MAP[$prefix],
|
||||
target_id: $id,
|
||||
source_type: BarcodeSourceType::INTERNAL
|
||||
);
|
||||
}
|
||||
|
||||
//Legacy Part-DB location labels used $L00336 format
|
||||
if (preg_match('#^\$L(\d{5,})$#', $input, $matches)) {
|
||||
return new BarcodeScanResult(
|
||||
target_type: LabelSupportedElement::STORELOCATION,
|
||||
target_id: (int) $matches[1],
|
||||
source_type: BarcodeSourceType::INTERNAL
|
||||
);
|
||||
}
|
||||
|
||||
//Legacy Part-DB used EAN8 barcodes for part labels. Format 0000001(2) (note the optional 8th digit => checksum)
|
||||
if (preg_match('#^(\d{7})\d?$#', $input, $matches)) {
|
||||
return new BarcodeScanResult(
|
||||
target_type: LabelSupportedElement::PART,
|
||||
target_id: (int) $matches[1],
|
||||
source_type: BarcodeSourceType::INTERNAL
|
||||
);
|
||||
}
|
||||
|
||||
//This function abstain from further parsing
|
||||
return null;
|
||||
}
|
||||
}
|
||||
39
src/Services/LabelSystem/Barcodes/BarcodeScanResult.php
Normal file
39
src/Services/LabelSystem/Barcodes/BarcodeScanResult.php
Normal file
@@ -0,0 +1,39 @@
|
||||
<?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\Barcodes;
|
||||
|
||||
use App\Entity\LabelSystem\LabelSupportedElement;
|
||||
|
||||
/**
|
||||
* This class represents the result of a barcode scan, with the target type and the ID of the element
|
||||
*/
|
||||
class BarcodeScanResult
|
||||
{
|
||||
public function __construct(
|
||||
public readonly LabelSupportedElement $target_type,
|
||||
public readonly int $target_id,
|
||||
public readonly BarcodeSourceType $source_type,
|
||||
) {
|
||||
}
|
||||
}
|
||||
40
src/Services/LabelSystem/Barcodes/BarcodeSourceType.php
Normal file
40
src/Services/LabelSystem/Barcodes/BarcodeSourceType.php
Normal file
@@ -0,0 +1,40 @@
|
||||
<?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\Barcodes;
|
||||
|
||||
/**
|
||||
* This enum represents the different types, where a barcode/QR-code can be generated from
|
||||
*/
|
||||
enum BarcodeSourceType
|
||||
{
|
||||
/** This Barcode was generated using Part-DB internal recommended barcode generator */
|
||||
case INTERNAL;
|
||||
/** This barcode is containing an internal part number (IPN) */
|
||||
case IPN;
|
||||
/**
|
||||
* This barcode is a custom barcode from a third party like a vendor, which was set via the vendor_barcode
|
||||
* field of a part lot.
|
||||
*/
|
||||
case VENDOR;
|
||||
}
|
||||
@@ -46,67 +46,47 @@ use App\Entity\Base\AbstractStructuralDBElement;
|
||||
use App\Entity\LabelSystem\BarcodeType;
|
||||
use App\Entity\LabelSystem\LabelOptions;
|
||||
use App\Services\LabelSystem\Barcodes\BarcodeContentGenerator;
|
||||
use App\Services\LabelSystem\Barcodes\BarcodeHelper;
|
||||
use Com\Tecnick\Barcode\Barcode;
|
||||
use InvalidArgumentException;
|
||||
|
||||
/**
|
||||
* @see \App\Tests\Services\LabelSystem\BarcodeGeneratorTest
|
||||
*/
|
||||
final class BarcodeGenerator
|
||||
final class LabelBarcodeGenerator
|
||||
{
|
||||
public function __construct(private readonly BarcodeContentGenerator $barcodeContentGenerator)
|
||||
public function __construct(private readonly BarcodeContentGenerator $barcodeContentGenerator, private readonly BarcodeHelper $barcodeHelper)
|
||||
{
|
||||
}
|
||||
|
||||
public function generateHTMLBarcode(LabelOptions $options, object $target): ?string
|
||||
{
|
||||
$svg = $this->generateSVG($options, $target);
|
||||
$base64 = $this->dataUri($svg, 'image/svg+xml');
|
||||
return '<img src="'.$base64.'" width="100%" style="min-height: 25px;" alt="'. $this->getContent($options, $target) . '" />';
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a data URI (RFC 2397).
|
||||
* Based on the Twig implementaion from HTMLExtension
|
||||
*
|
||||
* Length validation is not performed on purpose, validation should
|
||||
* be done before calling this filter.
|
||||
*
|
||||
* @return string The generated data URI
|
||||
/**
|
||||
* Generate the barcode for the given label as HTML image tag.
|
||||
* @param LabelOptions $options
|
||||
* @param AbstractDBElement $target
|
||||
* @return string|null
|
||||
*/
|
||||
private function dataUri(string $data, string $mime): string
|
||||
public function generateHTMLBarcode(LabelOptions $options, AbstractDBElement $target): ?string
|
||||
{
|
||||
$repr = 'data:';
|
||||
|
||||
$repr .= $mime;
|
||||
if (str_starts_with($mime, 'text/')) {
|
||||
$repr .= ','.rawurlencode($data);
|
||||
} else {
|
||||
$repr .= ';base64,'.base64_encode($data);
|
||||
}
|
||||
|
||||
return $repr;
|
||||
}
|
||||
|
||||
public function generateSVG(LabelOptions $options, object $target): ?string
|
||||
{
|
||||
$barcode = new Barcode();
|
||||
|
||||
$type = match ($options->getBarcodeType()) {
|
||||
BarcodeType::NONE => null,
|
||||
BarcodeType::QR => 'QRCODE',
|
||||
BarcodeType::DATAMATRIX => 'DATAMATRIX',
|
||||
BarcodeType::CODE39 => 'C39',
|
||||
BarcodeType::CODE93 => 'C93',
|
||||
BarcodeType::CODE128 => 'C128A',
|
||||
};
|
||||
|
||||
if ($type === null) {
|
||||
if ($options->getBarcodeType() === BarcodeType::NONE) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->barcodeHelper->barcodeAsHTML($this->getContent($options, $target), $options->getBarcodeType());
|
||||
}
|
||||
|
||||
return $barcode->getBarcodeObj($type, $this->getContent($options, $target))->getSvgCode();
|
||||
/**
|
||||
* Generate the barcode for the given label as SVG string.
|
||||
* @param LabelOptions $options
|
||||
* @param AbstractDBElement $target
|
||||
* @return string|null
|
||||
*/
|
||||
public function generateSVG(LabelOptions $options, AbstractDBElement $target): ?string
|
||||
{
|
||||
if ($options->getBarcodeType() === BarcodeType::NONE) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->barcodeHelper->barcodeAsSVG($this->getContent($options, $target), $options->getBarcodeType());
|
||||
}
|
||||
|
||||
public function getContent(LabelOptions $options, AbstractDBElement $target): ?string
|
||||
@@ -53,7 +53,7 @@ use Twig\Error\Error;
|
||||
|
||||
final class LabelHTMLGenerator
|
||||
{
|
||||
public function __construct(private readonly ElementTypeNameGenerator $elementTypeNameGenerator, private readonly LabelTextReplacer $replacer, private readonly Environment $twig, private readonly BarcodeGenerator $barcodeGenerator, private readonly SandboxedTwigProvider $sandboxedTwigProvider, private readonly Security $security, private readonly string $partdb_title)
|
||||
public function __construct(private readonly ElementTypeNameGenerator $elementTypeNameGenerator, private readonly LabelTextReplacer $replacer, private readonly Environment $twig, private readonly LabelBarcodeGenerator $barcodeGenerator, private readonly SandboxedTwigProvider $sandboxedTwigProvider, private readonly Security $security, private readonly string $partdb_title)
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
@@ -24,12 +24,18 @@ namespace App\Services\LabelSystem\PlaceholderProviders;
|
||||
|
||||
use App\Entity\LabelSystem\BarcodeType;
|
||||
use App\Entity\LabelSystem\LabelOptions;
|
||||
use App\Services\LabelSystem\BarcodeGenerator;
|
||||
use App\Entity\Parts\Part;
|
||||
use App\Entity\Parts\PartLot;
|
||||
use App\Services\LabelSystem\Barcodes\BarcodeHelper;
|
||||
use App\Services\LabelSystem\LabelBarcodeGenerator;
|
||||
use App\Services\LabelSystem\Barcodes\BarcodeContentGenerator;
|
||||
use Com\Tecnick\Barcode\Exception;
|
||||
|
||||
final class BarcodeProvider implements PlaceholderProviderInterface
|
||||
{
|
||||
public function __construct(private readonly BarcodeGenerator $barcodeGenerator, private readonly BarcodeContentGenerator $barcodeContentGenerator)
|
||||
public function __construct(private readonly LabelBarcodeGenerator $barcodeGenerator,
|
||||
private readonly BarcodeContentGenerator $barcodeContentGenerator,
|
||||
private readonly BarcodeHelper $barcodeHelper)
|
||||
{
|
||||
}
|
||||
|
||||
@@ -69,6 +75,37 @@ final class BarcodeProvider implements PlaceholderProviderInterface
|
||||
return $this->barcodeGenerator->generateHTMLBarcode($label_options, $label_target);
|
||||
}
|
||||
|
||||
if (($label_target instanceof Part || $label_target instanceof PartLot)
|
||||
&& str_starts_with($placeholder, '[[IPN_BARCODE_')) {
|
||||
if ($label_target instanceof PartLot) {
|
||||
$label_target = $label_target->getPart();
|
||||
}
|
||||
|
||||
if ($label_target === null || $label_target->getIPN() === null || $label_target->getIPN() === '') {
|
||||
//Replace with empty result, if no IPN is set
|
||||
return '';
|
||||
}
|
||||
|
||||
try {
|
||||
//Add placeholders for the IPN barcode
|
||||
if ('[[IPN_BARCODE_C39]]' === $placeholder) {
|
||||
return $this->barcodeHelper->barcodeAsHTML($label_target->getIPN(), BarcodeType::CODE39);
|
||||
}
|
||||
if ('[[IPN_BARCODE_C128]]' === $placeholder) {
|
||||
return $this->barcodeHelper->barcodeAsHTML($label_target->getIPN(), BarcodeType::CODE128);
|
||||
}
|
||||
if ('[[IPN_BARCODE_QR]]' === $placeholder) {
|
||||
return $this->barcodeHelper->barcodeAsHTML($label_target->getIPN(), BarcodeType::QR);
|
||||
}
|
||||
} catch (Exception $e) {
|
||||
//If an error occurs, output it
|
||||
return '<b>IPN Barcode ERROR!</b>: '.$e->getMessage();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -193,6 +193,10 @@ class LogEntryExtraFormatter
|
||||
htmlspecialchars($this->elementTypeNameGenerator->getLocalizedTypeLabel(PartLot::class))
|
||||
.' ' . $context->getMoveToTargetID();
|
||||
}
|
||||
if ($context->getActionTimestamp()) {
|
||||
$formatter = new \IntlDateFormatter($this->translator->getLocale(), \IntlDateFormatter::SHORT, \IntlDateFormatter::SHORT);
|
||||
$array['log.part_stock_changed.timestamp'] = $formatter->format($context->getActionTimestamp());
|
||||
}
|
||||
}
|
||||
|
||||
return $array;
|
||||
|
||||
@@ -9,13 +9,15 @@ use App\Entity\LogSystem\PartStockChangedLogEntry;
|
||||
use App\Entity\Parts\PartLot;
|
||||
use App\Services\LogSystem\EventCommentHelper;
|
||||
use App\Services\LogSystem\EventLogger;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
|
||||
/**
|
||||
* @see \App\Tests\Services\Parts\PartLotWithdrawAddHelperTest
|
||||
*/
|
||||
final class PartLotWithdrawAddHelper
|
||||
{
|
||||
public function __construct(private readonly EventLogger $eventLogger, private readonly EventCommentHelper $eventCommentHelper)
|
||||
public function __construct(private readonly EventLogger $eventLogger,
|
||||
private readonly EventCommentHelper $eventCommentHelper, private EntityManagerInterface $entityManager)
|
||||
{
|
||||
}
|
||||
|
||||
@@ -53,9 +55,10 @@ final class PartLotWithdrawAddHelper
|
||||
* @param PartLot $partLot The partLot from which the instock should be taken (which value should be decreased)
|
||||
* @param float $amount The amount of parts that should be taken from the part lot
|
||||
* @param string|null $comment The optional comment describing the reason for the withdrawal
|
||||
* @return PartLot The modified part lot
|
||||
* @param \DateTimeInterface|null $action_timestamp The optional timestamp, where the action happened. Useful if the action happened in the past, and the log entry is created afterwards.
|
||||
* @param bool $delete_lot_if_empty If true, the part lot will be deleted if the amount is 0 after the withdrawal.
|
||||
*/
|
||||
public function withdraw(PartLot $partLot, float $amount, ?string $comment = null): PartLot
|
||||
public function withdraw(PartLot $partLot, float $amount, ?string $comment = null, ?\DateTimeInterface $action_timestamp = null, bool $delete_lot_if_empty = false): void
|
||||
{
|
||||
//Ensure that amount is positive
|
||||
if ($amount <= 0) {
|
||||
@@ -83,7 +86,7 @@ final class PartLotWithdrawAddHelper
|
||||
$oldAmount = $partLot->getAmount();
|
||||
$partLot->setAmount($oldAmount - $amount);
|
||||
|
||||
$event = PartStockChangedLogEntry::withdraw($partLot, $oldAmount, $partLot->getAmount(), $part->getAmountSum() , $comment);
|
||||
$event = PartStockChangedLogEntry::withdraw($partLot, $oldAmount, $partLot->getAmount(), $part->getAmountSum() , $comment, $action_timestamp);
|
||||
$this->eventLogger->log($event);
|
||||
|
||||
//Apply the comment also to global events, so it gets associated with the elementChanged log entry
|
||||
@@ -91,7 +94,9 @@ final class PartLotWithdrawAddHelper
|
||||
$this->eventCommentHelper->setMessage($comment);
|
||||
}
|
||||
|
||||
return $partLot;
|
||||
if ($delete_lot_if_empty && $partLot->getAmount() === 0.0) {
|
||||
$this->entityManager->remove($partLot);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -100,9 +105,10 @@ final class PartLotWithdrawAddHelper
|
||||
* @param PartLot $partLot The partLot from which the instock should be taken (which value should be decreased)
|
||||
* @param float $amount The amount of parts that should be taken from the part lot
|
||||
* @param string|null $comment The optional comment describing the reason for the withdrawal
|
||||
* @param \DateTimeInterface|null $action_timestamp The optional timestamp, where the action happened. Useful if the action happened in the past, and the log entry is created afterwards.
|
||||
* @return PartLot The modified part lot
|
||||
*/
|
||||
public function add(PartLot $partLot, float $amount, ?string $comment = null): PartLot
|
||||
public function add(PartLot $partLot, float $amount, ?string $comment = null, ?\DateTimeInterface $action_timestamp = null): PartLot
|
||||
{
|
||||
if ($amount <= 0) {
|
||||
throw new \InvalidArgumentException('Amount must be positive');
|
||||
@@ -123,7 +129,7 @@ final class PartLotWithdrawAddHelper
|
||||
$oldAmount = $partLot->getAmount();
|
||||
$partLot->setAmount($oldAmount + $amount);
|
||||
|
||||
$event = PartStockChangedLogEntry::add($partLot, $oldAmount, $partLot->getAmount(), $part->getAmountSum() , $comment);
|
||||
$event = PartStockChangedLogEntry::add($partLot, $oldAmount, $partLot->getAmount(), $part->getAmountSum() , $comment, $action_timestamp);
|
||||
$this->eventLogger->log($event);
|
||||
|
||||
//Apply the comment also to global events, so it gets associated with the elementChanged log entry
|
||||
@@ -141,8 +147,10 @@ final class PartLotWithdrawAddHelper
|
||||
* @param PartLot $target The part lot to which the parts should be added
|
||||
* @param float $amount The amount of parts that should be moved
|
||||
* @param string|null $comment A comment describing the reason for the move
|
||||
* @param \DateTimeInterface|null $action_timestamp The optional timestamp, where the action happened. Useful if the action happened in the past, and the log entry is created afterwards.
|
||||
* @param bool $delete_lot_if_empty If true, the part lot will be deleted if the amount is 0 after the withdrawal.
|
||||
*/
|
||||
public function move(PartLot $origin, PartLot $target, float $amount, ?string $comment = null): void
|
||||
public function move(PartLot $origin, PartLot $target, float $amount, ?string $comment = null, ?\DateTimeInterface $action_timestamp = null, bool $delete_lot_if_empty = false): void
|
||||
{
|
||||
if ($amount <= 0) {
|
||||
throw new \InvalidArgumentException('Amount must be positive');
|
||||
@@ -177,12 +185,16 @@ final class PartLotWithdrawAddHelper
|
||||
//And add it to the target
|
||||
$target->setAmount($target->getAmount() + $amount);
|
||||
|
||||
$event = PartStockChangedLogEntry::move($origin, $oldOriginAmount, $origin->getAmount(), $part->getAmountSum() , $comment, $target);
|
||||
$event = PartStockChangedLogEntry::move($origin, $oldOriginAmount, $origin->getAmount(), $part->getAmountSum() , $comment, $target, $action_timestamp);
|
||||
$this->eventLogger->log($event);
|
||||
|
||||
//Apply the comment also to global events, so it gets associated with the elementChanged log entry
|
||||
if (!$this->eventCommentHelper->isMessageSet() && ($comment !== null && $comment !== '')) {
|
||||
$this->eventCommentHelper->setMessage($comment);
|
||||
}
|
||||
|
||||
if ($delete_lot_if_empty && $origin->getAmount() === 0.0) {
|
||||
$this->entityManager->remove($origin);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -230,12 +230,16 @@ class PermissionManager
|
||||
|
||||
/**
|
||||
* This functions sets all operations mentioned in the alsoSet value of a permission, so that the structure is always valid.
|
||||
* This function should be called after every setPermission() call.
|
||||
* @return bool true if values were changed/corrected, false if not
|
||||
*/
|
||||
public function ensureCorrectSetOperations(HasPermissionsInterface $user): void
|
||||
public function ensureCorrectSetOperations(HasPermissionsInterface $user): bool
|
||||
{
|
||||
//If we have changed anything on the permission structure due to the alsoSet value, this becomes true, so we
|
||||
//redo the whole process, to ensure that all alsoSet values are set recursively.
|
||||
|
||||
$return_value = false;
|
||||
|
||||
do {
|
||||
$anything_changed = false; //Reset the variable for the next iteration
|
||||
|
||||
@@ -254,12 +258,15 @@ class PermissionManager
|
||||
$this->setPermission($user, $set_perm, $set_op, true);
|
||||
//Mark the change, so we redo the whole process
|
||||
$anything_changed = true;
|
||||
$return_value = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} while($anything_changed);
|
||||
|
||||
return $return_value;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -22,15 +22,21 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Validator\Constraints;
|
||||
|
||||
use App\Controller\GroupController;
|
||||
use App\Controller\UserController;
|
||||
use App\Security\Interfaces\HasPermissionsInterface;
|
||||
use App\Services\UserSystem\PermissionManager;
|
||||
use Symfony\Component\Form\Exception\UnexpectedTypeException;
|
||||
use Symfony\Component\HttpFoundation\RequestStack;
|
||||
use Symfony\Component\HttpFoundation\Session\Session;
|
||||
use Symfony\Component\Validator\Constraint;
|
||||
use Symfony\Component\Validator\ConstraintValidator;
|
||||
|
||||
use function Symfony\Component\Translation\t;
|
||||
|
||||
class ValidPermissionValidator extends ConstraintValidator
|
||||
{
|
||||
public function __construct(protected PermissionManager $resolver)
|
||||
public function __construct(protected PermissionManager $resolver, protected RequestStack $requestStack)
|
||||
{
|
||||
}
|
||||
|
||||
@@ -49,6 +55,26 @@ class ValidPermissionValidator extends ConstraintValidator
|
||||
/** @var HasPermissionsInterface $perm_holder */
|
||||
$perm_holder = $this->context->getObject();
|
||||
|
||||
$this->resolver->ensureCorrectSetOperations($perm_holder);
|
||||
$changed = $this->resolver->ensureCorrectSetOperations($perm_holder);
|
||||
|
||||
//Sending a flash message if the permissions were fixed (only if called from UserController or GroupController)
|
||||
//This is pretty hacky and bad design but I dont see a better way without a complete rewrite of how permissions are validated
|
||||
//on the admin pages
|
||||
if ($changed) {
|
||||
//Check if this was called in context of UserController
|
||||
$request = $this->requestStack->getMainRequest();
|
||||
if (!$request) {
|
||||
return;
|
||||
}
|
||||
//Determine the controller class (the part before the ::)
|
||||
$controller_class = explode('::', $request->attributes->get('_controller'))[0];
|
||||
|
||||
if (in_array($controller_class, [UserController::class, GroupController::class], true)) {
|
||||
/** @var Session $session */
|
||||
$session = $this->requestStack->getSession();
|
||||
$flashBag = $session->getFlashBag();
|
||||
$flashBag->add('warning', t('user.edit.flash.permissions_fixed'));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,62 +7,62 @@
|
||||
<div class="dropdown-menu" aria-labelledby="navbar-search-options">
|
||||
<div class="px-2" style="width: max-content;">
|
||||
<div class="form-check">
|
||||
<input type="checkbox" class="form-check-input" id="search_name" name="name" value="1" checked>
|
||||
<input type="checkbox" class="form-check-input" id="search_name" name="name" value="1" checked {{ stimulus_controller('elements/localStorage_checkbox') }}>
|
||||
<label for="search_name" class="form-check-label justify-content-start">{% trans %}name.label{% endtrans %}</label>
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input type="checkbox" class="form-check-input" id="search_category" name="category" value="1" checked>
|
||||
<input type="checkbox" class="form-check-input" id="search_category" name="category" value="1" checked {{ stimulus_controller('elements/localStorage_checkbox') }}>
|
||||
<label for="search_category" class="form-check-label justify-content-start">{% trans %}category.label{% endtrans %}</label>
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input type="checkbox" class="form-check-input" id="search_description" name="description" value="1" checked>
|
||||
<input type="checkbox" class="form-check-input" id="search_description" name="description" value="1" checked {{ stimulus_controller('elements/localStorage_checkbox') }}>
|
||||
<label for="search_description" class="form-check-label justify-content-start">{% trans %}description.label{% endtrans %}</label>
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input type="checkbox" class="form-check-input" id="search_mpn" name="mpn" value="1" checked>
|
||||
<input type="checkbox" class="form-check-input" id="search_mpn" name="mpn" value="1" checked {{ stimulus_controller('elements/localStorage_checkbox') }}>
|
||||
<label for="search_mpn" class="form-check-label justify-content-start">{% trans %}part.edit.mpn{% endtrans %}</label>
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input type="checkbox" class="form-check-input" id="search_tags" name="tags" value="1" checked>
|
||||
<input type="checkbox" class="form-check-input" id="search_tags" name="tags" value="1" checked {{ stimulus_controller('elements/localStorage_checkbox') }}>
|
||||
<label for="search_tags" class="form-check-label justify-content-start">{% trans %}tags.label{% endtrans %}</label>
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input type="checkbox" class="form-check-input" id="search_storelocation" name="storelocation" value="1" checked>
|
||||
<input type="checkbox" class="form-check-input" id="search_storelocation" name="storelocation" value="1" checked {{ stimulus_controller('elements/localStorage_checkbox') }}>
|
||||
<label for="search_storelocation" class="form-check-label justify-content-start">{% trans %}storelocation.label{% endtrans %}</label>
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input type="checkbox" class="form-check-input" id="search_comment" name="comment" value="1" checked>
|
||||
<input type="checkbox" class="form-check-input" id="search_comment" name="comment" value="1" checked {{ stimulus_controller('elements/localStorage_checkbox') }}>
|
||||
<label for="search_comment" class="form-check-label justify-content-start">{% trans %}comment.label{% endtrans %}</label>
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input type="checkbox" class="form-check-input" id="search_ipn" name="ipn" value="1" checked>
|
||||
<input type="checkbox" class="form-check-input" id="search_ipn" name="ipn" value="1" checked {{ stimulus_controller('elements/localStorage_checkbox') }}>
|
||||
<label for="search_ipn" class="form-check-label justify-content-start">{% trans %}part.edit.ipn{% endtrans %}</label>
|
||||
</div>
|
||||
{% if true %}
|
||||
<div class="form-check">
|
||||
<input type="checkbox" class="form-check-input" id="search_supplierpartnr" name="ordernr" value="1" checked>
|
||||
<input type="checkbox" class="form-check-input" id="search_supplierpartnr" name="ordernr" value="1" checked {{ stimulus_controller('elements/localStorage_checkbox') }}>
|
||||
<label for="search_supplierpartnr" class="form-check-label justify-content-start">{% trans %}orderdetails.edit.supplierpartnr{% endtrans %}</label>
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input type="checkbox" class="form-check-input" id="search_supplier" name="supplier" value="1">
|
||||
<input type="checkbox" class="form-check-input" id="search_supplier" name="supplier" value="1" {{ stimulus_controller('elements/localStorage_checkbox') }}>
|
||||
<label for="search_supplier" class="form-check-label justify-content-start">{% trans %}supplier.label{% endtrans %}</label>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if true %}
|
||||
<div class="form-check">
|
||||
<input type="checkbox" class="form-check-input" id="search_manufacturer" name="manufacturer" value="1">
|
||||
<input type="checkbox" class="form-check-input" id="search_manufacturer" name="manufacturer" value="1" {{ stimulus_controller('elements/localStorage_checkbox') }}>
|
||||
<label for="search_manufacturer" class="form-check-label justify-content-start">{% trans %}manufacturer.label{% endtrans %}</label>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if true %}
|
||||
<div class="form-check">
|
||||
<input type="checkbox" class="form-check-input" id="search_footprint" name="footprint" value="1">
|
||||
<input type="checkbox" class="form-check-input" id="search_footprint" name="footprint" value="1" {{ stimulus_controller('elements/localStorage_checkbox') }}>
|
||||
<label for="search_footprint" class="form-check-label justify-content-start">{% trans %}footprint.label{% endtrans %}</label>
|
||||
</div>
|
||||
{% endif %}
|
||||
<hr>
|
||||
<div class="form-check">
|
||||
<input type="checkbox" class="form-check-input" id="regex" name="regex" value="1">
|
||||
<input type="checkbox" class="form-check-input" id="regex" name="regex" value="1" {{ stimulus_controller('elements/localStorage_checkbox') }}>
|
||||
<label for="regex" class="form-check-label justify-content-start">{% trans %}search.regexmatching{% endtrans %}</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
<div class="d-none" {{ stimulus_controller('turbo/locale_menu') }}>
|
||||
{% for locale in locale_menu %}
|
||||
<a class="dropdown-item" data-turbo="false" data-turbo-frame="_top" href="{{ path(app.request.attributes.get('_route'),
|
||||
app.request.attributes.get('_route_params')|merge({'_locale': locale})) }}">
|
||||
app.request.query.all|merge(app.request.attributes.get('_route_params'))|merge({'_locale': locale})) }}">
|
||||
{{ locale|language_name }} ({{ locale|upper }})</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
@@ -36,7 +36,7 @@
|
||||
{% if entity.buildPart %}
|
||||
<span class="form-control-static"><a href="{{ entity_url(entity.buildPart) }}">{{ entity.buildPart.name }}</a></span>
|
||||
{% else %}
|
||||
<a href="{{ path('part_new_build_part', {"project_id": entity.id , "_redirect": app.request.requestUri}) }}"
|
||||
<a href="{{ path('part_new_build_part', {"project_id": entity.id , "_redirect": app.request.baseUrl ~ app.request.requestUri}) }}"
|
||||
class="btn btn-outline-success">{% trans %}project.edit.associated_build_part.add{% endtrans %}</a>
|
||||
{% endif %}
|
||||
<p class="text-muted">{% trans %}project.edit.associated_build.hint{% endtrans %}</p>
|
||||
|
||||
@@ -29,4 +29,13 @@
|
||||
|
||||
{% macro delete_btn() %}
|
||||
{{ stimulus_action('elements/collection_type', 'deleteElement') }}
|
||||
{% endmacro %}
|
||||
|
||||
{% macro new_element_indicator(value) %}
|
||||
{% if value.id is not defined or value.id is null %}
|
||||
<span class="position-absolute top-0 start-100 translate-middle p-2 bg-primary border border-light rounded-circle"
|
||||
title="{% trans %}collection_type.new_element.tooltip{% endtrans %}">
|
||||
<span class="visually-hidden">New alerts</span>
|
||||
</span>
|
||||
{% endif %}
|
||||
{% endmacro %}
|
||||
@@ -19,7 +19,8 @@
|
||||
|
||||
{% macro partsDatatableWithForm(datatable, state_save_tag = 'parts') %}
|
||||
<form method="post" action="{{ path("table_action") }}"
|
||||
{{ stimulus_controller('elements/datatables/parts', {"stateSaveTag": state_save_tag}) }} data-dt-settings='{{ datatable_settings(datatable)|escape('html_attr') }}' data-dt-url="{{ app.request.requestUri }}"
|
||||
{# The app.request.baseUrl here is important or it wont work behind a reverse proxy with subfolder #}
|
||||
{{ stimulus_controller('elements/datatables/parts', {"stateSaveTag": state_save_tag}) }} data-dt-settings='{{ datatable_settings(datatable)|escape('html_attr') }}' data-dt-url="{{ app.request.baseUrl ~ app.request.requestUri }}"
|
||||
{{ stimulus_action('elements/datatables/parts', 'confirmDeletionAtSubmit') }} data-delete-title="{% trans %}part_list.action.delete-title{% endtrans %}"
|
||||
data-delete-message="{% trans %}part_list.action.delete-message{% endtrans %}">
|
||||
<input type="hidden" name="_token" value="{{ csrf_token('table_action') }}">
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
{{ stimulus_controller('elements/delete_btn') }} {{ stimulus_action('elements/delete_btn', "submit", "submit") }}
|
||||
data-delete-title="{% trans %}log.undo.confirm_title{% endtrans %}"
|
||||
data-delete-message="{% trans %}log.undo.confirm_message{% endtrans %}">
|
||||
<input type="hidden" name="redirect_back" value="{{ app.request.requestUri }}">
|
||||
<input type="hidden" name="redirect_back" value="{{ app.request.baseUrl ~ app.request.requestUri }}">
|
||||
|
||||
{{ datatables.logDataTable(datatable, tag) }}
|
||||
</form>
|
||||
|
||||
@@ -50,8 +50,9 @@
|
||||
{{ form_errors(form.name) }}
|
||||
</td>
|
||||
<td>
|
||||
<button type="button" class="btn btn-danger lot_btn_delete" {{ collection.delete_btn() }}>
|
||||
<button type="button" class="btn btn-danger lot_btn_delete position-relative" {{ collection.delete_btn() }}>
|
||||
<i class="fas fa-trash-alt fa-fw"></i>
|
||||
{{ collection.new_element_indicator(value) }}
|
||||
</button>
|
||||
{{ form_errors(form) }}
|
||||
</td>
|
||||
|
||||
@@ -89,6 +89,10 @@
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if show_dependency_notice %}
|
||||
<small class="text-muted mb-1 d-inline-block">{% trans %}permission.legend.dependency_note{% endtrans %}</small>
|
||||
{% endif %}
|
||||
|
||||
<ul class="nav nav-tabs">
|
||||
{% for group in form %}
|
||||
<li class="nav-item">
|
||||
|
||||
@@ -188,6 +188,15 @@
|
||||
{% endif %}
|
||||
{% endmacro %}
|
||||
|
||||
{% macro part_icon_link(part) %}
|
||||
{% set preview_attach = part_preview_generator.tablePreviewAttachment(part) %}
|
||||
{% if preview_attach %}
|
||||
<img src="{{ attachment_thumbnail(preview_attach, 'thumbnail_xs') }}" class="entity-image-xs" alt="Part image"
|
||||
{{ stimulus_controller('elements/hoverpic') }} data-thumbnail="{{ attachment_thumbnail(preview_attach) }}">
|
||||
{% endif %}
|
||||
<a href="{{ entity_url(part) }}">{{ part.name }}</a>
|
||||
{% endmacro %}
|
||||
|
||||
{% macro entity_preview_sm(entity) %}
|
||||
{# @var entity \App\Entity\Contracts\HasMasterAttachmentInterface #}
|
||||
{% if entity.masterPictureAttachment and entity.masterPictureAttachment.picture and attachment_manager.fileExisting(entity.masterPictureAttachment) %}
|
||||
|
||||
@@ -3,16 +3,25 @@
|
||||
{% import "info_providers/providers.macro.html.twig" as providers_macro %}
|
||||
{% import "helper.twig" as helper %}
|
||||
|
||||
{% block title %}{% trans %}info_providers.search.title{% endtrans %}{% endblock %}
|
||||
{% block title %}
|
||||
{% if update_target %}
|
||||
{% trans %}info_providers.update_part.title{% endtrans %}
|
||||
{% else %}
|
||||
{% trans %}info_providers.search.title{% endtrans %}
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
{% block card_title %}
|
||||
<i class="fas fa-cloud-arrow-down"></i> {% trans %}info_providers.search.title{% endtrans %}
|
||||
{% if update_target %} {# If update_target is set, we update an existing part #}
|
||||
<i class="fas fa-cloud-arrow-down"></i> {% trans %}info_providers.update_part.title{% endtrans %}:
|
||||
<b><a href="{{ entity_url(update_target) }}" target="_blank" class="text-bg-primary">{{ update_target.name }}</a></b>
|
||||
{% else %} {# Create a fresh part #}
|
||||
<i class="fas fa-cloud-arrow-down"></i> {% trans %}info_providers.search.title{% endtrans %}
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
{% block card_content %}
|
||||
|
||||
|
||||
|
||||
{{ form_start(form) }}
|
||||
|
||||
{{ form_row(form.keyword) }}
|
||||
@@ -86,7 +95,15 @@
|
||||
<br>
|
||||
<small class="text-muted">{{ result.provider_id }}</small>
|
||||
<td>
|
||||
<a class="btn btn-primary" href="{{ path('info_providers_create_part', {'providerKey': result.provider_key, 'providerId': result.provider_id}) }}"
|
||||
{% if update_target %} {# We update an existing part #}
|
||||
{% set href = path('info_providers_update_part',
|
||||
{'providerKey': result.provider_key, 'providerId': result.provider_id, 'id': update_target.iD}) %}
|
||||
{% else %} {# Create a fresh part #}
|
||||
{% set href = path('info_providers_create_part',
|
||||
{'providerKey': result.provider_key, 'providerId': result.provider_id}) %}
|
||||
{% endif %}
|
||||
|
||||
<a class="btn btn-primary" href="{{ href }}"
|
||||
target="_blank" title="{% trans %}part.create.btn{% endtrans %}">
|
||||
<i class="fa-solid fa-plus-square"></i>
|
||||
</a>
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
data-delete-title="{% trans %}log.undo.confirm_title{% endtrans %}"
|
||||
data-delete-message="{% trans %}log.undo.confirm_message{% endtrans %}">
|
||||
|
||||
<input type="hidden" name="redirect_back" value="{{ app.request.requestUri }}">
|
||||
<input type="hidden" name="redirect_back" value="{{ app.request.baseUrl ~ app.request.requestUri }}">
|
||||
|
||||
<div class="btn-group btn-group-sm" role="group">
|
||||
<button type="submit" class="btn btn-outline-secondary" name="undo" value="{{ entry.id }}" {% if disabled %}disabled{% endif %}>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user